c72bfe71d6b84afb4ce8138e4d02e9f50c5d51b4
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c312e37b4a
|
Drop the pooler from the MVP: point everything straight at Postgres
.env.example and the README still told people to use pgCat on :6432, the exact setup that hangs on every parameterised query. With no prod and one backend instance there is nothing for a pooler to multiplex, and postgres-js already pools in-process, so DATABASE_URL now points at Postgres on :5432 in .env.example, dev-env.sh and drizzle.config.ts. prepare:false is kept in src/db/index.ts on purpose, so adding PgBouncer later (tested working) is a config change rather than a code change. The long measurement narrative in that file's comment is cut to the decision and the pointer to PLAN.md, where the numbers now live. |
||
|
|
f02c9897c9
|
Relay camera signalling and mint TURN credentials
Makes the printer cameras playable from a hosted console, which they were not: GET /printers/:id/stream handed out the gateway's own LAN address, and a browser cannot use it. go2rtc sends no CORS headers and checks its API password before CORS, so a browser's unauthenticated preflight gets a 401 (measured against 1.9.14), and a gateway is behind a workshop NAT anyway. That endpoint could only ever have worked for an operator standing on the same network, and offered everyone else a player that silently never loaded. So /:id/stream now returns a camera name and short-lived ICE servers, and POST /:id/webrtc relays the offer to the gateway over the socket it already holds open and returns go2rtc's answer. Media travels browser-to-gateway through the TURN relay, DTLS-SRTP encrypted, so neither this process nor Cloudflare can read it - which is why this is signalling rather than proxying fMP4 through the API. That alternative also needed a credential in the URL, because <video> cannot send an Authorization header. Only the browser gets TURN credentials. It is pinned to relay-only, so it allocates the relay and go2rtc sends to it; one side is enough. That keeps rotation on a per-viewer request instead of in go2rtc's config, which is read once at startup - rotating there would restart it and drop every viewer on a timer. Unconfigured TURN is supported rather than fatal, unlike R2: a viewer on the gateway's LAN connects on host candidates. turnConfigured is returned alongside, because empty iceServers means "not set up" in one case and "minting failed" in the other, and those need different answers. Camera negotiations are failed immediately when a gateway disconnects, rather than waiting out the 12s timeout - a browser is holding a request open on each one. getStreamBaseUrl is kept but no longer served to anyone: gateways still report the field and it is the only record of where one thinks it is reachable, which is useful when diagnosing one. e2e-edge.sh asserts the new shape, that no streamUrl comes back, and drives a real offer through the relay - asserting the refusal came from go2rtc rather than from a timeout, since that is what proves the round trip happened. |
||
|
|
b7792ad335
|
Refuse to boot without R2 credentials
`?? ""` on the access key and secret meant a deployment with no object storage configured started happily, and `POST /jobs` handed clients presigned URLs signed with an empty key. The first sign of trouble was a 403 from R2 - either in the client's uploader or, worse, inside a gateway that had downloaded R2's XML error document in place of a print. Nothing in that chain said "R2 is not configured". Reads both credentials through a `required()` that throws, naming the variable, mirroring auth/verifier.ts and auth/edge_secret.ts. The endpoint is read with `||` rather than `??`, because the committed template ships `R2_ENDPOINT=` empty and `??` only catches null - so an empty value was taken as a real endpoint and the account-id derivation never ran. That is the trap auth/edge_secret.ts documents, and it caught this line too, in the first draft of it. A test covers the derivation. Each boot-failure test fills every other variable so the one under test is the only thing missing; otherwise whichever variable is read first wins and the assertion passes for the wrong reason. e2e-edge.sh gains dummy values, since it boots the backend. It presigns nothing, so they only have to exist. |
||
|
|
1db96caa91
|
Stop falling back to a committed edge signing secret
`EDGE_JWT_SECRET` was read as `process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me"` in two places. Anything holding that string can mint an edge JWT for any `farm_id` it likes and open `/ws/edge/v1` as that farm's gateway: enough to feed a farm false telemetry and to receive the commands meant for its printers. It is in a git repository, so it is not a secret. The `??` made it worse rather than better. It only catches null and undefined, and the committed `.env.example` ships `EDGE_JWT_SECRET=` *empty* - so a deployment that copied the template and filled in everything else signed its edge JWTs with the empty string and never reached the fallback at all. That is exactly the case the fallback existed to cover. Both reads now come from `src/auth/edge_secret.ts`, which throws at module load if the variable is unset or empty. Same shape as `verifier.ts` and for the same reason: a backend that boots and then fails every gateway handshake looks like a network problem somewhere else. There is no development default on purpose - a secret that works out of the box is a secret that reaches production, and `openssl rand -hex 32` is a one-time cost. Tests: the empty-string case, since that is the one the template produced, and a grep for both shapes that reintroduce the hole - a literal passed to `jwt()`, and a defaulted read of the variable - plus an assertion that the variable is read in exactly one place. 60 pass. |
||
|
|
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.
|
||
|
|
ab093f3136
|
Use Valkey instead of Redis
Redis 7.4+ is RSALv2/SSPL and 8.0+ adds AGPLv3. None of the three works for a closed-source commercial cloud service, and SSPL section 13 specifically targets offering the software's functionality as a service, which is what a control plane does. Valkey is BSD-3-Clause, under the Linux Foundation, forked from Redis 7.2.4. Deciding this now rather than after step 3 of PLAN.md stands up real infrastructure makes it free: Valkey speaks the same RESP protocol, so `ioredis` is untouched and only names change. `REDIS_URL` becomes `VALKEY_URL`, the exported client becomes `valkey`, and `/health` reports a `valkey` field. Two things deliberately do not change, documented in place so they are not "fixed" later: the `redis://` URL scheme, which names the wire protocol and is what every client actually parses (none understands `valkey://`), and `ioredis` itself, which is a RESP client whose name refers to the protocol's origin and not to the server we run. docker-compose.yml is new, pinning valkey/valkey:8-alpine alongside pgvector/pgvector:pg17 - the plain postgres image lacks the extension `frame_embeddings` needs. It makes the Valkey choice concrete rather than a note someone has to remember. |
||
|
|
fad0a52690
|
Initial boilerplate scaffold for continuum-backend
Bun + ElysiaJS control plane: Drizzle/Postgres schema (printers, farms, print_jobs, pgvector frame_embeddings), R2 presigned URLs, Redis Streams frame ingestion queue, REST routes, and the /ws/edge/v1 edge-gateway WebSocket uplink. |