Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ DISCORD_ROLE_FRIEND=
# with that call, as a JSON object of names to values.
# SPACESTATE_ADDITIONAL_HEADERS={"X-Example": "value"}

# Optional: the space state service calls Pixel's webhook when the space opens or closes, so the
# announcement goes out in seconds instead of at the next SpaceAPI poll (polling carries on as the
# fallback). At least 32 characters (openssl rand -hex 32), shared with that service, which signs each
# call with it. A secret (Key Vault spacestate-webhook-secret when hosted). Unset: no webhook, polling only.
# SPACESTATE_WEBHOOK_SECRET=
# The webhook's port: the only one meant to be reachable from outside. Must differ from HEALTH_PORT.
# WEBHOOK_PORT=8081

# Optional: control Home Assistant. Set both or neither. Use the address Pixel can reach it at (for
# example your Nabu Casa cloud URL) and a long-lived access token made by a NON-ADMIN Home Assistant
# user: Home Assistant can't limit a token to certain devices, so a non-admin user is the best fence.
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ src/
services/ # access-config, space-status, info-content
adapters/discord/ # interactive + publishers + calendar source
adapters/mastodon/ # outbound publisher only (toots); HTTP client stays here
adapters/webhook/ # the one inbound HTTP endpoint: the signed space state webhook
observability/ # logger, Sentry helpers, secret scrubbing, health
config/ # *.example.yaml committed; real admins.yaml / members.yaml gitignored
data/ # runtime state; gitignored. Safe to delete EXCEPT schedules.yaml (the scheduled posts)
Expand Down Expand Up @@ -109,6 +110,7 @@ infra/ # Terraform. bootstrap/ is #8; modules/pixel and envs/de
- **User-set posts:** scheduled messages and polls, the closing-time file, and any later user-authored announcement go through the core interpolator (`core/interpolate.ts`) at **post time**. Features call `interpolator.interpolate` right before send (for `/schedule`, pass the scheduled occurrence, not a late send). Date tokens accept a whole-day offset (`{{date+2}}`). Add a token by registering a provider on that interpolator (see `dateTokens`); do not grow an if/else in each feature. Unknown tokens are left as written (`{{name}}`). Don't put tokens in Pixel's own canned replies. After interpolating, `escapeMarkdown` any user-authored text that is embedded in a reply or a portable announcement field � interpolate first so date values are not escaped twice. Never log the template or the interpolated body.
- **Announcements:** features hand an announcement (a typed kind from `core/announcement.ts`) to the `Announcer` and never call a publisher or a platform directly. Publishers decide how each kind looks on their platform. Never hard-code platform accounts, handles or channels: they come from config. Announcement text must be safe to show anywhere, Discord posts must keep mentions disabled, and Mastodon toots must not mention anyone. Which channels get open/closed announcements, and in which style (live, timeline, single), is set at runtime by admins through the core `AnnouncementRoutes` (`/admin announcements`, saved in `data/announcement-routes.yaml`); the Discord adapter runs each pairing as its own publisher. Don't hard-code channels, and don't add a style without the same three things: a render in `announce-render.ts`, a publisher, and tests.
- **Mastodon:** outbound only. Features never import it. The HTTP client lives only in `src/adapters/mastodon/`. `MASTODON_ACCESS_TOKEN` is a secret (never log it, put it in a message or a test fixture that looks real). Both `MASTODON_INSTANCE` and the token or neither: missing config leaves the publisher off and Discord still works. Tests map kinds to toot text with plain objects and mock HTTP at the client boundary, never a live instance.
- **Webhook (inbound HTTP):** the only public endpoint is `POST /hooks/space-state` in `src/adapters/webhook/` ([ADR 0018](docs/adr/0018-space-state-webhook.md)). It is a nudge, never a source of truth: an accepted call only runs `spaceStatus.recheck()`, which reads SpaceAPI again. Never act on, log or echo anything in the body, and never let it set or announce a state. Every request is checked against `SPACESTATE_WEBHOOK_SECRET` (a secret) before anything else, refusals say only `unauthorized` (the log has the reason), and it never replaces polling. It listens on `WEBHOOK_PORT`, never on `HEALTH_PORT`: Azure ingress exposes only the webhook port, so don't add routes to the health server or point ingress at it. Adding another inbound route needs its own signature check, tests and an ADR.
- **Calendar:** features read events from the core `Calendar` (neutral `CalendarEvent`s) and never touch a platform. An adapter plugs in a `CalendarSource` when it's ready. **Text written by other people** (event titles, locations and so on) must go through `escapeMarkdown` before it's put in a reply.
- **Content:** `content/info/*.md` is committed to a **public** repository, so never put secrets (wifi passwords, door codes, tokens) or personal data in it, and don't add member-only topics. Keep topics short and link to the canonical page on pixelbar.nl rather than copying it. Don't invent facts: every claim should come from Pixelbar's own pages or from the board. Adding, removing or renaming a topic needs the commands re-registered (`just register dev` or `just register prod`), and CI loads the real content, so keep it valid.
- **Background work:** a feature that needs it declares `start()`, which returns a stop function. Don't start timers or listeners at import time or in a factory. Anything that runs from a timer must catch its own failures so none escapes as an unhandled rejection.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t
### Added

- `/fishbot` brings a little QuakeNet nostalgia to Discord: classic call-and-response phrases with autocomplete, feeding and petting the fish, cookie theft, and a harmless trout slap using Discord's user picker. Available to everyone with the usual command rate limits and no pings. It only responds to slash commands; no chat-reading permissions or IRC-specific behaviour.
- Space state webhook: the space state service can call `POST /hooks/space-state` when the space opens or closes, and Pixel reads SpaceAPI straight away instead of waiting for the next 30-second poll. A change found this way is confirmed after 5 seconds, so open/closed announcements go out in about 5 seconds rather than 30–60. Polling carries on unchanged as the fallback. Requests must be signed with HMAC-SHA256 (`X-Spacestate-Timestamp`, `X-Spacestate-Signature`) using the new secret `SPACESTATE_WEBHOOK_SECRET` (Key Vault `spacestate-webhook-secret`), and an accepted one only makes Pixel read SpaceAPI again: nothing in it is trusted or logged. Pixel listens on its own port, `WEBHOOK_PORT` (default 8081), only when the secret is set. On Azure, the new `spacestate_webhook_enabled` Terraform variable turns on HTTPS ingress to that port alone (the health probes stay private), `webhook_allowed_ip_ranges` can limit who reaches it, and the `spacestate_webhook_url` output is the address to give the service. `custom_domain` and `bind_custom_domain` put it on a hostname of our own, `pixel.pixelbar.nl` for prod, with an Azure-managed certificate: the first apply prints the TXT and CNAME records to create at TransIP (`custom_domain_dns_records`), the second binds the hostname once they resolve. Sentry now traces only incoming webhook requests, so probes and internet scanners don't use the trace quota. See [ADR 0018](docs/adr/0018-space-state-webhook.md) and the runbook's "Instant updates: the space state webhook".

## [0.18.0] - 2026-10-19

Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ COPY content ./content
# files whose loss only costs details like the "open for 2h" time.
RUN mkdir -p /app/data && chown node:node /app/data
USER node
EXPOSE 8080
# 8080: health probes (private). 8081: the space state webhook, when SPACESTATE_WEBHOOK_SECRET is set.
EXPOSE 8080 8081
# No --enable-source-maps: Node would rewrite stack frames to src/*.ts (not in the
# image) before Sentry sees them, so the maps uploaded for /app/dist would never
# match. Sentry applies those maps; the logs show dist/*.js frames.
Expand Down
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export default defineConfig({
text: "0017 · One writer for app secrets",
link: "/maintainers/adr/0017-one-writer-for-app-secrets",
},
{
text: "0018 · Space state webhook",
link: "/maintainers/adr/0018-space-state-webhook",
},
],
},
],
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0004-azure-container-apps.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0004. Azure Container Apps, single replica, GHCR, two environments

* Status: accepted
* Status: accepted ("no public ingress" amended by [0018](0018-space-state-webhook.md): optional ingress for the space state webhook only)
* Date: 2026-10-03

## Context
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0009-container-apps-dev.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0009. Container Apps module and the `dev` environment

* Status: accepted
* Status: accepted ("no public ingress" amended by [0018](0018-space-state-webhook.md): optional ingress for the space state webhook only)
* Date: 2026-10-08
* Issue: #9

Expand Down
39 changes: 39 additions & 0 deletions docs/adr/0018-space-state-webhook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 0018. A signed webhook from the space state service, as a nudge to read SpaceAPI

* Status: accepted (amends "no public ingress" in [ADR 0004](0004-azure-container-apps.md) and [ADR 0009](0009-container-apps-dev.md))
* Date: 2026-10-20

## Context

Pixel learns that the space opened or closed by polling SpaceAPI every 30 seconds, then confirms the change on the next poll before announcing it (`READINGS_TO_CONFIRM`). So an announcement goes out 30 to 60 seconds after the switch is flipped. The space state service ([`pixelbar/spacestate`](https://github.com/pixelbar/spacestate)) knows the moment the state changes, and can tell Pixel.

Until now Pixel had no public ingress at all: everything it does is outbound. Taking a webhook means opening an endpoint to the internet for the first time.

## Decision

- **The webhook is a nudge, not a source of truth.** An accepted call makes Pixel read SpaceAPI again at once (`SpaceStatus.recheck()`). Nothing in the body is used, logged or echoed. The state, and when it changed, still come only from SpaceAPI, so a forged or replayed call can do no more than cause one extra read, and the webhook and SpaceAPI can never disagree about what Pixel announces.
- **`recheck()` never reuses a read that was already under way**, because that read may have started before the change. It waits for it and starts a fresh one. Rechecks that arrive during the same read share the fresh one.
- **A change the webhook pointed out is confirmed after 5 seconds** (`PUSH_CONFIRM_MS`), with a second SpaceAPI read, instead of after a whole poll interval. A switch flicked back sends its own webhook, which resets the wait, so the flicker protection still holds. A change Pixel finds by polling is confirmed as before.
- **Polling stays exactly as it was.** If a webhook is lost, the next poll catches the change and it is announced the usual way, 30 to 60 seconds late.
- **Every request is signed.** The sender and Pixel share `SPACESTATE_WEBHOOK_SECRET` (at least 32 characters; Key Vault `spacestate-webhook-secret`, app group). The contract:

```
POST /hooks/space-state
X-Spacestate-Timestamp: <unix seconds when sent>
X-Spacestate-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>" with the secret>
Content-Type: application/json (the body is not read; send {"state":"open"} or similar)
```

Pixel answers `202` when it accepts the call, `401` for a missing, malformed, stale (more than 5 minutes either way) or wrong signature, `404` for any other path, `405` for anything but POST, and `413` for a body over 4 KiB. The answer never says why a call was refused; Pixel's log does (`webhook.rejected` with a `reason`). The sender should send once, with a short timeout, and not retry: polling is the retry. A test vector is in `src/adapters/webhook/signature.test.ts`.
- **Its own port.** The webhook listens on `WEBHOOK_PORT` (8081), and only when the secret is set. Azure ingress targets only that port, so `/healthz` and `/readyz` (8080) stay private. Config refuses to put both on the same port.
- **Ingress is opt-in per stack.** `spacestate_webhook_enabled` turns on external HTTPS ingress (`allow_insecure_connections = false`, Azure terminates TLS) and wires the secret. `webhook_allowed_ip_ranges` can limit it to the sender's addresses. The URL is the `spacestate_webhook_url` output. Ingress does not change scaling: `min_replicas = max_replicas = 1` still holds.
- **A custom hostname, optionally.** `custom_domain` (prod: `pixel.pixelbar.nl`) binds a hostname to the same ingress with an Azure-managed certificate (`infra/modules/pixel/domain.tf`). pixelbar.nl's DNS is at TransIP, outside Azure, so it takes two applies: the first only prints the `asuid.` TXT and CNAME records (`custom_domain_dns_records`); `bind_custom_domain = true` issues the certificate (CNAME validation, the same record the binding needs) and binds it once they resolve. Then `spacestate_webhook_url` uses the hostname, which survives the app or its environment being recreated, unlike the generated `*.azurecontainerapps.io` address. The generated address keeps working too.
- **Only `/hooks/` is traced in Sentry.** Probes and whatever else the internet sends to the public port are not, so scanners don't spend the trace quota.

## Consequences

- Pixel has a public address when the webhook is on. The code behind it is small (`src/adapters/webhook/`), checks the signature before doing anything, caps the body, times out slow senders, and has strict coverage floors.
- Rotating the secret means changing it in both places at once. Until both agree, webhooks are refused with `401` and Pixel falls back to polling; nothing breaks.
- The space state service needs to send the webhook. Until it does, or while SpaceAPI is still served by something other than that service, nothing changes: Pixel polls.
- During a deploy, a webhook can reach the old or the new revision, or neither while they swap. Any of those is fine: the next poll catches up.
- Home Assistant still has no inbound webhooks ([ADR 0011](0011-home-assistant.md)). That decision was about acting on devices on the say-so of a URL; this webhook acts on nothing.
Loading