6a2457315e671333cb928f3f0daa6fed50a6fe3b
19
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6a2457315e
|
Add a switch to turn job dispatch off, and use it in the dev stack
The scheduler sends a queued job to any idle claimed printer in any farm. The dashboard dev stack is wired to the real printers, so testing the job upload UI there would have started a real print on the first idle one. DISPATCH_ENABLED=false (exactly that string) disables the timer and the nudge from POST /jobs; any other value that looks like an attempt to disable it is warned about, because a typo that silently leaves it on is the dangerous direction. scripts/e2e-camera.sh now always runs the backend with it off. |
||
|
|
26d382b82d
|
Close the printers.metadata hole: not writable, not served
metadata was the way around the decision to hold no printer credentials. It is a free-form jsonb column that was both writable through POST /printers and listed in the public projection, so a client could stash an access code in it and the API would store it and serve it back. Nothing reads it: the frontend, the proxy and the dispatcher never touch printers.metadata (dispatch's metadata is print_jobs', a different column). It is removed from PRINTER_WRITABLE and from printerProjection. The column stays, so reopening it later is one line once something needs it, ideally with a shape to match rather than free-form JSON. The test that merely recorded the hole is replaced by one that asserts it is closed at both ends, because a field nobody can read or one nobody can write is still half of it: not in the projection, dropped from a create body by Value.Clean, and absent from PRINTER_WRITABLE. bun test 128 pass / 0 fail; e2e-authz.sh PASS with 68 checks. |
||
|
|
51e1827f5a
|
Stop storing printers' access codes
`printers.access_code` held a printer's LAN password - the same value Bambu uses for MQTT and as the FTPS password. It was writable through `POST /printers`, excluded from `printerProjection` so it could not leave the server, and **never read by any backend code path**. A secret at rest in the cloud, serving no purpose. The credential belongs on the proxy, which is the only thing that uses it. Under the three-tier architecture it goes FE -> API -> proxy, with the API relaying it rather than persisting it. Considered and rejected, deliberately: keeping the column so the console could show an operator their printer's access code. Useful, but not worth the risk. Removing the column is what makes that rejection real - a stored secret is one a future route can leak, and the projection was the only thing standing between this column and a response. `PRINTER_WRITABLE` and the two schemas derived from it move to `db/validators.ts`, which already declares itself the single place these shapes are defined. The reason is testability: a route imports `db/index.ts` and so needs a live `DATABASE_URL`, and "what may a client set" is exactly the kind of thing worth asserting with no database in sight. Four tests pin the decision so it is enforced rather than remembered, because a column is easy to re-add and the symptom would be silent. One of them records a hole rather than a fix: `metadata` is a free-form jsonb column that is both writable and in the public projection, so a well-meaning client can still stash a credential there and have it stored and served back. Removing the dedicated column narrowed the door; it did not close it. The e2e script still submits `accessCode: "SECRET123"` on purpose. The field is now unknown to the create schema, the schema is open, and Elysia cleans it away rather than rejecting the request - so the canary proves against a real server that a client which tries to hand the cloud a credential has it dropped and never echoed. That is runtime behaviour the unit test can only establish at the schema level. Migration 0003 is a single `DROP COLUMN IF EXISTS`, applied locally. There is no production, staging or dev environment and nothing was writing the column, so there is no data to preserve and no consumer to break. |
||
|
|
818f28ea5d
|
Pin the discovery frame against a real capture
The gateway and backend halves of `discovered_printers` are hand-synced, so every hand-written example in this file agrees with whichever side wrote it - which is exactly how the two ends of /ws/edge/v1 drifted into different protocols before. A capture is the only thing that catches that. This one came off a live LAN with a Bambu P1P and H2C on it, through the gateway's own serde enum, with its fleet file pointed at an empty file so nothing was filtered out. The addresses and the shape are as captured. The two serials are placeholders of the captured shape. They identify someone's physical hardware, no test depends on their value, and no other fixture here carries one - so the real ones are not something to commit on the owner's behalf. |
||
|
|
cd7aed0028
|
Receive and serve a gateway's printer discovery results
The gateway can find printers on its own network and USB ports; nothing carried that back. So adding a printer in the console still began with someone walking to a machine to read its IP address off the screen. Three pieces: - A `discovered_printers` handler on /ws/edge/v1, recording the latest scan per farm. - `discoveredByFarm`, one entry per farm, replaced wholesale by each report. - `GET /farms/:id/discovered-printers`, viewer-gated like its unclaimed sibling. Why a separate store rather than folding this into `unclaimedByFarm`: an unclaimed printer is already in the gateway's `printers.toml`, so someone has written down its address and credential and the gateway is talking to it - all that is missing is the row. A discovered printer was merely seen. Nobody configured it, no credential for it exists anywhere, and the gateway cannot reach it as a printer. One is a row waiting to be created; the other is a suggestion. Keyed per farm rather than per printer because discovery has no stable key to merge on: a Bambu printer has a serial, a Klipper host has only the address DHCP gave it this week, and merging on that accumulates a phantom candidate per lease. The route cross-checks candidates against the farm's existing printer rows so the console can grey one out instead of offering a duplicate - the gateway filters its own fleet file but knows nothing about rows registered in the cloud and never claimed. `matchedOn` reports the basis because the two bases are not equally sure: a serial identifies a machine, an address identifies a DHCP lease and may well be last month's printer. `discovery: null` means the gateway has never reported a scan, which is the normal case because discovery is off by default in the gateway's config. That is deliberately distinguishable from a report whose `printers` is empty, which means it looked and found nothing. The protocol tests pin the parts that matter for safety: an over-long name is refused, and so is a candidate carrying an `access_code`. That second one is not tidiness - no probe can learn an access code, so a candidate that has one got it elsewhere, and the closed object is what stops this message quietly becoming a channel for secrets nobody meant to send to the cloud. |
||
|
|
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. |
||
|
|
0d87c8cbce
|
Make the edge protocol contract test pass on its own
tests/edge_protocol.test.ts validates schemas carrying format: "uuid" and format: "uri" but registered neither. TypeBox treats an unregistered format as a failure, not a pass, so every start_print in that file was rejected - and the file still went green, because some other test in the same run imported Elysia first and registered the formats globally. Run alone it failed. A contract test that depends on which files ran before it is the worst way for one to behave. It now imports src/validation/formats, which exists for exactly this and gains a "uri" validator: anything the platform parses as an absolute URL. Lenient on purpose - the only user is a presigned object-store GET that this backend mints itself, so a stricter pattern would have to anticipate whatever query parameters a signer adds, and rejecting a URL R2 would have honoured costs a print for no security. |
||
|
|
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. |
||
|
|
24142d4603
|
Add the operator console's live telemetry socket
The only socket until now was the gateway-facing /ws/edge/v1, which
authenticates as a *farm* with a credential any of its gateways holds,
and which accepts telemetry. Pointing a browser at it would mean handing
the browser something that can drive printers. /ws/telemetry
authenticates as a *user* with a Supabase access token, and accepts
nothing at all: it is push-only, and a frame arriving from the browser is
ignored. Everything that changes a printer keeps going over HTTP, where
the role checks and the audit trail already are.
Auth is checked at the upgrade, as on the edge socket, so an
unauthenticated browser never gets a socket. The token rides in
`Sec-WebSocket-Protocol` - `new WebSocket(url, ["bearer", token])` -
because that field is the whole of what JavaScript controls about a
browser handshake, and the usual workaround of `?token=` would put a
bearer credential into every access and proxy log. "bearer" is first
because the runtime echoes the *first* offered value back in the
response header, so the token must not be in that position; asserted,
not assumed.
The payload is the printer resource itself, returned straight out of
`persistTelemetry`'s UPDATE through `printerProjection` - the same
projection the REST routes use. So a push is byte-for-byte what
`GET /printers/:id` would have answered, and a client applies it to the
row it already holds. Rebuilding it from the gateway's snapshot instead
would be a second translation of the same data, free to drift, and
missing the columns a gateway never reports at all like `name` and
`vendor`.
`maySee` is what keeps one organisation's telemetry out of another's
console, and it lives in `telemetry/broadcast.ts` with no imports but a
type so it can be tested without a socket or a database - the same reason
as `dispatch/job_status.ts`, `stream.ts` and `auth/edge_upgrade.ts`. It
refuses a printer with no farm rather than matching it against anything,
which no row can be today, because this is the one function where being
wrong leaks another org's machines.
**A latent bug this turned up:** the edge route's resolve was
`{ as: "scoped" }`, copied from `requireUser`. For `requireUser` that is
right - it is used inside each route file and must propagate `user` to
that file's routes. On a ws plugin it propagates the check to the parent
app, where it gates every route mounted *after* that plugin. Measured
with a two-plugin probe: the second plugin's own socket is refused
because the first one's check runs first and finds no edge token. Nothing
shipped broken only because /ws/telemetry is the first route mounted
after it - and it was refused outright until this was found. Both ws
resolves now use the default `local` scope.
Known limits, both written down rather than discovered later:
- **In-process fan-out.** A `Set` of callbacks in one process, so two
backend instances would each push only the telemetry their own
gateways are connected to and an operator would see half a fleet. The
fix is Valkey pub/sub, already a dependency; doing it now would mean
testing a message broker to deliver events between two objects in the
same process.
- **Visibility is resolved once, when the socket opens.** Re-deriving it
per message would put a database round trip on every push to every
viewer. The direction that matters: someone removed from an
organisation keeps receiving its telemetry until their socket drops.
Closing that needs a signal on membership change that this backend does
not have; a periodic re-resolve would narrow the window without closing
it. Telemetry is also the least sensitive thing here, and every HTTP
route re-checks on each request.
Verified against the real stack, not just mirrored in unit tests:
`scripts/e2e-edge.sh` now drives the actual route with a real
JWKS-verified token and asserts the handshake echoes "bearer", that a
`ready` frame arrives, and that a real push carries the printer resource
with live readings - "2 push(es); last was H2C state=FINISH nozzle=30
camera=h2c". A bogus token is refused before any socket exists.
`scripts/watch-telemetry.ts` is the client it uses, and is the quickest
way to see by hand whether a farm's telemetry reaches a console.
110 tests (was 95), `bunx tsc --noEmit` clean.
|
||
|
|
57af710b1a
|
Verify the edge token at the upgrade, not in the first frame
`/ws/edge/v1` took its credential from a `token` field on the `hello` message. It now takes it from `Authorization: Bearer` on the upgrade request, verified before the socket exists, so an unauthenticated peer gets an HTTP 401 and no WebSocket: no entry in `connections`, no frame ever parsed, no state of any kind allocated for it. The old arrangement worked, and every message type refused to act without a verified farm. But "it cannot do anything" is a weaker guarantee than "it does not exist" - an anonymous peer still got a live socket and had its JSON run through `EdgeClientMessageSchema` and the message switch before proving anything, and it took a timeout to stop anyone holding sockets open for free. A `?token=` query parameter was never on the table: a bearer credential in a URL lands in access logs, proxy logs and error breadcrumbs, which is the thing the proxy's hand-written `Debug` for `PrinterCommand` already exists to prevent. The decision lives in `src/auth/edge_upgrade.ts`, a leaf module with no imports but the error types - the same reason as `dispatch/job_status.ts` and `stream.ts`. This is the authentication boundary for the entire gateway protocol, so it should be the last thing only reachable through a running database. It checks `scope` and not just the signature (the same secret could be made to sign something else one day), rejects a token whose farm claim is absent or not a string (that would otherwise produce a connection whose `farmId` is `undefined`, which reads as "every farm" to a lookup), and answers every failure with one message - which part of a forged token to fix next is not something to tell its holder. `conn.farmId` is therefore set from the verified token in `open`, and the `hello` handler no longer verifies anything. It still checks `message.farm_id` against the token's farm, so a gateway naming a farm its token was not minted for is refused rather than silently working against the wrong one. The handshake timer stays but changed meaning: it is no longer an auth timeout, just a bound on an authenticated socket that never identifies its gateway. Verified, not assumed. That a throwing `resolve` fails the upgrade is a framework behaviour the whole design rests on, so `tests/ edge_upgrade.test.ts` stands up a real Elysia app with a real JWT plugin and asserts a socket never opens without a valid edge token, and that a resolved value reaches `ws.data`. The real `continuum-proxy` was also run against this composition: valid token gives HTTP 101 with the farm taken from the token, forged token gives 401 at the handshake. The committed captures predate this and still contain `token: "dev-token"`. That validates - the schema is a liberal reader - and is harmless, because the field is now ignored and such a gateway is refused at the upgrade for having no header. A test says so. 95 tests (was 85), `bunx tsc --noEmit` clean. |
||
|
|
4de0d86d43
|
Tell a client where to watch a printer's camera
`continuum-proxy` now generates go2rtc's configuration from the camera
URLs the printers themselves report, and sends two new things up: a
`stream_base_url` on its hello, and a `camera_stream` per printer in
telemetry. This is the other end of both.
`GET /printers/:id/stream` answers `{ streamUrl, cameraId }`, which is
everything a browser needs to open a WebRTC session against the gateway's
go2rtc. 404 rather than 200-with-nulls when either half is missing: a
client handed `{ streamUrl: null }` has to remember to check, and one that
forgets renders a player that can never load. The two causes get
different messages, because they are different problems for whoever is
debugging - a P1/A1 that can never stream is not a gateway that happens
to be offline - and the printer is checked first so a disconnected
gateway cannot mask the permanent case.
The resolution lives in `src/stream.ts` with no imports at all, for the
same reason `dispatch/job_status.ts` does: `../db` opens a connection at
import time, and these two messages are exactly the part worth testing
without one.
Storage, both decided against the obvious alternative:
- `stream_base_url` lives on the in-memory connection, so it goes away
with the socket. It is a LAN address that is only reachable while that
gateway is up; a column on `farms` would keep handing out an address
nothing answers after the gateway disconnected or moved.
- `camera_stream` gets a plain column, because every sibling telemetry
field already has one, and it is cleared when absent like the rest - a
printer that loses its stream must stop being offered one.
`cameraStream` is also added to `printerProjection`, which that file
deliberately requires someone to decide per column. It is a go2rtc stream
name, which is our own printer id echoed back, not a credential, and
having it in the list a client already fetches is what saves it from
calling the stream endpoint once per printer to discover that most of
them 404 by design.
Worth writing down, and it is in the route's comment too: `streamUrl` is
a LAN address and nothing relays it. A browser on the gateway's network
can play the stream and one outside cannot, and this endpoint cannot tell
which the caller is. Good enough for an operator in the workshop, a real
limit for anyone else, and a relay is the fix that does not exist yet.
85 tests (was 77), `bunx tsc --noEmit` clean.
|
||
|
|
564d01816b
|
Dispatch queued jobs, and derive their state from the printers
`POST /jobs` ended at a `queued` row and an upload URL; nothing moved it. This is the join, plus the two things that join turns out to need. ## The scheduler `src/dispatch` ticks every 5s: for each idle, claimed printer on a farm with a connected gateway, take the oldest unassigned queued job and send `start_print`. A timer rather than an event, because a job becomes dispatchable for two unrelated reasons - someone queued it, or a printer became free - and the second has no event this backend owns. It arrives as a telemetry snapshot, seconds after the fact. A scheduler that only ran on job creation would leave a queue stuck forever behind one busy printer. Claiming is an `UPDATE ... WHERE status = 'queued'` that returns the row, so two ticks cannot both win the same job. Selecting and then updating would let a tick and a nudge each believe they owned it and send two starts. It checks the bucket before dispatching. `POST /jobs` hands back a presigned PUT and returns, so the row exists before the file does; dispatching anyway would have the gateway download R2's 403 error document and give it to a printer as a project. A job whose file never arrives just stays queued, and a zero-byte upload fails outright because it will never become valid. The download URL is presigned at dispatch and not at creation - a queued job may wait days. ## Job state comes from telemetry, not from the gateway's ok This is the part that is not obvious and was learned the expensive way on 2026-10-15: a real print was accepted, uploaded byte-perfect, reached RUNNING, and then the printer paused itself on a physical broken-filament fault. A job marked `printing` on the gateway's reply would have claimed to be printing for the rest of the day. So `advanceJobsFrom` runs on every telemetry snapshot and the mapping lives in `job_status.ts`, apart from the database so every pair can be tested without a Postgres or a printer. The line that matters: an idle printer does *not* complete a job that is still `uploading`. A printer is idle for the entire transfer - minutes of it, at the 0.2 MiB/s measured over FTPS - so reading that as completion would mark every job successful seconds after dispatch, before the file reached the machine. 15 tests cover the table exhaustively. Because the gateway now acks a start immediately (it must - awaiting the transfer would stop its telemetry for minutes, and telemetry is what this depends on), a failure after the ack has no result message to travel on. Hence the reaper: a job `uploading` for 20 minutes is failed, not requeued, because the printer may actually be printing it and a requeue would put a second job on a working machine. ## Unclaimed printers are visible now `persistTelemetry` still refuses to create printer rows for gateway ids it does not recognise - a typo in a `printers.toml` becoming a permanent phantom printer is worse. But the refusal was silent, which made the worst first-run experience in the platform: gateway connects, reports happily, fleet stays empty, and the only evidence is in stderr. The observation is now kept and served by `GET /farms/:id/unclaimed-printers`, with `gatewayOnline` alongside it because an empty list otherwise cannot distinguish "all claimed" from "no gateway ever connected". Claiming is still `POST /printers`, still owner-only. 77 tests pass. |
||
|
|
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. |
||
|
|
b5382ab47c
|
Accept a telemetry stage, and prove the field is really there
`stage` comes from the shared schema, so this is contract tests only.
The test that matters asserts a *wrongly typed* stage is rejected rather than
only that a valid one is accepted. `PrinterTelemetrySchema` permits unknown
properties - verified empirically, `{ totally_bogus: 1 }` validates - so
`{ stage: 54 }` passes whether or not the field is declared, and a test built
only on the positive case would keep passing if someone deleted it.
Worth knowing separately: that open `additionalProperties` means a gateway
sending a misspelled field name is accepted and the value silently dropped,
which is the same drift this suite exists to catch. Not changed here, because
tightening it needs a check against what real gateways send.
|
||
|
|
398a739edd
|
Expose control_print, and say what the gateway's ok does not mean
The route's body is `PrinterCommandSchema` straight from the shared schema, so adding the variant there is what exposes it here - that is the point of taking the type from one place. What needed saying is in the comments: stop is a cancel with no undo, and a gateway returning ok means the printer accepted the command rather than that the print state changed, since an idle P1P answers success to all three verbs. A UI that puts stop where pause is expected, or that treats the 200 as confirmation, gets this wrong. The contract test gains the three control frames and one guard: a verb only one vendor recognises - `cancel`, Moonraker's word for it - must not validate, or it reaches a gateway that cannot map it. Mirrored in continuum-proxy/tests/uplink_protocol.rs. |
||
|
|
b42cbac299
|
Fix the frame producer's field names, and pipeline the batch
The other end of the `stream:camera_frames` drift. This side wrote `farmId`, `printerId`, `printJobId`, `r2ObjectKey` and `capturedAt`; `continuum-ai-worker` read `printer_id` and nothing else. Every task was delivered and acknowledged, and every frame was attributed to a printer called "unknown", with no error on either side - a stream entry is a flat string map, so there is nothing to parse and nothing to fail. The field names are now snake_case, from `schemas/continuum/v1/ai/frame_task.schema.json`. That is the convention this repo already follows for anything shared with a Rust consumer (see `/ws/edge/v1`), and it is not an inconsistency with the camelCase HTTP routes: this is not an HTTP route. `print_job_id` is omitted from the XADD when there is no job rather than written as `""`. An empty string is a value: a consumer that believes it looks up job id `""`, or puts it in a uuid column. A stream entry has no null, so "no value" has to mean "no field". The producer checks each task against the shared schema before writing it. The check is not for the database's sake - a stream accepts any field name - it is to make this exact class of drift loud. It earned its keep immediately by failing on every task with `Unknown format 'uuid'`: TypeBox ships no format validators and treats an *unregistered* format as a failure rather than skipping it, so a schema using `format: "uuid"` checked outside Elysia rejects everything. `src/validation/formats.ts` registers the two this codebase uses, guarded by `Has` so Elysia's own registrations still win for the route validators. (The HTTP routes were never affected - importing Elysia registers ~24 formats including `uuid`, which I confirmed by asserting a non-uuid body gets a 422.) A `frame_batch` now goes onto the stream in one pipelined round trip instead of one per frame, which was a round trip each while holding up the WebSocket message handler - and a farm produces roughly a frame per printer per second. ioredis reports per-command pipeline errors in the result tuple rather than throwing, so those are checked explicitly; a pipeline where one XADD failed otherwise looks like success. `frameTaskToStreamFields` and the compiled check live in their own module because `src/queue/index.ts` opens a Valkey connection on import. Keeping the wire format pure means `tests/frame_task_contract.test.ts` asserts it with nothing running. Tests, mirroring the worker's own so neither side can agree only with itself: the exact key set, values carried through unchanged, `print_job_id` present when there is a job and the key absent when there is not, no camelCase key ever emitted, and the uuid and date-time formats actually enforced - that last one fails if the format registration is lost (everything rejected) or becomes a no-op (nothing rejected). Plus `PrintAlertSchema`, which this repo will have to parse once the fan-out route exists. `scripts/e2e-ai-frames.sh` runs this producer against the real worker binary over real Valkey and asserts the field names by reading them back out of Valkey - rather than from either side's source, which is what made the original bug invisible. Two faults in the first draft of that script, both worth naming because a test that passes for the wrong reason is worse than no test: it stranded *every* entry on a dead consumer, so the ordinary delivery path was never exercised at all; and it grepped the subscriber output for "print_alert", which matched `redis-cli`'s own subscription confirmation line and reported a published alert that did not exist. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
e2c1e0abe2
|
Speak the gateway's actual protocol on /ws/edge/v1, and persist telemetry
This route and continuum-proxy had drifted into entirely different protocols. It expected an `auth` handshake the gateway never sends, one printer per telemetry message where the gateway sends a batch, UUID printer ids a gateway has no way to know, and camelCase field names against the gateway's snake_case. It had no way to send a command to a printer at all. Telemetry was acknowledged and thrown away - the handler said as much. It now implements schemas/continuum/v1/edge/ via its TypeBox mirror in @continuum/ts-types, so both ends mirror one written contract instead of each other's assumptions. The missing link was identity: a gateway knows its printers as "p1p", this database knows them as UUIDs, and nothing mapped between them. printers.gateway_printer_id is that mapping, unique per farm since two farms may each have a printer called "p1p". An unrecognised id is logged and skipped rather than inserted - a gateway should not be able to create printer records by mentioning them, or a typo in someone's printers.toml becomes a permanent phantom printer. Telemetry now lands in `printers` as a latest-value snapshot, extending the pattern status/nozzle_temp_c/bed_temp_c already used: target temperatures, progress, layers, remaining minutes, job name, active alert count. Also vendor_state alongside the narrowed status enum, because a state we do not recognise yet would otherwise be flattened and lost - and an unrecognised state deliberately leaves status untouched rather than falling back to "idle", since idle is what the scheduler reads as safe to send work to. Commands can now go down: sendPrinterCommand() takes our own printer id, translates it on the way out, and waits for the gateway's result, with a 10s timeout and a registry keyed by farm. Pending commands are failed immediately when a gateway disconnects rather than making every caller wait out the timeout for a socket that is already gone. POST /printers/:id/commands exposes it, validated against the same shared schema the gateway accepts, so this route cannot offer something no gateway can execute - which notably means there is still no way to start a print. tests/edge_protocol.test.ts is the contract test, run against real frames captured from a live gateway talking to a real P1P and H2C rather than hand-written examples - hand-written ones agree with whoever wrote them, which is how this drifted in the first place. The second fixture is the same capture from before the gateway was taught to omit empty fields, kept deliberately: the validator must keep accepting explicit nulls. Not yet applied to a database: this adds columns but no migration, and the repo has none committed. Run db:generate (or push) before the next deploy. |