shutter/docs/development.md at main · Traydr/shutter · GitHub
Skip to content

Latest commit

 

History

History
149 lines (121 loc) · 6.69 KB

development.md

File metadata and controls

149 lines (121 loc) · 6.69 KB

Development

Requirements

  • Node 24 (>=24.0.0 <25, pinned in .node-version)
  • pnpm 12 (pinned via packageManager)
  • A container runtime — Docker, OrbStack, or Podman — for the Control tests, which start a throwaway postgres:17-alpine through testcontainers (apps/control/src/postgres-test-global.ts)
pnpm install --frozen-lockfile
pnpm check

pnpm check is the full gate: lint + typecheck + test + build. Only pnpm test:node needs a running container runtime; lint, typecheck, build, and the worker tests do not.

Cloudflare Workers Builds sets WORKERS_CI=1 and has no Docker support, so scripts/run-workspace-tests.mjs skips test:node there and still runs the worker tests plus the rest of the gate. GitHub Actions runs everything.

Configuration

Copy .env.example to .env at the repository root and fill in what you need; apps/edge/.dev.vars.example covers the Worker separately. The Node services load the root .env automatically in dev, and Wrangler loads .dev.vars.

Services are fail-closed: a missing variable does not crash startup, but the affected route returns 401 or 503 until it is configured. You can bring the stack up one capability at a time. Control reports what it could not enable in one control.service.features event at startup, naming each disabled feature and the variables that would enable it; a variable that is set but malformed (such as a bad SHUTTER_ENCRYPTION_KEY or a non-Postgres DATABASE_URL) fails the boot instead.

Control reads configuration only through apps/control/src/env/server.ts; production modules do not touch process.env directly.

Running the services

Each Node service is a Hono server with a /healthz endpoint, and its port comes from PORT.

pnpm --filter @shutter/control dev         # control plane + Preview Job API
pnpm --filter @shutter/executor-video dev
pnpm --filter @shutter/executor-pdf dev
pnpm --filter @shutter/edge dev

To exercise the Preview Job API end to end you need Postgres plus DATABASE_URL and SHUTTER_ENCRYPTION_KEY. Run migrations first:

DATABASE_URL=... pnpm --filter @shutter/control db:migrate

Create a Space, API token, and Capability Key in the Space Registry before you submit a job — either through one-shot import (DATABASE_URL=... SHUTTER_ENCRYPTION_KEY=... pnpm --filter @shutter/control db:import-spaces <input.json>, input format in docs/runbooks/foundation-phase-2.md) or through the admin application (see below). Mint Source Capabilities with issueSourceCapability from @shutter/protocol. A capability's locator origin must be allowed by the target Space record.

The registry operations are JSON under /v1/admin when ADMIN_API_TOKEN (at least 32 characters) is set alongside DATABASE_URL and SHUTTER_ENCRYPTION_KEY; the contract is in docs/contracts/v1/admin-api.md and @shutter/admin-api has a typed client. Set IMGPROXY_ALLOWED_SOURCES on Control to let the overview compare the deployed imgproxy guard with active Space origins. From a shell:

curl -sS -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://localhost:3000/v1/admin/overview

The admin application

apps/admin is the operator interface, a TanStack Start app that calls Control's /v1/admin routes from its server. It needs no database. For UI work, run a Control that serves only the admin API over an in-memory registry and point the app at it:

pnpm --filter @shutter/control dev:admin-api      # http://localhost:3200, seeded
cp apps/admin/.env.example apps/admin/.env         # matches the dev Control's token
pnpm --filter @shutter/admin dev                   # http://localhost:3100

Sign in with the ADMIN_BOOTSTRAP_TOKEN from .env. The same app pointed at a deployed Control (CONTROL_BASE_URL=https://<control-domain> and its real ADMIN_API_TOKEN) works against production data; the session cookie is Secure, which browsers accept on localhost.

The shutter CLI

apps/cli is the same admin API from a terminal, meant for agents and scripts: Spaces, resolvers, API tokens, Capability Keys, and the derived imgproxy allowlist. It is the one part of the workspace that uses Bun, and only to run and compile; its code sticks to node: modules so it typechecks and tests with everything else.

pnpm --filter @shutter/cli dev -- overview         # run from source
pnpm --filter @shutter/cli build:cli               # apps/cli/dist/shutter, a standalone binary
ln -s "$PWD/apps/cli/dist/shutter" ~/.local/bin/shutter
shutter auth login                                 # Control's URL and its ADMIN_API_TOKEN

Credentials resolve from --url/--token, then SHUTTER_URL/SHUTTER_TOKEN, then ~/.config/shutter/credentials (mode 0600). skills/shutter/SKILL.md is the agent skill that goes with it.

The video Executor needs ffmpeg on PATH; the PDF Executor also needs poppler-utils.

Repository conventions

  • Edge runtime boundary. scripts/check-edge-boundary.mjs fails the build if Worker code imports Node APIs. The Worker runs on Web standards only and must not enable nodejs_compat.
  • Reviewed infrastructure. Normal configuration tests guard the R2 lifecycle rule, the seeded and unseeded Railway graphs, the Worker's R2 binding, the pinned imgproxy image, the imgproxy SSRF guards and resource bounds, the services' start commands, and railpack.json. The self-hosting runbook covers deployment values.
  • Railway builds. Railway builds the Node services with Railpack, which reads the root railpack.json. Its pnpm version must equal packageManager: Railpack cannot read pnpm 12's lockfile, and without the pin a fresh service falls back to pnpm 9 and fails its install. Each service starts its built entrypoint with node from the repository root (node apps/control/dist/index.js), never pnpm … start, so Node is PID 1 and receives SIGTERM itself. The start scripts remain for local use. The reasoning and measurements are in research/railpack-images.md.
  • Versioned protocol. URLs and capabilities carry an explicit v1, so incompatible drift fails closed rather than degrading. Cross-consumer behavior is pinned by fixtures in @shutter/testkit.
  • Formatting and linting are Biome (pnpm format) plus Oxlint running the local anti-slop plugin (tools/oxlint/anti-slop/, configured in .oxlintrc.json). The plugin rejects unparsed unknown inputs, typeof narrowing, undocumented type assertions, and conditional {} spreads: decode external values with a zod schema at the boundary, build optional properties in statements, and give any unavoidable assertion a SAFETY: comment stating the invariant.