Commit Graph
3 Commits
Author SHA1 Message Date
octoturge e9083cc9e3 Cover the camera path in the edge e2e test
The camera work had unit tests on both ends and nothing exercising it
against a real printer. This adds that, and it immediately earned its
keep: it is the only test that covers `stream_base_url` surviving the
hello, living on an in-memory connection, and coming back out of
`GET /printers/:id/stream` - a path with no unit test behind it, because
the connection map is not reachable without a socket.

Verified against a live H2C: it reports a camera stream named after its
own gateway id, and the endpoint answers
`{"streamUrl":"http://e2e-gateway.invalid:1984","cameraId":"h2c"}`.

Both halves are asserted, because "some printers have a stream and most
do not" is the expected state rather than a defect. Only the X1/H2 class
publishes an `rtsp_url` - confirmed at the network layer, where the H2C
accepts TCP on 322 and a P1P refuses it and listens on 6000 - so the
test also asserts that a printer without one is refused with a 404 that
blames the printer's camera and not the gateway. A run where no printer
has a camera still passes, but says so rather than claiming the path
works.

`CONTINUUM_STREAM_BASE_URL` is set to an unreachable name on purpose:
nothing here plays video, and it only has to prove the value made the
round trip. The gateway's go2rtc config is also pointed into the
scratch directory now, so a run no longer writes `go2rtc.yaml` into the
proxy checkout.
2026-10-18 13:35:10 +00:00
octoturge d8f8653dee Authenticate and authorize every route: org layer + Supabase JWKS
Until now every HTTP route was unauthenticated. A probe against the
running server returned a farm's `edgeProxyToken` and a printer's
`accessCode` to an anonymous caller, and that pair is enough to connect
as a farm's gateway and drive its printers. The previous commit stopped
the credentials appearing in responses; this one makes the routes ask
who is calling.

Authentication. Access tokens are verified against the Supabase
project's published JWKS via `createSupabaseJwtVerifier` from
`@continuum/ts-core`: RS256, public key only, no secret in this
process. It replaces the HS256 `@elysiajs/jwt` path, where
`SUPABASE_JWT_SECRET` was simultaneously the verification key and a
key that could mint tokens, present in the environment of every
instance. `src/auth/verifier.ts` builds it at module load so a
misconfigured deployment fails at boot rather than on first login.

The edge-gateway token stays symmetric and that is not an
inconsistency: this service is both its issuer and its only verifier,
so there is no second party to keep a secret from. It now carries an
`exp`, which it did not, despite the response saying
`expiresInSeconds: 3600`.

Tenancy. An org layer above farms - `organisations`,
`org_members(org_id, user_id, role)`, `farms.org_id` - so a farm can
have more than one operator without sharing an account. `farms.owner_id`
is gone. Authorization is one rule applied everywhere: resolve the
thing being acted on back to an organisation and check the caller's
role there, user -> org -> farm -> printer, with nothing in between
carrying permissions of its own. The ladder is
`viewer` < `operator` < `owner`; printer commands and job submission
need `operator`, because they move physical machines, and that is the
one boundary here that is not about data.

A non-member gets 404, not 403: a 403 would confirm that a given farm
or printer id exists and belongs to someone, which is an enumeration
oracle over the whole system. 403 is kept for a member who may see the
record but not do this to it.

List endpoints scope in the `where` clause, not after the query - a
filter applied to the result is one `return` away from being skipped.
They are also bounded now (`limit`/`offset`, default 50, max 200
refused rather than clamped).

`ownerId` and `submittedBy` no longer come from request bodies. Nor do
the printer telemetry columns: `POST /printers` is narrowed to the
columns a client owns, because a client that could POST
`status: "idle"` could make the scheduler send work to a printer that
is mid-print.

Also fills the CRUD gaps that made the API unusable - PATCH and DELETE
for farms, printers, jobs and orgs, and membership management - plus
one error shape for the whole API via `toErrorResponse`, so an
authorization failure is a throw that leaves the handler rather than a
status somebody has to remember to set.

Guards on the two changes that cannot be undone through the API:
removing or demoting an organisation's last owner, and deleting an
organisation while farms are still attached (it would cascade to every
printer, print job and stored frame).

Migration 0001 is hand-edited after generation. drizzle-kit emitted
`ADD COLUMN org_id uuid NOT NULL`, which cannot apply to a populated
table, and dropped `owner_id` without moving what it encodes anywhere.
It now adds the column nullable, creates one organisation per distinct
legacy owner with that user as its `owner` member, points each farm at
it, and only then tightens the column and drops `owner_id`. Verified
both ways: from empty via `drizzle-kit migrate`, and against a seeded
database where two farms shared an owner and a third did not.

Testing. `scripts/dev-auth.ts` is a local RS256 issuer publishing a
JWKS at the path the backend fetches, so the real verification path is
testable without a Supabase project. The alternative was a test-only
bypass in the backend - an env var making it trust an unsigned token -
which would be a permanent hole one misconfiguration from production,
and would mean the code under test is not the code that ships.

- `tests/authz.test.ts` (19 tests): the role ladder written out in full
  rather than derived the same way the implementation derives it, and
  the real JWKS path - a token signed by a key outside the JWKS, an
  expired token and a token with `aud: "anon"` (Supabase's own anon key
  is a valid project JWT) are all rejected, with the same message, so
  probing cannot tell which part of a forged token to fix next.
- `scripts/e2e-authz.sh` (68 assertions, real Postgres, real routes):
  four users across two organisations; anonymous 401s, cross-org 404s,
  viewer-cannot-command, operator-cannot-rotate, a spoofed
  `submittedBy` in the body ignored, no credential in any read, and the
  rotated token still minting an edge JWT.
- `scripts/e2e-edge.sh` updated for the org layer and now authenticates
  as a seeded `operator` - the weakest role that should work, so the
  run would fail if the command boundary were not where it claims to
  be. Re-verified against both live printers: telemetry from `h2c` and
  `p1p` persisted, and an authenticated `set_fan_speed` went
  HTTP -> WebSocket -> MQTT -> P1P and back `ok:true`, fan returned
  to 0.

Two bugs found in the test scripts while writing this, both the same
shape as the earlier `pkill -f` incident: `kill "${PID:-0}"` with an
unset PID signals the whole process group, including the harness, and
`uuidgen` is not installed in this sandbox.
2026-10-03 15:49:29 +00:00
octoturge 42d4b3820a Add a runnable dev environment, and fix the pins that stopped it booting
The edge route had never been run. Adding a way to run it turned up that the
server could not start at all, for two independent dependency reasons that
typechecking could not see:

- `elysia: ^1.1.26` had resolved to 1.4.30, which calls `t.Module` - a TypeBox
  0.34 API - while this repo pinned TypeBox 0.33. `t.Module is not a function`.
- `drizzle-typebox: ^0.2.1` resolved to a version whose peers demand
  `drizzle-orm >= 0.36`, against the `^0.33.0` pinned here. Importing it threw
  `Export named 'getViewSelectedFields' not found`.

Both are caret ranges that drifted into combinations nobody chose. elysia,
@sinclair/typebox and the drizzle packages are now pinned tightly, TypeBox
forward to 0.34.52 (which satisfies Elysia and drizzle-typebox 0.1.1 alike),
and tests/dependency_identity.test.ts fails if the TypeBox version ever differs
between here and @continuum/ts-types - the skew that made every shared schema
invalid, with an error message pointing at Standard Schema instead.

Dropping to drizzle-typebox 0.1.1 widens `jsonb` in the generated validators,
so `metadata` needs an explicit object shape in validators.ts; the column is
declared `$type<Record<string, unknown>>()` and a validated body otherwise is
not assignable to `.values()`.

scripts/:

- `install-docker.sh` - one-time host setup, kept separate because it touches
  the machine rather than containers.
- `dev-env.sh up|down|reset|status|psql|schema|env` - Valkey + Postgres/pgvector.
  Uses docker-compose.yml when a daemon answers, and otherwise runs the same two
  services from apt. That fallback is not hypothetical: Docker cannot run in this
  sandbox at all. dockerd needs CAP_NET_ADMIN to build its bridge; with
  `--bridge=none --iptables=false` it starts but cannot unpack an image without
  CAP_SYS_ADMIN; with `--storage-driver=vfs` it still fails because registering a
  layer needs user namespaces, which are blocked outright. All three are
  host-level grants, so no script can work around them - hence a second path
  rather than a broken first one.
- `e2e-edge.sh` - the test the unit tests cannot be. Seeds a farm, claims its
  printers, mints an edge token, runs the real continuum-proxy, and asserts live
  telemetry reached Postgres and a command reached a printer. It only sends a fan
  speed, only to a printer reporting IDLE, and puts it back afterwards.
- `capture-gateway-frames.js` - records what a gateway really sends, for
  refreshing tests/fixtures/. Read-only unless FAN_TEST_PRINTER is set. The test
  comment referred to this before it existed.

Verified end to end, from a dropped and freshly migrated database: `h2c`
persisted as printing/RUNNING 22% layer 2/25 nozzle 230 bed 35 with 3 HMS
alerts, `p1p` as idle/IDLE, and `POST /printers/:id/commands` reached the P1P's
fan and came back ok:true. An unclaimed printer is refused 409
PRINTER_UNREACHABLE rather than guessed at.

Two things the e2e script got wrong first, both now fixed in it: it waited for
the first printer row instead of all of them, which silently tested only the v2
printer (a v1 Bambu reports one tick later); and `$!` after `( cd ... ) &` is
the subshell, so cleanup left an orphaned gateway reconnecting with a token for
a deleted farm and polluting the next run.

Note: `drizzle/` is gitignored, so the generated migration is not committed and
there is no migration history in the repo. Left as-is deliberately - that is a
workflow call, flagged in PLAN.md.
2026-10-03 12:17:10 +00:00