Skip to content

Space state webhook for instant open/closed, with opt-in ingress and pixel.pixelbar.nl - #146

Open
NinjaLikesCheez wants to merge 3 commits into
mainfrom
claude/spacestate-webhook
Open

NinjaLikesCheez wants to merge 3 commits into
mainfrom
claude/spacestate-webhook

Conversation

@NinjaLikesCheez

Copy link
Copy Markdown
Contributor

What

The space state service (pixelbar/spacestate) can now call POST /hooks/space-state when the space opens or closes. Pixel then reads SpaceAPI straight away instead of waiting for the next 30-second poll. Open/closed announcements go out in about 5 s instead of 30–60 s. Polling is unchanged and stays the fallback.

Design: ADR 0018.

The webhook is a nudge, not a source of truth

  • An accepted call only runs spaceStatus.recheck(). Nothing in the body is used, logged or echoed, so a forged or replayed call can cause one extra SpaceAPI read and nothing more. The webhook and SpaceAPI can't disagree about what gets announced.
  • recheck() never reuses a check that was already under way, since that read may have started before the change. It waits for it, then asks again. Rechecks that arrive during the same check share one follow-up.
  • A change found this way is confirmed by a second SpaceAPI read after PUSH_CONFIRM_MS (5 s) instead of a whole poll. A switch flicked back sends its own webhook, which resets the wait, so flicker protection still holds.

Signed requests (contract for the sender)

POST /hooks/space-state
X-Spacestate-Timestamp: <unix seconds>
X-Spacestate-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>" with SPACESTATE_WEBHOOK_SECRET>
  • Responses: 202 accepted. 401 for missing, malformed, stale (more than 5 minutes either way) or wrong signatures; the log has the reason, the reply doesn't. 404 for other paths, 405 for anything but POST, 413 above 4 KiB.
  • Signatures are compared in constant time, and slow senders are cut off (5 s for headers, 10 s per request).
  • A fixed test vector is in src/adapters/webhook/signature.test.ts.
  • The sender should send once, with a short timeout and no retries: polling is the retry.

Config

  • SPACESTATE_WEBHOOK_SECRET: a new app secret, at least 32 characters. Key Vault spacestate-webhook-secret; entry added to infra/secrets.manifest.yaml. Unset means no listener and polling only.
  • WEBHOOK_PORT: default 8081. It is its own port, never HEALTH_PORT, and config refuses to make them the same, so /healthz and /readyz stay private.

Azure

The Container App had no ingress, so nothing inbound could reach it.

  • spacestate_webhook_enabled (default off) adds external HTTPS ingress to port 8081 only, with allow_insecure_connections = false, and wires in the secret. min=max=1 is unchanged.

  • webhook_allowed_ip_ranges (optional) adds Allow rules.

  • spacestate_webhook_url output: the address to give the service.

  • Custom domain (infra/modules/pixel/domain.tf): custom_domain (prod pixel.pixelbar.nl, dev e.g. pixel-dev.pixelbar.nl) uses an Azure-managed certificate with CNAME validation. pixelbar.nl's DNS is at TransIP, so it takes two applies:

    1. Set custom_domain, apply, and read the custom_domain_dns_records output: TXT asuid.pixel and CNAME pixel.
    2. Create those records at TransIP. Once they resolve, set bind_custom_domain = true and apply.

    After that, spacestate_webhook_url becomes https://pixel.pixelbar.nl/hooks/space-state. The variables are cross-validated: a domain needs the ingress, and binding needs a domain. pixel.pixelbar.nl doesn't exist yet, and pixelbar.nl has no CAA records, so nothing blocks the certificate.

Sentry

Only incoming /hooks/ requests are traced now (isUntracedRequest, which replaces isHealthProbe). Probes and internet scanners on the public port don't use up the trace quota.

Testing

  • just check equivalents pass: Biome, tsc, and all 2157 tests with coverage floors. New strict floors cover src/adapters/webhook/{signature,server}.ts. The secrets-manifest drift test passes.
  • New tests cover signing (fixed openssl vector), the stale and malformed cases, server routing, body caps, connection drops, repeated headers, a client hanging up, recheck() ordering and sharing, push-confirm timing and the flick-back case, and config validation.
  • terraform fmt -check and validate pass for bootstrap, dev and prod (Terraform 1.16.5, locked azurerm 5.8.0, lockfiles unchanged).
  • Throwaway terraform test runs with a mocked provider checked ingress, env and secret wiring, both custom-domain phases, the URL switch-over, and the validation errors. I didn't commit them, because CI doesn't run terraform test.
  • End-to-end with the real webhook server and SpaceApiStatus against a fake SpaceAPI, using the runbook's curl snippet: wrong secret gives 401 plus webhook.rejected bad-signature; the signed call gives 202 and spaceapi.changed via: push, with polling set to once an hour; /healthz on the public port gives 404.
  • The docs site builds.

Rollout (nothing applied)

  1. openssl rand -hex 32 → .env.<env> as SPACESTATE_WEBHOOK_SECRET → just secrets-push <env>.
  2. spacestate_webhook_enabled = true (optionally custom_domain) in tfvars → just push <env> → just tf-plan <env>. A maintainer applies; prod needs PIXEL_APPLY_PROD=1.
  3. Optionally add the DNS records at TransIP, then bind_custom_domain = true and apply.
  4. Give the service the URL and the secret. Step-by-step in the runbook, "Instant updates: the space state webhook".

The sending side isn't in this PR. pixelbar/spacestate still has to send the webhook after a state change that actually changed something. Until it does, nothing changes: Pixel polls. Also, spaceapi.pixelbar.nl is still served by the old setup, so until it's cut over, a webhook can arrive before SpaceAPI shows the change, and the next poll catches it.

Review notes

  • This gives Pixel a public address for the first time (opt-in), so a human reviewer should look at it. It doesn't touch any of the rule-14 files (core/access*, core/dispatcher*, etc.).
  • Docs updated: architecture (config table, webhook section, announcements, tracing, deployment), infra (diagrams, ingress section), runbook (setup, hostname, rotation, troubleshooting), AGENTS.md (layout and a webhook convention), .env.example, example tfvars, env READMEs, and ADRs 0004/0009 marked as amended by 0018. CHANGELOG entry under Unreleased → Added.

Generated by Claude Code

The space state service can POST /hooks/space-state when the space opens
or closes. Pixel checks the HMAC-SHA256 signature (X-Spacestate-Timestamp,
X-Spacestate-Signature, 5-minute window) and then only re-reads SpaceAPI:
nothing in the body is trusted, logged or echoed. SpaceStatus.recheck()
never reuses a check already under way, and a change found that way is
confirmed after 5 s instead of a whole poll, so announcements go out in
about 5 s rather than 30-60. Polling is unchanged and stays the fallback.

The webhook listens on its own port (WEBHOOK_PORT, 8081) only when
SPACESTATE_WEBHOOK_SECRET is set, so the health probes stay private.
Terraform gains spacestate_webhook_enabled (external HTTPS ingress to
8081 only, plus the Key Vault secret spacestate-webhook-secret),
optional webhook_allowed_ip_ranges and the spacestate_webhook_url output.
Sentry now traces only incoming /hooks/ requests.

See ADR 0018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6YEJeThAVV24LzGijYXTB
custom_domain and bind_custom_domain put the webhook ingress on a
hostname of our own with an Azure-managed certificate. pixelbar.nl's DNS
is at TransIP, so it takes two applies: the first only prints the
asuid TXT and CNAME records (custom_domain_dns_records); the second
issues the certificate (CNAME validation) and binds the hostname once
they resolve. spacestate_webhook_url then uses the hostname. Both
variables are validated: a domain needs the webhook ingress, binding
needs a domain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6YEJeThAVV24LzGijYXTB
@github-actions

Copy link
Copy Markdown
Contributor

Merged main (8d5c2f2) into this branch: CHANGELOG.md was the only conflict, and both sides' [Unreleased] entries are kept. Pull before pushing again. GitHub doesn't start CI for this bot's pushes, so CI hasn't run on it: push again, or close and reopen the PR.

This branch was successfully deployed

2 active (outdated) deployments
prod — 5eb47954 Deployed Oct 20, 2026 by NinjaLikesCheez via Plan envs/prod #110
dev — 5eb47954 Deployed Oct 20, 2026 by NinjaLikesCheez via Plan envs/dev #110
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants