9036d23e6ba07e432df512ae81a5214d2892858f
21
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9036d23e6b
|
Prove provisioning with the real proxy, and a real printer when one is named
e2e-provision.sh used a stand-in gateway; this drives the real continuum-proxy over the real socket, which is what catches the two ends disagreeing about the provision_printer frame. With no printer named it proves the failure path (a useful error returns, no row, nothing stored on the gateway, the code in neither process's log) and that the proxy starts and connects with no printers file at all. With REAL_HOST/REAL_SERIAL/ REAL_ACCESS_CODE it also proves the success path - telemetry under the new id, an owner-only store file, and the printer returning after a restart from the store alone. It only connects and listens; it sends no command. |
||
|
|
46bfecf3e7
|
Add a printer through the gateway without the cloud keeping its credential
POST /farms/:id/printers (owner) is the console's add-printer call. It needs a connected gateway and relays the printer's access code to it in a single provision_printer frame; the gateway connects to the printer to prove the code, saves it in its own database, and only then does the cloud create the printer row. A refused or timed-out add leaves no row, and a row insert that fails after the gateway said yes tells the gateway to forget the printer. The access code is held in the request and the one frame and nowhere else: not in any table, not in a log line, not in a response or an error (the gateway's error text is scrubbed of it as a second line of defence). Deleting a printer now also tells the gateway to forget it and its stored credential, best effort, so an unplugged gateway cannot block an owner. Only Bambu is accepted, because it is the only vendor with a working driver; the rest are found by discovery but cannot yet be driven. e2e-provision.sh drives a stand-in gateway over the real socket and, after adding a printer, searches every table of the database and the backend's log for the code (checked to fail when the code is logged). |
||
|
|
847f7cb686
|
Raise alerts: failed or paused prints, and AI detections
Nothing subscribed to channel:print_alerts and nothing watched a printer fail, so an operator away from the screen found out when they came back to a ruined plate. Alerts are now rows in printer_alerts, listed at GET /alerts (open=true for what still needs a person) and cleared with POST /alerts/:id/acknowledge, which is operator-only and keeps the first acknowledger on the record. Two sources. Telemetry: a change into error is a failed print if the printer was printing or paused, otherwise a printer error; a print the printer paused itself (how an AMS fault looks) is announced too. Transitions, not levels - a printer sitting in error is one alert, not one per frame - and a connection's first sight of a printer raises nothing, so a gateway that reconnects does not re-announce known faults. AI: the worker's Valkey publications are stored when confidence is at least ALERT_MIN_CONFIDENCE (default 0.6); the worker deliberately does not threshold, so the policy lives here. A bad message costs one message, not the listener. e2e-authz.sh gains the scoping/ack cases; new e2e-alerts.sh drives a stand-in gateway over the real /ws/edge/v1 and the real Valkey channel (checked to fail when the derivation is broken). |
||
|
|
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. |
||
|
|
705336b422
|
Add SERVE=1: hold the whole stack up for working on the dashboard
Building a dashboard needs the backend, the proxy with its go2rtc, the web console with hot reload and a seeded farm to stay up while it is being edited, and e2e-camera.sh already boots exactly that. SERVE=1 skips every check, seeds the user as owner (the dashboard has to command and manage the org) with every printer in printers.toml, named as that file names them, and holds until SIGTERM or Ctrl-C, when the usual cleanup removes the farm, the processes and the sign-in file. The password is written to a 0600 file under umask 077 and only the path is printed. The web console binds loopback unless APP_HOST says otherwise, because it serves a password sign-in. Verified: signing in through the real form against the live stack shows Live and both printers (the H2C live, the P1P honestly "offline, Never reported" because it was switched off); SIGTERM leaves no process, port, login file or seeded farm behind. |
||
|
|
c72bfe71d6
|
Show the picture in the real web console, in a real browser
Every earlier camera proof stood in for the browser with a werift peer. The question that decides whether the feature works is whether a person looking at the fleet page sees a picture, and the web console had never been opened. BROWSER=1 on scripts/e2e-camera.sh now starts the real Nuxt app and drives it in real Chrome (puppeteer-core): sign in, open the fleet page, wait for the camera player, and check the result. Measured against the H2C: the app's own Supabase client signs in, the header reports Live (the telemetry socket authenticated with the real token), the player reports connected, Chrome decodes 1680x1080 frames, playback time advances, the frame has picture content (mean luma 88, stdev 62.8), and the browser's own ICE stats show the selected candidate is a TURN relay. No uncaught page errors. The login page only offers a magic link by email, which a script cannot follow, so the driver signs in with the app's $supabase.client and a password just set on a throwaway user. That means AUTH=supabase: the backend verifies against the real project's JWKS rather than the local dev issuer, with e2e-supabase-user.ts creating or reusing continuum-e2e@example.invalid (a reserved .invalid address) and resetting its password each run. The JSON it prints carries a password and a live token, so the shell captures it and never echoes it. The frame is judged by pixel statistics and no screenshot is kept: it is the inside of someone's workshop, and "is there a picture" does not need one stored. The relay is read from getStats() through a wrapped RTCPeerConnection rather than inferred from configuration. A browser run with fewer than its nine results fails, so a driver that dies quietly cannot read as a pass. puppeteer-core is a devDependency for these scripts alone. Its 22 resolved packages are all Apache-2.0, BSD-3-Clause, ISC or MIT. |
||
|
|
e4940f9404
|
Prove the TURN-relayed camera path, the one the browser actually uses
The first e2e-camera.sh connected over direct host candidates. The real browser is pinned to iceTransportPolicy "relay", and PLAN.md recorded go2rtc's known trap for that configuration: a relay candidate on one side only can leave ICE stuck in `checking`. The script now runs the peer a second time pinned the same way, with the iceServers the backend hands out. The peer asserts its offer contains only `typ relay` candidates, so a pass cannot mean it connected directly, and never prints the credentials. Measured against the H2C: 937 packets (~943 KB) arrive through Cloudflare, and the trap did not occur. The phase is skipped with an explicit message when TURN credentials are absent, so a run without them cannot be mistaken for one that verified the relay. Both limits are written into the script header: viewer and gateway share a host, so this is not two distinct NATs, and no real browser has rendered a frame. |
||
|
|
ac484a99ba
|
Prove video flows: an e2e suite with a real WebRTC viewer
e2e-edge.sh proves the camera signalling relay round-trips, and says so honestly - it posts a stub SDP with go2rtc absent and accepts a 503 that came back from the gateway. But "the offer round-trips" says nothing about whether anyone would see a picture, and it once read as more than it was: a stray go2rtc left over from an earlier run answered the stub with an HTTP 500 and the test still printed ok. Video had never been shown to flow. scripts/e2e-camera.sh runs the real backend, the real proxy and the real go2rtc against the H2C, and scripts/e2e-camera-peer.ts plays the browser's part with werift: createOffer, relay through the backend, setRemoteDescription, then ICE, DTLS and SRTP, counting RTP out the far end. Measured: 124-149 packets and ~119-147 KB of payload within a second or two. It sends no printer command; the camera is a pull and the MQTT subscription is passive. It refuses to start if a go2rtc is already running, and fails if one survives cleanup. werift is a devDependency for that one file; nothing under src/ imports it. Its 30 resolved packages were audited against LICENSES.md: 26 MIT, Apache-2.0, BSD-3-Clause, Unlicense, and one MPL-2.0 (mediabunny), which the policy allows while we do not edit its files. Smaller fixes in the same pass: - e2e-edge.sh now unsets the TURN variables. It asserts TURN is off, but sourcing dev-secrets.sh turns it on, and the failure message then printed the live Cloudflare username and credential. The new script prints only the camera id and a count, never the /stream body. - the last hardcoded `psql -h localhost` in e2e-edge.sh now uses DATABASE_URL, like the rest. |
||
|
|
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. |
||
|
|
0e6a4aaa93
|
Record that this client cannot run behind pgCat 1.2.0, and stop lying about it
A real pooled database became available, so the deployment assumption in src/db/index.ts - "points at pgCat/PgBouncer, so keep prepared statements disabled" - was finally testable. It does not hold. Through pgCat 1.2.0 an unparameterised `select 1` succeeds, and so does `select count(*) from organisations`, but any query carrying a bind parameter hangs forever. That is true with prepare:false, with prepare:true, with fetch_types:false, and inside an explicit transaction. The same queries straight at Postgres all pass, and psql's \bind through the same pool returns its row, so the fault is specific to how postgres-js frames those messages rather than to extended protocol. Every Drizzle query binds parameters, so no subset of this app works there. drizzle-kit hung at commit for the same reason - it builds its own client and drizzle.config.ts cannot pass prepare:false - having executed all 14 statements of 0000, which then rolled back. Transaction-mode pooling itself was confirmed, not assumed: in one client session consecutive statements hopped between server backends while statements inside BEGIN..COMMIT stayed pinned to one. So the comment now records what was measured, names PgBouncer as the pooler this configuration actually suits, and says migrations go straight at Postgres. dev-env.sh's note gets the same correction. Two real bugs found on the way there: - e2e-authz.sh and e2e-edge.sh hardcoded `psql -h localhost -U continuum -d continuum` while both read DATABASE_URL for the backend under test. Point DATABASE_URL elsewhere and the suite seeds fixtures into one database while exercising another - and its own failure message says "no database at $DATABASE_URL", naming a URL nothing ever tried. Both now use `psql "$DATABASE_URL"`. - drizzle.config.ts fell back to port 6432, where nothing listens here, so a bare db:migrate failed confusingly. It now matches dev-env.sh's DEV_DATABASE_URL. bun test 128 pass / 0 fail; e2e-authz.sh PASS with 68 checks against local Postgres after the psql_q change. e2e-edge.sh was not re-run because it drives the real printers, and its change is the same line. |
||
|
|
adf77fe0ef
|
Add an e2e suite that runs against the real external services
The other e2e scripts stand in for S3, TURN and the auth issuer, and the stand-ins hide the failures that matter. e2e-authz.sh presigns with the literal credentials "e2e"/"e2e" and never sends the URL anywhere - but presigning is local HMAC, so it succeeds against any key, and a wrong endpoint, a missing bucket or a revoked token all pass it. The failure that motivated this already happened once: a gateway downloaded S3's XML 403 error document and handed it to a printer as a project file. So this one talks to the live devstack: a presigned PUT/HEAD/GET round-trip, a tampered signature answering the XML error document download.rs exists to refuse, Cloudflare minting ICE credentials for our key id, and a real Supabase user's ES256 token verified against the project's published JWKS before and after tampering, then spent on a route of the booted backend. The first draft of this script announced PASS while printing nothing: it captured results with `grep -E "^(ok|FAILED)\t"`, and POSIX ERE has no \t escape, so every check was silently dropped and FAILURES stayed 0. run_probe now matches a real tab, and a probe that returns fewer results than expected is itself a failure with its raw output shown - a check that cannot fail is worse than no check. Both modes verified: a nonexistent bucket and a probe that will not import each exit 1. Credentials come from the environment and are never printed; the devstack's live in the workspace root, outside every repo. |
||
|
|
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. |
||
|
|
dddfff6800
|
Say what the e2e's WebRTC assertion actually proves
go2rtc is not running in this test, so the gateway cannot reach it and the message "its go2rtc answered (as a refusal)" was wrong. What the assertion really establishes is that the offer travelled cloud -> uplink -> gateway at all, which a timeout would not. Records that the happy path was verified by hand - the real go2rtc and a valid offer return 200 with an answer SDP carrying go2rtc's own host and server-reflexive candidates - and why that is not automated here: it needs a live camera and go2rtc installed, and this script should not depend on either. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
d827b465cf
|
Commit the first migration, and apply schema the same way everywhere
`drizzle/` was gitignored, so every environment re-derived the schema from src/db/schema and nothing recorded what a given release expected. That is survivable while nothing is deployed and a problem the moment something is. The generated migration is now committed: four tables, three enums, five foreign keys, and the per-farm unique index on gateway_printer_id. It needed one hand-added line to be usable on its own. drizzle-kit does not emit extension statements, but frame_embeddings.embedding is vector(512) with an ivfflat index, so as generated the migration only applied to a database somebody had already run `CREATE EXTENSION vector` on - which was true locally precisely because dev-env.sh does that, and would not have been true on a fresh deploy. `CREATE EXTENSION IF NOT EXISTS vector` now leads the file, and it survives regeneration: drizzle only appends new migrations, and the diffing snapshot lives in meta/0000_snapshot.json rather than in the SQL. Verified by dropping the database, recreating it with no extensions at all (`pg_extension` count 0), and running db:migrate: the extension and all four tables were created, `db:generate` then reported no pending changes, and the e2e script still passed against both live printers on the result. `dev-env.sh schema` now runs `migrate` instead of `push`, so a local database and a deployed one get the schema by the same route - testing `push` locally while production runs `migrate` tests two different things. `push` is still available as its own command for iterating on a table that is still moving, with a comment about what it does not record. |
||
|
|
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. |