TypeScript rewrite of Pixelbar's SpaceAPI + space-open/closed state service
(SpaceApiScripts + SpaceStateScripts), deployed on Azure Container Apps
via Terraform.
A clean new API lives alongside an isolated, temporary src/legacy/ shim
that keeps the old URL paths, token+IP toggle auth, and response shapes
working for hardware/consumers that haven't migrated yet (the physical
space-state button, in particular). See src/legacy/plugin.ts for what that
covers and why.
The HTTP API is described by an OpenAPI 3.1 spec: docs/openapi.yaml
(validate with npx @redocly/cli lint docs/openapi.yaml).
It is published as a browsable reference at https://pixelbar.nl/spacestate/ (vitepress-openapi, themed to match pixelbar.nl/pixel), with the raw spec alongside it at https://pixelbar.nl/spacestate/openapi.yaml.
Editing docs/openapi.yaml is the whole update: the pages, the sidebar and a
page per operation are all generated from it. Cutting a release republishes the
site from the released commit, so the live reference is the released API;
.github/workflows/docs.yml builds it on every PR that touches it, and can
republish by hand (Actions → Docs site → Run workflow).
just docs-preview # hot-reloading preview on http://localhost:8000/spacestate/
just docs-build # build only, into docs/.vitepress/dist/See AGENTS.md for refreshing the theme from /pixel, previewing
over Tailscale, and the two spec rules the build enforces.
Requires Docker (for Azurite, the local Table Storage emulator) and Node 22+.
npm install
docker compose up -d azurite
just dev # http://localhost:3000, config from config/*.sample.*Or run the whole thing containerized: docker compose up --build.
just test-unit # pure domain/config logic, no I/O
docker compose up -d azurite
just test-integration # exercises both the new API and the legacy adapterInfra lives in infra/ (Terraform, azurerm provider). The domain is on
external DNS, so the custom domain rollout is two phases:
cd infra
terraform init -backend-config=backend.azurerm.hcl
# Once, so every apply from this clone keeps Sentry wired (see "Observability"):
echo 'sentry_dsn = "https://<key>@<org>.ingest.de.sentry.io/<project>"' > sentry.auto.tfvars
terraform apply -var="custom_domain=spacestate.pixelbar.nl" -var="container_image=ghcr.io/pixelbar/spacestate:<tag>"
# read the `domain_verification_id` and `default_fqdn` outputs, create the
# TXT + CNAME records at your DNS provider, wait for them to propagate, then:
terraform apply -var="custom_domain=spacestate.pixelbar.nl" -var="container_image=ghcr.io/pixelbar/spacestate:<tag>" -var="bind_custom_domain=true"State is remote: storage account pixelbartfstate, container spacestate
(see infra/backend.azurerm.hcl). Initialise with
terraform init -backend-config=backend.azurerm.hcl; this needs az login
and Storage Blob Data Contributor on the container.
To migrate an existing local state, run this once from the directory holding
terraform.tfstate, then answer yes to copy it:
az storage container create --account-name pixelbartfstate --name spacestate --auth-mode login
terraform init -migrate-state -backend-config=backend.azurerm.hclThe app reads its config from three blobs rather than a database, each
with a sample under config/:
spaceapi.json— the public SpaceAPI document, exactly as served, minus the live fields (open,state.open), which come from current state (config/spaceapi.sample.json)api-keys.yaml— API keys for the/api/v1endpoints (config/api-keys.sample.yaml)legacy.yaml— legacy device credentials (config/legacy.sample.yaml)
After the first apply, upload real ones:
for f in spaceapi.json api-keys.yaml legacy.yaml; do
az storage blob upload \
--account-name <storage_account_name output> \
--container-name config \
--name "$f" \
--file "./$f" \
--auth-mode login
doneTo change a live config safely (pull, edit, validate, review diff, confirm, push):
cp .env.example .env # then set SPACESTATE_STORAGE_ACCOUNT (just loads .env automatically)
just edit spaceapi # or api-keys | legacy: edit remote in $EDITOR, diff, confirm, push
just pull # diff remote vs config/prod/ (gitignored), confirm overwriting local
just push # diff remote vs config/prod/, confirm overwriting remote (add a name for one file)To add an API key: just api-key <id> --role read|write --add appends a freshly
generated key to config/prod/api-keys.yaml, then just push api-keys.
Locally, override the sources with SPACEAPI_CONFIG_SOURCE,
API_KEYS_CONFIG_SOURCE and LEGACY_CONFIG_SOURCE (file:// or blob://).
Editing those blobs later takes effect on the next cold start (or within a
few minutes if a replica is already running — see src/config/loader.ts),
no redeploy needed.
Set SENTRY_DSN (and optionally SENTRY_ENVIRONMENT) to enable Sentry
error tracking, tracing, and logs — see src/observability/sentry.ts. The
app runs fine with it unset; Sentry calls become no-ops. Startup says which
it is: the spacestate started log carries sentryEnabled: true|false, and
a run without a DSN also logs a Sentry is disabled warning (visible in
Container Apps logs / Log Analytics — it cannot reach Sentry itself).
In production the DSN comes from Terraform, and an apply without it fails rather than quietly deploying with reporting off:
# infra/sentry.auto.tfvars (gitignored) — picked up by every apply:
sentry_dsn = "https://<key>@<org>.ingest.de.sentry.io/<project>"Equivalently, pass -var="sentry_dsn=..." per apply. If sentry_dsn is empty,
terraform plan/apply stops with a validation error naming the variable. To
deploy without error reporting on purpose, say so explicitly with
-var="sentry_enabled=false"; the DSN is then ignored (no sentry-dsn secret,
no SENTRY_DSN on the container) and the sentry_enabled output reads false.
There is no Key Vault in this stack, and just pull/push/edit only move the
three config blobs (spaceapi.json, api-keys.yaml, legacy.yaml), so
Terraform is the one place the DSN lives. Rotating it is an apply.
What is reported: 5xx errors (with route/method), config reload and
state-service failures, startup crashes, and every request.log/app.log
line (as Sentry Logs, via pinoIntegration), including state changes and
rejected writes.
.github/workflows/ci.yml typechecks, builds, runs unit + integration tests
(against Azurite), builds the Docker image, and on PRs requires a
CHANGELOG.md entry under ## [Unreleased] (label a PR skip-changelog
for pure chores).
.github/workflows/changelog-merge.yml keeps open PRs mergeable: when main's
CHANGELOG.md changes (and after a release empties [Unreleased]), it merges
main into every open PR that edits the file and, if CHANGELOG.md is the only
conflict, resolves it by keeping both sides' entries and pushes that merge.
It comments on the PR either way, and leaves anything it can't resolve safely
to a person. just changelog-merge does the same in a stopped local merge or
rebase.
Releases are cut from main via Actions → Release → Run workflow. It turns
[Unreleased] into a dated version (Added → minor, anything else → patch,
never 1.0 automatically), bumps package.json/package-lock.json and the
spec's info.version, commits Release vX.Y.Z, tags it, opens a GitHub
Release, and pushes
ghcr.io/pixelbar/spacestate:{vX.Y.Z,<sha>,latest}. A deploy job
then waits for approval of the GitHub Environment prod, rolls the Container
App to the <sha> image with az containerapp update, and waits for
/healthz. Terraform ignores the image (lifecycle.ignore_changes), so
terraform apply never rolls prod back; it only manages infrastructure.
In parallel with the deploy, docs-build/docs-deploy publish the docs site
to GitHub Pages from the same commit — publishing docs needs no prod
approval, and a failed deploy does not hold the site back.
The bot pushes to main, so if branch protection is on, allow
github-actions[bot] to bypass it.
The changelog merge workflow works out of the box, with one caveat: pushes made
with GITHUB_TOKEN don't start CI, and can't carry workflow changes from main
into a branch. A GitHub App fixes both. Create one on the org with Contents,
Pull requests and Workflows: read and write, install it on
pixelbar/spacestate, then:
gh variable set CHANGELOG_BOT_CLIENT_ID --body "<the App's client ID>"
gh secret set CHANGELOG_BOT_PRIVATE_KEY < app-private-key.pemUntil that exists, the workflow pushes with GITHUB_TOKEN, its PR comment says
CI hasn't run on the merge (push again, or close and reopen the PR), and a merge
that would bring in workflow changes is refused.
SUB=$(az account show --query id -o tsv)
TENANT=$(az account show --query tenantId -o tsv)
# Identity GitHub logs in as (no client secret; OIDC only)
APP_ID=$(az ad app create --display-name spacestate-github-deploy --query appId -o tsv)
az ad sp create --id "$APP_ID"
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-prod",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:pixelbar/spacestate:environment:prod",
"audiences": ["api://AzureADTokenExchange"]
}'
# Allowed to update the app, scoped to its resource group only
az role assignment create --assignee "$APP_ID" --role Contributor \
--scope "/subscriptions/$SUB/resourceGroups/pixelbar-spacestate"
# GitHub: repo variables + the approval-gated environment
gh variable set AZURE_CLIENT_ID --body "$APP_ID"
gh variable set AZURE_TENANT_ID --body "$TENANT"
gh variable set AZURE_SUBSCRIPTION_ID --body "$SUB"Then in GitHub → Settings → Environments, create prod and add required
reviewers.
After the first release publishes the image, make the GHCR package public once (Packages → spacestate → Package settings → Change visibility). GitHub has no API for this; Azure pulls anonymously, and the release fails before deploying if the package is still private.