Compare commits

..
35 Commits
Author SHA1 Message Date
octoturge 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.
2026-10-29 14:35:40 +00:00
octoturge 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.
2026-10-29 10:17:54 +00:00
octoturge 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.
2026-10-29 10:13:28 +00:00
octoturge 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.
2026-10-24 11:47:24 +00:00
octoturge 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.
2026-10-24 08:06:58 +00:00
octoturge 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.
2026-10-23 16:01:47 +00:00
octoturge 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.
2026-10-20 10:24:19 +00:00
octoturge a9aec03d47 State that PgBouncer was measured, not assumed
The previous commit named PgBouncer as the pooler this client suits, from
background knowledge. It is now tested: PgBouncer 1.22 in transaction
mode (pool of 2, max_prepared_statements 0) passes every query that
hangs through pgCat 1.2.0, multiplexes 40 concurrent parameterised
queries over 2 server connections, and runs scripts/e2e-authz.sh to a
full PASS with 68 checks.
2026-10-20 10:18:27 +00:00
octoturge 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.
2026-10-20 09:39:39 +00:00
octoturge 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.
2026-10-20 08:57:37 +00:00
octoturge 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.
2026-10-20 00:44:35 +00:00
octoturge 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.
2026-10-19 08:20:58 +00:00
octoturge 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.
2026-10-19 08:07:22 +00:00
octoturge 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.
2026-10-19 00:37:27 +00:00
octoturge 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.
2026-10-19 00:33:18 +00:00
octoturge 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.
2026-10-18 22:22:41 +00:00
octoturge 8848e396cf Log the reason a late command result carries
The warning for a result with no pending command discarded `ok` and
`error` and said only that the id was unknown or expired - which is
exactly the information the gateway now sends when a long-running
command fails after being acknowledged. A `start_print` can only ever
report its real outcome late, since it is answered on acceptance and
this side times out after ten seconds.

Nothing else changes: the job never reaching `printing` is what fails
it. This is the difference between an operator seeing "job failed" and
seeing which half of a download-then-upload failed and what the printer
said.
2026-10-18 20:00:00 +00:00
octoturge 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.
2026-10-18 19:52:22 +00:00
octoturge 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.
2026-10-18 19:45:35 +00:00
octoturge e9083cc9e3 Cover the camera path in the edge e2e test
The camera work had unit tests on both ends and nothing exercising it
against a real printer. This adds that, and it immediately earned its
keep: it is the only test that covers `stream_base_url` surviving the
hello, living on an in-memory connection, and coming back out of
`GET /printers/:id/stream` - a path with no unit test behind it, because
the connection map is not reachable without a socket.

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

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

`CONTINUUM_STREAM_BASE_URL` is set to an unreachable name on purpose:
nothing here plays video, and it only has to prove the value made the
round trip. The gateway's go2rtc config is also pointed into the
scratch directory now, so a run no longer writes `go2rtc.yaml` into the
proxy checkout.
2026-10-18 13:35:10 +00:00
octoturge 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.
2026-10-17 13:59:56 +00:00
octoturge 7a0e8ba6ef Close an edge socket that never authenticates
The edge protocol carries its token in the first message rather than the
handshake, which is the right call for a WebSocket - a browser cannot set
headers, and a query parameter puts a bearer credential into every access
and proxy log. But the cost of that choice is a window in which an open
socket belongs to nobody, and nothing was closing it.

Every other message type already refuses to act without a `farmId`, so
such a socket could do nothing. It did sit in `connections` forever
though, and that map is keyed by whoever can reach the port.

Ten seconds, which is generous for a round trip a real gateway makes
immediately on open; a tighter bound would only drop gateways on slow
links. The timer is cleared after the token verifies and the farm
matches, so a client retrying a bad token on one socket still gets
dropped rather than being handed an unbounded number of attempts.

85 tests, `bunx tsc --noEmit` clean.
2026-10-16 06:42:56 +00:00
octoturge 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.
2026-10-15 23:09:22 +00:00
octoturge 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.
2026-10-15 22:54:44 +00:00
octoturge 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.
2026-10-15 21:43:16 +00:00
octoturge 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.
2026-10-15 21:37:20 +00:00
octoturge 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.
2026-10-11 17:38:42 +00:00
octoturge 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.
2026-10-11 16:59:46 +00:00
octoturge d8f8653dee Authenticate and authorize every route: org layer + Supabase JWKS
Until now every HTTP route was unauthenticated. A probe against the
running server returned a farm's `edgeProxyToken` and a printer's
`accessCode` to an anonymous caller, and that pair is enough to connect
as a farm's gateway and drive its printers. The previous commit stopped
the credentials appearing in responses; this one makes the routes ask
who is calling.

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

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

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

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

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

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

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

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

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

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

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

Two bugs found in the test scripts while writing this, both the same
shape as the earlier `pkill -f` incident: `kill "${PID:-0}"` with an
unset PID signals the whole process group, including the harness, and
`uuidgen` is not installed in this sandbox.
2026-10-03 15:49:29 +00:00
octoturge 01bcc73996 Stop returning credentials in API responses
`GET /printers` returned `accessCode` and `POST /farms` returned
`edgeProxyToken`, both to callers with no authentication of any kind. Verified
against a running server, not inferred:

    POST /farms    (no auth) -> "edgeProxyToken": "a39c1709-..."
    GET  /printers (no auth) -> {'accessCode': 'SECRET123', ...}

Those are not incidental fields. `accessCode` is the printer's own LAN
password, which Bambu uses for MQTT and as the FTPS password. `edgeProxyToken`
trades at POST /auth/edge-token for an edge JWT, which is enough to connect to
/ws/edge/v1 as that farm's gateway and send commands to its printers - a path
this repo's own e2e script walks end to end. So reading one unauthenticated
list was enough to take over a farm's machines.

src/db/projections.ts now defines, per table, which columns may appear in a
response, and the routes select those instead of `*`. Opt-in rather than a
deny-list: a column added later is excluded until somebody decides it should
be public, which is the safe direction to be wrong in. One helper builds both
shapes Drizzle needs - the `{ col: true }` map for the relational query API and
the column references for `.returning()` - from a single list of names, so the
two cannot drift.

`POST /farms/:id/edge-proxy-token` gives that credential one deliberate home.
It rotates and returns the new value rather than revealing the stored one: a
reveal endpoint hands the same secret to everyone who asks and leaves no trace,
whereas rotating makes a leaked token useless and reprovisioning the fix. The
cost is that it locks out a gateway still using the old token, which is the
right trade for a credential that drives physical machines.

This is defence in depth and NOT the fix. Every route here is still
unauthenticated and nothing scopes a farm to a caller - `ownerId` and
`submittedBy` are still taken from the request body, which means a client
declares who owns what. That is the next piece of work and it needs a decision
about the tenancy model first.
2026-10-03 15:21:37 +00:00
octoturge 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.
2026-10-03 15:17:42 +00:00
octoturge 42d4b3820a Add a runnable dev environment, and fix the pins that stopped it booting
The edge route had never been run. Adding a way to run it turned up that the
server could not start at all, for two independent dependency reasons that
typechecking could not see:

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

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

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

scripts/:

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

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

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

Note: `drizzle/` is gitignored, so the generated migration is not committed and
there is no migration history in the repo. Left as-is deliberately - that is a
workflow call, flagged in PLAN.md.
2026-10-03 12:17:10 +00:00
octoturge 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.
2026-10-03 10:37:41 +00:00
octoturge 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.
2026-10-03 10:10:28 +00:00
octoturge 349c4073cd Declare proprietary licensing
Continuum is intended to be sold, so every dependency has to permit
distribution in closed-source commercial software. This package carried
no licence field; `"UNLICENSED"` with `"private": true` is npm's
convention for that. The policy and the dependency audit are in
continuum-common's docs/LICENSES.md.

No `deny.toml` here - there are no cargo dependencies in this repo.
2026-10-03 10:10:28 +00:00
73 changed files with 10987 additions and 239 deletions
+46 -8
View File
@@ -1,22 +1,60 @@
PORT=3000
# Postgres, routed through pgCat/PgBouncer
DATABASE_URL=postgres://continuum:continuum@localhost:6432/continuum
# Postgres, directly - no pooler for the MVP (see src/db/index.ts)
DATABASE_URL=postgres://continuum:continuum@localhost:5432/continuum
DATABASE_POOL_MAX=10
# Redis (state + streams)
REDIS_URL=redis://localhost:6379
# Valkey (state + streams), NOT Redis - Redis 7.4+ is RSALv2/SSPL and 8.0+
# adds AGPLv3, none of which we can ship commercially. Valkey is BSD-3-Clause
# and wire-compatible. The URL scheme stays `redis://` because that names the
# RESP wire protocol, which is what the client libraries parse - no client
# understands a `valkey://` scheme.
VALKEY_URL=redis://localhost:6379
# Cloudflare R2 (S3-compatible)
# Cloudflare R2 (S3-compatible). The two credentials are required and the
# process will not start without them: an empty key signs presigned URLs that
# R2 answers with a 403, which surfaced as a gateway downloading an XML error
# document in place of a print rather than as "R2 is not configured".
#
# Either R2_ENDPOINT or R2_ACCOUNT_ID is needed too - the endpoint is derived
# from the account id the way Cloudflare's own URLs are formed. R2_BUCKET is
# not a credential and defaults to "continuum".
R2_ACCOUNT_ID=
R2_BUCKET=continuum
R2_ENDPOINT=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
# Supabase Auth
SUPABASE_JWT_SECRET=
# Cloudflare Realtime TURN, for relaying printer camera video.
#
# Optional, and unset is a supported state rather than a broken one: with no
# TURN the console can still play a camera for a viewer on the gateway's own
# network, over host candidates. Remote viewers need this.
#
# Why a relay at all: a browser cannot reach a gateway directly. go2rtc sends
# no CORS headers and checks its API password before CORS, so a browser's
# unauthenticated preflight gets a 401 - and a gateway is behind a workshop
# NAT besides. Only the SDP exchange is relayed by this backend; the media
# goes browser-to-gateway through TURN, DTLS-SRTP encrypted, so neither this
# process nor Cloudflare can read it.
#
# The browser is pinned to relay-only when these are set, so video never goes
# peer-to-peer and behaves the same on every customer network. Credentials
# are minted per request with a 1 hour TTL (src/camera/ice.ts).
# CLOUDFLARE_TURN_KEY_ID=
# CLOUDFLARE_TURN_API_TOKEN=
# Supabase Auth. Access tokens are verified against this project's published
# JWKS (RS256), so no signing secret is needed - or wanted - here.
SUPABASE_URL=
# Minted for continuum-proxy edge gateways via POST /auth/edge-token
# Signs the edge JWTs minted for continuum-proxy gateways via
# POST /auth/edge-token and verified by /ws/edge/v1. Required - the process
# refuses to start without it, because anything that knows this secret can
# connect as any farm's gateway. Generate one with: openssl rand -hex 32
EDGE_JWT_SECRET=
# The scheduler sends queued jobs to any idle, claimed printer in any farm.
# Set to "false" (exactly) on a stack wired to real printers that should not
# start prints - scripts/e2e-camera.sh does this for you. Unset = on.
# DISPATCH_ENABLED=false
-1
View File
@@ -4,6 +4,5 @@ bun.lockb
bun.lock
.env
.env.local
drizzle/
*.log
.DS_Store
+67 -11
View File
@@ -1,30 +1,86 @@
# continuum-backend
Cloud control plane for Continuum. Bun + ElysiaJS, PostgreSQL (via pgCat/PgBouncer,
with pgvector for frame embeddings), Redis Streams, and Cloudflare R2 storage.
Cloud control plane for Continuum. Bun + ElysiaJS, PostgreSQL (with pgvector for frame embeddings), Valkey Streams, and Cloudflare R2 storage.
## Run
```sh
bun install
cp .env.example .env # fill in DATABASE_URL, REDIS_URL, R2_*, SUPABASE_JWT_SECRET
bun run db:generate && bun run db:migrate
scripts/dev-env.sh up # Valkey + Postgres/pgvector, and print the URLs
scripts/dev-env.sh schema # apply drizzle/ - same as bun run db:migrate
cp .env.example .env # fill in the URLs it printed, plus R2_*, SUPABASE_JWT_SECRET
bun run dev
```
`scripts/dev-env.sh` uses `docker-compose.yml` when a Docker daemon is
answering, and falls back to the same two services from apt when there is no
usable container runtime - some sandboxes cannot run Docker at all, because
dockerd needs `CAP_NET_ADMIN` for its bridge and user namespaces to unpack an
image, and neither can be granted from inside. `scripts/dev-env.sh status`
says which backend is in use.
Schema changes go through migrations: edit `src/db/schema/`, run
`bun run db:generate`, and commit the file it writes to `drizzle/` along with
the schema change. `scripts/dev-env.sh push` reshapes a local database straight
from the schema with no migration file, which is faster while a table is still
moving but records nothing and will drop a column to make things match - so
generate before committing. `drizzle/0000_*.sql` creates the `vector` extension
itself; that line is hand-added, since drizzle-kit does not emit extension
statements and the migration would otherwise only apply to a database somebody
had already prepared.
## Test
```sh
bun test # unit + protocol contract tests, no services needed
scripts/e2e-edge.sh # the whole edge path: real database, real printers
```
`scripts/e2e-edge.sh` seeds a farm, claims its printers, mints an edge token,
runs the real `continuum-proxy` against this backend, and asserts that live
telemetry reached Postgres and that a command reached a printer. It only ever
sends a fan speed, and only to a printer reporting `IDLE`.
`scripts/capture-gateway-frames.js` is the smaller tool: it stands in for this
backend and records what a gateway sends, for refreshing the fixtures in
`tests/fixtures/`.
## Layout
- `src/db/` - Drizzle schema (`printers`, `farms`, `print_jobs`, `frame_embeddings`)
and client, pointed at pgCat. `src/db/validators.ts` derives Elysia/TypeBox
and client, pointed straight at Postgres. `src/db/validators.ts` derives Elysia/TypeBox
request validators straight from the Drizzle tables via `drizzle-typebox`,
so the Postgres schema and the API validators come from one definition
instead of two hand-synced ones.
- `src/s3/` - R2 presigned URL helpers (`PUT` for client uploads, `GET` for the
edge proxy).
- `src/queue/` - Redis client + `XADD` helper pushing onto `stream:camera_frames`
for `continuum-ai-worker`.
- `src/routes/` - `/auth`, `/farms`, `/printers`, `/jobs`, and the edge-gateway
WebSocket at `/ws/edge/v1`.
- `src/queue/` - Valkey client (`ioredis`) + `XADD` helper pushing onto
`stream:camera_frames` for `continuum-ai-worker`.
`frame_embeddings.embedding` requires the `vector` extension:
`CREATE EXTENSION IF NOT EXISTS vector;`
Valkey, not Redis: Redis 7.4+ is RSALv2/SSPL and 8.0+ adds AGPLv3, none of
which a commercial product can ship. Valkey is BSD-3-Clause and speaks the
same RESP protocol, so `ioredis` is unchanged - only the server differs. See
`continuum-common/docs/LICENSES.md`. `docker-compose.yml` here brings up
Valkey and Postgres+pgvector for local development.
- `src/routes/` - `/auth`, `/farms`, `/printers`, `/jobs`, and the edge-gateway
WebSocket at `/ws/edge/v1`. That route's wire format is not defined here: it
is `schemas/continuum/v1/edge/` in continuum-schemas, validated through its
TypeBox mirror in `@continuum/ts-types`, because both ends of it are separate
programs in different languages and they had already drifted apart once.
`printers.gateway_printer_id` is what maps a gateway's own printer ids
(`"p1p"`) onto rows here; telemetry for an unclaimed id is logged and skipped,
never inserted.
`frame_embeddings.embedding` requires the `vector` extension. The first
migration creates it, and `scripts/dev-env.sh up` also does so for the `push`
path, which does not run migrations.
## Dependency pins
`elysia`, `@sinclair/typebox` and the `drizzle-*` packages are pinned tightly
on purpose, not out of caution. Caret ranges here resolved into a combination
that could not boot: `elysia: ^1.1.26` installed 1.4.30, which calls
`t.Module` and so needs TypeBox >=0.34, while `drizzle-typebox: ^0.2.1`
installed a version whose peers demand `drizzle-orm >=0.36` against the
`^0.33.0` pinned here. Typechecking passed throughout - only starting the
server failed. `tests/dependency_identity.test.ts` guards the part of this that
can silently come back.
+44
View File
@@ -0,0 +1,44 @@
# Local development dependencies for continuum-backend.
#
# Valkey rather than Redis on purpose: Redis 7.4+ is RSALv2/SSPL and 8.0+
# adds AGPLv3, none of which a closed-source commercial product can ship.
# Valkey is BSD-3-Clause and speaks the same RESP protocol, so both clients
# (`ioredis` here, the `redis` crate in continuum-ai-worker) are unchanged.
# See continuum-common/docs/LICENSES.md.
#
# No pooler anywhere for the MVP: DATABASE_URL points straight at Postgres on
# :5432 here and in .env.example. See src/db/index.ts for why not pgCat.
services:
valkey:
image: valkey/valkey:8-alpine
ports:
- "6379:6379"
command: ["valkey-server", "--appendonly", "yes"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
retries: 5
postgres:
# pgvector/pgvector ships the extension prebuilt; the plain postgres
# image does not, and `frame_embeddings` needs it.
image: pgvector/pgvector:pg17
ports:
- "5432:5432"
environment:
POSTGRES_USER: continuum
POSTGRES_PASSWORD: continuum
POSTGRES_DB: continuum
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U continuum -d continuum"]
interval: 5s
retries: 5
volumes:
valkey-data:
postgres-data:
+4 -1
View File
@@ -5,7 +5,10 @@ export default defineConfig({
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://continuum:continuum@localhost:6432/continuum",
// Matches scripts/dev-env.sh's DEV_DATABASE_URL and .env.example: straight
// at Postgres. Migrations must never go through a pooler - drizzle-kit
// cannot pass `prepare: false`.
url: process.env.DATABASE_URL ?? "postgres://continuum:continuum@localhost:5432/continuum",
},
verbose: true,
strict: true,
+126
View File
@@ -0,0 +1,126 @@
-- Hand-added to the generated migration: drizzle-kit does not emit extension
-- statements, but frame_embeddings.embedding is vector(512) and its index is
-- ivfflat, so this migration cannot apply to a database without pgvector. A
-- committed migration has to stand on its own - relying on whoever set the
-- database up having run this first is how a deploy fails.
--
-- Safe to keep across regeneration: drizzle only ever appends new files, and
-- the diffing snapshot lives in meta/0000_snapshot.json, not here.
CREATE EXTENSION IF NOT EXISTS vector;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."printer_status" AS ENUM('offline', 'idle', 'printing', 'paused', 'error', 'maintenance');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."printer_vendor" AS ENUM('bambu', 'prusa', 'klipper');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "public"."print_job_status" AS ENUM('queued', 'uploading', 'printing', 'paused', 'completed', 'failed', 'cancelled');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "farms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"owner_id" uuid NOT NULL,
"edge_proxy_token" text NOT NULL,
"location" text,
"settings" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "printers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"farm_id" uuid NOT NULL,
"name" text NOT NULL,
"vendor" "printer_vendor" NOT NULL,
"status" "printer_status" DEFAULT 'offline' NOT NULL,
"lan_address" text,
"serial_number" text,
"access_code" text,
"gateway_printer_id" text,
"nozzle_temp_c" real,
"nozzle_target_temp_c" real,
"bed_temp_c" real,
"bed_target_temp_c" real,
"progress_percent" integer,
"current_layer" integer,
"total_layers" integer,
"remaining_minutes" integer,
"current_job_name" text,
"active_alerts" integer,
"vendor_state" text,
"vendor_error_code" integer,
"metadata" jsonb DEFAULT '{}'::jsonb,
"last_seen_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "print_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"farm_id" uuid NOT NULL,
"printer_id" uuid,
"file_name" text NOT NULL,
"r2_object_key" text NOT NULL,
"status" "print_job_status" DEFAULT 'queued' NOT NULL,
"progress_pct" real DEFAULT 0 NOT NULL,
"estimated_time_s" real,
"elapsed_time_s" real,
"submitted_by" uuid NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "frame_embeddings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"print_job_id" uuid NOT NULL,
"printer_id" uuid NOT NULL,
"r2_object_key" text NOT NULL,
"embedding" vector(512) NOT NULL,
"captured_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "printers" ADD CONSTRAINT "printers_farm_id_farms_id_fk" FOREIGN KEY ("farm_id") REFERENCES "public"."farms"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "print_jobs" ADD CONSTRAINT "print_jobs_farm_id_farms_id_fk" FOREIGN KEY ("farm_id") REFERENCES "public"."farms"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "print_jobs" ADD CONSTRAINT "print_jobs_printer_id_printers_id_fk" FOREIGN KEY ("printer_id") REFERENCES "public"."printers"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "frame_embeddings" ADD CONSTRAINT "frame_embeddings_print_job_id_print_jobs_id_fk" FOREIGN KEY ("print_job_id") REFERENCES "public"."print_jobs"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "frame_embeddings" ADD CONSTRAINT "frame_embeddings_printer_id_printers_id_fk" FOREIGN KEY ("printer_id") REFERENCES "public"."printers"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "printers_farm_gateway_printer_id_idx" ON "printers" USING btree ("farm_id","gateway_printer_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "frame_embeddings_embedding_ivfflat_idx" ON "frame_embeddings" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists=100);
+81
View File
@@ -0,0 +1,81 @@
-- Hand-edited after generation. drizzle-kit emitted
-- ALTER TABLE "farms" ADD COLUMN "org_id" uuid NOT NULL;
-- which cannot apply to a farms table that already has rows, and it emitted
-- the DROP of "owner_id" without moving the ownership it encodes anywhere -
-- so an existing database would either refuse the migration or silently lose
-- who owned each farm.
--
-- The order below is the fix: add the column nullable, derive one
-- organisation per distinct owner and make that owner its `owner` member,
-- point each farm at it, and only then tighten the column and drop
-- "owner_id". Edited statements are marked; the rest is generated output.
--
-- Safe to keep across regeneration: drizzle only appends new migration files
-- and diffs against meta/0001_snapshot.json, not against this SQL.
DO $$ BEGIN
CREATE TYPE "public"."org_role" AS ENUM('viewer', 'operator', 'owner');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "organisations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "org_members" (
"org_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" "org_role" DEFAULT 'viewer' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "org_members_org_id_user_id_pk" PRIMARY KEY("org_id","user_id")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "org_members" ADD CONSTRAINT "org_members_org_id_organisations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "org_members_user_id_idx" ON "org_members" USING btree ("user_id");--> statement-breakpoint
-- EDITED: generated as `uuid NOT NULL`. Nullable for now; tightened below,
-- once every row has a value.
ALTER TABLE "farms" ADD COLUMN "org_id" uuid;--> statement-breakpoint
-- EDITED (added): backfill. One organisation per distinct farms.owner_id,
-- with that user as its sole `owner` member. Farms that shared an owner end
-- up in one organisation together, which is what the old column meant.
--
-- A DO block rather than a temporary table so the whole thing is one
-- statement with no state outlasting it, and so RETURNING ... INTO can carry
-- each generated organisation id straight to the rows that need it.
DO $$
DECLARE
legacy_owner record;
new_org_id uuid;
BEGIN
FOR legacy_owner IN SELECT DISTINCT "owner_id" FROM "farms" LOOP
INSERT INTO "organisations" ("name")
VALUES ('Organisation ' || left(legacy_owner."owner_id"::text, 8))
RETURNING "id" INTO new_org_id;
INSERT INTO "org_members" ("org_id", "user_id", "role")
VALUES (new_org_id, legacy_owner."owner_id", 'owner');
UPDATE "farms" SET "org_id" = new_org_id WHERE "owner_id" = legacy_owner."owner_id";
END LOOP;
END $$;
--> statement-breakpoint
-- EDITED (added): now that the backfill has run, enforce what the schema says.
ALTER TABLE "farms" ALTER COLUMN "org_id" SET NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "farms" ADD CONSTRAINT "farms_org_id_organisations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "farms_org_id_idx" ON "farms" USING btree ("org_id");--> statement-breakpoint
ALTER TABLE "farms" DROP COLUMN IF EXISTS "owner_id";
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "printers" ADD COLUMN "camera_stream" text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "printers" DROP COLUMN IF EXISTS "access_code";
+544
View File
@@ -0,0 +1,544 @@
{
"id": "f60fed84-9b69-403f-a789-9e29cee970f1",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.farms": {
"name": "farms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_id": {
"name": "owner_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"edge_proxy_token": {
"name": "edge_proxy_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.printers": {
"name": "printers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"farm_id": {
"name": "farm_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"vendor": {
"name": "vendor",
"type": "printer_vendor",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "printer_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'offline'"
},
"lan_address": {
"name": "lan_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"serial_number": {
"name": "serial_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_code": {
"name": "access_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gateway_printer_id": {
"name": "gateway_printer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"nozzle_temp_c": {
"name": "nozzle_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"nozzle_target_temp_c": {
"name": "nozzle_target_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"bed_temp_c": {
"name": "bed_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"bed_target_temp_c": {
"name": "bed_target_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"progress_percent": {
"name": "progress_percent",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_layer": {
"name": "current_layer",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"total_layers": {
"name": "total_layers",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"remaining_minutes": {
"name": "remaining_minutes",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_job_name": {
"name": "current_job_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active_alerts": {
"name": "active_alerts",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"vendor_state": {
"name": "vendor_state",
"type": "text",
"primaryKey": false,
"notNull": false
},
"vendor_error_code": {
"name": "vendor_error_code",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"printers_farm_gateway_printer_id_idx": {
"name": "printers_farm_gateway_printer_id_idx",
"columns": [
{
"expression": "farm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "gateway_printer_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"printers_farm_id_farms_id_fk": {
"name": "printers_farm_id_farms_id_fk",
"tableFrom": "printers",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.print_jobs": {
"name": "print_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"farm_id": {
"name": "farm_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"printer_id": {
"name": "printer_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"r2_object_key": {
"name": "r2_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "print_job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"progress_pct": {
"name": "progress_pct",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"estimated_time_s": {
"name": "estimated_time_s",
"type": "real",
"primaryKey": false,
"notNull": false
},
"elapsed_time_s": {
"name": "elapsed_time_s",
"type": "real",
"primaryKey": false,
"notNull": false
},
"submitted_by": {
"name": "submitted_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"print_jobs_farm_id_farms_id_fk": {
"name": "print_jobs_farm_id_farms_id_fk",
"tableFrom": "print_jobs",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"print_jobs_printer_id_printers_id_fk": {
"name": "print_jobs_printer_id_printers_id_fk",
"tableFrom": "print_jobs",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.frame_embeddings": {
"name": "frame_embeddings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"print_job_id": {
"name": "print_job_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"printer_id": {
"name": "printer_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"r2_object_key": {
"name": "r2_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(512)",
"primaryKey": false,
"notNull": true
},
"captured_at": {
"name": "captured_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"frame_embeddings_embedding_ivfflat_idx": {
"name": "frame_embeddings_embedding_ivfflat_idx",
"columns": [
{
"expression": "embedding",
"isExpression": false,
"asc": true,
"nulls": "last",
"opclass": "vector_cosine_ops"
}
],
"isUnique": false,
"concurrently": false,
"method": "ivfflat",
"with": {
"lists": 100
}
}
},
"foreignKeys": {
"frame_embeddings_print_job_id_print_jobs_id_fk": {
"name": "frame_embeddings_print_job_id_print_jobs_id_fk",
"tableFrom": "frame_embeddings",
"tableTo": "print_jobs",
"columnsFrom": [
"print_job_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"frame_embeddings_printer_id_printers_id_fk": {
"name": "frame_embeddings_printer_id_printers_id_fk",
"tableFrom": "frame_embeddings",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {
"public.printer_status": {
"name": "printer_status",
"schema": "public",
"values": [
"offline",
"idle",
"printing",
"paused",
"error",
"maintenance"
]
},
"public.printer_vendor": {
"name": "printer_vendor",
"schema": "public",
"values": [
"bambu",
"prusa",
"klipper"
]
},
"public.print_job_status": {
"name": "print_job_status",
"schema": "public",
"values": [
"queued",
"uploading",
"printing",
"paused",
"completed",
"failed",
"cancelled"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+709
View File
@@ -0,0 +1,709 @@
{
"id": "55f32571-d0c7-46a5-b763-ea7bef84faeb",
"prevId": "f60fed84-9b69-403f-a789-9e29cee970f1",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.org_members": {
"name": "org_members",
"schema": "",
"columns": {
"org_id": {
"name": "org_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "org_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'viewer'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"org_members_user_id_idx": {
"name": "org_members_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"org_members_org_id_organisations_id_fk": {
"name": "org_members_org_id_organisations_id_fk",
"tableFrom": "org_members",
"tableTo": "organisations",
"columnsFrom": [
"org_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"org_members_org_id_user_id_pk": {
"name": "org_members_org_id_user_id_pk",
"columns": [
"org_id",
"user_id"
]
}
},
"uniqueConstraints": {}
},
"public.organisations": {
"name": "organisations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.farms": {
"name": "farms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"org_id": {
"name": "org_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"edge_proxy_token": {
"name": "edge_proxy_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"farms_org_id_idx": {
"name": "farms_org_id_idx",
"columns": [
{
"expression": "org_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"farms_org_id_organisations_id_fk": {
"name": "farms_org_id_organisations_id_fk",
"tableFrom": "farms",
"tableTo": "organisations",
"columnsFrom": [
"org_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.printers": {
"name": "printers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"farm_id": {
"name": "farm_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"vendor": {
"name": "vendor",
"type": "printer_vendor",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "printer_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'offline'"
},
"lan_address": {
"name": "lan_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"serial_number": {
"name": "serial_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_code": {
"name": "access_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gateway_printer_id": {
"name": "gateway_printer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"nozzle_temp_c": {
"name": "nozzle_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"nozzle_target_temp_c": {
"name": "nozzle_target_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"bed_temp_c": {
"name": "bed_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"bed_target_temp_c": {
"name": "bed_target_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"progress_percent": {
"name": "progress_percent",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_layer": {
"name": "current_layer",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"total_layers": {
"name": "total_layers",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"remaining_minutes": {
"name": "remaining_minutes",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_job_name": {
"name": "current_job_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active_alerts": {
"name": "active_alerts",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"vendor_state": {
"name": "vendor_state",
"type": "text",
"primaryKey": false,
"notNull": false
},
"vendor_error_code": {
"name": "vendor_error_code",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"printers_farm_gateway_printer_id_idx": {
"name": "printers_farm_gateway_printer_id_idx",
"columns": [
{
"expression": "farm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "gateway_printer_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"printers_farm_id_farms_id_fk": {
"name": "printers_farm_id_farms_id_fk",
"tableFrom": "printers",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.print_jobs": {
"name": "print_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"farm_id": {
"name": "farm_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"printer_id": {
"name": "printer_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"r2_object_key": {
"name": "r2_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "print_job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"progress_pct": {
"name": "progress_pct",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"estimated_time_s": {
"name": "estimated_time_s",
"type": "real",
"primaryKey": false,
"notNull": false
},
"elapsed_time_s": {
"name": "elapsed_time_s",
"type": "real",
"primaryKey": false,
"notNull": false
},
"submitted_by": {
"name": "submitted_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"print_jobs_farm_id_farms_id_fk": {
"name": "print_jobs_farm_id_farms_id_fk",
"tableFrom": "print_jobs",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"print_jobs_printer_id_printers_id_fk": {
"name": "print_jobs_printer_id_printers_id_fk",
"tableFrom": "print_jobs",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.frame_embeddings": {
"name": "frame_embeddings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"print_job_id": {
"name": "print_job_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"printer_id": {
"name": "printer_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"r2_object_key": {
"name": "r2_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(512)",
"primaryKey": false,
"notNull": true
},
"captured_at": {
"name": "captured_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"frame_embeddings_embedding_ivfflat_idx": {
"name": "frame_embeddings_embedding_ivfflat_idx",
"columns": [
{
"expression": "embedding",
"isExpression": false,
"asc": true,
"nulls": "last",
"opclass": "vector_cosine_ops"
}
],
"isUnique": false,
"concurrently": false,
"method": "ivfflat",
"with": {
"lists": 100
}
}
},
"foreignKeys": {
"frame_embeddings_print_job_id_print_jobs_id_fk": {
"name": "frame_embeddings_print_job_id_print_jobs_id_fk",
"tableFrom": "frame_embeddings",
"tableTo": "print_jobs",
"columnsFrom": [
"print_job_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"frame_embeddings_printer_id_printers_id_fk": {
"name": "frame_embeddings_printer_id_printers_id_fk",
"tableFrom": "frame_embeddings",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {
"public.org_role": {
"name": "org_role",
"schema": "public",
"values": [
"viewer",
"operator",
"owner"
]
},
"public.printer_status": {
"name": "printer_status",
"schema": "public",
"values": [
"offline",
"idle",
"printing",
"paused",
"error",
"maintenance"
]
},
"public.printer_vendor": {
"name": "printer_vendor",
"schema": "public",
"values": [
"bambu",
"prusa",
"klipper"
]
},
"public.print_job_status": {
"name": "print_job_status",
"schema": "public",
"values": [
"queued",
"uploading",
"printing",
"paused",
"completed",
"failed",
"cancelled"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+715
View File
@@ -0,0 +1,715 @@
{
"id": "95497aac-876c-40e4-9683-f7a177fa5177",
"prevId": "55f32571-d0c7-46a5-b763-ea7bef84faeb",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.org_members": {
"name": "org_members",
"schema": "",
"columns": {
"org_id": {
"name": "org_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "org_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'viewer'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"org_members_user_id_idx": {
"name": "org_members_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"org_members_org_id_organisations_id_fk": {
"name": "org_members_org_id_organisations_id_fk",
"tableFrom": "org_members",
"tableTo": "organisations",
"columnsFrom": [
"org_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"org_members_org_id_user_id_pk": {
"name": "org_members_org_id_user_id_pk",
"columns": [
"org_id",
"user_id"
]
}
},
"uniqueConstraints": {}
},
"public.organisations": {
"name": "organisations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.farms": {
"name": "farms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"org_id": {
"name": "org_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"edge_proxy_token": {
"name": "edge_proxy_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"farms_org_id_idx": {
"name": "farms_org_id_idx",
"columns": [
{
"expression": "org_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"farms_org_id_organisations_id_fk": {
"name": "farms_org_id_organisations_id_fk",
"tableFrom": "farms",
"tableTo": "organisations",
"columnsFrom": [
"org_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.printers": {
"name": "printers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"farm_id": {
"name": "farm_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"vendor": {
"name": "vendor",
"type": "printer_vendor",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "printer_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'offline'"
},
"lan_address": {
"name": "lan_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"serial_number": {
"name": "serial_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_code": {
"name": "access_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gateway_printer_id": {
"name": "gateway_printer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"nozzle_temp_c": {
"name": "nozzle_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"nozzle_target_temp_c": {
"name": "nozzle_target_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"bed_temp_c": {
"name": "bed_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"bed_target_temp_c": {
"name": "bed_target_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"progress_percent": {
"name": "progress_percent",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_layer": {
"name": "current_layer",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"total_layers": {
"name": "total_layers",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"remaining_minutes": {
"name": "remaining_minutes",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_job_name": {
"name": "current_job_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active_alerts": {
"name": "active_alerts",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"camera_stream": {
"name": "camera_stream",
"type": "text",
"primaryKey": false,
"notNull": false
},
"vendor_state": {
"name": "vendor_state",
"type": "text",
"primaryKey": false,
"notNull": false
},
"vendor_error_code": {
"name": "vendor_error_code",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"printers_farm_gateway_printer_id_idx": {
"name": "printers_farm_gateway_printer_id_idx",
"columns": [
{
"expression": "farm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "gateway_printer_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"printers_farm_id_farms_id_fk": {
"name": "printers_farm_id_farms_id_fk",
"tableFrom": "printers",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.print_jobs": {
"name": "print_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"farm_id": {
"name": "farm_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"printer_id": {
"name": "printer_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"r2_object_key": {
"name": "r2_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "print_job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"progress_pct": {
"name": "progress_pct",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"estimated_time_s": {
"name": "estimated_time_s",
"type": "real",
"primaryKey": false,
"notNull": false
},
"elapsed_time_s": {
"name": "elapsed_time_s",
"type": "real",
"primaryKey": false,
"notNull": false
},
"submitted_by": {
"name": "submitted_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"print_jobs_farm_id_farms_id_fk": {
"name": "print_jobs_farm_id_farms_id_fk",
"tableFrom": "print_jobs",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"print_jobs_printer_id_printers_id_fk": {
"name": "print_jobs_printer_id_printers_id_fk",
"tableFrom": "print_jobs",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.frame_embeddings": {
"name": "frame_embeddings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"print_job_id": {
"name": "print_job_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"printer_id": {
"name": "printer_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"r2_object_key": {
"name": "r2_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(512)",
"primaryKey": false,
"notNull": true
},
"captured_at": {
"name": "captured_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"frame_embeddings_embedding_ivfflat_idx": {
"name": "frame_embeddings_embedding_ivfflat_idx",
"columns": [
{
"expression": "embedding",
"isExpression": false,
"asc": true,
"nulls": "last",
"opclass": "vector_cosine_ops"
}
],
"isUnique": false,
"concurrently": false,
"method": "ivfflat",
"with": {
"lists": 100
}
}
},
"foreignKeys": {
"frame_embeddings_print_job_id_print_jobs_id_fk": {
"name": "frame_embeddings_print_job_id_print_jobs_id_fk",
"tableFrom": "frame_embeddings",
"tableTo": "print_jobs",
"columnsFrom": [
"print_job_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"frame_embeddings_printer_id_printers_id_fk": {
"name": "frame_embeddings_printer_id_printers_id_fk",
"tableFrom": "frame_embeddings",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {
"public.org_role": {
"name": "org_role",
"schema": "public",
"values": [
"viewer",
"operator",
"owner"
]
},
"public.printer_status": {
"name": "printer_status",
"schema": "public",
"values": [
"offline",
"idle",
"printing",
"paused",
"error",
"maintenance"
]
},
"public.printer_vendor": {
"name": "printer_vendor",
"schema": "public",
"values": [
"bambu",
"prusa",
"klipper"
]
},
"public.print_job_status": {
"name": "print_job_status",
"schema": "public",
"values": [
"queued",
"uploading",
"printing",
"paused",
"completed",
"failed",
"cancelled"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+709
View File
@@ -0,0 +1,709 @@
{
"id": "e4751490-d3cf-44ca-8807-f7dba03415ea",
"prevId": "95497aac-876c-40e4-9683-f7a177fa5177",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.org_members": {
"name": "org_members",
"schema": "",
"columns": {
"org_id": {
"name": "org_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "org_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'viewer'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"org_members_user_id_idx": {
"name": "org_members_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"org_members_org_id_organisations_id_fk": {
"name": "org_members_org_id_organisations_id_fk",
"tableFrom": "org_members",
"tableTo": "organisations",
"columnsFrom": [
"org_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"org_members_org_id_user_id_pk": {
"name": "org_members_org_id_user_id_pk",
"columns": [
"org_id",
"user_id"
]
}
},
"uniqueConstraints": {}
},
"public.organisations": {
"name": "organisations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.farms": {
"name": "farms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"org_id": {
"name": "org_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"edge_proxy_token": {
"name": "edge_proxy_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"farms_org_id_idx": {
"name": "farms_org_id_idx",
"columns": [
{
"expression": "org_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"farms_org_id_organisations_id_fk": {
"name": "farms_org_id_organisations_id_fk",
"tableFrom": "farms",
"tableTo": "organisations",
"columnsFrom": [
"org_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.printers": {
"name": "printers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"farm_id": {
"name": "farm_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"vendor": {
"name": "vendor",
"type": "printer_vendor",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "printer_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'offline'"
},
"lan_address": {
"name": "lan_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"serial_number": {
"name": "serial_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gateway_printer_id": {
"name": "gateway_printer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"nozzle_temp_c": {
"name": "nozzle_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"nozzle_target_temp_c": {
"name": "nozzle_target_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"bed_temp_c": {
"name": "bed_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"bed_target_temp_c": {
"name": "bed_target_temp_c",
"type": "real",
"primaryKey": false,
"notNull": false
},
"progress_percent": {
"name": "progress_percent",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_layer": {
"name": "current_layer",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"total_layers": {
"name": "total_layers",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"remaining_minutes": {
"name": "remaining_minutes",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"current_job_name": {
"name": "current_job_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active_alerts": {
"name": "active_alerts",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"camera_stream": {
"name": "camera_stream",
"type": "text",
"primaryKey": false,
"notNull": false
},
"vendor_state": {
"name": "vendor_state",
"type": "text",
"primaryKey": false,
"notNull": false
},
"vendor_error_code": {
"name": "vendor_error_code",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"printers_farm_gateway_printer_id_idx": {
"name": "printers_farm_gateway_printer_id_idx",
"columns": [
{
"expression": "farm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "gateway_printer_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"printers_farm_id_farms_id_fk": {
"name": "printers_farm_id_farms_id_fk",
"tableFrom": "printers",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.print_jobs": {
"name": "print_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"farm_id": {
"name": "farm_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"printer_id": {
"name": "printer_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"r2_object_key": {
"name": "r2_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "print_job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"progress_pct": {
"name": "progress_pct",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"estimated_time_s": {
"name": "estimated_time_s",
"type": "real",
"primaryKey": false,
"notNull": false
},
"elapsed_time_s": {
"name": "elapsed_time_s",
"type": "real",
"primaryKey": false,
"notNull": false
},
"submitted_by": {
"name": "submitted_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"print_jobs_farm_id_farms_id_fk": {
"name": "print_jobs_farm_id_farms_id_fk",
"tableFrom": "print_jobs",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"print_jobs_printer_id_printers_id_fk": {
"name": "print_jobs_printer_id_printers_id_fk",
"tableFrom": "print_jobs",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.frame_embeddings": {
"name": "frame_embeddings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"print_job_id": {
"name": "print_job_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"printer_id": {
"name": "printer_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"r2_object_key": {
"name": "r2_object_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(512)",
"primaryKey": false,
"notNull": true
},
"captured_at": {
"name": "captured_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"frame_embeddings_embedding_ivfflat_idx": {
"name": "frame_embeddings_embedding_ivfflat_idx",
"columns": [
{
"expression": "embedding",
"isExpression": false,
"asc": true,
"nulls": "last",
"opclass": "vector_cosine_ops"
}
],
"isUnique": false,
"concurrently": false,
"method": "ivfflat",
"with": {
"lists": 100
}
}
},
"foreignKeys": {
"frame_embeddings_print_job_id_print_jobs_id_fk": {
"name": "frame_embeddings_print_job_id_print_jobs_id_fk",
"tableFrom": "frame_embeddings",
"tableTo": "print_jobs",
"columnsFrom": [
"print_job_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"frame_embeddings_printer_id_printers_id_fk": {
"name": "frame_embeddings_printer_id_printers_id_fk",
"tableFrom": "frame_embeddings",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {
"public.org_role": {
"name": "org_role",
"schema": "public",
"values": [
"viewer",
"operator",
"owner"
]
},
"public.printer_status": {
"name": "printer_status",
"schema": "public",
"values": [
"offline",
"idle",
"printing",
"paused",
"error",
"maintenance"
]
},
"public.printer_vendor": {
"name": "printer_vendor",
"schema": "public",
"values": [
"bambu",
"prusa",
"klipper"
]
},
"public.print_job_status": {
"name": "print_job_status",
"schema": "public",
"values": [
"queued",
"uploading",
"printing",
"paused",
"completed",
"failed",
"cancelled"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1791029521420,
"tag": "0000_watery_firedrake",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1791041832172,
"tag": "0001_luxuriant_jane_foster",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1792105504827,
"tag": "0002_lucky_stepford_cuckoos",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1792399397819,
"tag": "0003_colorful_electro",
"breakpoints": true
}
]
}
+15 -9
View File
@@ -1,5 +1,6 @@
{
"name": "@continuum/backend",
"license": "UNLICENSED",
"version": "0.1.0",
"private": true,
"type": "module",
@@ -9,24 +10,29 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"elysia": "^1.1.26",
"@elysiajs/cors": "^1.1.1",
"@elysiajs/jwt": "^1.1.1",
"@elysiajs/swagger": "^1.1.6",
"@continuum/ts-types": "file:../continuum-schemas/packages/ts-types",
"@continuum/ts-core": "file:../continuum-common/packages/ts-core",
"elysia": "~1.4.30",
"@elysiajs/cors": "~1.4.2",
"@elysiajs/jwt": "~1.4.2",
"@elysiajs/swagger": "~1.3.1",
"@aws-sdk/client-s3": "^3.658.1",
"@aws-sdk/s3-request-presigner": "^3.658.1",
"drizzle-orm": "^0.33.0",
"drizzle-typebox": "^0.2.1",
"@sinclair/typebox": "^0.33.12",
"drizzle-orm": "0.33.0",
"drizzle-typebox": "0.1.1",
"@sinclair/typebox": "0.34.52",
"postgres": "^3.4.4",
"ioredis": "^5.4.1"
},
"devDependencies": {
"drizzle-kit": "^0.24.2",
"bun-types": "^1.1.10",
"typescript": "^5.6.2"
"drizzle-kit": "0.24.2",
"puppeteer-core": "^25.16.0",
"typescript": "^5.6.2",
"werift": "^0.25.1"
}
}
+108
View File
@@ -0,0 +1,108 @@
// Stands in for this backend's /ws/edge/v1 and records every frame a real
// gateway sends, one JSON object per line.
//
// What it is for: refreshing tests/fixtures/gateway_frames.json. Those fixtures
// are deliberately real captures rather than hand-written examples - a
// hand-written example agrees with whoever wrote it, which is how the two ends
// of this protocol drifted into incompatible shapes without anything failing.
// So when the protocol changes, re-capture rather than editing the fixture.
//
// It needs no database and no auth, which is the point: it isolates the
// gateway's side of the wire from everything else. For the full path through a
// real database and real printers, use e2e-edge.sh instead.
//
// Usage:
// bun run scripts/capture-gateway-frames.js > frames.jsonl
// # then, in continuum-proxy, with its CONTINUUM_* env set:
// CONTINUUM_UPLINK_URL=ws://localhost:59999/ws/edge/v1 cargo run
//
// PORT=59999 where to listen
// FAN_TEST_PRINTER=p1p send one set_fan_speed to this printer, to capture a
// command_result. Leave unset to stay read-only.
//
// SAFETY: with FAN_TEST_PRINTER set this writes to a real printer. Only ever a
// fan speed, and only to the printer you name - but name an idle one.
const PORT = Number(process.env.PORT ?? 59999);
const FAN_TEST_PRINTER = process.env.FAN_TEST_PRINTER;
let commandSent = false;
Bun.serve({
port: PORT,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("this endpoint only speaks WebSocket\n", { status: 400 });
},
websocket: {
open() {
console.error(`[capture] gateway connected`);
},
message(ws, raw) {
// stdout is the capture, stderr is the commentary, so the two can be
// redirected separately.
console.log(typeof raw === "string" ? raw : raw.toString());
let msg;
try {
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString());
} catch (err) {
console.error(`[capture] not JSON, recorded anyway: ${err}`);
return;
}
switch (msg.type) {
case "hello":
// No token check: this is not the backend, it is a recorder.
console.error(`[capture] hello from ${msg.gateway_id} (farm ${msg.farm_id})`);
ws.send(JSON.stringify({ type: "hello_ack", session_id: "capture" }));
break;
case "heartbeat":
ws.send(JSON.stringify({ type: "heartbeat_ack", seq: msg.seq }));
break;
case "telemetry":
console.error(
`[capture] telemetry: ${msg.printers
.map((p) => `${p.printer_id}=${p.state ?? "?"}`)
.join(" ")}`,
);
if (FAN_TEST_PRINTER && !commandSent) {
commandSent = true;
const reported = msg.printers.find((p) => p.printer_id === FAN_TEST_PRINTER);
// Refuse to touch a printer that is not idle, and refuse to guess
// at one the gateway has not reported at all.
if (!reported) {
console.error(`[capture] '${FAN_TEST_PRINTER}' not in this batch, not sending`);
} else if (reported.state !== "IDLE" && reported.state !== "FINISH") {
console.error(`[capture] '${FAN_TEST_PRINTER}' is ${reported.state}, not sending`);
} else {
console.error(`[capture] -> set_fan_speed 0 to ${FAN_TEST_PRINTER}`);
ws.send(JSON.stringify({
type: "command",
command_id: "capture-1",
printer_id: FAN_TEST_PRINTER,
command: { action: "set_fan_speed", percent: 0 },
}));
}
}
break;
case "command_result":
console.error(`[capture] command_result ${msg.command_id}: ok=${msg.ok}`);
break;
default:
console.error(`[capture] ${msg.type}`);
}
},
close() {
console.error("[capture] gateway disconnected");
},
},
});
console.error(`[capture] listening on :${PORT} - frames to stdout, commentary to stderr`);
if (!FAN_TEST_PRINTER) {
console.error("[capture] read-only (set FAN_TEST_PRINTER to also capture a command_result)");
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bun
/**
* A stand-in for Supabase Auth: an RS256 issuer that publishes a JWKS at the
* same path a Supabase project does, and mints access tokens on request.
*
* This exists so the *real* verification path is testable. The backend
* verifies tokens with `createRemoteJWKSet` against
* `$SUPABASE_URL/auth/v1/.well-known/jwks.json`, and the alternative to
* standing that endpoint up locally is a test-only bypass in the backend -
* an env var that makes it trust an unsigned token. That bypass would be a
* permanent hole one misconfiguration away from production, and it would mean
* the code paths under test are not the code paths that ship.
*
* Point the backend at it with `SUPABASE_URL=http://localhost:<port>`.
*
* Usage as a server:
* bun scripts/dev-auth.ts [--port 4545]
* GET /auth/v1/.well-known/jwks.json the public keys
* GET /token?sub=<uuid>&email=<email> a signed access token
*
* Usage from a test:
* const issuer = await startDevAuthIssuer();
* const token = await issuer.mint({ sub: userId });
* issuer.stop();
*/
import { SignJWT, exportJWK, generateKeyPair, calculateJwkThumbprint } from "jose";
export interface MintOptions {
sub: string;
email?: string;
/** Seconds until expiry. Negative values mint an already-expired token. */
expiresIn?: number;
/** Overrides the `aud` claim; the backend requires "authenticated". */
audience?: string;
}
export async function startDevAuthIssuer(port = 0) {
const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true });
const publicJwk = await exportJWK(publicKey);
const kid = await calculateJwkThumbprint(publicJwk);
const jwks = { keys: [{ ...publicJwk, kid, alg: "RS256", use: "sig" }] };
async function mint(options: MintOptions): Promise<string> {
const expiresIn = options.expiresIn ?? 3600;
return new SignJWT({ sub: options.sub, email: options.email, role: "authenticated" })
.setProtectedHeader({ alg: "RS256", kid })
.setIssuedAt()
.setAudience(options.audience ?? "authenticated")
.setExpirationTime(Math.floor(Date.now() / 1000) + expiresIn)
.sign(privateKey);
}
const server = Bun.serve({
port,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/auth/v1/.well-known/jwks.json") {
return Response.json(jwks);
}
if (url.pathname === "/token") {
const sub = url.searchParams.get("sub");
if (!sub) return new Response("?sub=<uuid> is required", { status: 400 });
const expiresIn = url.searchParams.get("expiresIn");
return Response.json({
access_token: await mint({
sub,
email: url.searchParams.get("email") ?? undefined,
expiresIn: expiresIn ? Number(expiresIn) : undefined,
audience: url.searchParams.get("audience") ?? undefined,
}),
});
}
return new Response("not found", { status: 404 });
},
});
return {
url: `http://localhost:${server.port}`,
port: server.port,
kid,
mint,
/** A token signed by a *different* key, for testing rejection. */
async mintWithForeignKey(options: MintOptions): Promise<string> {
const other = await generateKeyPair("RS256", { extractable: true });
const otherJwk = await exportJWK(other.publicKey);
return new SignJWT({ sub: options.sub, role: "authenticated" })
.setProtectedHeader({ alg: "RS256", kid: await calculateJwkThumbprint(otherJwk) })
.setIssuedAt()
.setAudience(options.audience ?? "authenticated")
.setExpirationTime(Math.floor(Date.now() / 1000) + (options.expiresIn ?? 3600))
.sign(other.privateKey);
},
stop: () => server.stop(true),
};
}
if (import.meta.main) {
const portFlag = process.argv.indexOf("--port");
const port = portFlag === -1 ? 4545 : Number(process.argv[portFlag + 1]);
const issuer = await startDevAuthIssuer(port);
console.log(`dev-auth issuer on ${issuer.url} (kid ${issuer.kid})`);
console.log(` SUPABASE_URL=${issuer.url}`);
}
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# Brings the local dependency stack (Valkey + Postgres/pgvector) up and down,
# and applies the Drizzle schema to it.
#
# Two backends behind one interface, chosen automatically:
#
# docker docker-compose.yml - Valkey 8 + pgvector/pgvector:pg17. What CI
# and a normal dev machine should use; closest to production.
# native apt packages started directly - postgresql-16 + pgvector + Valkey
# 7.2 from Ubuntu's own repositories.
#
# The native path exists because Docker provably cannot run in some sandboxes,
# including the one this was written in: dockerd needs CAP_NET_ADMIN to build
# its bridge, and even with `--bridge=none --iptables=false
# --storage-driver=vfs` it cannot unpack an image, because registering a layer
# needs user namespaces. Both are host-level grants, so there is nothing a
# script can do about it. Rather than leave the database untestable there, this
# falls back to running the same two services from apt. Set CONTINUUM_DEV_BACKEND
# to force one.
#
# Usage:
# scripts/dev-env.sh up start the services, wait until they answer
# scripts/dev-env.sh down stop them (keeps data)
# scripts/dev-env.sh reset stop them AND delete all data
# scripts/dev-env.sh status what is running, and which backend
# scripts/dev-env.sh psql open a shell on the database
# scripts/dev-env.sh schema apply the committed migrations (drizzle-kit migrate)
# scripts/dev-env.sh push reshape the db from src/db/schema, no migration
# scripts/dev-env.sh env print the DATABASE_URL/VALKEY_URL to use
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PG_VERSION=16
PG_PORT=5432
VALKEY_PORT=6379
# Straight at Postgres: there is no pooler for the MVP. Do not point this at
# pgCat even where one is reachable - parameterised queries from postgres-js
# hang through 1.2.0. See src/db/index.ts.
DEV_DATABASE_URL="postgres://continuum:continuum@localhost:${PG_PORT}/continuum"
DEV_VALKEY_URL="redis://localhost:${VALKEY_PORT}"
NATIVE_STATE="${XDG_RUNTIME_DIR:-/tmp}/continuum-dev-env"
# --- backend selection -----------------------------------------------------
# Docker is used when its daemon is already answering. Deliberately does not
# try to start dockerd: on a machine where it works there is a service manager
# doing that, and on a machine where it does not, starting it just produces a
# daemon that cannot unpack an image (see the header). Force either backend
# with CONTINUUM_DEV_BACKEND=docker|native.
docker_available() {
command -v docker >/dev/null 2>&1 || return 1
sudo -n docker info >/dev/null 2>&1
}
backend() {
if [ -n "${CONTINUUM_DEV_BACKEND:-}" ]; then
echo "$CONTINUUM_DEV_BACKEND"
elif docker_available; then
echo docker
else
echo native
fi
}
# --- waiting ---------------------------------------------------------------
# Poll for readiness rather than sleeping a fixed amount: Postgres accepts
# connections before it will serve them, and a failure should report why.
wait_for_postgres() {
for _ in $(seq 1 60); do
if pg_isready -h localhost -p "$PG_PORT" -U continuum -d continuum >/dev/null 2>&1; then
return 0
fi
sleep 0.5
done
echo "postgres did not become ready on port $PG_PORT" >&2
return 1
}
wait_for_valkey() {
for _ in $(seq 1 40); do
if valkey-cli -p "$VALKEY_PORT" ping 2>/dev/null | grep -q PONG; then
return 0
fi
sleep 0.5
done
echo "valkey did not answer on port $VALKEY_PORT" >&2
return 1
}
# --- native backend --------------------------------------------------------
native_up() {
mkdir -p "$NATIVE_STATE"
if ! pg_isready -h localhost -p "$PG_PORT" >/dev/null 2>&1; then
echo "==> starting postgresql-$PG_VERSION"
# pg_ctlcluster rather than systemctl: there is no service manager here.
sudo pg_ctlcluster "$PG_VERSION" main start
fi
wait_for_postgres_socket
# Idempotent: both of these are no-ops on a second run.
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='continuum'" | grep -q 1; then
echo "==> creating role 'continuum'"
sudo -u postgres psql -qc "CREATE ROLE continuum LOGIN PASSWORD 'continuum' SUPERUSER" >/dev/null
fi
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='continuum'" | grep -q 1; then
echo "==> creating database 'continuum'"
sudo -u postgres createdb -O continuum continuum
fi
# frame_embeddings needs it, and it is why the compose file uses the
# pgvector image rather than plain postgres.
sudo -u postgres psql -d continuum -qc "CREATE EXTENSION IF NOT EXISTS vector" >/dev/null
if ! valkey-cli -p "$VALKEY_PORT" ping >/dev/null 2>&1; then
echo "==> starting valkey"
valkey-server --port "$VALKEY_PORT" --daemonize yes \
--dir "$NATIVE_STATE" --appendonly yes \
--logfile "$NATIVE_STATE/valkey.log"
fi
wait_for_valkey
}
# Postgres has to be up before the role exists, so this waits on the server
# rather than on a successful `continuum` login.
wait_for_postgres_socket() {
for _ in $(seq 1 60); do
if sudo -u postgres pg_isready -q 2>/dev/null; then return 0; fi
sleep 0.5
done
echo "postgres did not start" >&2
return 1
}
native_down() {
if valkey-cli -p "$VALKEY_PORT" ping >/dev/null 2>&1; then
echo "==> stopping valkey"
valkey-cli -p "$VALKEY_PORT" shutdown nosave 2>/dev/null || true
fi
if sudo -u postgres pg_isready -q 2>/dev/null; then
echo "==> stopping postgresql-$PG_VERSION"
sudo pg_ctlcluster "$PG_VERSION" main stop || true
fi
}
native_reset() {
native_down
echo "==> dropping the continuum database and valkey's data"
sudo pg_ctlcluster "$PG_VERSION" main start
wait_for_postgres_socket
sudo -u postgres dropdb --if-exists continuum
sudo pg_ctlcluster "$PG_VERSION" main stop || true
rm -rf "$NATIVE_STATE"
}
native_status() {
echo "backend: native (apt packages, no container runtime)"
if sudo -u postgres pg_isready -q 2>/dev/null; then
echo " postgres: up on :$PG_PORT ($(sudo -u postgres psql -tAc 'SELECT version()' | cut -d, -f1))"
else
echo " postgres: down"
fi
if valkey-cli -p "$VALKEY_PORT" ping >/dev/null 2>&1; then
echo " valkey: up on :$VALKEY_PORT"
else
echo " valkey: down"
fi
}
# --- docker backend --------------------------------------------------------
docker_up() {
sudo docker compose up -d
echo "==> waiting for containers to report healthy"
for _ in $(seq 1 60); do
local unhealthy
unhealthy="$(sudo docker compose ps --format '{{.Service}} {{.Health}}' \
| awk '$2 != "healthy" { print $1 }' || true)"
[ -z "$unhealthy" ] && return 0
sleep 1
done
echo "timed out: $(sudo docker compose ps --format '{{.Service}} {{.Health}}')" >&2
return 1
}
# --- commands --------------------------------------------------------------
cmd_up() {
case "$(backend)" in
docker) echo "==> backend: docker compose"; docker_up ;;
native) echo "==> backend: native (docker is unavailable here - see this script's header)"; native_up ;;
esac
echo
cmd_env
echo
echo "Next: scripts/dev-env.sh schema"
}
cmd_down() { case "$(backend)" in docker) sudo docker compose down ;; native) native_down ;; esac; }
cmd_reset() { case "$(backend)" in docker) sudo docker compose down -v ;; native) native_reset ;; esac; }
cmd_status() { case "$(backend)" in docker) echo "backend: docker"; sudo docker compose ps ;; native) native_status ;; esac; }
cmd_psql() { PGPASSWORD=continuum psql -h localhost -p "$PG_PORT" -U continuum -d continuum "$@"; }
cmd_env() { echo "DATABASE_URL=$DEV_DATABASE_URL"; echo "VALKEY_URL=$DEV_VALKEY_URL"; }
# Applies the committed migrations in drizzle/, the same way a deploy would.
# This is the default because it is the path that has to work in production -
# testing `push` locally and `migrate` in production tests two different things.
cmd_schema() {
echo "==> applying committed migrations to $DEV_DATABASE_URL"
DATABASE_URL="$DEV_DATABASE_URL" bunx drizzle-kit migrate
}
# Diffs the live database against src/db/schema and reshapes it in place, with
# no migration file. Faster while iterating on the schema, and destructive: it
# will drop a column to make the database match. Local only - and whatever it
# does is not recorded anywhere, so run `bun run db:generate` before committing
# a schema change made this way.
cmd_push() {
echo "==> pushing src/db/schema straight onto $DEV_DATABASE_URL"
DATABASE_URL="$DEV_DATABASE_URL" bunx drizzle-kit push --force
}
case "${1:-}" in
up) cmd_up ;;
down) cmd_down ;;
reset) cmd_reset ;;
status) cmd_status ;;
psql) shift; cmd_psql "$@" ;;
schema) cmd_schema ;;
push) cmd_push ;;
env) cmd_env ;;
*) awk 'NR>1 && /^#/ { sub(/^# ?/, ""); print; next } NR>1 { exit }' "${BASH_SOURCE[0]}"; exit 1 ;;
esac
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env bash
# End-to-end test of the stream:camera_frames contract: the real
# continuum-backend producer, real Valkey, and the real continuum-ai-worker
# binary.
#
# tests/frame_task_contract.test.ts and continuum-ai-worker's task.rs tests
# each prove one side agrees with the schema. Only this proves the two agree
# with each other, which is the thing that was wrong: the backend wrote
# `printerId` and the worker read `printer_id`, so every task was delivered,
# acknowledged, and attributed to a printer called "unknown". Nothing failed,
# because a stream entry is a flat string map and asking it for a field that
# is not there is not an error.
#
# What it asserts:
# 1. a task the backend enqueues is consumed and acknowledged
# 2. an old-format camelCase entry is dead-lettered, not silently mishandled
# 3. an entry left pending by a dead consumer is reclaimed and acknowledged
# 4. nothing is published to channel:print_alerts - the inference stub
# abstains, and used to fabricate a 0.91 "spaghetti" for every frame
#
# Usage:
# scripts/dev-env.sh up
# scripts/e2e-ai-frames.sh
#
# WORKER_DIR=../continuum-ai-worker
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
WORKER_DIR="${WORKER_DIR:-$REPO_ROOT/../continuum-ai-worker}"
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
VALKEY_PORT="${VALKEY_PORT:-6379}"
STREAM="stream:camera_frames"
DEAD="stream:camera_frames:dead"
GROUP="ai-worker"
ALERTS="channel:print_alerts"
export VALKEY_URL
WORK="$(mktemp -d)"
WORKER_PID=""
SUB_PID=""
FAILURES=0
cli() { redis-cli -p "$VALKEY_PORT" "$@"; }
new_uuid() {
if [ -r /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid
else
python3 -c 'import uuid; print(uuid.uuid4())'
fi
}
cleanup() {
local rc=$?
# Never `kill $PID` with an unset PID: `kill 0` signals the whole process
# group, which includes this script and whatever launched it.
for pid in "$WORKER_PID" "$SUB_PID"; do
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true
done
if [ -z "${KEEP_DATA:-}" ]; then
cli DEL "$STREAM" "$DEAD" >/dev/null 2>&1 || true
fi
if [ $rc -ne 0 ] || [ "$FAILURES" -ne 0 ]; then
echo
echo "--- worker log ---"; cat "$WORK/worker.log" 2>/dev/null || true
fi
rm -rf "$WORK"
exit $rc
}
trap cleanup EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
# --- 0. preconditions -----------------------------------------------------
echo "==> checking Valkey"
cli ping >/dev/null 2>&1 || fail "no Valkey at $VALKEY_URL - run scripts/dev-env.sh up"
# This test owns and then deletes these two keys. Refuse to touch a Valkey
# that has real frames queued on it.
EXISTING="$(cli XLEN "$STREAM" 2>/dev/null || echo 0)"
[ "$EXISTING" = "0" ] || fail "$STREAM already has $EXISTING entries; refusing to run against live data"
cli DEL "$STREAM" "$DEAD" >/dev/null
ok "Valkey up, $STREAM empty"
echo "==> building the worker"
( cd "$WORKER_DIR" && cargo build --quiet 2>&1 | tail -5 ) || fail "the worker did not build"
WORKER_BIN="$WORKER_DIR/target/debug/continuum-ai-worker"
[ -x "$WORKER_BIN" ] || fail "no worker binary at $WORKER_BIN"
ok "worker built"
FARM="$(new_uuid)"; PRINTER="$(new_uuid)"; JOB="$(new_uuid)"
# --- 1. an entry stranded by a dead consumer -----------------------------
# Done first, and with COUNT 1, so exactly one entry goes through recovery
# and everything added afterwards goes through the ordinary `>` read. An
# earlier version stranded every entry on the stream, which meant the normal
# delivery path was never exercised at all.
echo "==> stranding one entry on a consumer that never acknowledges it"
cli XGROUP CREATE "$STREAM" "$GROUP" 0 MKSTREAM >/dev/null 2>&1 || true
cli XADD "$STREAM" '*' \
farm_id "$FARM" printer_id "$PRINTER" \
r2_object_key "farms/$FARM/frames/stranded.jpg" captured_at "2026-10-11T12:00:03Z" >/dev/null
# Read as a consumer that then "dies": the entry is delivered and pending, so
# no other consumer is offered it. Without recovery it sits there forever.
cli XREADGROUP GROUP "$GROUP" dead-consumer COUNT 1 STREAMS "$STREAM" '>' >/dev/null
STRANDED="$(cli XPENDING "$STREAM" "$GROUP" | head -1)"
[ "${STRANDED:-0}" = "1" ] && ok "1 entry pending on a consumer that is gone" \
|| bad "expected exactly 1 pending entry, XPENDING says '$STRANDED'"
# --- 2. enqueue through the real producer --------------------------------
echo "==> enqueuing two tasks through the backend's own producer"
# The real `enqueueFrameIngestionBatch`, not a hand-written XADD: the point is
# to test what the backend actually writes.
bun -e "
const { enqueueFrameIngestionBatch, valkey } = await import('./src/queue/index.ts');
const ids = await enqueueFrameIngestionBatch([
{ farm_id: '$FARM', printer_id: '$PRINTER', r2_object_key: 'farms/$FARM/frames/a.jpg', captured_at: '2026-10-11T12:00:00Z' },
{ farm_id: '$FARM', printer_id: '$PRINTER', print_job_id: '$JOB', r2_object_key: 'farms/$FARM/frames/b.jpg', captured_at: '2026-10-11T12:00:01Z' },
]);
console.log(ids.join(' '));
valkey.disconnect();
" > "$WORK/ids.txt" 2>"$WORK/producer.err" || fail "the producer failed: $(cat "$WORK/producer.err")"
ok "2 tasks enqueued"
# The field names actually on the wire, read back from Valkey rather than from
# either side's source. This is the assertion the two repos could not make
# about each other before.
echo "==> the field names on the wire"
cli XRANGE "$STREAM" - + > "$WORK/xrange.txt"
for field in farm_id printer_id r2_object_key captured_at print_job_id; do
grep -qx "$field" "$WORK/xrange.txt" || bad "field '$field' is not on the wire"
done
for camel in farmId printerId r2ObjectKey capturedAt printJobId; do
grep -qx "$camel" "$WORK/xrange.txt" && bad "camelCase field '$camel' is on the wire" || true
done
# Exactly one entry carries a job id; the others omit the field entirely
# rather than writing "".
JOB_FIELDS="$(grep -cx print_job_id "$WORK/xrange.txt" || true)"
[ "$JOB_FIELDS" = "1" ] && ok "print_job_id present on one entry, omitted on the others" \
|| bad "print_job_id appears $JOB_FIELDS times, expected 1"
ok "every field is snake_case, as the schema says"
# --- 3. an old-format entry ----------------------------------------------
echo "==> adding one entry in the old camelCase format"
# Exactly what the previous producer wrote. The worker must dead-letter it
# rather than process it with defaults.
cli XADD "$STREAM" '*' \
farmId "$FARM" printerId "$PRINTER" printJobId "" \
r2ObjectKey "farms/$FARM/frames/legacy.jpg" capturedAt "2026-10-11T12:00:02Z" >/dev/null
ok "legacy entry added"
# --- 4. watch the alert channel ------------------------------------------
cli SUBSCRIBE "$ALERTS" > "$WORK/alerts.txt" 2>/dev/null &
SUB_PID=$!
sleep 0.4
# --- 5. run the real worker ----------------------------------------------
echo "==> running the worker"
CONTINUUM_VALKEY_URL="$VALKEY_URL" \
CONTINUUM_FRAME_STREAM="$STREAM" \
CONTINUUM_DEAD_LETTER_STREAM="$DEAD" \
CONTINUUM_CONSUMER_GROUP="$GROUP" \
CONTINUUM_CONSUMER_NAME="e2e-worker" \
CONTINUUM_BLOCK_MS=500 \
CONTINUUM_CLAIM_IDLE_MS=1000 \
CONTINUUM_MAX_DELIVERIES=5 \
RUST_LOG=continuum_ai_worker=debug \
"$WORKER_BIN" > "$WORK/worker.log" 2>&1 &
WORKER_PID=$!
# Long enough for the claim-idle window (1s) to elapse and a recovery pass to
# run after it.
for _ in $(seq 1 40); do
PENDING="$(cli XPENDING "$STREAM" "$GROUP" | head -1)"
DEADLEN="$(cli XLEN "$DEAD")"
[ "${PENDING:-1}" = "0" ] && [ "${DEADLEN:-0}" -ge 1 ] && break
sleep 0.5
done
kill "$WORKER_PID" 2>/dev/null || true
sleep 0.5
ok "worker ran"
# --- 6. assertions -------------------------------------------------------
echo "==> what the worker did"
grep -qi "panic" "$WORK/worker.log" && bad "the worker panicked" || ok "no panic"
PENDING="$(cli XPENDING "$STREAM" "$GROUP" | head -1)"
[ "${PENDING:-x}" = "0" ] && ok "nothing left pending: every entry was acknowledged" \
|| bad "$PENDING entries are still pending"
DEADLEN="$(cli XLEN "$DEAD")"
[ "$DEADLEN" = "1" ] && ok "exactly one entry was dead-lettered" \
|| bad "the dead-letter stream holds $DEADLEN entries, expected 1"
cli XRANGE "$DEAD" - + > "$WORK/dead.txt"
grep -q "dead_reason" "$WORK/dead.txt" || bad "the dead-lettered entry carries no reason"
grep -q "missing required field 'farm_id'" "$WORK/dead.txt" \
&& ok "the legacy camelCase entry was dead-lettered, naming the field it lacked" \
|| bad "wrong dead-letter reason: $(grep -A1 dead_reason "$WORK/dead.txt" | head -4 | tr '\n' ' ')"
# The original fields are kept, so it can be inspected and replayed.
grep -q "farmId" "$WORK/dead.txt" && ok "the original entry's fields were preserved" \
|| bad "the dead-lettered entry did not keep the original fields"
grep -q "reclaimed a pending frame task" "$WORK/worker.log" \
&& ok "the stranded entry was reclaimed from the dead consumer" \
|| bad "no evidence the stranded entry was reclaimed"
# The three well-formed entries (one reclaimed, two read normally) must have
# been *parsed*, not merely acknowledged - a parse failure would have
# dead-lettered them instead, and an acknowledged-but-unparsed entry is
# exactly the old bug. `no detections` is only reachable past the parse.
PARSED="$(grep -c "no detections" "$WORK/worker.log" || true)"
[ "${PARSED:-0}" -ge 3 ] && ok "all 3 well-formed tasks parsed and scored ($PARSED)" \
|| bad "only $PARSED tasks got past parsing, expected 3"
# The whole point of the inference fix. The stub used to return a fixed
# 0.91 "spaghetti" for every frame, including the empty slice it was always
# handed, so a running worker was a false-alarm generator.
# `redis-cli SUBSCRIBE` prints its own confirmation first - the three lines
# "subscribe", the channel name, "1" - so grepping the file for the channel
# name matches that and not a message. A delivered message is a line that is
# exactly "message".
if grep -qx "message" "$WORK/alerts.txt" 2>/dev/null; then
bad "an alert was published: $(grep -A2 -x message "$WORK/alerts.txt" | tail -2 | tr '\n' ' ')"
else
ok "no alert was published - the inference stub abstained instead of inventing one"
fi
echo
if [ "$FAILURES" -eq 0 ]; then
echo "PASS - the backend's producer and the worker's consumer agree on"
echo " stream:camera_frames, against real Valkey."
else
echo "FAIL - $FAILURES assertion(s) failed." >&2
exit 1
fi
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env bash
# End-to-end test of authentication and the org -> farm -> printer
# authorization chain, against a real database and the real HTTP routes.
#
# This is the test that the unit tests cannot be. tests/authz.test.ts proves
# tokens are verified and that the role ladder orders correctly; only this
# proves that each route actually asks, that scoping is applied in the query
# rather than after it, and that credentials stay out of responses - the three
# things that were wrong when every route was unauthenticated.
#
# Four users, two organisations, no printers or gateways needed:
# owner_a, operator_a, viewer_a in org A, which owns farm A and printer A
# outsider_b in org B, which owns nothing
#
# Usage:
# scripts/dev-env.sh up && scripts/dev-env.sh schema
# scripts/e2e-authz.sh
#
# KEEP_DATA=1 leave the seeded organisations behind for inspection
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PORT="${PORT:-3020}"
AUTH_PORT="${AUTH_PORT:-3021}"
BASE="http://localhost:$PORT"
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
# Presigning a PUT is local HMAC with no network call, so POST /jobs works
# offline - but the SDK refuses to sign with empty credentials.
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
WORK="$(mktemp -d)"
BACKEND_PID=""
AUTH_PID=""
FAILURES=0
# `uuidgen` is not installed everywhere; the kernel's generator always is.
new_uuid() {
if [ -r /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid
else
python3 -c 'import uuid; print(uuid.uuid4())'
fi
}
# Uses DATABASE_URL rather than a hardcoded localhost. The backend under test
# reads that variable, so a psql pointed anywhere else seeds fixtures into a
# different database than the one being exercised - and the failure message
# below names DATABASE_URL, which would then be naming a URL nothing tried.
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
cleanup() {
local rc=$?
# Never `kill $PID` with an unset PID: `kill 0` signals the whole process
# group, which includes this script and whatever launched it.
local pids=()
[ -n "$BACKEND_PID" ] && pids+=("$BACKEND_PID")
[ -n "$AUTH_PID" ] && pids+=("$AUTH_PID")
if [ ${#pids[@]} -gt 0 ]; then
kill "${pids[@]}" 2>/dev/null || true
sleep 0.3
kill -9 "${pids[@]}" 2>/dev/null || true
fi
if [ -z "${KEEP_DATA:-}" ]; then
for org in "${ORG_A:-}" "${ORG_B:-}"; do
[ -n "$org" ] && psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true
done
fi
if [ $rc -ne 0 ]; then
echo
echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true
fi
rm -rf "$WORK"
exit $rc
}
trap cleanup EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
# `req <method> <path> <token|-> [json body]` -> prints the status code,
# leaves the response body in $WORK/last.json.
req() {
local method="$1" path="$2" token="$3" body="${4:-}"
local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$method" "$BASE$path")
[ "$token" != "-" ] && args+=(-H "authorization: Bearer $token")
[ -n "$body" ] && args+=(-H 'content-type: application/json' -d "$body")
curl "${args[@]}"
}
# `expect <code> <method> <path> <token> [body] -- <description>`
expect() {
local want="$1"; shift
local method="$1" path="$2" token="$3"; shift 3
local body=""
if [ "${1:-}" != "--" ]; then body="$1"; shift; fi
shift # the --
local what="$*"
local got; got="$(req "$method" "$path" "$token" "$body")"
if [ "$got" = "$want" ]; then
ok "$what ($got)"
else
bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")"
fi
}
body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; }
body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; }
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; }
# --- 0. preconditions ------------------------------------------------------
echo "==> checking the database is up and migrated"
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|| fail "farms.org_id is missing - run scripts/dev-env.sh schema"
psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \
|| fail "org_members is missing - run scripts/dev-env.sh schema"
ok "schema has the org layer"
# --- 1. the issuer and the backend ----------------------------------------
echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT"
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
AUTH_PID=$!
AUTH_URL="http://localhost:$AUTH_PORT"
for _ in $(seq 1 40); do
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break
sleep 0.25
done
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \
|| fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")"
export SUPABASE_URL="$AUTH_URL"
ok "issuer up, SUPABASE_URL=$SUPABASE_URL"
mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; }
OWNER_A_ID="$(new_uuid)"; OWNER_A="$(mint "$OWNER_A_ID")"
OPERATOR_A_ID="$(new_uuid)"; OPERATOR_A="$(mint "$OPERATOR_A_ID")"
VIEWER_A_ID="$(new_uuid)"; VIEWER_A="$(mint "$VIEWER_A_ID")"
OUTSIDER_ID="$(new_uuid)"; OUTSIDER="$(mint "$OUTSIDER_ID")"
ok "minted four access tokens"
echo "==> starting continuum-backend on :$PORT"
bun run src/index.ts > "$WORK/backend.log" 2>&1 &
BACKEND_PID=$!
for _ in $(seq 1 60); do
curl -sf "$BASE/health" >/dev/null 2>&1 && break
sleep 0.5
done
curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
ok "backend healthy"
# --- 2. anonymous access --------------------------------------------------
echo "==> nothing works without a token"
expect 200 GET /health - -- "GET /health is public"
expect 401 GET /orgs - -- "GET /orgs rejects anonymous"
expect 401 GET /farms - -- "GET /farms rejects anonymous"
expect 401 GET /printers - -- "GET /printers rejects anonymous"
expect 401 GET /jobs - -- "GET /jobs rejects anonymous"
expect 401 GET /auth/session - -- "GET /auth/session rejects anonymous"
expect 401 POST /farms - '{"name":"x","orgId":"00000000-0000-0000-0000-000000000000"}' -- "POST /farms rejects anonymous"
expect 401 GET /farms -H.invalid.token -- "a malformed token is rejected"
# --- 3. the seeded world --------------------------------------------------
echo "==> building org A (via the API, as owner_a)"
expect 201 POST /orgs "$OWNER_A" '{"name":"e2e org A"}' -- "owner_a creates org A"
ORG_A="$(json 'd["id"]')"
body_has '"role":"owner"'
ok "org A = $ORG_A, creator is its owner"
expect 200 GET /auth/session "$OWNER_A" -- "owner_a reads their session"
body_has "$ORG_A"
expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_A_ID" "$OWNER_A" '{"role":"operator"}' -- "owner_a adds operator_a"
expect 200 PUT "/orgs/$ORG_A/members/$VIEWER_A_ID" "$OWNER_A" '{"role":"viewer"}' -- "owner_a adds viewer_a"
expect 201 POST /farms "$OWNER_A" "{\"name\":\"e2e farm A\",\"orgId\":\"$ORG_A\"}" -- "owner_a creates farm A"
FARM_A="$(json 'd["id"]')"
body_lacks edgeProxyToken
ok "farm A = $FARM_A, and the create response carries no proxy token"
# `accessCode` is deliberately still sent, even though the column was removed
# (2026-10-19) and the field is now unknown to the create schema. That is the
# point: a client that believes it can hand the cloud a printer's LAN password
# must have it dropped and never see it again. The schema is open, so the
# field validates and Elysia cleans it away rather than rejecting the request
# - which is behaviour worth proving against a real server, not just against
# the schema (see the credentials block in tests/authz.test.ts).
expect 201 POST /printers "$OWNER_A" \
"{\"farmId\":\"$FARM_A\",\"name\":\"e2e printer A\",\"vendor\":\"bambu\",\"accessCode\":\"SECRET123\",\"serialNumber\":\"SN-SECRET\"}" \
-- "owner_a adds printer A, trying to set an access code"
PRINTER_A="$(json 'd["id"]')"
body_lacks SECRET123
ok "printer A = $PRINTER_A, and the submitted access code was dropped, not echoed"
echo "==> building org B (via the API, as the outsider)"
expect 201 POST /orgs "$OUTSIDER" '{"name":"e2e org B"}' -- "outsider creates their own org B"
ORG_B="$(json 'd["id"]')"
# --- 4. scoping: a list shows only your own ------------------------------
echo "==> lists are scoped to the caller's organisations"
expect 200 GET /farms "$VIEWER_A" -- "viewer_a lists farms"
body_has "$FARM_A"
expect 200 GET /farms "$OUTSIDER" -- "the outsider lists farms"
body_lacks "$FARM_A"
ok "the outsider's farm list does not contain farm A"
expect 200 GET /printers "$OUTSIDER" -- "the outsider lists printers"
body_lacks "$PRINTER_A"
ok "the outsider's printer list does not contain printer A"
expect 200 GET /orgs "$OUTSIDER" -- "the outsider lists organisations"
body_lacks "$ORG_A"
ok "the outsider cannot see org A exists"
# --- 5. a non-member gets 404, not 403 -----------------------------------
echo "==> a non-member cannot distinguish 'forbidden' from 'does not exist'"
expect 404 GET "/farms/$FARM_A" "$OUTSIDER" -- "GET another org's farm"
expect 404 GET "/printers/$PRINTER_A" "$OUTSIDER" -- "GET another org's printer"
expect 404 GET "/orgs/$ORG_A" "$OUTSIDER" -- "GET another org"
expect 404 GET "/orgs/$ORG_A/members" "$OUTSIDER" -- "GET another org's members"
expect 404 DELETE "/farms/$FARM_A" "$OUTSIDER" -- "DELETE another org's farm"
expect 404 PATCH "/printers/$PRINTER_A" "$OUTSIDER" '{"name":"hijacked"}' -- "PATCH another org's printer"
expect 404 PUT "/orgs/$ORG_A/members/$OUTSIDER_ID" "$OUTSIDER" '{"role":"owner"}' -- "add themselves to another org"
expect 404 POST "/farms/$FARM_A/edge-proxy-token" "$OUTSIDER" -- "rotate another farm's gateway credential"
expect 404 POST "/printers/$PRINTER_A/commands" "$OUTSIDER" '{"action":"set_fan_speed","percent":100}' \
-- "command another org's printer"
# The same id, as a member: proves the 404s above are about membership and not
# about the farm being missing or the route being broken.
expect 200 GET "/farms/$FARM_A" "$VIEWER_A" -- "the same farm id, as a member of its org"
# --- 6. the role ladder, over HTTP ---------------------------------------
echo "==> roles gate what a member can do"
expect 200 GET "/printers/$PRINTER_A" "$VIEWER_A" -- "a viewer reads a printer"
expect 403 POST "/printers/$PRINTER_A/commands" "$VIEWER_A" '{"action":"set_fan_speed","percent":100}' \
-- "a viewer cannot command a printer"
body_has FORBIDDEN
# 409 PRINTER_UNREACHABLE, not 403: no gateway is connected in this test, so
# authorization passing is exactly what reaching that error means.
expect 409 POST "/printers/$PRINTER_A/commands" "$OPERATOR_A" '{"action":"set_fan_speed","percent":100}' \
-- "an operator passes authorization and fails on delivery"
body_has PRINTER_UNREACHABLE
expect 403 POST "/jobs" "$VIEWER_A" "{\"farmId\":\"$FARM_A\",\"fileName\":\"v.3mf\"}" -- "a viewer cannot submit a job"
expect 201 POST "/jobs" "$OPERATOR_A" "{\"farmId\":\"$FARM_A\",\"fileName\":\"o.3mf\"}" -- "an operator submits a job"
JOB_A="$(json 'd["job"]["id"]')"
SUBMITTER="$(json 'd["job"]["submittedBy"]')"
[ "$SUBMITTER" = "$OPERATOR_A_ID" ] \
&& ok "submittedBy came from the token, not the body ($SUBMITTER)" \
|| bad "submittedBy is '$SUBMITTER', expected '$OPERATOR_A_ID'"
# The old route took `submittedBy` from the body. Sending it now must not
# change the recorded submitter - an ignored extra field, not an override.
expect 201 POST "/jobs" "$OPERATOR_A" \
"{\"farmId\":\"$FARM_A\",\"fileName\":\"spoof.3mf\",\"submittedBy\":\"$OWNER_A_ID\"}" \
-- "a job body claiming another submitter"
SPOOFED="$(json 'd["job"]["submittedBy"]')"
[ "$SPOOFED" = "$OPERATOR_A_ID" ] \
&& ok "a spoofed submittedBy in the body is ignored" \
|| bad "the body overrode submittedBy: got '$SPOOFED'"
expect 403 PATCH "/farms/$FARM_A" "$OPERATOR_A" '{"name":"renamed by operator"}' -- "an operator cannot rename a farm"
expect 200 PATCH "/farms/$FARM_A" "$OWNER_A" '{"name":"e2e farm A renamed"}' -- "an owner can rename a farm"
expect 403 POST "/printers" "$OPERATOR_A" "{\"farmId\":\"$FARM_A\",\"name\":\"nope\",\"vendor\":\"bambu\"}" \
-- "an operator cannot add a printer"
# --- 7. credentials stay on the server ------------------------------------
echo "==> credentials never appear in an ordinary response"
expect 200 GET "/farms/$FARM_A" "$OWNER_A" -- "an owner fetches their own farm"
body_lacks edgeProxyToken
expect 200 GET /farms "$OWNER_A" -- "an owner lists their farms"
body_lacks edgeProxyToken
expect 200 GET "/printers/$PRINTER_A" "$OWNER_A" -- "an owner fetches their own printer"
body_lacks SECRET123
body_lacks accessCode
ok "no proxy token and no access code in any read"
echo "==> rotation is owner-only, and returns the new value exactly once"
expect 403 POST "/farms/$FARM_A/edge-proxy-token" "$OPERATOR_A" -- "an operator cannot rotate"
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER_A" -- "an owner rotates"
ROTATED="$(json 'd["edgeProxyToken"]')"
[ -n "$ROTATED" ] || bad "rotation returned no token"
STORED="$(psql_q "SELECT edge_proxy_token FROM farms WHERE id='$FARM_A'")"
[ "$ROTATED" = "$STORED" ] && ok "the returned token is the one now stored" || bad "rotation did not persist"
# And it still works for what it is for.
CODE="$(req POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$ROTATED\"}")"
[ "$CODE" = "200" ] && ok "the rotated token still mints an edge JWT (no user token needed)" \
|| bad "POST /auth/edge-token returned $CODE for the rotated token"
expect 401 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"not-the-token\"}" \
-- "a wrong proxy token is refused"
# --- 8. the guards that stop an org becoming unadministrable --------------
echo "==> an organisation cannot be left without an owner"
expect 409 DELETE "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" -- "the only owner cannot remove themselves"
expect 409 PUT "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" '{"role":"viewer"}' -- "the only owner cannot demote themselves"
expect 409 DELETE "/orgs/$ORG_A" "$OWNER_A" -- "an org with farms attached cannot be deleted"
body_has CONFLICT
expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_A_ID" "$OWNER_A" '{"role":"owner"}' -- "promote a second owner"
expect 200 DELETE "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" -- "now the first owner may leave"
expect 404 GET "/orgs/$ORG_A" "$OWNER_A" -- "and immediately loses access to the org"
# --- 9. pagination is bounded --------------------------------------------
echo "==> list endpoints are bounded"
expect 422 GET "/jobs?limit=10000" "$OPERATOR_A" -- "an oversized limit is refused, not silently clamped"
expect 200 GET "/jobs?limit=1" "$OPERATOR_A" -- "a limit of 1 is honoured"
COUNT="$(json 'len(d)')"
[ "$COUNT" = "1" ] && ok "one row returned" || bad "limit=1 returned $COUNT rows"
echo
if [ "$FAILURES" -eq 0 ]; then
echo "PASS - authentication, org scoping, the role ladder and credential"
echo " containment all verified against a real database."
else
echo "FAIL - $FAILURES assertion(s) failed." >&2
exit 1
fi
+186
View File
@@ -0,0 +1,186 @@
/**
* Drives the real Continuum web console in a real Chrome, for
* scripts/e2e-camera.sh (BROWSER=1).
*
* Everything before this proved the camera path with a stand-in for the
* browser. This is the browser: the actual Nuxt app, the actual
* `WebRTCStreamView`, a real RTCPeerConnection pinned to relay-only, and Chrome's
* own H.264 decoder. The question it answers is whether a person looking at
* the fleet page would see a picture.
*
* Signing in: the login page only offers a magic link by email, which a
* script cannot follow, so this signs in with the app's own Supabase client
* (`$supabase.client`) using a password the caller just set on a throwaway
* user. The app then behaves exactly as it does for a real session: cookie,
* redirect, token on every API call, the telemetry socket.
*
* What it reads from the browser rather than trusting the UI: the page wraps
* RTCPeerConnection so the selected ICE candidate pair can be inspected, and
* the decoded frame is sampled on a canvas. It deliberately saves no
* screenshot - the frame is the inside of someone's workshop, and pixel
* statistics answer "is there a picture" without keeping one.
*
* Results are `ok<TAB>label` / `FAILED<TAB>label`. The password and token
* never reach a log line.
*
* Environment: APP_URL, SUPABASE_E2E_EMAIL, SUPABASE_E2E_PASSWORD, CHROME_BIN.
*/
import puppeteer from "puppeteer-core";
const appUrl = process.env.APP_URL!;
const email = process.env.SUPABASE_E2E_EMAIL!;
const password = process.env.SUPABASE_E2E_PASSWORD!;
const chromeBin = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
let failed = false;
const say = (good: boolean, label: string, detail = "") => {
if (!good) failed = true;
console.log(`${good ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`);
};
const browser = await puppeteer.launch({
executablePath: chromeBin,
headless: true,
// No sandbox because this container cannot create the namespaces Chrome's
// sandbox needs (see continuum-local-env-limits); the page it loads is our
// own dev server.
args: ["--no-sandbox", "--disable-dev-shm-usage", "--autoplay-policy=no-user-gesture-required"],
});
try {
const page = await browser.newPage();
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(String((err as Error).message ?? err).slice(0, 160)));
// Keeps every RTCPeerConnection the app creates, so its stats can be read.
await page.evaluateOnNewDocument(() => {
const Native = window.RTCPeerConnection;
(window as any).__pcs = [];
(window as any).RTCPeerConnection = function (this: unknown, ...args: any[]) {
const pc = new Native(...(args as [RTCConfiguration?]));
(window as any).__pcs.push(pc);
return pc;
} as unknown as typeof RTCPeerConnection;
(window as any).RTCPeerConnection.prototype = Native.prototype;
});
// The first load compiles the app, so it is slow.
await page.goto(`${appUrl}/login`, { waitUntil: "domcontentloaded", timeout: 120_000 });
await page.waitForFunction(
() => typeof (window as any).useNuxtApp === "function" && Boolean((window as any).useNuxtApp().$supabase),
{ timeout: 120_000 },
);
const signInError = await page.evaluate(
async (e: string, p: string) => {
const { client } = (window as any).useNuxtApp().$supabase;
const { error } = await client.auth.signInWithPassword({ email: e, password: p });
return error ? String(error.message) : null;
},
email,
password,
);
say(signInError === null, "the app's own Supabase client signs the user in", signInError ?? "");
if (signInError) process.exit(1);
await page.goto(`${appUrl}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
const stayed = await page
.waitForSelector(".fleet", { timeout: 60_000 })
.then(() => true)
.catch(() => false);
say(stayed && !page.url().includes("/login"), "the fleet page loads and does not bounce back to /login", page.url());
const live = await page
.waitForFunction(() => document.querySelector(".app-shell__status.is-online") !== null, { timeout: 30_000 })
.then(() => true)
.catch(() => false);
say(live, "the header reports Live: the telemetry socket authenticated with the real token");
const connected = await page
.waitForFunction(() => document.querySelector(".webrtc-view__status.is-connected") !== null, {
timeout: 60_000,
})
.then(() => true)
.catch(() => false);
const status = await page.evaluate(
() => document.querySelector(".webrtc-view__status")?.textContent?.trim() ?? "no player rendered",
);
say(connected, "the camera player reports connected", `status: ${status}`);
if (connected) {
// Waits for a decoded frame, then samples it twice a second apart.
const decoded = await page
.waitForFunction(
() => {
const v = document.querySelector<HTMLVideoElement>(".webrtc-view video");
return Boolean(v && v.readyState >= 2 && v.videoWidth > 0);
},
{ timeout: 30_000 },
)
.then(() => true)
.catch(() => false);
const frame = decoded
? await page.evaluate(async () => {
const v = document.querySelector<HTMLVideoElement>(".webrtc-view video")!;
const t0 = v.currentTime;
await new Promise((r) => setTimeout(r, 1500));
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 36;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
const px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let sum = 0;
let sumSq = 0;
const n = px.length / 4;
for (let i = 0; i < px.length; i += 4) {
const y = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
sum += y;
sumSq += y * y;
}
const mean = sum / n;
return {
width: v.videoWidth,
height: v.videoHeight,
advanced: v.currentTime - t0,
mean,
stdev: Math.sqrt(Math.max(0, sumSq / n - mean * mean)),
};
})
: null;
say(decoded && frame !== null, "Chrome decoded a video frame", frame ? `${frame.width}x${frame.height}` : "");
if (frame) {
say(frame.advanced > 0.5, "playback time is advancing, so the stream is live", `+${frame.advanced.toFixed(1)}s`);
say(frame.stdev > 2, "the frame has picture content, not black or one flat colour",
`mean luma ${frame.mean.toFixed(0)}, stdev ${frame.stdev.toFixed(1)}`);
}
// Read from the browser's own ICE stats, so "relay-only" is observed
// rather than inferred from the configuration.
const selected = await page.evaluate(async () => {
for (const pc of (window as any).__pcs as RTCPeerConnection[]) {
const stats = await pc.getStats();
const byId = new Map<string, any>();
stats.forEach((s) => byId.set(s.id, s));
for (const s of byId.values()) {
if (s.type === "transport" && s.selectedCandidatePairId) {
const pair = byId.get(s.selectedCandidatePairId);
const local = pair && byId.get(pair.localCandidateId);
if (local) return local.candidateType as string;
}
}
}
return null;
});
say(selected === "relay", "the browser's selected local candidate is a TURN relay", `type: ${selected ?? "none"}`);
}
say(pageErrors.length === 0, "the page raised no uncaught errors", pageErrors.slice(0, 2).join(" | "));
} finally {
await browser.close();
}
process.exit(failed ? 1 : 0);
+159
View File
@@ -0,0 +1,159 @@
/**
* A WebRTC receiver that is not a browser, for scripts/e2e-camera.sh.
*
* What it proves that nothing else here does: that video actually arrives.
* The signalling relay has always been testable with a canned SDP, and the
* go2rtc-to-printer leg was confirmed by hand, but "the offer round-trips"
* says nothing about whether a viewer would see a picture. This runs the
* browser's half of the exchange for real - createOffer, relay through the
* backend, setRemoteDescription, then ICE, DTLS and SRTP to go2rtc - and
* counts the RTP packets that come out the other end.
*
* It also exists because the suite's earlier stub offer (`v=0 ... t=0 0`, no
* media section, no ice-ufrag) could only ever be refused, and the test
* counted that refusal as success. A real offer has to be accepted.
*
* Results are printed as `ok<TAB>label` / `FAILED<TAB>label`, the convention
* run_probe in e2e-real-services.sh also reads. werift is a devDependency of
* this repo for this file alone; nothing under src/ imports it.
*
* Environment: BASE, PRINTER_UUID, ACCESS_TOKEN. The token never reaches a
* log line.
*
* ICE_MODE=relay runs it the way the real browser is configured: pinned to
* `iceTransportPolicy: "relay"` with the short-lived TURN servers that
* `GET /printers/:id/stream` hands out, so media can only travel through
* Cloudflare. That is the product's actual path, and the one go2rtc's issue
* tracker warns can strand ICE in `checking` when only one side holds a relay.
* The TURN credentials are used and never printed.
*/
import { RTCPeerConnection, RTCRtpCodecParameters } from "werift";
const base = process.env.BASE!;
const printer = process.env.PRINTER_UUID!;
const token = process.env.ACCESS_TOKEN!;
const MIN_PACKETS = Number(process.env.MIN_RTP_PACKETS ?? 50);
const WAIT_MS = Number(process.env.MEDIA_WAIT_MS ?? 25_000);
let failed = false;
const say = (good: boolean, label: string, detail = "") => {
if (!good) failed = true;
console.log(`${good ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`);
};
// werift offers only VP8 by default, and a Bambu camera is H.264 (High, level
// 4.1), so go2rtc refused the first version of this offer with "codecs not
// matched: video:H264 => video:VP8" - correctly. Every browser offers H.264,
// so offering it here is what a real viewer does, not a concession to the test.
// The parameters are Chrome's: packetization-mode=1, constrained baseline.
const relayOnly = process.env.ICE_MODE === "relay";
let iceServers: { urls: string | string[]; username?: string; credential?: string }[] = [];
if (relayOnly) {
const info = await fetch(`${base}/printers/${printer}/stream`, {
headers: { authorization: `Bearer ${token}` },
});
const parsed = (await info.json()) as { iceServers?: typeof iceServers };
iceServers = parsed.iceServers ?? [];
say(info.ok && iceServers.length > 0, "the backend hands out ICE servers for a relay-only viewer",
`${iceServers.length} entr(ies)`);
if (iceServers.length === 0) process.exit(1);
}
const pc = new RTCPeerConnection({
iceServers,
iceTransportPolicy: relayOnly ? "relay" : "all",
codecs: {
video: [
new RTCRtpCodecParameters({
mimeType: "video/H264",
clockRate: 90000,
rtcpFeedback: [{ type: "nack" }, { type: "nack", parameter: "pli" }, { type: "goog-remb" }],
parameters: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
}),
],
},
});
const transceiver = pc.addTransceiver("video", { direction: "recvonly" });
let packets = 0;
let bytes = 0;
transceiver.onTrack.subscribe((track) => {
track.onReceiveRtp.subscribe((rtp) => {
packets += 1;
bytes += rtp.payload.length;
});
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const offerSdp = pc.localDescription!.sdp;
say(/^m=video /m.test(offerSdp) && /^a=ice-ufrag:/m.test(offerSdp),
"the offer has a video section and an ice-ufrag, unlike the stub it replaces");
if (relayOnly) {
// Asserted rather than assumed: if the viewer also offered host or srflx
// candidates, a pass below could mean "connected directly" and prove nothing
// about TURN.
const kinds = [...offerSdp.matchAll(/^a=candidate:.* typ (\w+)/gm)].map((m) => m[1]);
say(kinds.length > 0 && kinds.every((k) => k === "relay"),
"the viewer offers only relay candidates, as the browser does",
`candidates: ${kinds.length ? [...new Set(kinds)].join(",") : "none - the TURN allocation failed"}`);
}
// A player that gets "stream not found" just asks again, and so does this: the
// gateway advertises a camera as soon as the printer reports its URL, a moment
// before go2rtc has been restarted with the stream in its config. Bounded, and
// the attempt count is reported, so a window that grows shows up as a number
// rather than as a flaky pass. Only that one reason is retried - a codec
// mismatch or any other refusal is a real failure and must fail at once.
const RETRY_MS = Number(process.env.OFFER_RETRY_MS ?? 20_000);
const started = Date.now();
let attempts = 0;
let res: Response;
let bodyText = "";
do {
attempts += 1;
res = await fetch(`${base}/printers/${printer}/webrtc`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ sdp: offerSdp }),
});
bodyText = await res.text();
if (res.status !== 503 || !bodyText.includes("stream not found")) break;
await Bun.sleep(1000);
} while (Date.now() - started < RETRY_MS);
say(res.status === 200, "the relayed offer is answered 200",
`HTTP ${res.status} after ${attempts} attempt(s), ${Math.round((Date.now() - started) / 1000)}s`);
if (res.status !== 200) {
// The gateway's reason is the whole diagnosis, and it carries no secrets.
console.log(`# ${bodyText.slice(0, 300).replace(/\s+/g, " ")}`);
await pc.close();
process.exit(1);
}
const answer = (JSON.parse(bodyText) as { sdp?: string }).sdp ?? "";
say(/^m=video /m.test(answer) && /H264\/90000/i.test(answer),
"the answer negotiates H264 video, which is what the printer sends");
await pc.setRemoteDescription({ type: "answer", sdp: answer });
const connected = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), WAIT_MS);
pc.connectionStateChange.subscribe((state) => {
if (state === "connected") { clearTimeout(timer); resolve(true); }
if (state === "failed") { clearTimeout(timer); resolve(false); }
});
if (pc.connectionState === "connected") { clearTimeout(timer); resolve(true); }
});
say(connected, "ICE and DTLS completed with go2rtc", `state=${pc.connectionState}`);
if (connected) {
const deadline = Date.now() + WAIT_MS;
while (packets < MIN_PACKETS && Date.now() < deadline) await Bun.sleep(250);
}
say(packets >= MIN_PACKETS,
`video RTP reached the viewer: at least ${MIN_PACKETS} packets`,
`${packets} packets, ${bytes} payload bytes`);
await pc.close();
process.exit(failed ? 1 : 0);
+416
View File
@@ -0,0 +1,416 @@
#!/usr/bin/env bash
# End-to-end test that a camera actually delivers video through the whole
# stack, not just that its signalling round-trips.
#
# e2e-edge.sh covers the relay with go2rtc deliberately absent: it posts a
# stub offer and treats a 503 that came back *from the gateway* as proof the
# message travelled. That is honest about what it proves, which is only the
# round trip. It cannot say a viewer would see anything, and it once read as
# more than it was because a stray go2rtc left over from an earlier run
# answered the stub with an HTTP 500 and the test still printed "ok".
#
# This one runs the real thing end to end, twice - once over direct candidates
# and once pinned to relay-only through Cloudflare TURN, which is how the real
# browser is configured:
#
# werift peer (stands in for the browser)
# -> POST /printers/:id/webrtc on the real backend
# -> /ws/edge/v1 -> the real continuum-proxy -> its real go2rtc
# -> RTSPS to the printer's camera
# and the RTP that comes back is counted. The relay phase needs
# CLOUDFLARE_TURN_KEY_ID / CLOUDFLARE_TURN_API_TOKEN and is skipped, with a
# message saying the TURN path went unverified, when they are absent.
#
# Not covered: the viewer and the gateway share this host, so what is proven is
# that ICE, DTLS and media work via TURN, not that they survive two distinct
# NATs; and a real browser has still not rendered a frame.
#
# SAFETY: read-only against the printers. It sends no printer command of any
# kind; the proxy's MQTT subscription is passive and the camera is a pull of
# the existing stream. Printer credentials stay in continuum-proxy/printers.toml
# (gitignored) and are never read here beyond the id and name lines.
#
# Needs: a printer with an RTSP camera (an H2C - a P1P exposes none, see
# continuum-pending-hardware-tests finding 14), go2rtc, and these ports free:
# 1984 (go2rtc API, loopback) and 8555 (its WebRTC listener).
#
# Usage:
# scripts/dev-env.sh up && scripts/dev-env.sh schema
# scripts/e2e-camera.sh
#
# CAMERA_ID=h2c the gateway id (in printers.toml) with a camera
# GO2RTC_BIN=~/.local/bin/go2rtc
# PROXY_DIR=../continuum-proxy
# KEEP_DATA=1 leave the seeded farm behind for inspection
#
# BROWSER=1 also drive the real web console in a real Chrome
# (../continuum-app) and check the picture on
# screen. Implies AUTH=supabase, because the app's
# client needs a real Supabase session; needs
# SUPABASE_URL, SUPABASE_SECRET_KEY and
# SUPABASE_PUBLISHABLE_KEY (source ../dev-secrets.sh).
# It creates/reuses one Supabase user,
# continuum-e2e@example.invalid, and resets its
# password each run. Port 3042 must be free.
# AUTH=supabase use that real user's token for the API phases too
# (default is the local dev-auth issuer)
#
# SERVE=1 not a test: bring the whole stack up for working on
# the dashboard and hold it until stopped (SIGTERM or
# Ctrl-C tears it down and deletes the seeded farm).
# Seeds the user as `owner` with every printer in
# printers.toml, runs the web console with hot
# reload, and writes the sign-in to a 0600 file whose
# path it prints (the password is never printed).
# Implies BROWSER=1. Skips every check.
# APP_HOST=127.0.0.1 the interface the web console binds. Loopback by
# default because it serves a password sign-in; set
# 0.0.0.0 only on a network you trust.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
CAMERA_ID="${CAMERA_ID:-h2c}"
GO2RTC_BIN="${GO2RTC_BIN:-$HOME/.local/bin/go2rtc}"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
PORT="${PORT:-3040}"
AUTH_PORT="${AUTH_PORT:-3041}"
BASE="http://localhost:$PORT"
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
# Nothing here presigns or uploads; the backend just refuses to boot without
# object-storage credentials (src/s3/index.ts).
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
SERVE="${SERVE:-0}"
BROWSER="${BROWSER:-0}"
[ "$SERVE" = "1" ] && BROWSER=1
AUTH="${AUTH:-dev}"
[ "$BROWSER" = "1" ] && AUTH=supabase
APP_HOST="${APP_HOST:-127.0.0.1}"
APP_DIR="${APP_DIR:-$REPO_ROOT/../continuum-app}"
APP_PORT="${APP_PORT:-3042}"
CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome}"
WORK="$(mktemp -d)"
BACKEND_PID=""
PROXY_PID=""
AUTH_PID=""
APP_PID=""
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
new_uuid() { cat /proc/sys/kernel/random/uuid; }
USER_ID="$(new_uuid)"
# The proxy supervises go2rtc, so it is the proxy's PID that has to die, and
# go2rtc with it. Anything that survives holds a printer access code in memory
# and answers the next run's offers - that exact leftover is how this script
# came to exist - so cleanup checks for it rather than assuming.
cleanup() {
local rc=$?
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
done
# The dev server forks workers, so it is signalled as a group: it was started
# with setsid, which makes its PID the group id. `kill 0` would hit us.
[ -n "$APP_PID" ] && kill -- "-$APP_PID" 2>/dev/null || true
for _ in $(seq 1 20); do
[ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null || break
sleep 0.3
done
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true
done
[ -n "$APP_PID" ] && kill -9 -- "-$APP_PID" 2>/dev/null || true
if [ -z "${KEEP_DATA:-}" ] && [ -n "${ORG_ID:-}" ]; then
psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
fi
if [ $rc -ne 0 ]; then
echo; echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
fi
rm -rf "$WORK"
[ -n "${LOGIN_FILE:-}" ] && rm -f "$LOGIN_FILE"
# A go2rtc that outlived us is a leak, not a detail.
sleep 0.5
if ps -eo comm | grep -qx go2rtc; then
echo "WARNING: a go2rtc process is still running after cleanup:" >&2
ps -eo pid,etime,cmd | awk '$3 ~ /go2rtc/' >&2
[ $rc -eq 0 ] && rc=1
fi
exit $rc
}
trap cleanup EXIT
# --- 0. preconditions ------------------------------------------------------
echo "==> checking preconditions"
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|| fail "no migrated database at DATABASE_URL - run scripts/dev-env.sh up && schema"
[ -x "$GO2RTC_BIN" ] || fail "no go2rtc at $GO2RTC_BIN (set GO2RTC_BIN)"
[ -f "$PROXY_DIR/printers.toml" ] || fail "no $PROXY_DIR/printers.toml"
grep -q "^id *= *\"$CAMERA_ID\"" "$PROXY_DIR/printers.toml" \
|| fail "printers.toml has no printer with id '$CAMERA_ID' (set CAMERA_ID)"
# A leftover go2rtc would answer for the one under test and make this pass or
# fail for reasons that have nothing to do with the code. Refuse to start.
if ps -eo comm | grep -qx go2rtc; then
fail "a go2rtc is already running - it would answer instead of the one under test: $(ps -eo pid,etime,cmd | awk '$3 ~ /go2rtc/' | head -2)"
fi
for p in 1984 8555; do
ss -ltnu 2>/dev/null | grep -q ":$p " && fail "port $p is already in use"
done
CAM_HOST="$(awk -v id="$CAMERA_ID" -F'"' '/^id *=/ {cur=$2} cur==id && /^(host|address|ip) *=/ {print $2; exit}' "$PROXY_DIR/printers.toml")"
if [ -n "$CAM_HOST" ]; then
timeout 4 bash -c "</dev/tcp/$CAM_HOST/322" 2>/dev/null \
|| fail "$CAMERA_ID ($CAM_HOST) refuses TCP 322: it has no RTSP camera, or is off"
ok "$CAMERA_ID accepts RTSP on 322"
else
ok "could not read $CAMERA_ID's address from printers.toml; skipping the 322 pre-check"
fi
if [ "$AUTH" = "supabase" ]; then
for name in SUPABASE_URL SUPABASE_SECRET_KEY SUPABASE_PUBLISHABLE_KEY; do
[ -n "${!name:-}" ] || fail "$name is not set - source ../dev-secrets.sh (AUTH=supabase / BROWSER=1)"
done
fi
if [ "$BROWSER" = "1" ]; then
[ -x "$CHROME_BIN" ] || fail "no Chrome at $CHROME_BIN (set CHROME_BIN)"
[ -d "$APP_DIR/apps/web/node_modules" ] || fail "$APP_DIR has no installed web app - run pnpm install there"
ss -ltn 2>/dev/null | grep -q ":$APP_PORT " && fail "port $APP_PORT is already in use"
fi
# The real Supabase user, when one is needed. Captured, never echoed: the JSON
# holds a password and a live access token.
if [ "$AUTH" = "supabase" ]; then
SB_USER="$(bun scripts/e2e-supabase-user.ts)" || fail "could not provision the Supabase test user"
sb_field() { printf '%s' "$SB_USER" | python3 -c 'import json,sys; print(json.load(sys.stdin)[sys.argv[1]])' "$1"; }
USER_ID="$(sb_field id)"
USER_TOKEN="$(sb_field accessToken)"
SB_EMAIL="$(sb_field email)"
SB_PASSWORD="$(sb_field password)"
unset SB_USER
ok "signed in as the real Supabase test user $USER_ID"
fi
# --- 1. seed ---------------------------------------------------------------
echo "==> seeding an organisation and a farm claiming '$CAMERA_ID'"
EDGE_PROXY_TOKEN="e2e-$(date +%s)"
ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('e2e camera org') RETURNING id")"
# `viewer`: the relay route asks for viewer, so seeding the weakest role that
# should work shows the camera is watchable without any authority to command.
# Serving is for working on the dashboard, which needs to command and to manage
# the org, so it seeds the strongest role instead.
ROLE=viewer; [ "$SERVE" = "1" ] && ROLE=owner
psql_q "INSERT INTO org_members (org_id, user_id, role) VALUES ('$ORG_ID', '$USER_ID', '$ROLE')" >/dev/null
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token)
VALUES ('e2e camera farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
# Named as printers.toml names it (only the name line is read), so a dashboard
# shows "H2C" and not a test placeholder.
CAM_NAME="$(awk -F'"' -v id="$CAMERA_ID" '/^id *=/ {cur=$2} cur==id && /^name *=/ {print $2; exit}' "$PROXY_DIR/printers.toml")"
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id)
VALUES ('$FARM_ID', '${CAM_NAME:-camera under test}', 'bambu', '$CAMERA_ID')" >/dev/null
CAM_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID' AND gateway_printer_id='$CAMERA_ID'")"
if [ "$SERVE" = "1" ]; then
# Every other printer the gateway will report, or its telemetry is skipped as
# unknown. Only ids and names are read from printers.toml, never credentials.
while read -r pid pname; do
[ -z "$pid" ] || [ "$pid" = "$CAMERA_ID" ] && continue
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id)
VALUES ('$FARM_ID', '$pname', 'bambu', '$pid')" >/dev/null
done < <(awk -F'"' '/^id *=/ {id=$2} /^name *=/ {print id, $2}' "$PROXY_DIR/printers.toml")
fi
ok "org $ORG_ID, farm $FARM_ID, $ROLE $USER_ID"
# --- 2. backend ------------------------------------------------------------
if [ "$AUTH" = "dev" ]; then
echo "==> starting the dev-auth issuer on :$AUTH_PORT and the backend on :$PORT"
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
AUTH_PID=$!
AUTH_URL="http://localhost:$AUTH_PORT"
for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 || fail "dev-auth did not come up"
export SUPABASE_URL="$AUTH_URL"
USER_TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
else
echo "==> starting the backend on :$PORT, verifying tokens against the real Supabase JWKS"
fi
# The scheduler would send any queued job to any idle printer in the fleet - and
# this stack is wired to the real ones. Off, always: a job queued while working
# on the dashboard must never become a real print. (Dispatch is exercised
# against fakes by the other e2e scripts.)
DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 &
BACKEND_PID=$!
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done
curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy"
TOKEN="$(curl -sf -X POST "$BASE/auth/edge-token" -H 'content-type: application/json' \
-d "{\"farmId\":\"$FARM_ID\",\"edgeProxyToken\":\"$EDGE_PROXY_TOKEN\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("token",""))')"
[ -n "$TOKEN" ] || fail "no edge token"
ok "backend healthy, edge token minted"
# --- 3. the real gateway, with the real go2rtc ------------------------------
echo "==> starting the real continuum-proxy, which spawns the real go2rtc"
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
# STREAM_BASE_URL has to be set for /stream to answer 200; it is never
# dialled, because the media path here is the relay, not a gateway URL.
env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=e2e-cam-gw \
CONTINUUM_FARM_ID="$FARM_ID" \
CONTINUUM_GATEWAY_TOKEN="$TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" \
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_STREAM_BASE_URL="http://e2e-gateway.invalid:1984" \
CONTINUUM_PRINTERS_PATH=printers.toml \
RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
echo "==> waiting for the camera to be advertised"
STREAM=""
for _ in $(seq 1 45); do
STREAM="$(curl -sf -H "authorization: Bearer $USER_TOKEN" "$BASE/printers/$CAM_UUID/stream" 2>/dev/null || true)"
[ -n "$STREAM" ] && break
sleep 1
done
[ -n "$STREAM" ] || fail "GET /printers/$CAM_UUID/stream never answered 200 - the gateway did not report a camera"
# Deliberately not echoed: with TURN configured the body carries a live
# username and credential, and printing it put them in a transcript once.
ok "the gateway advertises camera '$(printf '%s' "$STREAM" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["cameraId"], "- iceServers:", len(d["iceServers"]))')'"
# The generated config holds the printer's access code and go2rtc's API
# password. $WORK is mode 0700, which hides a bad mode here, so the file's own
# mode is what is asserted - it was 0644 until this check caught it.
[ -f "$WORK/go2rtc.yaml" ] || fail "the proxy never wrote a go2rtc config"
mode="$(stat -c %a "$WORK/go2rtc.yaml")"
[ "$mode" = "600" ] || fail "go2rtc.yaml is mode $mode, but it holds an access code and must be 600"
ok "the generated go2rtc config is owner-only (mode $mode)"
# --- 4. the viewer ---------------------------------------------------------
# Runs the peer once and reads its `ok<TAB>label` / `FAILED<TAB>label` lines.
# $1 names the phase, $2 is how many results a complete run produces: fewer
# means the peer died before finishing, and a silent peer must not read as a
# pass.
run_viewer() {
local phase="$1" expected="$2" results seen=0 bad=0 verdict label
shift 2
results="$(env BASE="$BASE" PRINTER_UUID="$CAM_UUID" ACCESS_TOKEN="$USER_TOKEN" "$@" \
bun scripts/e2e-camera-peer.ts 2>&1 || true)"
while IFS=$'\t' read -r verdict label; do
case "$verdict" in
ok) ok "$label"; seen=$((seen + 1)) ;;
FAILED) echo " FAILED: $label" >&2; seen=$((seen + 1)); bad=$((bad + 1)) ;;
esac
done < <(printf '%s\n' "$results" | grep -E "^(ok|FAILED)$(printf '\t')")
if [ "$seen" -lt "$expected" ] && [ "$bad" -eq 0 ]; then
bad=1; echo " FAILED: the $phase peer produced only $seen of $expected results" >&2
fi
if [ "$bad" -ne 0 ]; then
printf '%s\n' "$results" | sed 's/^/ | /' >&2
fail "the camera did not deliver video ($phase)"
fi
}
if [ "$SERVE" != "1" ]; then
echo "==> a real WebRTC peer plays the camera through the relay (direct candidates)"
run_viewer "direct" 5
# The path the product actually uses: the browser is pinned to relay-only, so
# media goes through Cloudflare TURN. Needs real credentials, which come from
# the environment, so it is skipped - loudly - rather than failed without them.
if [ -n "${CLOUDFLARE_TURN_KEY_ID:-}" ] && [ -n "${CLOUDFLARE_TURN_API_TOKEN:-}" ]; then
echo "==> the same camera, viewer pinned to relay-only through Cloudflare TURN"
run_viewer "relay-only" 7 ICE_MODE=relay
else
echo " SKIPPED: relay-only phase - CLOUDFLARE_TURN_KEY_ID / CLOUDFLARE_TURN_API_TOKEN not set."
echo " The TURN-relayed path, which the browser is pinned to, is NOT verified by this run."
fi
fi
# --- 5. the real web console in a real browser -------------------------------
if [ "$BROWSER" = "1" ]; then
echo "==> starting the web console on :$APP_PORT"
# setsid so the whole dev server (it forks workers) shares one process group
# that cleanup can signal; the app is told where the backend is and given the
# real Supabase project it signs in against.
setsid env -C "$APP_DIR/apps/web" \
NUXT_PUBLIC_API_BASE="$BASE" \
NUXT_PUBLIC_WS_BASE="ws://localhost:$PORT" \
SUPABASE_URL="$SUPABASE_URL" \
SUPABASE_KEY="$SUPABASE_PUBLISHABLE_KEY" \
NUXT_PUBLIC_PASSWORD_LOGIN=true \
pnpm exec nuxt dev --host "$APP_HOST" --port "$APP_PORT" > "$WORK/app.log" 2>&1 &
APP_PID=$!
for _ in $(seq 1 120); do
curl -sf -o /dev/null "http://127.0.0.1:$APP_PORT/login" 2>/dev/null && break
sleep 1
done
curl -sf -o /dev/null "http://127.0.0.1:$APP_PORT/login" \
|| { tail -20 "$WORK/app.log" >&2; fail "the web console did not start on :$APP_PORT"; }
ok "the web console is up"
if [ "$SERVE" = "1" ]; then
# Written to a file rather than printed: the password is real, if
# throwaway. umask first so it is never briefly readable by anyone else.
LOGIN_FILE="${SERVE_LOGIN_FILE:-/tmp/continuum-dev-login}"
( umask 077; printf 'email: %s\npassword: %s\n' "$SB_EMAIL" "$SB_PASSWORD" > "$LOGIN_FILE" )
echo
echo "READY - the dashboard stack is running and will hold until stopped."
echo " web console http://$APP_HOST:$APP_PORT (hot reload on edits in $APP_DIR)"
echo " backend API $BASE"
echo " sign in $LOGIN_FILE (mode 600)"
echo " gateway e2e-cam-gw, farm $FARM_ID, printers: $(psql_q "SELECT string_agg(gateway_printer_id, ', ') FROM printers WHERE farm_id='$FARM_ID'")"
# `sleep & wait` rather than a foreground sleep: bash defers a trapped signal
# until a foreground child exits, but `wait` is interrupted at once. The
# trap turns TERM/INT into a normal exit so the EXIT trap tears it all down.
trap 'exit 143' TERM INT
sleep infinity &
wait $!
fi
echo "==> the real console in real Chrome: sign in, open the fleet, watch the camera"
BROWSER_OUT="$(env APP_URL="http://127.0.0.1:$APP_PORT" SUPABASE_E2E_EMAIL="$SB_EMAIL" \
SUPABASE_E2E_PASSWORD="$SB_PASSWORD" CHROME_BIN="$CHROME_BIN" \
timeout 240 bun scripts/e2e-camera-browser.ts 2>&1 || true)"
seen=0; bad=0
while IFS=$'\t' read -r verdict label; do
case "$verdict" in
ok) ok "$label"; seen=$((seen + 1)) ;;
FAILED) echo " FAILED: $label" >&2; seen=$((seen + 1)); bad=$((bad + 1)) ;;
esac
done < <(printf '%s\n' "$BROWSER_OUT" | grep -E "^(ok|FAILED)$(printf '\t')")
# A complete run is nine results. Fewer means the driver died part way, and a
# silent driver must not read as a pass.
if [ "$seen" -lt 9 ] && [ "$bad" -eq 0 ]; then
bad=1; echo " FAILED: the browser run produced only $seen of 9 results" >&2
fi
if [ "$bad" -ne 0 ]; then
printf '%s\n' "$BROWSER_OUT" | sed 's/^/ | /' >&2
echo "--- web console log (tail) ---" >&2; tail -15 "$WORK/app.log" >&2
fail "the real browser did not show the camera"
fi
fi
echo
echo "PASS - a real WebRTC peer received video from the printer's camera"
echo " through the backend relay, the uplink, the proxy and go2rtc."
if [ -n "${CLOUDFLARE_TURN_KEY_ID:-}" ] && [ -n "${CLOUDFLARE_TURN_API_TOKEN:-}" ]; then
echo " Both directly and pinned to relay-only through Cloudflare TURN."
fi
if [ "$BROWSER" = "1" ]; then
echo " And the real web console, in real Chrome, showed the picture."
fi
+461
View File
@@ -0,0 +1,461 @@
#!/usr/bin/env bash
# End-to-end test of the /ws/edge/v1 contract against a real database and real
# printers: seed a farm, claim its printers, mint an edge token, run the actual
# continuum-proxy daemon, and assert that live telemetry reached Postgres and
# that a command sent over HTTP reached a printer.
#
# This is the test that the unit tests cannot be: tests/edge_protocol.test.ts
# only proves the two ends agree about the wire format. Nothing but this proves
# the printer-id translation, persistTelemetry and sendPrinterCommand actually
# work against a database.
#
# SAFETY: the only command it sends is a fan speed, and only to the printer
# named by PRINTER_ID (default p1p), which it refuses to send unless that
# printer reports IDLE. It never starts, pauses or cancels a print. Point it at
# a printer you can see.
#
# Usage:
# scripts/dev-env.sh up && scripts/dev-env.sh schema
# scripts/e2e-edge.sh
#
# PRINTER_ID=p1p which printer to send the fan command to
# PROXY_DIR=../continuum-proxy
# KEEP_DATA=1 leave the seeded farm behind for inspection
#
# The HTTP routes now require a verified Supabase access token, so this starts
# scripts/dev-auth.ts - a local RS256 issuer publishing a JWKS at the path the
# backend fetches - and mints a token for a seeded org `operator`. That is the
# real verification path, not a bypass: see the header of dev-auth.ts.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PRINTER_ID="${PRINTER_ID:-p1p}"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
PORT="${PORT:-3010}"
AUTH_PORT="${AUTH_PORT:-3011}"
BASE="http://localhost:$PORT"
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
# The backend refuses to boot without object-storage credentials, which is
# deliberate - see src/s3/index.ts. Nothing in this test presigns or uploads
# anything, so these only have to exist.
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
# This script asserts TURN is unconfigured (below). Sourcing dev-secrets.sh
# turns it on, which used to fail the assertion - and print the live
# credentials in the failure message. Make the assumption true.
unset CLOUDFLARE_TURN_KEY_ID CLOUDFLARE_TURN_API_TOKEN
WORK="$(mktemp -d)"
BACKEND_PID=""
PROXY_PID=""
AUTH_PID=""
# `uuidgen` is not installed everywhere; the kernel's generator always is.
new_uuid() {
if [ -r /proc/sys/kernel/random/uuid ]; then
cat /proc/sys/kernel/random/uuid
else
python3 -c 'import uuid; print(uuid.uuid4())'
fi
}
# Uses DATABASE_URL rather than a hardcoded localhost. The backend under test
# reads that variable, so a psql pointed anywhere else seeds fixtures into a
# different database than the one being exercised - and the failure message
# below names DATABASE_URL, which would then be naming a URL nothing tried.
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
# The farm's operator. A real Supabase user id would come from the project;
# here it only has to be a uuid that the token's `sub` and the org_members row
# agree on.
USER_ID="$(new_uuid)"
cleanup() {
local rc=$?
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do
[ -z "$pid" ] && continue
kill "$pid" 2>/dev/null || true
done
# A gateway that outlives this script keeps reconnecting with a token for a
# farm that is about to be deleted, and pollutes the next run's log.
for _ in $(seq 1 10); do
[ -n "$PROXY_PID" ] || break
kill -0 "$PROXY_PID" 2>/dev/null || break
sleep 0.3
done
# Never `kill $PID` with an unset PID: `kill 0` signals the whole process
# group, which includes this script and whatever launched it.
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true
done
if [ -z "${KEEP_DATA:-}" ] && [ -n "${ORG_ID:-}" ]; then
# Farms cascade from the organisation, printers from the farm.
psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
fi
if [ $rc -ne 0 ]; then
echo
echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true
echo "--- dev-auth log (tail) ---"; tail -10 "$WORK/auth.log" 2>/dev/null || true
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
fi
rm -rf "$WORK"
exit $rc
}
trap cleanup EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
# --- 0. preconditions ------------------------------------------------------
echo "==> checking the database is up and migrated"
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
psql_q "SELECT gateway_printer_id FROM printers LIMIT 0" >/dev/null 2>&1 \
|| fail "printers.gateway_printer_id is missing - run scripts/dev-env.sh schema"
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|| fail "farms.org_id is missing - run scripts/dev-env.sh schema"
ok "schema has the edge columns and the org layer"
[ -f "$PROXY_DIR/printers.toml" ] || fail "no $PROXY_DIR/printers.toml - the proxy needs real printers"
# --- 1. seed ---------------------------------------------------------------
echo "==> seeding an organisation, a farm and claiming its printers"
EDGE_PROXY_TOKEN="e2e-$(date +%s)"
ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('e2e org') RETURNING id")"
[ -n "$ORG_ID" ] || fail "could not create an organisation"
# `operator`, not `owner`: the command route requires operator, so seeding the
# weakest role that should work proves the boundary is where it claims to be
# rather than passing because the test user can do everything.
psql_q "INSERT INTO org_members (org_id, user_id, role)
VALUES ('$ORG_ID', '$USER_ID', 'operator')" >/dev/null
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token)
VALUES ('e2e farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
[ -n "$FARM_ID" ] || fail "could not create a farm"
ok "org $ORG_ID, farm $FARM_ID, operator $USER_ID"
# gateway_printer_id is the whole point: it maps the gateway's own ids from
# printers.toml onto these rows. Telemetry for an id nothing claims is skipped.
while read -r pid pname; do
[ -z "$pid" ] && continue
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id)
VALUES ('$FARM_ID', '$pname', 'bambu', '$pid')" >/dev/null
ok "claimed '$pid' as '$pname'"
done < <(awk -F'"' '/^id *=/ {id=$2} /^name *=/ {print id, $2}' "$PROXY_DIR/printers.toml")
# --- 2. backend ------------------------------------------------------------
echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT"
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
AUTH_PID=$!
AUTH_URL="http://localhost:$AUTH_PORT"
for _ in $(seq 1 40); do
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break
sleep 0.25
done
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \
|| fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")"
export SUPABASE_URL="$AUTH_URL"
ok "issuer up, SUPABASE_URL=$SUPABASE_URL"
USER_TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
[ -n "$USER_TOKEN" ] || fail "could not mint a user token"
AUTH_HEADER="authorization: Bearer $USER_TOKEN"
ok "minted an access token for the operator"
echo "==> starting continuum-backend on :$PORT"
bun run src/index.ts > "$WORK/backend.log" 2>&1 &
BACKEND_PID=$!
for _ in $(seq 1 60); do
curl -sf "$BASE/health" >/dev/null 2>&1 && break
sleep 0.5
done
HEALTH="$(curl -sf "$BASE/health")" || fail "backend did not become healthy"
echo "$HEALTH" | grep -q '"db":true' || fail "backend reports the database is down: $HEALTH"
echo "$HEALTH" | grep -q '"valkey":true' || fail "backend reports valkey is down: $HEALTH"
ok "health: $HEALTH"
# --- 3. edge token ---------------------------------------------------------
echo "==> minting an edge token"
TOKEN="$(curl -sf -X POST "$BASE/auth/edge-token" \
-H 'content-type: application/json' \
-d "{\"farmId\":\"$FARM_ID\",\"edgeProxyToken\":\"$EDGE_PROXY_TOKEN\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("token",""))')"
[ -n "$TOKEN" ] || fail "no token returned from POST /auth/edge-token"
ok "token minted (${#TOKEN} chars)"
# --- 4. the real gateway ---------------------------------------------------
echo "==> starting the real continuum-proxy against it"
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
# env -C rather than a `( cd ... ) &` subshell: $! has to be the daemon itself,
# or cleanup kills only the wrapper and leaves an orphan reconnecting with a
# stale token. An earlier version did exactly that, and the orphan then
# reported a deleted farm's printers into the next run.
# STREAM_BASE_URL is deliberately set: it is what the gateway advertises in
# its hello and the only way GET /printers/:id/stream can answer 200, so
# leaving it unset would make the camera assertions below untestable rather
# than passing. It does not have to be reachable - nothing here plays video.
STREAM_BASE_URL="http://e2e-gateway.invalid:1984"
env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=e2e-gw \
CONTINUUM_FARM_ID="$FARM_ID" \
CONTINUUM_GATEWAY_TOKEN="$TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
`# Deliberately absent, not an oversight: what is under test is the config` \
`# the gateway writes and the camera_stream it advertises, both of which` \
`# happen without go2rtc. Spawning the real one would bind :1984 and dial` \
`# the printers on the LAN, so the test would depend on hardware it does` \
`# not need. The supervisor logs a spawn failure every 5s; that is expected.` \
CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc \
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_STREAM_BASE_URL="$STREAM_BASE_URL" \
CONTINUUM_PRINTERS_PATH=printers.toml \
RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
# Wait for *every* claimed printer, not just the first. Telemetry is on a 5s
# timer and a Bambu on protocol v1 reports one tick behind the rest (it only
# sends a full status when asked, so each tick reads the previous tick's
# answer) - stopping at the first row that appears is how an earlier version of
# this script silently tested only the v2 printer.
CLAIMED="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL")"
echo "==> waiting for live telemetry from all $CLAIMED claimed printer(s)"
for _ in $(seq 1 45); do
COUNT="$(psql_q "SELECT count(*) FROM printers
WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL
AND last_seen_at IS NOT NULL AND vendor_state IS NOT NULL")"
[ "${COUNT:-0}" -ge "$CLAIMED" ] && break
sleep 1
done
[ "${COUNT:-0}" -ge 1 ] || fail "no telemetry was persisted at all (see the proxy log below)"
if [ "${COUNT:-0}" -lt "$CLAIMED" ]; then
# Not fatal: a printer that is genuinely switched off cannot report, and that
# should not fail a run against the ones that can. Name them so the
# difference between "off" and "broken" is visible.
echo " WARNING: only $COUNT of $CLAIMED printers reported. Silent:"
psql_q "SELECT ' - ' || gateway_printer_id FROM printers
WHERE farm_id='$FARM_ID' AND gateway_printer_id IS NOT NULL
AND (last_seen_at IS NULL OR vendor_state IS NULL)"
else
ok "all $COUNT claimed printer(s) reported live telemetry"
fi
if grep "reported unknown printer" "$WORK/backend.log" | grep -q "$FARM_ID"; then
fail "the backend could not map a gateway printer id for this farm - check gateway_printer_id"
fi
ok "every printer id this gateway reported mapped to a row"
echo "==> what landed in the database:"
psql "$DATABASE_URL" -c \
"SELECT gateway_printer_id AS gw, status, vendor_state, progress_percent AS pct,
current_layer || '/' || total_layers AS layers, remaining_minutes AS mins,
nozzle_temp_c AS nozzle, bed_temp_c AS bed, active_alerts AS alerts,
coalesce(current_job_name, '-') AS job
FROM printers WHERE farm_id = '$FARM_ID' ORDER BY gateway_printer_id"
# Proves the telemetry is real rather than a row touched with nulls.
psql_q "SELECT 1 FROM printers WHERE farm_id='$FARM_ID' AND vendor_state IS NOT NULL
AND nozzle_temp_c IS NOT NULL LIMIT 1" | grep -q 1 \
|| fail "rows were touched but carry no actual telemetry"
ok "rows carry real vendor state and temperatures"
# --- 5. a command, the whole way down --------------------------------------
TARGET_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID' AND gateway_printer_id='$PRINTER_ID'")"
[ -n "$TARGET_UUID" ] || fail "no printer claimed as '$PRINTER_ID'"
GW="$(curl -sf -H "$AUTH_HEADER" "$BASE/printers/$TARGET_UUID/gateway")"
echo "$GW" | grep -q '"gatewayOnline":true' || fail "the gateway is not registered as online: $GW"
ok "gateway reports online for $PRINTER_ID"
COMMAND_TESTED=no
STATE="$(psql_q "SELECT vendor_state FROM printers WHERE id='$TARGET_UUID'")"
if [ "$STATE" != "IDLE" ] && [ "$STATE" != "FINISH" ]; then
echo " SKIPPED the command test: $PRINTER_ID reports '${STATE:-no telemetry yet}', not IDLE/FINISH."
echo " Refusing to touch a printer that may be mid-print."
else
echo "==> sending set_fan_speed to $PRINTER_ID ($STATE) over HTTP"
RESP="$(curl -s -o "$WORK/cmd.json" -w '%{http_code}' -X POST \
"$BASE/printers/$TARGET_UUID/commands" -H 'content-type: application/json' \
-H "$AUTH_HEADER" -d '{"action":"set_fan_speed","percent":40}')"
[ "$RESP" = "200" ] || fail "command returned HTTP $RESP: $(cat "$WORK/cmd.json")"
grep -q '"ok":true' "$WORK/cmd.json" || fail "command was not acknowledged: $(cat "$WORK/cmd.json")"
ok "command acknowledged by the printer: $(cat "$WORK/cmd.json")"
# Put it back, so this leaves the printer as it found it.
curl -s -X POST "$BASE/printers/$TARGET_UUID/commands" \
-H 'content-type: application/json' -H "$AUTH_HEADER" \
-d '{"action":"set_fan_speed","percent":0}' > /dev/null
ok "fan returned to 0"
COMMAND_TESTED=yes
fi
# --- 5b. the camera path ---------------------------------------------------
# Only the X1/H2 class publishes an rtsp_url - confirmed at the network layer:
# the H2C accepts TCP on 322, a P1P refuses it and listens on 6000 instead. So
# "some printers have a stream and most do not" is the expected state, and both
# halves are asserted. A run where no printer has a camera still passes, but
# says so rather than claiming the path works.
echo "==> the camera path: what the gateway reported, and what the API answers"
WITH_CAMERA="$(psql_q "SELECT gateway_printer_id FROM printers
WHERE farm_id='$FARM_ID' AND camera_stream IS NOT NULL
ORDER BY gateway_printer_id LIMIT 1")"
if [ -z "$WITH_CAMERA" ]; then
echo " SKIPPED: no printer reported a camera stream."
echo " Expected if only P1/A1-class printers are on: they publish no rtsp_url."
else
# The gateway sets camera_stream to its own printer id, so this also checks
# the two ends agree about the name a stream is served under.
STREAM_NAME="$(psql_q "SELECT camera_stream FROM printers
WHERE farm_id='$FARM_ID' AND gateway_printer_id='$WITH_CAMERA'")"
[ "$STREAM_NAME" = "$WITH_CAMERA" ] \
|| fail "camera_stream for '$WITH_CAMERA' is '$STREAM_NAME', expected its own gateway id"
ok "'$WITH_CAMERA' reported camera stream '$STREAM_NAME'"
CAM_UUID="$(psql_q "SELECT id FROM printers
WHERE farm_id='$FARM_ID' AND gateway_printer_id='$WITH_CAMERA'")"
STREAM="$(curl -sf -H "$AUTH_HEADER" "$BASE/printers/$CAM_UUID/stream")" \
|| fail "GET /printers/$CAM_UUID/stream failed for a printer that has a camera"
echo "$STREAM" | grep -q "\"cameraId\":\"$WITH_CAMERA\"" \
|| fail "wrong cameraId: $STREAM"
# Deliberately NOT a URL any more. A browser cannot play from a gateway's
# own address - go2rtc sends no CORS headers and 401s an unauthenticated
# preflight - so serving one offered a player that could never load. If
# this reappears, something has regressed to the pre-relay design.
echo "$STREAM" | grep -q '"streamUrl"' \
&& fail "the stream endpoint is serving a gateway URL again: $STREAM"
# No Cloudflare credentials in this test, so TURN is off and iceServers is
# empty. Both are asserted, because an empty list with turnConfigured true
# would mean minting silently failed.
echo "$STREAM" | grep -q '"turnConfigured":false' \
|| fail "expected TURN to be unconfigured in the test env: $STREAM"
echo "$STREAM" | grep -q '"iceServers":\[\]' \
|| fail "expected no ICE servers when TURN is unconfigured: $STREAM"
ok "GET .../stream: $STREAM"
# The signalling relay, end to end through the real gateway: an offer goes
# cloud -> uplink -> gateway -> its own go2rtc. go2rtc is not running in
# this test (see CONTINUUM_GO2RTC_BIN above), so the honest expectation is
# a 503 whose reason came back *from the gateway* rather than from a
# timeout - that is what proves the round trip happened at all.
OFFER_CODE="$(curl -s -o "$WORK/offer.json" -w '%{http_code}' -X POST \
-H "$AUTH_HEADER" -H 'Content-Type: application/json' \
-d '{"sdp":"v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n"}' \
"$BASE/printers/$CAM_UUID/webrtc")"
# go2rtc is deliberately not running here, so the expected answer is the
# gateway reporting that it could not reach it - which still proves the
# offer travelled cloud -> uplink -> gateway. A timeout would mean it did
# not. Verified once by hand with the real go2rtc and a valid offer, which
# returns a 200 and an answer SDP carrying go2rtc's own ICE candidates;
# that is not automated here because it needs a live camera and go2rtc
# installed, which would make this script depend on both.
if [ "$OFFER_CODE" = "503" ] && grep -q 'go2rtc' "$WORK/offer.json"; then
ok "the WebRTC offer round-tripped to the gateway: $(head -c 170 "$WORK/offer.json")"
elif [ "$OFFER_CODE" = "503" ] && grep -q 'did not answer within' "$WORK/offer.json"; then
fail "the gateway never answered the offer - the relay did not round-trip: $(cat "$WORK/offer.json")"
else
fail "unexpected answer to a WebRTC offer (HTTP $OFFER_CODE): $(cat "$WORK/offer.json")"
fi
fi
# A printer with no camera must 404, and say which half is missing. This is
# the case a frontend hits for most of a mixed fleet, so it matters that it is
# a clean refusal rather than a 200 carrying nulls.
NO_CAMERA="$(psql_q "SELECT id FROM printers
WHERE farm_id='$FARM_ID' AND camera_stream IS NULL
AND gateway_printer_id IS NOT NULL LIMIT 1")"
if [ -n "$NO_CAMERA" ]; then
CODE="$(curl -s -o "$WORK/nocam.json" -w '%{http_code}' -H "$AUTH_HEADER" \
"$BASE/printers/$NO_CAMERA/stream")"
[ "$CODE" = "404" ] || fail "expected 404 for a printer with no camera, got $CODE"
grep -q 'no camera' "$WORK/nocam.json" \
|| fail "the 404 should blame the printer's camera, not the gateway: $(cat "$WORK/nocam.json")"
ok "a printer with no camera is refused with 404, blaming the right half"
fi
# --- 5c. the operator console's live feed ----------------------------------
# The user-facing socket, driven for real. The unit tests can only mirror its
# composition, because the route imports ../../db; this is the only thing that
# proves it is wired into the app at all, that the Supabase token verifies on
# the upgrade, and that a gateway's telemetry actually reaches a console.
echo "==> /ws/telemetry: connecting as the operator and waiting for a push"
WS_OUT="$WORK/telemetry.json"
if bun scripts/watch-telemetry.ts "ws://localhost:$PORT" "$USER_TOKEN" 12 > "$WS_OUT" 2>"$WORK/ws.err"; then
python3 - "$WS_OUT" <<'PYCHK' || fail "the telemetry socket did not behave: $(cat "$WS_OUT" "$WORK/ws.err")"
import json, sys
d = json.load(open(sys.argv[1]))
# "bearer" and not the token: the runtime echoes the first offered subprotocol
# back in the response header, so the credential must not be in that position.
assert d["negotiated"] == "bearer", f'echoed subprotocol was {d["negotiated"]!r}, expected "bearer"'
kinds = [f.get("type") for f in d["frames"]]
assert "ready" in kinds, f"no ready frame; got {kinds}"
ready = next(f for f in d["frames"] if f.get("type") == "ready")
assert ready["visiblePrinters"] >= 1, f"ready says {ready['visiblePrinters']} visible printers"
pushes = [f for f in d["frames"] if f.get("type") == "printer.telemetry"]
assert pushes, f"no printer.telemetry push arrived; got {kinds}"
# A push must be the printer resource, not a telemetry-shaped subset, and must
# carry real readings rather than a touched row of nulls.
p = pushes[-1]["printer"]
for key in ("id", "farmId", "name", "vendor"):
assert key in p, f"push is missing {key}: {p}"
assert p.get("vendorState"), f"push carries no vendor state: {p}"
print(f' ok: {len(pushes)} push(es); last was {p["name"]} '
f'state={p["vendorState"]} nozzle={p.get("nozzleTempC")} camera={p.get("cameraStream")}')
PYCHK
else
fail "could not connect to /ws/telemetry: $(cat "$WORK/ws.err")"
fi
ok "the console socket authenticated and received live telemetry"
# A socket with no valid credential must not open at all - the whole point of
# checking at the upgrade rather than in a first message.
echo "==> an unauthenticated console socket must be refused"
if bun scripts/watch-telemetry.ts "ws://localhost:$PORT" "not-a-real-token" 5 \
> "$WORK/ws-bad.json" 2>&1; then
fail "a socket opened with a bogus token: $(cat "$WORK/ws-bad.json")"
fi
ok "a bogus token is refused before any socket exists"
# --- 6. the negative case --------------------------------------------------
echo "==> an unclaimed printer must be refused, not guessed at"
ORPHAN="$(psql_q "INSERT INTO printers (farm_id, name, vendor)
VALUES ('$FARM_ID', 'unclaimed', 'bambu') RETURNING id")"
CODE="$(curl -s -o "$WORK/orphan.json" -w '%{http_code}' -X POST \
"$BASE/printers/$ORPHAN/commands" -H 'content-type: application/json' \
-H "$AUTH_HEADER" -d '{"action":"set_fan_speed","percent":0}')"
[ "$CODE" = "409" ] || fail "expected HTTP 409 for an unclaimed printer, got $CODE"
grep -q 'PRINTER_UNREACHABLE' "$WORK/orphan.json" || fail "wrong error: $(cat "$WORK/orphan.json")"
ok "unclaimed printer refused with 409 PRINTER_UNREACHABLE"
echo
# Say what was actually exercised. Claiming commands work when the command test
# was skipped is worse than not running it.
if [ -n "${WITH_CAMERA:-}" ]; then
echo " Camera path verified: '$WITH_CAMERA' reported a stream and the API served it."
else
echo " Camera path NOT verified: no printer on this LAN publishes an rtsp_url."
fi
if [ "$COMMAND_TESTED" = yes ]; then
echo "PASS - telemetry and commands both verified against a real database and real printers."
else
echo "PARTIAL PASS - telemetry verified against a real database and real printers."
echo " The command path was NOT exercised: no printer was idle enough to send to."
echo " Rerun with PRINTER_ID=<an idle printer> once one is free."
fi
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# End-to-end test of the three external services the other e2e scripts fake,
# against the real thing.
#
# This is the test the others cannot be. e2e-authz.sh signs presigned URLs
# with the literal credentials "e2e"/"e2e" and never sends them anywhere,
# because presigning is local HMAC and succeeds against any key - so a wrong
# endpoint, a missing bucket or a revoked token all still pass. The failure
# that motivated this script already happened once: a gateway downloading
# S3's XML 403 error document and handing it to a printer as a project file.
# Likewise it authenticates against a local dev issuer, which proves the role
# ladder but never exercises a real JWKS fetch or ES256.
#
# What it proves, each against live infrastructure:
# 1. S3 - a presigned PUT, HEAD and GET round-trip real bytes, and a
# tampered signature yields the XML error document that
# continuum-proxy's download.rs refuses to treat as a print.
# 2. TURN - Cloudflare mints short-lived ICE credentials for our key id.
# 3. Auth - a real Supabase user's ES256 access token verifies against the
# project's published JWKS and opens an authenticated route,
# while an unsigned request does not.
#
# Credentials come from the environment and are never printed. This repo holds
# none of them: /home/coder/workspace/.env holds the devstack's, and
# ../dev-secrets.sh maps them onto the names read here.
#
# Usage:
# scripts/dev-env.sh up && scripts/dev-env.sh schema
# set -a; . ../dev-secrets.sh; set +a
# scripts/e2e-real-services.sh
#
# SKIP_AUTH=1 skip section 3 (it creates a user in the Supabase project)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PORT="${PORT:-3031}"
FAILURES=0
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
need() {
for name in "$@"; do
[ -n "${!name:-}" ] || fail "$name is not set - source ../dev-secrets.sh first (see the header)"
done
}
need R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_BUCKET DATABASE_URL VALKEY_URL
[ -n "${R2_ENDPOINT:-}" ] || [ -n "${R2_ACCOUNT_ID:-}" ] || fail "neither R2_ENDPOINT nor R2_ACCOUNT_ID is set"
# Runs a TypeScript probe and reports each `ok<TAB>label` line it printed.
#
# The "at least this many results" argument is not defensive padding. The
# first version of this script grepped for `^(ok|FAILED)\t`, and POSIX ERE has
# no `\t` escape - so it matched nothing, every section printed no results,
# and the script announced PASS. A check that cannot fail is worse than no
# check, so a probe that goes quiet is now a failure and its raw output is
# printed.
run_probe() {
local name="$1" expected="$2" src="$3" out seen=0 verdict label
cp "$src" "./.e2e-probe.ts"
out="$(bun run ./.e2e-probe.ts 2>&1 || true)"
rm -f "./.e2e-probe.ts"
while IFS=$'\t' read -r verdict label; do
case "$verdict" in
ok) ok "$label"; seen=$((seen + 1)) ;;
FAILED) bad "$label"; seen=$((seen + 1)) ;;
esac
done < <(printf '%s\n' "$out" | grep -E "^(ok|FAILED)$(printf '\t')")
if [ "$seen" -lt "$expected" ]; then
bad "$name produced $seen of $expected expected results - the probe itself failed"
printf '%s\n' "$out" | sed 's/^/ | /' >&2
fi
}
WORK="$(mktemp -d)"
BACKEND_PID=""
cleanup() {
[ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup EXIT
echo "==> 1. object storage: the presign path, against a live endpoint"
cat > "$WORK/s3.ts" <<'TS'
import { HeadBucketCommand } from "@aws-sdk/client-s3";
import { s3, getUploadUrl, getDownloadUrl, headObject, jobFileKey } from "./src/s3";
const expect = (label: string, cond: boolean, detail = "") => {
console.log(`${cond ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`);
if (!cond) process.exitCode = 1;
};
try {
await s3.send(new HeadBucketCommand({ Bucket: process.env.R2_BUCKET! }));
expect(`bucket '${process.env.R2_BUCKET}' exists and the credentials may read it`, true);
} catch (err) {
expect(`bucket '${process.env.R2_BUCKET}' reachable`, false, (err as Error).name);
process.exit(1);
}
const key = jobFileKey("e2e-farm", crypto.randomUUID(), "verify.3mf");
const payload = `continuum e2e ${new Date().toISOString()}\n`;
const body = new TextEncoder().encode(payload);
const put = await getUploadUrl(key, "application/octet-stream", 300);
const up = await fetch(put, { method: "PUT", body, headers: { "content-type": "application/octet-stream" } });
expect("a presigned PUT uploads real bytes", up.ok, `HTTP ${up.status}`);
if (!up.ok) process.exit(1);
const head = await headObject(key);
expect("headObject reports the size the scheduler checks before dispatching", head?.size === body.length, `got ${head?.size}, want ${body.length}`);
const down = await fetch(await getDownloadUrl(key, 300));
const text = await down.text();
expect("a presigned GET returns those bytes unchanged", down.ok && text === payload, `HTTP ${down.status}`);
expect("a missing object is undefined, not a throw", (await headObject(`${key}.nope`)) === undefined);
// The documented trap: a bad signature answers 200-shaped XML, not an error
// the HTTP layer notices. continuum-proxy/src/uplink/download.rs checks the
// status before the body for exactly this reason.
const bad = await fetch(put.replace(/Signature=[^&]+/, "Signature=deadbeef"), { method: "PUT", body });
const badBody = await bad.text();
expect("a tampered signature is refused with an XML error document", !bad.ok && badBody.includes("<?xml"), `HTTP ${bad.status}`);
TS
run_probe "object storage" 6 "$WORK/s3.ts"
echo "==> 2. TURN: Cloudflare mints ICE credentials for this key id"
if [ -z "${CLOUDFLARE_TURN_KEY_ID:-}" ] || [ -z "${CLOUDFLARE_TURN_API_TOKEN:-}" ]; then
ok "skipped - TURN is unconfigured, which the API reports rather than failing on"
else
cat > "$WORK/turn.ts" <<'TS'
import { turnConfigured, mintIceServers } from "./src/camera/ice";
const servers = await mintIceServers();
// Never print the credentials themselves: they are live for an hour.
const relay = (servers ?? []).find((s) => {
const urls = Array.isArray(s.urls) ? s.urls : [s.urls];
return urls.some((u) => u.startsWith("turn:") || u.startsWith("turns:"));
});
console.log(`${turnConfigured() ? "ok" : "FAILED"}\tTURN reports itself configured`);
console.log(`${servers && servers.length > 0 ? "ok" : "FAILED"}\tCloudflare minted ${servers?.length ?? 0} ICE server entr(ies)`);
console.log(`${relay?.username && relay?.credential ? "ok" : "FAILED"}\ta relay entry carries a username and credential`);
TS
run_probe "TURN" 3 "$WORK/turn.ts"
fi
echo "==> 3. auth: a real Supabase token against the real JWKS"
if [ "${SKIP_AUTH:-0}" = "1" ]; then
ok "skipped - SKIP_AUTH=1"
elif [ -z "${SUPABASE_URL:-}" ] || [ -z "${SUPABASE_SECRET_KEY:-}" ] || [ -z "${SUPABASE_PUBLISHABLE_KEY:-}" ]; then
ok "skipped - SUPABASE_URL / SUPABASE_SECRET_KEY / SUPABASE_PUBLISHABLE_KEY not all set"
else
export PORT
bun run src/index.ts > "$WORK/backend.log" 2>&1 &
BACKEND_PID=$!
for _ in $(seq 1 60); do
curl -fsS -m 1 -o /dev/null "http://localhost:$PORT/health" 2>/dev/null && break
sleep 0.25
done
curl -fsS -m 5 -o /dev/null "http://localhost:$PORT/health" \
|| { sed -n '1,20p' "$WORK/backend.log" >&2; fail "the backend did not start on :$PORT"; }
health="$(curl -fsS -m 5 "http://localhost:$PORT/health")"
case "$health" in
*'"db":true'*'"valkey":true'*) ok "the backend booted on real services; /health has db and valkey up" ;;
*) bad "/health reports a dependency down: $health" ;;
esac
cat > "$WORK/auth.ts" <<'TS'
const url = process.env.SUPABASE_URL!;
const secret = process.env.SUPABASE_SECRET_KEY!;
const pub = process.env.SUPABASE_PUBLISHABLE_KEY!;
const port = process.env.PORT!;
// `.invalid` is reserved by RFC 2606, so this address can never be a real
// person's, and the account is reused rather than piling up per run.
const email = "continuum-e2e@example.invalid";
const password = `e2e-${crypto.randomUUID()}`;
const admin = { apikey: secret, Authorization: `Bearer ${secret}`, "Content-Type": "application/json" };
const expect = (label: string, cond: boolean, detail = "") => {
console.log(`${cond ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`);
if (!cond) process.exitCode = 1;
};
const made = await fetch(`${url}/auth/v1/admin/users`, {
method: "POST", headers: admin,
body: JSON.stringify({ email, password, email_confirm: true }),
});
if (!made.ok) {
// Already there from a previous run: reset the password so this one can
// still sign in. Keeps the script re-runnable without leaving users behind.
const list = (await fetch(`${url}/auth/v1/admin/users?per_page=200`, { headers: admin }).then((r) => r.json())) as any;
const existing = (list.users ?? []).find((u: any) => u.email === email);
if (!existing) { expect("the test user could be created or found", false, `HTTP ${made.status}`); process.exit(1); }
const reset = await fetch(`${url}/auth/v1/admin/users/${existing.id}`, {
method: "PUT", headers: admin, body: JSON.stringify({ password, email_confirm: true }),
});
expect("the existing test user's password was reset", reset.ok, `HTTP ${reset.status}`);
}
const session = (await fetch(`${url}/auth/v1/token?grant_type=password`, {
method: "POST", headers: { apikey: pub, "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}).then((r) => r.json())) as any;
const token: string | undefined = session.access_token;
expect("the test user signs in and receives an access token", Boolean(token));
if (!token) process.exit(1);
const alg = JSON.parse(atob(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/"))).alg;
expect("the token is asymmetric, so the backend needs no shared secret", alg === "ES256" || alg === "RS256", `alg=${alg}`);
const { jwtVerifier } = await import("./src/auth/verifier");
const claims = (await jwtVerifier.verify(token)) as any;
expect("it verifies against the project's published JWKS", claims.aud === "authenticated", `aud=${claims.aud}`);
try {
await jwtVerifier.verify(`${token.slice(0, -4)}AAAA`);
expect("a token with a broken signature is refused", false);
} catch {
expect("a token with a broken signature is refused", true);
}
const anon = await fetch(`http://localhost:${port}/orgs`);
expect("an unauthenticated request to /orgs is refused", anon.status === 401, `HTTP ${anon.status}`);
const authed = await fetch(`http://localhost:${port}/orgs`, { headers: { Authorization: `Bearer ${token}` } });
expect("the same route answers that token", authed.ok, `HTTP ${authed.status}`);
TS
run_probe "auth" 6 "$WORK/auth.ts"
fi
echo
if [ "$FAILURES" -eq 0 ]; then
echo "PASS - object storage, TURN and the real Supabase auth path all verified"
echo " against live services, not stand-ins."
else
echo "FAIL - $FAILURES check(s) failed above." >&2
exit 1
fi
+54
View File
@@ -0,0 +1,54 @@
/**
* Provisions the real Supabase test user for scripts/e2e-camera.sh and prints
* one JSON object: { id, email, password, accessToken }.
*
* The output carries a password and a live access token, so the caller must
* capture it into a variable and never echo it. The account is reused rather
* than piling up one per run: `.invalid` is reserved by RFC 2606, so the
* address can never belong to a real person, and its password is reset every
* time to a fresh random one that exists only for this run.
*
* Environment: SUPABASE_URL, SUPABASE_SECRET_KEY, SUPABASE_PUBLISHABLE_KEY.
*/
const url = process.env.SUPABASE_URL!;
const secret = process.env.SUPABASE_SECRET_KEY!;
const publishable = process.env.SUPABASE_PUBLISHABLE_KEY!;
const email = "continuum-e2e@example.invalid";
const password = `e2e-${crypto.randomUUID()}`;
const admin = { apikey: secret, Authorization: `Bearer ${secret}`, "Content-Type": "application/json" };
const die = (message: string): never => {
console.error(`e2e-supabase-user: ${message}`);
process.exit(1);
};
let id: string | undefined;
const made = await fetch(`${url}/auth/v1/admin/users`, {
method: "POST",
headers: admin,
body: JSON.stringify({ email, password, email_confirm: true }),
});
if (made.ok) {
id = ((await made.json()) as { id?: string }).id;
} else {
const list = (await fetch(`${url}/auth/v1/admin/users?per_page=200`, { headers: admin }).then((r) => r.json())) as {
users?: { id: string; email: string }[];
};
id = list.users?.find((u) => u.email === email)?.id;
if (!id) die(`could not create or find the test user (HTTP ${made.status})`);
const reset = await fetch(`${url}/auth/v1/admin/users/${id}`, {
method: "PUT",
headers: admin,
body: JSON.stringify({ password, email_confirm: true }),
});
if (!reset.ok) die(`could not reset the test user's password (HTTP ${reset.status})`);
}
const session = (await fetch(`${url}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: publishable, "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}).then((r) => r.json())) as { access_token?: string };
if (!session.access_token || !id) die("the test user could not sign in");
console.log(JSON.stringify({ id, email, password, accessToken: session.access_token }));
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# One-time host setup: installs Docker Engine + the compose plugin from
# Docker's own apt repository.
#
# Separate from dev-env.sh on purpose - this touches the machine (apt, a new
# apt source, a usermod), dev-env.sh only touches containers. Run this once,
# then use dev-env.sh for everything else.
#
# Ubuntu-specific, and written for this workspace's WSL2 environment where
# PID 1 is not systemd: nothing here enables a service, because there is no
# service manager to enable it with. dev-env.sh starts dockerd itself.
set -euo pipefail
if command -v docker >/dev/null 2>&1; then
echo "docker is already installed: $(docker --version)"
exit 0
fi
. /etc/os-release
echo "==> installing Docker Engine for ${ID} ${VERSION_CODENAME}"
sudo apt-get update -qq
sudo apt-get install -y -qq ca-certificates curl gnupg iptables
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${ID}/gpg" \
| sudo gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
sudo apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
# So dev-env.sh can talk to the daemon without sudo. Needs a fresh login to
# take effect, which is why dev-env.sh also falls back to sudo.
sudo usermod -aG docker "$USER" || true
echo "==> installed: $(docker --version)"
echo "==> $(docker compose version)"
echo
echo "Next: scripts/dev-env.sh up"
+38
View File
@@ -0,0 +1,38 @@
/**
* Connects to /ws/telemetry as a user and prints what arrives, then exits.
*
* Exists for scripts/e2e-edge.sh, which can then assert on real frames from
* the real route rather than on a mirrored composition in a unit test. Also
* useful by hand: it is the quickest way to see whether a farm's telemetry is
* actually reaching a console.
*
* Usage: bun scripts/watch-telemetry.ts <ws-base> <access-token> [seconds]
*/
const [base, token, secondsArg] = process.argv.slice(2);
if (!base || !token) {
console.error("usage: watch-telemetry.ts <ws-base> <access-token> [seconds]");
process.exit(2);
}
const seconds = Number(secondsArg ?? 15);
// The token goes in the subprotocol, not the URL - a browser cannot set
// headers, and a query parameter would put a bearer credential in every
// access log. "bearer" first: the server echoes the first offered value back,
// and the token must not be what comes back.
const ws = new WebSocket(`${base}/ws/telemetry`, ["bearer", token]);
const frames: unknown[] = [];
ws.onmessage = (event) => {
frames.push(typeof event.data === "string" ? JSON.parse(event.data) : event.data);
};
ws.onerror = () => {
console.error("REFUSED: the socket would not open");
process.exit(1);
};
setTimeout(() => {
// Printed as one JSON document so the caller can parse it rather than
// scraping log lines.
console.log(JSON.stringify({ negotiated: ws.protocol, frames }, null, 2));
process.exit(0);
}, seconds * 1000);
+34
View File
@@ -0,0 +1,34 @@
/**
* The HS256 secret that signs and verifies edge-gateway JWTs, read once at
* module load so a misconfigured deployment fails at boot rather than on the
* first gateway connection. Same reasoning as `verifier.ts`, and the same
* shape deliberately.
*
* It replaces a literal `"dev-edge-secret-change-me"` fallback that was
* duplicated in `routes/auth.ts` and `routes/ws/edge.ts`. Two problems with
* that, beyond the duplication:
*
* - The fallback is in a git repository, so it is not a secret. Anything that
* knows it can mint an edge JWT for any `farm_id` it likes and open
* `/ws/edge/v1` as that farm's gateway - which is enough to feed a farm
* false telemetry and to receive the commands meant for its printers.
* - It was reached with `??`, which only catches null and undefined. The
* committed `.env.example` ships `EDGE_JWT_SECRET=` *empty*, so a
* deployment that copies the template and fills in everything else got an
* empty-string secret and no fallback at all - the one case the fallback
* was there to cover, missed.
*
* There is no development default on purpose. A secret that works out of the
* box is a secret that reaches production: `openssl rand -hex 32` into
* `.env` is a one-time cost, and unlike `SUPABASE_URL` there is nothing to
* look up to get it. Tests that need one set it before importing this.
*/
const secret = process.env.EDGE_JWT_SECRET;
if (!secret) {
throw new Error(
"EDGE_JWT_SECRET is not set - it signs the edge JWTs that /ws/edge/v1 trusts, " +
"so there is no safe default. Generate one with: openssl rand -hex 32",
);
}
export const edgeJwtSecret = secret;
+61
View File
@@ -0,0 +1,61 @@
import { UnauthorizedError, extractBearerToken } from "@continuum/ts-core";
/**
* Edge-token checking for the `/ws/edge/v1` upgrade, with no imports beyond
* the error types - the same reason `dispatch/job_status.ts` and `stream.ts`
* are leaf modules: `../db` opens a connection at import time, and this is
* the authentication boundary for the whole gateway protocol, so it is the
* last thing that should only be reachable through a running database.
*/
/**
* Whatever verifies an edge JWT. Narrowed to what this needs rather than
* taking the `@elysiajs/jwt` decorator's type, so a test can pass a stub and
* exercise the real decision logic.
*/
export type EdgeTokenVerifier = (
token: string,
) => Promise<Record<string, unknown> | false | null | undefined>;
/**
* The farm a verified edge token belongs to, or an `UnauthorizedError`.
*
* Every failure gets one message. Which part of a forged token to fix next -
* signature, scope, expiry - is not something to tell whoever is holding
* one; the reason is logged instead. Same reasoning as `requireUser`.
*
* The farm is returned rather than the claims: it is the only thing the
* socket needs, and handing back the whole payload invites a later caller to
* start trusting some other claim without deciding to.
*/
export async function farmFromEdgeUpgrade(
authorization: string | null | undefined,
verify: EdgeTokenVerifier,
): Promise<string> {
let token: string;
try {
token = extractBearerToken(authorization);
} catch {
throw new UnauthorizedError("Authorization header must be 'Bearer <edge token>'");
}
const payload = await verify(token);
// `scope` is checked, not just the signature: the same secret could be
// made to sign something else one day, and a token minted for another
// purpose must not open a farm's gateway socket.
if (!payload || payload.scope !== "edge") {
console.warn("[edge] upgrade rejected: token missing or not scoped to edge");
throw new UnauthorizedError("Invalid or expired edge token");
}
// A token whose farm claim is absent or not a string would otherwise
// produce a connection whose `farmId` is `undefined`, which reads as
// "every farm" to anything doing a lookup.
if (typeof payload.farmId !== "string" || payload.farmId.length === 0) {
console.warn("[edge] upgrade rejected: edge token carries no farm");
throw new UnauthorizedError("Invalid or expired edge token");
}
return payload.farmId;
}
+12
View File
@@ -0,0 +1,12 @@
export { requireUser, type AuthenticatedUser } from "./plugin";
export { roleSatisfies, ORG_ROLES, type OrgRole } from "./roles";
export {
listMemberships,
requireFarm,
requireJob,
requireOrg,
requirePrinter,
visibleFarms,
visibleJobs,
visiblePrinters,
} from "./scope";
+51
View File
@@ -0,0 +1,51 @@
import { Elysia } from "elysia";
import { JwtVerificationError, UnauthorizedError, extractBearerToken } from "@continuum/ts-core";
import { jwtVerifier } from "./verifier";
export interface AuthenticatedUser {
id: string;
email?: string;
}
/**
* Requires a verified Supabase access token and exposes the caller as `user`.
*
* Applied per route group with `.use(requireUser)` rather than globally,
* because three things here legitimately have no user behind them: `/health`,
* `POST /auth/edge-token` (authenticated by a farm's own proxy token) and the
* `/ws/edge/v1` socket (authenticated by the edge JWT that mints). A global
* guard with holes punched in it reads as if those holes were oversights.
*
* `scoped` propagates `user` to the instance that uses this plugin, so each
* route file gets it without re-deriving; `name` makes it a singleton, so
* using it in five route files still verifies each token once.
*/
export const requireUser = new Elysia({ name: "continuum/require-user" })
.resolve({ as: "scoped" }, async ({ headers }): Promise<{ user: AuthenticatedUser }> => {
let token: string;
try {
token = extractBearerToken(headers.authorization);
} catch {
throw new UnauthorizedError("Authorization header must be 'Bearer <access token>'");
}
let claims;
try {
claims = await jwtVerifier.verify(token);
} catch (err) {
// Never pass the underlying reason through: "signature mismatch" versus
// "expired" versus "wrong audience" tells an attacker which part of a
// forged token to fix next. It is logged instead.
if (err instanceof JwtVerificationError) {
console.warn(`[auth] token rejected: ${err.cause instanceof Error ? err.cause.message : err.message}`);
throw new UnauthorizedError("Invalid or expired access token");
}
throw err;
}
if (!claims.sub) {
throw new UnauthorizedError("Access token has no subject claim");
}
return { user: { id: claims.sub, email: claims.email } };
});
+17
View File
@@ -0,0 +1,17 @@
import { ORG_ROLES, type OrgRole } from "../db/schema";
/**
* The role ladder, kept apart from `./scope.ts` because that module opens a
* database connection on import and this comparison needs nothing but the
* enum. Tests can exercise the ladder without Postgres.
*/
const RANK: Record<OrgRole, number> = Object.fromEntries(
ORG_ROLES.map((role, index) => [role, index]),
) as Record<OrgRole, number>;
/** Whether `actual` is at least as privileged as `minimum`. */
export function roleSatisfies(actual: OrgRole, minimum: OrgRole): boolean {
return RANK[actual] >= RANK[minimum];
}
export { ORG_ROLES, type OrgRole };
+158
View File
@@ -0,0 +1,158 @@
import { and, eq, inArray } from "drizzle-orm";
import { ForbiddenError, NotFoundError } from "@continuum/ts-core";
import { db } from "../db";
import { farms, orgMembers, printJobs, printers } from "../db/schema";
import { roleSatisfies, type OrgRole } from "./roles";
/**
* Authorization for everything except the edge-gateway socket, which
* authenticates as a farm rather than as a user.
*
* One rule, applied at the top of every handler: resolve the thing being
* acted on back to an organisation, and check the caller's membership role
* there. user -> org -> farm -> printer, with nothing carrying permissions of
* its own in between.
*
* ## 404 rather than 403 for non-members
*
* The `require*` helpers below answer "not found" when the record exists but
* the caller is not in its organisation. That is deliberate: a 403 would
* confirm that a given farm id exists and belongs to someone, which is an
* enumeration oracle over every farm and printer in the system. 403 is kept
* for the case where the caller *can* see the record and simply is not
* allowed to do this to it - a viewer trying to move a print head - where
* saying so is useful and leaks nothing they did not already know.
*/
/** The organisations this user belongs to, with their role in each. */
export function listMemberships(userId: string) {
return db
.select({ orgId: orgMembers.orgId, role: orgMembers.role, joinedAt: orgMembers.createdAt })
.from(orgMembers)
.where(eq(orgMembers.userId, userId));
}
/**
* Subquery of the org ids this user belongs to, for use inside a `where`.
*
* A subquery rather than a fetched array of ids so list endpoints stay one
* round trip, and so a user in many organisations does not build an IN list
* whose length depends on their membership count.
*/
function orgIdsFor(userId: string) {
return db.select({ id: orgMembers.orgId }).from(orgMembers).where(eq(orgMembers.userId, userId));
}
/** `where` condition limiting `farms` to those the user can see. */
export function visibleFarms(userId: string) {
return inArray(farms.orgId, orgIdsFor(userId));
}
/** `where` condition limiting `printers` to those the user can see. */
export function visiblePrinters(userId: string) {
return inArray(
printers.farmId,
db.select({ id: farms.id }).from(farms).where(visibleFarms(userId)),
);
}
/** `where` condition limiting `print_jobs` to those the user can see. */
export function visibleJobs(userId: string) {
return inArray(
printJobs.farmId,
db.select({ id: farms.id }).from(farms).where(visibleFarms(userId)),
);
}
function check(role: OrgRole | null, minimum: OrgRole, missing: string, action: string): OrgRole {
if (role === null) throw new NotFoundError(missing);
if (!roleSatisfies(role, minimum)) {
throw new ForbiddenError(`${action} requires the '${minimum}' role; you are '${role}'`);
}
return role;
}
/** Asserts the caller holds at least `minimum` in this organisation. */
export async function requireOrg(
userId: string,
orgId: string,
minimum: OrgRole,
action = "this operation",
): Promise<OrgRole> {
const [row] = await db
.select({ role: orgMembers.role })
.from(orgMembers)
.where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
.limit(1);
return check(row?.role ?? null, minimum, `Organisation ${orgId} not found`, action);
}
/**
* Asserts the caller holds at least `minimum` in the farm's organisation.
*
* A left join so that "no such farm" and "a farm you are not a member of"
* run the same query and return the same 404 - see the note above.
*/
export async function requireFarm(
userId: string,
farmId: string,
minimum: OrgRole,
action = "this operation",
): Promise<{ orgId: string; role: OrgRole }> {
const [row] = await db
.select({ orgId: farms.orgId, role: orgMembers.role })
.from(farms)
.leftJoin(
orgMembers,
and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)),
)
.where(eq(farms.id, farmId))
.limit(1);
const role = check(row?.role ?? null, minimum, `Farm ${farmId} not found`, action);
return { orgId: row!.orgId, role };
}
/** As `requireFarm`, resolved through the printer's farm. */
export async function requirePrinter(
userId: string,
printerId: string,
minimum: OrgRole,
action = "this operation",
): Promise<{ farmId: string; orgId: string; role: OrgRole }> {
const [row] = await db
.select({ farmId: printers.farmId, orgId: farms.orgId, role: orgMembers.role })
.from(printers)
.innerJoin(farms, eq(farms.id, printers.farmId))
.leftJoin(
orgMembers,
and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)),
)
.where(eq(printers.id, printerId))
.limit(1);
const role = check(row?.role ?? null, minimum, `Printer ${printerId} not found`, action);
return { farmId: row!.farmId, orgId: row!.orgId, role };
}
/** As `requireFarm`, resolved through the job's farm. */
export async function requireJob(
userId: string,
jobId: string,
minimum: OrgRole,
action = "this operation",
): Promise<{ farmId: string; orgId: string; role: OrgRole }> {
const [row] = await db
.select({ farmId: printJobs.farmId, orgId: farms.orgId, role: orgMembers.role })
.from(printJobs)
.innerJoin(farms, eq(farms.id, printJobs.farmId))
.leftJoin(
orgMembers,
and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)),
)
.where(eq(printJobs.id, jobId))
.limit(1);
const role = check(row?.role ?? null, minimum, `Print job ${jobId} not found`, action);
return { farmId: row!.farmId, orgId: row!.orgId, role };
}
+26
View File
@@ -0,0 +1,26 @@
import { createSupabaseJwtVerifier } from "@continuum/ts-core";
/**
* The one JWT verifier in this process.
*
* Asymmetric verification against the Supabase project's published JWKS -
* this service holds no signing key and no shared secret, so a leak of
* anything here cannot be used to mint a token. That is the whole reason for
* preferring it to the HS256 path this replaces, where `SUPABASE_JWT_SECRET`
* was both the verification key and a forging key, present in the
* environment of every backend instance.
*
* `createRemoteJWKSet` does not fetch at construction: it fetches on first
* verification and then caches, re-fetching only when a token arrives with a
* key id it has not seen (which is what key rotation looks like from here).
* So building this at module load costs nothing but still fails the process
* at boot if the project URL is missing, rather than on the first request.
*/
const supabaseUrl = process.env.SUPABASE_URL;
if (!supabaseUrl) {
throw new Error(
"SUPABASE_URL is not set - it is the issuer whose JWKS verifies every access token",
);
}
export const jwtVerifier = createSupabaseJwtVerifier(supabaseUrl);
+50
View File
@@ -0,0 +1,50 @@
import { UnauthorizedError } from "@continuum/ts-core";
/**
* Pulls a bearer token out of a WebSocket's `Sec-WebSocket-Protocol` header.
*
* A leaf module with no imports but the error type, like
* `auth/edge_upgrade.ts` next to it, and for the same reason: this is an
* authentication boundary and should not need a database to test.
*
* ## Why the subprotocol and not a query parameter
*
* A browser cannot set headers on a WebSocket - `new WebSocket(url, protos)`
* is the whole of what JavaScript controls about the handshake. So the usual
* `Authorization: Bearer` is unavailable, and the common workaround is
* `?token=...`, which puts a bearer credential in a URL and therefore into
* access logs, proxy logs and error breadcrumbs. This codebase already goes
* out of its way to avoid that: continuum-proxy hand-writes a `Debug` so a
* presigned URL prints as `<redacted>`.
*
* The subprotocol field is a header, is not logged by default, and - unlike
* a message-based handshake - is available *before* the upgrade, so an
* unauthenticated peer never gets a socket at all. The gateway socket gets
* the same property through a real `Authorization` header, which it can use
* because it is not a browser.
*
* The client sends `["bearer", "<token>"]`. "bearer" goes first because the
* server must echo exactly one offered value back or the browser fails the
* connection, and the runtime echoes the first - so the token itself must
* not be in that position, or it would come back in the response header.
*/
const SCHEME = "bearer";
export function tokenFromSubprotocol(header: string | null | undefined): string {
const offered = (header ?? "")
.split(",")
.map((part) => part.trim())
.filter((part) => part.length > 0);
// Exactly two: the scheme and the token. Being strict here rather than
// scanning for "the entry that looks like a JWT" means a client that gets
// the order or the count wrong is told so, instead of working by accident
// until the day something else is in the list.
if (offered.length !== 2 || offered[0] !== SCHEME) {
throw new UnauthorizedError(
`WebSocket subprotocol must be ["${SCHEME}", "<access token>"]`,
);
}
return offered[1]!;
}
+106
View File
@@ -0,0 +1,106 @@
/**
* ICE servers for a browser about to watch a printer camera.
*
* ## Why the browser needs these at all
*
* A viewer's browser cannot reach a gateway directly. The gateway is behind a
* workshop NAT, and go2rtc will not talk to a browser cross-origin anyway -
* measured against 1.9.14, it sends no CORS headers and checks its API
* password before CORS, so a browser's unauthenticated preflight gets a 401.
* The SDP exchange is therefore relayed through this backend, and the media
* itself goes over a TURN relay.
*
* ## Why only the browser gets credentials
*
* The browser is pinned to `iceTransportPolicy: "relay"`, so it gathers a
* relay candidate and go2rtc simply sends to it from its own candidate. Only
* one side of a WebRTC session has to hold a relay for media to traverse one.
*
* That asymmetry is load-bearing rather than tidy: go2rtc reads its config
* exactly once at startup, so TURN credentials on the gateway would have to
* be rotated by restarting go2rtc and dropping every viewer on a timer.
* Browser-side, rotation lands on a request that is already per-viewer.
*
* ## What this buys over proxying the video
*
* WebRTC media is DTLS-SRTP encrypted between the browser and the gateway, so
* the relay forwards packets it cannot decrypt. The alternative considered -
* tunnelling fMP4 up the gateway's uplink and streaming it from this process -
* would have put plaintext video through the API, and `<video>` cannot send
* an `Authorization` header, so it would have needed a credential in the URL.
* That is the pattern `/ws/telemetry` deliberately rejected.
*
* ## Unconfigured is a supported state
*
* No Cloudflare credentials means no TURN, and that is not a boot failure the
* way R2 is: a farm with no cameras, or one whose operator is on the same LAN
* as the gateway, works without it. The endpoint says so rather than
* pretending, because "no iceServers" and "TURN is broken" need different
* answers from whoever is looking.
*/
const KEY_ID = process.env.CLOUDFLARE_TURN_KEY_ID;
const API_TOKEN = process.env.CLOUDFLARE_TURN_API_TOKEN;
/**
* Short on purpose. These are handed to a browser, so the window in which a
* leaked one is useful is the window this sets. An hour outlasts setting up a
* session comfortably; a viewer watching longer than that has their relay
* drop and the player renegotiates, which costs a reconnect and gets fresh
* credentials - the right trade against a credential that stays good all day.
*/
const TTL_SECONDS = 3600;
/** Whether TURN is configured at all. */
export function turnConfigured(): boolean {
return Boolean(KEY_ID && API_TOKEN);
}
export type IceServer = {
urls: string | string[];
username?: string;
credential?: string;
};
/**
* Mints a short-lived set of ICE servers, or returns null when TURN is not
* configured.
*
* Throws only when TURN *is* configured and Cloudflare refused: that is a
* real fault and must not be reported as "no TURN here", which would send a
* viewer into a session that cannot carry media.
*/
export async function mintIceServers(): Promise<IceServer[] | null> {
if (!KEY_ID || !API_TOKEN) return null;
const response = await fetch(
`https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(KEY_ID)}/credentials/generate-ice-servers`,
{
method: "POST",
headers: {
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ttl: TTL_SECONDS }),
},
);
// 201 on success, per Cloudflare's own docs, so this checks the class.
if (!response.ok) {
// The body carries the reason - a wrong key id, a revoked token - and a
// bare status would send whoever reads this at the network instead.
const detail = await response.text().catch(() => "");
throw new Error(
`Cloudflare TURN refused to mint credentials (HTTP ${response.status})` +
(detail.trim() ? `: ${detail.trim().slice(0, 300)}` : ""),
);
}
const body = (await response.json()) as { iceServers?: IceServer | IceServer[] };
if (!body.iceServers) {
throw new Error("Cloudflare TURN returned no iceServers");
}
// Cloudflare returns a single object; the browser's RTCPeerConnection wants
// a list. Normalised here so every caller does not have to know that.
return Array.isArray(body.iceServers) ? body.iceServers : [body.iceServers];
}
+11 -2
View File
@@ -2,8 +2,17 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
// Points at pgCat/PgBouncer, not Postgres directly, so keep prepared
// statements disabled and cap the pool per-instance connections low.
// No pooler in front of this for the MVP: DATABASE_URL points straight at
// Postgres, and postgres-js already pools connections in-process (`max`).
//
// `prepare: false` is kept deliberately so a transaction-mode pooler can be
// added later without touching this file. Which one matters - measured
// 2026-10-20: through pgCat 1.2.0 any query with a bind parameter hangs
// forever (every Drizzle query has one), while PgBouncer 1.22 in transaction
// mode runs the same queries and passes scripts/e2e-authz.sh. See PLAN.md.
//
// Migrations go straight at Postgres either way: drizzle.config.ts cannot
// pass `prepare: false`, and drizzle-kit hung at commit through pgCat.
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is not set");
+89
View File
@@ -0,0 +1,89 @@
import { farms, printers } from "./schema";
/**
* Which columns each table is allowed to put in an API response.
*
* These exist because `farms` holds a credential in an ordinary-looking
* column, and `select *` handed it out to unauthenticated callers:
*
* - `farms.edge_proxy_token` is what `POST /auth/edge-token` trades for an
* edge JWT. With it and a farm id, anyone can connect to /ws/edge/v1 as
* that farm's gateway and drive its printers.
*
* A client that lists or displays these records does not need it, so it is
* not listed below.
*
* A printer's own LAN password (Bambu's access code, also its FTPS password)
* is deliberately NOT stored by the backend at all: it belongs on the proxy,
* the only thing that uses it.
*
* Opt-in projections rather than a deny-list on purpose: a column added to a
* table later is excluded until somebody decides it should be public, which
* is the safe direction in which to be wrong.
*
* This is defence in depth, NOT authorization. It narrows what a response can
* contain and says nothing about who may ask - the routes are still
* unauthenticated.
*/
/**
* Builds the two shapes Drizzle needs from one list of column names, so they
* cannot drift: `columns` for the relational query API
* (`db.query.x.findMany({ columns })`) and `returning` for
* `insert/update(...).returning(...)`, which wants column references rather
* than booleans.
*/
// `T extends object` rather than `Record<string, unknown>`: a Drizzle table has
// no index signature, so the stricter constraint rejects it.
function projection<T extends object, K extends keyof T>(table: T, keys: readonly K[]) {
const columns = {} as { [P in K]: true };
const returning = {} as { [P in K]: T[P] };
for (const key of keys) {
columns[key] = true;
returning[key] = table[key];
}
return { columns, returning };
}
export const farmProjection = projection(farms, [
"id",
"name",
"orgId",
"location",
"settings",
"createdAt",
"updatedAt",
] as const);
export const printerProjection = projection(printers, [
"id",
"farmId",
"name",
"vendor",
"status",
"lanAddress",
"serialNumber",
"gatewayPrinterId",
"nozzleTempC",
"nozzleTargetTempC",
"bedTempC",
"bedTargetTempC",
"progressPercent",
"currentLayer",
"totalLayers",
"remainingMinutes",
"currentJobName",
"activeAlerts",
// Public on purpose, which this list requires someone to decide: it is a
// go2rtc stream name, which is our own printer id echoed back, not a
// credential. It is here so a client can tell which printers have a camera
// from the list it already fetched - without it, finding out means a
// `GET /printers/:id/stream` per printer, and in a mixed fleet most of
// those 404 by design.
"cameraStream",
"vendorState",
"vendorErrorCode",
"lastSeenAt",
"createdAt",
"updatedAt",
] as const);
+19 -4
View File
@@ -1,15 +1,30 @@
import { pgTable, uuid, text, timestamp, jsonb } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, jsonb, index } from "drizzle-orm/pg-core";
import { organisations } from "./orgs";
export const farms = pgTable("farms", {
export const farms = pgTable(
"farms",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
ownerId: uuid("owner_id").notNull(),
/**
* Replaces the old `owner_id`. A farm used to point straight at a single
* Supabase user, which left no way to let a second person operate it
* without handing over the account - so ownership moved up a level and
* who may do what is now a row in `org_members`.
*/
orgId: uuid("org_id")
.notNull()
.references(() => organisations.id, { onDelete: "cascade" }),
edgeProxyToken: text("edge_proxy_token").notNull(),
location: text("location"),
settings: jsonb("settings").$type<Record<string, unknown>>().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
},
(table) => ({
orgIdx: index("farms_org_id_idx").on(table.orgId),
}),
);
export type Farm = typeof farms.$inferSelect;
export type NewFarm = typeof farms.$inferInsert;
+1
View File
@@ -1,3 +1,4 @@
export * from "./orgs";
export * from "./farms";
export * from "./printers";
export * from "./print_jobs";
+65
View File
@@ -0,0 +1,65 @@
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, index, primaryKey } from "drizzle-orm/pg-core";
/**
* The role ladder, lowest first. Order matters: `src/auth/scope.ts` compares
* roles by their position in this array, so a role inserted in the middle
* changes what every `minRole` check means.
*
* - `viewer` - read farms, printers, jobs.
* - `operator` - plus submit jobs and send printer commands. Those commands
* move physical machines (home, pause, set fan/temperature),
* so this is the boundary that matters most here.
* - `owner` - plus manage membership, create and delete farms and
* printers, and rotate a farm's edge-proxy token.
*/
export const ORG_ROLES = ["viewer", "operator", "owner"] as const;
export type OrgRole = (typeof ORG_ROLES)[number];
export const orgRoleEnum = pgEnum("org_role", ORG_ROLES);
/**
* An organisation is the unit of ownership and billing: it owns farms, which
* own printers. Nothing below this level carries its own access control -
* authorization is always resolved as user -> org -> farm -> printer.
*/
export const organisations = pgTable("organisations", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
settings: jsonb("settings").$type<Record<string, unknown>>().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
/**
* Membership is the only thing that grants access to anything in Continuum.
*
* `userId` is a Supabase `auth.users.id` - the `sub` claim of a verified
* access token. It is deliberately *not* a foreign key: that table lives in
* Supabase's own schema and may not even be in this database, so the
* reference is by value and a deleted user leaves a row that matches no
* token. Harmless, since nothing can authenticate as them any more.
*/
export const orgMembers = pgTable(
"org_members",
{
orgId: uuid("org_id")
.notNull()
.references(() => organisations.id, { onDelete: "cascade" }),
userId: uuid("user_id").notNull(),
role: orgRoleEnum("role").notNull().default("viewer"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.orgId, table.userId] }),
// Every authenticated request starts by asking "which orgs is this user
// in?", so that lookup gets its own index; the primary key is no help
// because it leads with org_id.
userIdx: index("org_members_user_id_idx").on(table.userId),
}),
);
export type Organisation = typeof organisations.$inferSelect;
export type NewOrganisation = typeof organisations.$inferInsert;
export type OrgMember = typeof orgMembers.$inferSelect;
export type NewOrgMember = typeof orgMembers.$inferInsert;
+69 -4
View File
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real, integer, uniqueIndex } from "drizzle-orm/pg-core";
import { farms } from "./farms";
export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper"]);
@@ -11,7 +11,9 @@ export const printerStatusEnum = pgEnum("printer_status", [
"maintenance",
]);
export const printers = pgTable("printers", {
export const printers = pgTable(
"printers",
{
id: uuid("id").primaryKey().defaultRandom(),
farmId: uuid("farm_id")
.notNull()
@@ -21,14 +23,77 @@ export const printers = pgTable("printers", {
status: printerStatusEnum("status").notNull().default("offline"),
lanAddress: text("lan_address"),
serialNumber: text("serial_number"),
accessCode: text("access_code"),
/**
* The id this printer has in its gateway's own `printers.toml`, e.g.
* "p1p". The missing link between the two halves of the system: a
* gateway identifies its printers by this and knows nothing about
* `id` above, so every telemetry row and every outbound command is
* translated through it. Nullable because a printer can be registered
* in the cloud before any gateway claims it.
*
* Not globally unique - two farms may each have a printer called
* "p1p" - hence the per-farm unique index below rather than
* `.unique()`.
*/
gatewayPrinterId: text("gateway_printer_id"),
/**
* Latest-snapshot telemetry, written by the /ws/edge/v1 route. This
* table deliberately keeps only the *current* value of each of these
* rather than a history: a time-series of them is a separate concern
* with very different storage characteristics, and nothing needs it
* yet. `status` and the two measured temperatures were already part of
* this pattern; the rest extend it.
*/
nozzleTempC: real("nozzle_temp_c"),
nozzleTargetTempC: real("nozzle_target_temp_c"),
bedTempC: real("bed_temp_c"),
bedTargetTempC: real("bed_target_temp_c"),
progressPercent: integer("progress_percent"),
currentLayer: integer("current_layer"),
totalLayers: integer("total_layers"),
remainingMinutes: integer("remaining_minutes"),
currentJobName: text("current_job_name"),
/**
* How many Health-Management-System entries the printer reports as
* active. A count, not decoded messages - that needs Bambu's HMS
* tables, which Continuum does not have.
*/
activeAlerts: integer("active_alerts"),
/**
* The go2rtc stream name the gateway can serve this printer's camera
* under, or null when it cannot. A plain column because every sibling
* telemetry field above is one; a JSON snapshot column would have been
* the odd one out. Null is the common case, not a fault: only Bambu's
* X1/H2 class publishes an RTSP URL, a P1/A1 has a camera but no way to
* restream it.
*/
cameraStream: text("camera_stream"),
/**
* The printer's own state string, kept alongside the narrowed
* `status` enum. Worth storing both: the enum is what the UI filters
* and sorts on, but a vendor state Continuum does not recognise yet
* would otherwise be flattened into "idle" and lost. If this reads
* something unexpected, that is the signal to widen the mapping.
*/
vendorState: text("vendor_state"),
/** Vendor fault code; non-zero means the printer is reporting a fault. */
vendorErrorCode: integer("vendor_error_code"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
},
(table) => ({
// What the edge route looks printers up by, on every telemetry batch.
gatewayPrinterIdx: uniqueIndex("printers_farm_gateway_printer_id_idx").on(
table.farmId,
table.gatewayPrinterId,
),
}),
);
export type Printer = typeof printers.$inferSelect;
export type NewPrinter = typeof printers.$inferInsert;
+51
View File
@@ -11,12 +11,63 @@ import { printers, printJobs } from "./schema";
// The second argument overrides fields Drizzle can't infer correctly for an
// insert - `id` is DB-generated (defaultRandom()) so it must be optional on
// the way in, even though it's required on the way out.
//
// `metadata` needs an override for a different reason: drizzle-typebox widens a
// `jsonb` column to "any JSON value", so the generated schema admits a bare
// string or number. The column is declared `$type<Record<string, unknown>>()`,
// so a validated body would then not be assignable to `.values()` - the object
// shape has to be restated here.
const JsonObject = t.Optional(t.Record(t.String(), t.Unknown()));
export const PrinterInsertSchema = createInsertSchema(printers, {
id: t.Optional(t.String({ format: "uuid" })),
metadata: JsonObject,
});
/**
* The columns a client may set on a printer, derived from the insert schema
* above so a renamed column still fails to compile.
*
* `metadata` is deliberately not here, and nor is it in the projection. It is
* a free-form jsonb column that nothing reads, so leaving it open meant a
* well-meaning client could store a printer's access code in it and the API
* would serve it back - the same credential that was removed from the schema
* on purpose. The column stays, so reopening it is a one-line change once
* something actually needs it, with a shape to match.
*
* Everything else left out is owned by the gateway, not by the API: `status`, the
* temperatures, layer counts, progress, `vendorState`, `lastSeenAt` and the
* rest are written only by `persistTelemetry` in routes/ws/edge.ts. A client
* that could POST `status: "idle"` could make the scheduler send work to a
* printer that is mid-print.
*
* Here rather than in `routes/printers.ts` so it can be asserted on without
* importing a route - a route pulls in `db/index.ts` and therefore needs a
* live `DATABASE_URL`, and what is writable is exactly the kind of thing
* worth testing with no database in sight. See the credentials block in
* `tests/authz.test.ts`.
*/
export const PRINTER_WRITABLE = [
"farmId",
"name",
"vendor",
"lanAddress",
"serialNumber",
"gatewayPrinterId",
] as const;
export const PrinterCreateSchema = t.Pick(PrinterInsertSchema, PRINTER_WRITABLE);
/**
* `farmId` omitted: moving a printer to another farm would move its print
* history across an authorization boundary, so it is not a field on an
* update.
*/
export const PrinterUpdateSchema = t.Partial(t.Omit(PrinterCreateSchema, ["farmId"]));
export const PrinterSelectSchema = createSelectSchema(printers);
export const PrintJobInsertSchema = createInsertSchema(printJobs, {
id: t.Optional(t.String({ format: "uuid" })),
metadata: JsonObject,
});
export const PrintJobSelectSchema = createSelectSchema(printJobs);
+25
View File
@@ -0,0 +1,25 @@
/**
* Whether the scheduler may put jobs on printers. On unless
* `DISPATCH_ENABLED=false`.
*
* Exists because dispatch is global: it sends a queued job to *any* idle,
* claimed printer in *any* farm, so a development stack wired to real
* printers would start a real print the moment anyone queued a job. The dev
* stack (`SERVE=1 scripts/e2e-camera.sh`) turns it off; production leaves it
* unset.
*
* Only the exact string `false` disables it. A typo ("0", "off", "False")
* leaving the scheduler running is the safe-for-the-product failure, but a
* loud one would be better, so anything else that looks like an attempt to
* disable it is reported by `dispatchSwitchWarning`.
*/
export function dispatchEnabled(env: Record<string, string | undefined> = process.env): boolean {
return env.DISPATCH_ENABLED !== "false";
}
/** A message when `DISPATCH_ENABLED` is set to something that does not disable it. */
export function dispatchSwitchWarning(env: Record<string, string | undefined> = process.env): string | undefined {
const value = env.DISPATCH_ENABLED;
if (value === undefined || value === "" || value === "true" || value === "false") return undefined;
return `DISPATCH_ENABLED=${JSON.stringify(value)} does not disable the scheduler (only "false" does); jobs WILL be dispatched`;
}
+392
View File
@@ -0,0 +1,392 @@
import { and, asc, eq, inArray, isNotNull, isNull, lt } from "drizzle-orm";
import { db } from "../db";
import { printers, printJobs } from "../db/schema";
import { getDownloadUrl, headObject } from "../s3";
import { isGatewayOnline, sendPrinterCommand } from "../routes/ws/edge";
import { nextJobStatus } from "./job_status";
import { dispatchEnabled, dispatchSwitchWarning } from "./enabled";
/**
* The job scheduler: picks up `queued` jobs and puts them on idle printers.
*
* Until now `POST /jobs` ended at a `queued` row and an upload URL, and
* nothing ever moved it. This is the join - deliberately the simplest thing
* that is correct, because every interesting failure here is a physical one.
*
* ## Why a timer rather than an event
*
* A job becomes dispatchable for two unrelated reasons: someone queued it, or
* a printer became free. The second has no event the backend owns - it
* arrives as a telemetry snapshot from a gateway, five seconds after the fact
* at best. A timer catches both without needing a nudge from either, and a
* scheduler that only ran on job creation would leave a queue stuck forever
* behind one busy printer.
*
* `POST /jobs` nudges it anyway, so a queued job on an idle farm starts in
* well under a second rather than waiting out the tick.
*
* ## What it will not do
*
* It sends to a printer whose *last reported* status is `idle`, which is a
* claim about several seconds ago. Two things follow. A printer someone
* started by hand at the machine can still be sent a job, and the gateway's
* own guard is what refuses it - `start_uploaded_file` checks the printer is
* in a terminal state first. And a job is only ever dispatched once, because
* `claimNextJob` moves the row out of `queued` in the same statement that
* selects it; two ticks racing cannot both win.
*/
/** How often to look for dispatchable work. */
const TICK_MS = 5_000;
/**
* How long the gateway gets to download the file from R2 and transfer it to
* the printer before the presigned URL stops working.
*
* Generous on purpose. The FTPS transfer to the printer alone was measured at
* 0.2 MiB/s on a real P1P, so a 62 MB project - which is the size of the
* largest file sitting on that printer's card - is over five minutes of
* upload before a byte of printing happens. A URL that expires mid-transfer
* would fail the job for a reason that looks like a storage outage.
*/
const DOWNLOAD_URL_TTL_S = 1800;
/**
* Printer states that may be sent work.
*
* `idle` only. `offline` is the trap worth naming: a printer the gateway has
* not heard from keeps whatever status it last had, so `offline` can mean
* "powered off mid-print", and a printer in `error` may be sitting in a
* paused state with filament loaded. Neither is a machine to put a job on.
*/
const DISPATCHABLE_PRINTER_STATUS = ["idle"] as const;
/**
* How long a job may sit in `uploading` before it is given up on.
*
* `uploading` means the gateway has accepted the start and is downloading the
* file and transferring it to the printer. That reply is deliberately an
* "accepted, working on it" - the gateway answers immediately because
* awaiting the transfer inline would stop its telemetry for minutes, and
* telemetry is what every job's state is derived from.
*
* The consequence is that a failure *after* the ack has no `CommandResult` to
* carry it, so a job whose download 403s on an expired URL, or whose archive
* the printer refuses, would otherwise sit in `uploading` forever claiming to
* be nearly printing.
*
* Twenty minutes: the transfer alone is over five for the largest project
* seen (62 MB at the measured 0.2 MiB/s), and the printer then spends minutes
* in bed levelling before it reports `RUNNING` - a P1P sat at layer 0 for
* several while `percent` climbed to 14. Anything tighter would reap jobs
* that are working perfectly.
*/
const UPLOADING_TIMEOUT_MS = 20 * 60_000;
/**
* Takes one queued job for one printer, atomically.
*
* The `where` repeats `status = 'queued'`, which is what makes this safe: an
* `UPDATE ... WHERE status = 'queued'` that returns a row is proof this
* caller is the one that moved it. Selecting and then updating would let two
* ticks - or a tick and a nudge - both believe they owned the same job and
* send two `start_print` commands for it.
*/
async function claimNextJob(farmId: string, printerId: string) {
const next = await db.query.printJobs.findFirst({
columns: { id: true },
where: and(
eq(printJobs.farmId, farmId),
eq(printJobs.status, "queued"),
// A job that already names a printer was aimed at one deliberately;
// this scheduler only places the unassigned ones. Honouring a request
// for a *specific* printer is a separate feature and wants its own
// rule for what happens when that printer is busy.
isNull(printJobs.printerId),
),
// Oldest first. Not a priority system - just the absence of one, made
// explicit so the order is not whatever Postgres felt like returning.
orderBy: [asc(printJobs.createdAt)],
});
if (!next) return undefined;
const [claimed] = await db
.update(printJobs)
.set({ printerId, status: "uploading", startedAt: new Date(), updatedAt: new Date() })
.where(and(eq(printJobs.id, next.id), eq(printJobs.status, "queued")))
.returning();
return claimed;
}
/** Puts a job back for another tick, or fails it outright. */
async function releaseJob(jobId: string, outcome: "requeue" | "fail", reason: string) {
const metadataNote = { lastDispatchError: reason, lastDispatchErrorAt: new Date().toISOString() };
if (outcome === "requeue") {
await db
.update(printJobs)
.set({
status: "queued",
printerId: null,
startedAt: null,
updatedAt: new Date(),
metadata: metadataNote,
})
.where(eq(printJobs.id, jobId));
return;
}
await db
.update(printJobs)
.set({ status: "failed", completedAt: new Date(), updatedAt: new Date(), metadata: metadataNote })
.where(eq(printJobs.id, jobId));
}
/**
* One pass: for every farm with a connected gateway and an idle printer, try
* to place a job.
*
* Returns how many jobs it dispatched, which is what the tests assert on -
* a scheduler that silently does nothing is the failure mode to catch.
*/
export { nextJobStatus } from "./job_status";
export async function dispatchTick(): Promise<number> {
// Also covers the nudge from `POST /jobs`, which calls this directly.
if (!dispatchEnabled()) return 0;
await reapStalledUploads();
let dispatched = 0;
// Idle, claimed printers. The `gateway_printer_id` filter is not redundant
// with the status check: an unclaimed printer has no id to send a command
// to, and `sendPrinterCommand` would throw for every one of them.
const candidates = await db.query.printers.findMany({
columns: { id: true, farmId: true, name: true },
where: and(
inArray(printers.status, [...DISPATCHABLE_PRINTER_STATUS]),
// Only printers a gateway has claimed. Not redundant with the status
// check: an unclaimed printer has no gateway id to address, and
// `sendPrinterCommand` throws for every one of them.
isNotNull(printers.gatewayPrinterId),
),
});
for (const printer of candidates) {
// Cheap and in-memory, so it goes before the queue query rather than
// after: a farm whose gateway is offline has nothing dispatchable no
// matter what is queued, and asking the database first would be a
// round trip per printer to find that out.
if (!isGatewayOnline(printer.farmId)) continue;
const job = await claimNextJob(printer.farmId, printer.id);
if (!job) continue;
try {
// Is the file even there? `POST /jobs` hands the client a presigned PUT
// and returns, so the row exists before the object does. Asking the
// bucket is the only honest answer: a client that crashed mid-upload
// would never tell us, and dispatching anyway would have the gateway
// download R2's 403 XML error document and hand it to a printer as a
// project. A job whose file never arrives just stays queued.
const object = await headObject(job.r2ObjectKey);
if (!object) {
await releaseJob(job.id, "requeue", "the job file is not in object storage yet");
continue;
}
if (object.size === 0) {
// Zero bytes is a finished upload of nothing, not an upload in
// progress, and it will never become valid on its own.
await releaseJob(job.id, "fail", "the job file is empty");
console.warn(`[dispatch] job ${job.id} failed: uploaded file is 0 bytes`);
continue;
}
// Presigned here, at dispatch, and never at job creation: a queued job
// may wait days, and a URL minted when it was queued would be long
// expired by the time anything sent it.
const url = await getDownloadUrl(job.r2ObjectKey, DOWNLOAD_URL_TTL_S);
const result = await sendPrinterCommand(printer.id, {
action: "start_print",
job_id: job.id,
file_name: job.fileName,
url,
});
if (!result.ok) {
// Requeued, not failed. The gateway refusing a start is usually
// transient from the cloud's point of view - the printer was busier
// than its last snapshot said, or the gateway dropped mid-command -
// and a job that fails permanently because of a five-second-stale
// status reading would be indefensible.
await releaseJob(job.id, "requeue", result.error ?? "gateway reported the start failed");
console.warn(`[dispatch] job ${job.id} requeued: ${result.error}`);
continue;
}
// Note what this does *not* do: it does not mark the job `printing`.
// The gateway accepting a start is three steps away from a running
// print - confirmed the expensive way on 2026-10-15, when a real print
// was accepted, uploaded byte-perfect, reached RUNNING and then paused
// itself on a physical filament fault. The job stays `uploading` until
// telemetry from the printer says otherwise. See `advanceJobsFrom`.
dispatched += 1;
console.log(`[dispatch] job ${job.id} sent to printer ${printer.name} (${printer.id})`);
} catch (err) {
// `sendPrinterCommand` throws only for things that were true before
// anything was sent: no such printer, unclaimed, or no gateway. All
// three are transient or fixable, so the job goes back in the queue.
const reason = err instanceof Error ? err.message : String(err);
await releaseJob(job.id, "requeue", reason);
console.warn(`[dispatch] job ${job.id} requeued before sending: ${reason}`);
}
}
return dispatched;
}
/**
* Fails jobs that have been `uploading` too long.
*
* Marked `failed` rather than requeued, deliberately. A requeue would send
* the same file to the same printer and, if the cause was the file rather
* than a hiccup, do it again every twenty minutes forever. More importantly
* the printer may by now be *printing* it - the gateway could have succeeded
* and only its telemetry be late - and requeueing would then dispatch a
* second job onto a working machine. Failing is the recoverable mistake
* here; a human can requeue, and the printer's own telemetry will still
* report what it is really doing.
*/
async function reapStalledUploads(): Promise<void> {
const cutoff = new Date(Date.now() - UPLOADING_TIMEOUT_MS);
const stalled = await db
.update(printJobs)
.set({
status: "failed",
completedAt: new Date(),
updatedAt: new Date(),
metadata: {
lastDispatchError:
`the gateway accepted the start but the printer never reported printing within ` +
`${Math.round(UPLOADING_TIMEOUT_MS / 60_000)} minutes - check the gateway's log for ` +
`the download or transfer error, which has no path back to the cloud yet`,
lastDispatchErrorAt: new Date().toISOString(),
},
})
.where(and(eq(printJobs.status, "uploading"), lt(printJobs.startedAt, cutoff)))
.returning({ id: printJobs.id });
for (const job of stalled) {
console.warn(`[dispatch] job ${job.id} failed: stalled in uploading`);
}
}
let timer: ReturnType<typeof setInterval> | undefined;
/** Starts the scheduler. Idempotent, so a second call does not double-tick. */
export function startDispatcher() {
if (timer) return;
const warning = dispatchSwitchWarning();
if (warning) console.warn(`[dispatch] ${warning}`);
if (!dispatchEnabled()) {
console.log("[dispatch] DISABLED (DISPATCH_ENABLED=false): queued jobs will not be sent to printers");
return;
}
timer = setInterval(() => {
dispatchTick().catch((err) => console.error("[dispatch] tick failed", err));
}, TICK_MS);
// Node/Bun keep the process alive for an interval; this one should not be
// the reason a server refuses to exit.
timer.unref?.();
console.log(`[dispatch] scheduler started, every ${TICK_MS}ms`);
}
export function stopDispatcher() {
if (timer) clearInterval(timer);
timer = undefined;
}
/** Runs a tick now, without waiting for the next one. */
export function nudgeDispatcher() {
dispatchTick().catch((err) => console.error("[dispatch] nudge failed", err));
}
/**
* Moves job rows along from what the printers are actually reporting.
*
* This is the half that matters, and the reason the dispatcher above refuses
* to mark anything `printing`. A gateway accepting `start_print` means it
* accepted the command - no more. Confirmed the expensive way on a real P1P
* (2026-10-15): the command was accepted, the file uploaded byte-perfect, the
* printer reached `RUNNING`, and then it paused itself on a physical
* broken-filament fault. A job marked `printing` on the gateway's `ok` would
* have sat there claiming to print for the rest of the day.
*
* So job state is derived from the telemetry that follows, and the mapping is
* narrow on purpose:
*
* - `printing` while the printer reports it is printing.
* - `paused` likewise - including the printer pausing *itself* on a fault,
* which is what that P1P did and which an operator needs to see.
* - `completed` when the printer goes idle having been printing. A Bambu
* reports a finished print as `FINISH`, which `mapStatus` narrows to
* `idle`, so "was printing, now idle" is the completion signal.
* - `failed` when the printer reports an error. Note a Bambu reports an
* operator-cancelled print as `FAILED` too, so this cannot tell a
* cancellation from a fault; `cancelled` is set by whoever sends the stop,
* and is left alone here precisely so it is not overwritten by the `FAILED`
* that follows it.
*
* Progress is copied across whenever it is reported, because a job row that
* says 0% through a two-hour print is worse than useless to an operator.
*/
export async function advanceJobsFrom(
printerId: string,
status: "idle" | "printing" | "paused" | "error" | undefined,
percent: number | null | undefined,
): Promise<void> {
if (!status) return;
// The one job this printer could be running. `uploading` is included
// because that is the state the dispatcher leaves a job in, and the first
// telemetry that says `printing` is what promotes it.
const job = await db.query.printJobs.findFirst({
columns: { id: true, status: true },
where: and(
eq(printJobs.printerId, printerId),
inArray(printJobs.status, ["uploading", "printing", "paused"]),
),
orderBy: [asc(printJobs.createdAt)],
});
if (!job) return;
// Narrowed explicitly rather than cast: the query's `where` already limits
// this to the three, so a value outside them means the query and this call
// have drifted apart - which is exactly the bug a cast would hide.
if (job.status !== "uploading" && job.status !== "printing" && job.status !== "paused") return;
const next = nextJobStatus(job.status, status);
const terminal = next === "completed" || next === "failed";
// Progress alone still deserves a write, so an unchanged status does not
// freeze the percentage.
if (!next && percent == null) return;
await db
.update(printJobs)
.set({
...(next ? { status: next } : {}),
...(percent != null ? { progressPct: percent } : {}),
...(terminal ? { completedAt: new Date(), progressPct: next === "completed" ? 100 : undefined } : {}),
updatedAt: new Date(),
})
.where(eq(printJobs.id, job.id));
if (next && next !== job.status) {
console.log(`[dispatch] job ${job.id} ${job.status} -> ${next} (printer reported ${status})`);
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* The job-status decision, on its own and with no imports.
*
* Separate from `./index.ts` for one concrete reason: that module opens a
* database connection at import time, and this is the piece of logic most
* worth testing exhaustively without one. A decision that cannot be tested
* cheaply is a decision that does not get tested.
*/
/**
* What a job's status becomes, given what its printer is reporting.
*
* Pulled out of the database path on purpose: this is the one piece of logic
* here where a mistake reports a print that never happened, and it is worth
* being able to test every pair exhaustively without a printer or a
* Postgres. `undefined` means "leave it alone".
*/
export function nextJobStatus(
current: "uploading" | "printing" | "paused",
printerStatus: "idle" | "printing" | "paused" | "error",
): "printing" | "paused" | "completed" | "failed" | undefined {
switch (printerStatus) {
case "printing":
return "printing";
case "paused":
return "paused";
case "error":
return "failed";
case "idle":
// Only from a state that means the print had actually begun. A job
// still `uploading` on an idle printer has not started yet - the
// transfer takes minutes at the measured 0.2 MiB/s, and the printer is
// legitimately idle for all of it - so calling that "completed" would
// report a successful print that never ran. This is the single most
// important line in the file.
return current === "printing" || current === "paused" ? "completed" : undefined;
}
}
+50 -4
View File
@@ -2,29 +2,75 @@ import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import { sql } from "drizzle-orm";
import { toErrorResponse } from "@continuum/ts-core";
import { db } from "./db";
import { redis } from "./queue";
import { valkey } from "./queue";
import { authRoutes } from "./routes/auth";
import { orgRoutes } from "./routes/orgs";
import { farmRoutes } from "./routes/farms";
import { printerRoutes } from "./routes/printers";
import { jobRoutes } from "./routes/jobs";
import { edgeWsRoute } from "./routes/ws/edge";
import { telemetryWsRoute } from "./routes/ws/telemetry";
import { startDispatcher } from "./dispatch";
const app = new Elysia()
.use(cors())
.use(swagger({ path: "/docs" }))
.use(
swagger({
path: "/docs",
documentation: {
info: { title: "Continuum API", version: "0.1.0" },
tags: [
{ name: "auth", description: "Sessions and edge-gateway tokens" },
{ name: "orgs", description: "Organisations and membership - the root of all access" },
{ name: "farms", description: "Sites, each belonging to one organisation" },
{ name: "printers", description: "Machines, each belonging to one farm" },
{ name: "jobs", description: "Print jobs" },
],
components: {
securitySchemes: {
supabase: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description:
"A Supabase access token. Verified against the project JWKS; " +
"everything except /health and POST /auth/edge-token needs one.",
},
},
},
},
}),
)
/**
* One error shape for the whole API, from @continuum/ts-core: every failure
* is `{ error: { code, message, details? } }`. The route handlers throw
* `UnauthorizedError`/`ForbiddenError`/`NotFoundError`/`ConflictError`
* rather than setting a status and returning an ad-hoc object, so an
* authorization failure cannot be forgotten halfway through a handler - the
* throw leaves it.
*/
.onError(toErrorResponse)
.get("/health", async () => {
const rows = await db.execute<{ ok: number }>(sql`select 1 as ok`);
const redisPing = await redis.ping();
return { status: "ok", db: rows[0]?.ok === 1, redis: redisPing === "PONG" };
const valkeyPing = await valkey.ping();
return { status: "ok", db: rows[0]?.ok === 1, valkey: valkeyPing === "PONG" };
})
.use(authRoutes)
.use(orgRoutes)
.use(farmRoutes)
.use(printerRoutes)
.use(jobRoutes)
.use(edgeWsRoute)
.use(telemetryWsRoute)
.listen(Number(process.env.PORT ?? 3000));
console.log(`continuum-backend listening on :${app.server?.port}`);
// After `listen`, so a scheduler tick cannot race the server coming up. It
// only ever sends work to farms with a connected gateway, so on a fresh boot
// it does nothing until one connects.
startDispatcher();
export type App = typeof app;
+54
View File
@@ -0,0 +1,54 @@
import { TypeCompiler } from "@sinclair/typebox/compiler";
import { FrameTaskSchema, type FrameTask } from "@continuum/ts-types";
// Side-effect import, and it must come before the Compile() below: an
// unregistered format fails rather than being skipped, so without this every
// task is rejected with `Unknown format 'uuid'`.
import "../validation/formats";
/**
* The `stream:camera_frames` wire form, kept apart from `./index.ts` because
* that module opens a Valkey connection on import and this needs nothing but
* the schema. `tests/frame_task_contract.test.ts` asserts the exact field
* names against this module, with no infrastructure running.
*/
/**
* Compiled once at module load. The check is not for the sake of the
* database - a stream will accept any field name - it is to catch this end
* of the contract drifting from `schemas/continuum/v1/ai/frame_task.schema.json`.
*
* It is worth the line because the failure it catches is silent: this
* producer previously wrote `printerId`/`r2ObjectKey`/`capturedAt` while the
* worker read `printer_id` and nothing else, so every task was accepted,
* delivered, and attributed to a printer named "unknown". No error anywhere.
*/
export const checkFrameTask = TypeCompiler.Compile(FrameTaskSchema);
/**
* Flattens a task into the flat field/value list XADD takes.
*
* Exported because this - the exact field names, and which of them are
* written at all - *is* the contract with continuum-ai-worker. Keeping it a
* pure function means `tests/frame_task_contract.test.ts` can assert the
* wire form without a Valkey connection, and means there is one place to
* look when the Rust mirror in `continuum-ai-worker/src/task.rs` disagrees.
*/
export function frameTaskToStreamFields(task: FrameTask): string[] {
// Field order is not significant to a consumer, which reads a map - but
// keeping it stable keeps `XRANGE` output readable by hand.
const fields = [
"farm_id", task.farm_id,
"printer_id", task.printer_id,
"r2_object_key", task.r2_object_key,
"captured_at", task.captured_at,
];
// Omitted, not empty. `print_job_id` used to be written as `""` when there
// was no job; an empty string is a value, and a consumer that believed it
// would look up job id `""` or try to put it in a uuid column. A stream
// entry has no null, so "no value" means "no field".
if (task.print_job_id !== undefined) {
fields.push("print_job_id", task.print_job_id);
}
return fields;
}
+47 -24
View File
@@ -1,33 +1,56 @@
// ioredis talks RESP, so it drives Valkey unchanged - see docs/LICENSES.md in
// continuum-common for why the server is Valkey and not Redis. The `redis://`
// scheme names the wire protocol, not the product.
import Redis from "ioredis";
import { CAMERA_FRAMES_STREAM, type FrameTask } from "@continuum/ts-types";
import { checkFrameTask, frameTaskToStreamFields } from "./frame_task";
export const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
export const valkey = new Redis(process.env.VALKEY_URL ?? "redis://localhost:6379", {
maxRetriesPerRequest: 3,
});
export const CAMERA_FRAMES_STREAM = "stream:camera_frames";
export { CAMERA_FRAMES_STREAM };
export type { FrameTask };
export { frameTaskToStreamFields } from "./frame_task";
export interface FrameIngestionTask {
farmId: string;
printerId: string;
printJobId: string | null;
r2ObjectKey: string;
capturedAt: string;
/** Pushes one frame-ingestion task for continuum-ai-worker to pick up. */
export async function enqueueFrameIngestion(task: FrameTask): Promise<string> {
const [id] = await enqueueFrameIngestionBatch([task]);
return id!;
}
/** Pushes a frame-ingestion task for continuum-ai-worker to pick up via XREADGROUP. */
export async function enqueueFrameIngestion(task: FrameIngestionTask): Promise<string> {
return redis.xadd(
CAMERA_FRAMES_STREAM,
"*",
"farmId",
task.farmId,
"printerId",
task.printerId,
"printJobId",
task.printJobId ?? "",
"r2ObjectKey",
task.r2ObjectKey,
"capturedAt",
task.capturedAt,
) as Promise<string>;
/**
* Pushes a whole batch in one round trip.
*
* Frames arrive from a gateway as a `frame_batch` - one message covering
* several stills - and a farm produces one per printer per second or so.
* Enqueuing them one at a time, as this used to, costs a round trip each
* while holding up the WebSocket message handler.
*/
export async function enqueueFrameIngestionBatch(tasks: FrameTask[]): Promise<string[]> {
if (tasks.length === 0) return [];
const pipeline = valkey.pipeline();
for (const task of tasks) {
if (!checkFrameTask.Check(task)) {
const first = [...checkFrameTask.Errors(task)][0];
throw new Error(
`frame task does not match continuum/v1/ai/frame_task: ${first?.path} ${first?.message}`,
);
}
pipeline.xadd(CAMERA_FRAMES_STREAM, "*", ...frameTaskToStreamFields(task));
}
const results = await pipeline.exec();
if (!results) throw new Error("valkey pipeline returned no results");
return results.map(([err, id], index) => {
// ioredis reports per-command errors in the result tuple rather than
// throwing, so a pipeline where one XADD failed looks like success.
if (err) throw err;
if (typeof id !== "string") {
throw new Error(`XADD for frame ${index} returned ${typeof id}, expected an id`);
}
return id;
});
}
+56 -36
View File
@@ -1,61 +1,81 @@
import { Elysia, t } from "elysia";
import jwt from "@elysiajs/jwt";
import { eq } from "drizzle-orm";
import { UnauthorizedError } from "@continuum/ts-core";
import { db } from "../db";
import { farms } from "../db/schema";
import { eq } from "drizzle-orm";
import { listMemberships, requireUser } from "../auth";
import { edgeJwtSecret } from "../auth/edge_secret";
const EDGE_TOKEN_TTL_SECONDS = 3600;
/**
* Verifies Supabase-issued JWTs (HS256, shared secret) and issues short-lived
* edge-gateway tokens scoped to a single farm for the /ws/edge/v1 handshake.
* Two different kinds of authentication meet here, and they are not the same
* mechanism:
*
* - **Users** present a Supabase-issued access token, verified against the
* project's JWKS (RS256, public key, no secret in this process). That is
* `requireUser` and it guards everything user-facing.
* - **Edge gateways** present their farm's `edgeProxyToken` - a credential
* provisioned onto a machine, not held by a person - and trade it for a
* short-lived edge JWT that `/ws/edge/v1` accepts. That one *is* symmetric:
* this service is both its issuer and its only verifier, so there is no
* second party to keep a secret from.
*
* `POST /edge-token` is therefore deliberately not behind `requireUser`:
* a gateway has no user account to log in as. Its `edgeProxyToken` is the
* credential, and it is only ever handed out by
* `POST /farms/:id/edge-proxy-token`, which is behind `requireUser` and
* requires the `owner` role.
*/
export const authRoutes = new Elysia({ prefix: "/auth" })
.use(
jwt({
name: "supabaseJwt",
secret: process.env.SUPABASE_JWT_SECRET ?? "dev-secret-change-me",
}),
)
.use(
jwt({
name: "edgeJwt",
secret: process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me",
secret: edgeJwtSecret,
}),
)
.post(
"/session",
async ({ supabaseJwt, headers, set }) => {
const authHeader = headers.authorization;
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
if (!token) {
set.status = 401;
return { error: { code: "UNAUTHORIZED", message: "Missing bearer token" } };
}
const payload = await supabaseJwt.verify(token);
if (!payload) {
set.status = 401;
return { error: { code: "UNAUTHORIZED", message: "Invalid or expired token" } };
}
return { userId: payload.sub, email: payload.email, role: payload.role };
},
)
.post(
"/edge-token",
async ({ edgeJwt, body, set }) => {
const farm = await db.query.farms.findFirst({ where: eq(farms.id, body.farmId) });
async ({ edgeJwt, body }) => {
const farm = await db.query.farms.findFirst({
columns: { id: true, edgeProxyToken: true },
where: eq(farms.id, body.farmId),
});
// One message for both "no such farm" and "wrong token": a gateway
// being reprovisioned cannot tell which half it got wrong, but neither
// can someone walking farm ids.
if (!farm || farm.edgeProxyToken !== body.edgeProxyToken) {
set.status = 401;
return { error: { code: "UNAUTHORIZED", message: "Unknown farm or bad proxy token" } };
throw new UnauthorizedError("Unknown farm or bad proxy token");
}
const token = await edgeJwt.sign({ farmId: farm.id, scope: "edge" });
return { token, expiresInSeconds: 3600 };
const token = await edgeJwt.sign({
farmId: farm.id,
scope: "edge",
exp: Math.floor(Date.now() / 1000) + EDGE_TOKEN_TTL_SECONDS,
});
return { token, expiresInSeconds: EDGE_TOKEN_TTL_SECONDS };
},
{
body: t.Object({
farmId: t.String({ format: "uuid" }),
edgeProxyToken: t.String(),
edgeProxyToken: t.String({ minLength: 1 }),
}),
detail: { tags: ["auth"], summary: "Trade a farm's proxy token for an edge JWT" },
},
)
.use(requireUser)
/**
* Who the caller is and what they can reach. A client calls this once after
* sign-in: the memberships it returns are what every other endpoint
* authorizes against, so a UI can decide what to even offer without
* probing endpoints for 403s.
*/
.get(
"/session",
async ({ user }) => ({
user,
memberships: await listMemberships(user.id),
}),
{ detail: { tags: ["auth"], summary: "The caller's identity and organisation roles" } },
);
+289 -17
View File
@@ -1,38 +1,310 @@
import { Elysia, t } from "elysia";
import { eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { NotFoundError } from "@continuum/ts-core";
import { db } from "../db";
import { farms } from "../db/schema";
import { farms, printers } from "../db/schema";
import { farmProjection } from "../db/projections";
import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth";
import { PaginationQuery, paginate } from "./pagination";
import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters } from "./ws/edge";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
export const farmRoutes = new Elysia({ prefix: "/farms" })
.get("/", async () => db.select().from(farms))
.get("/:id", async ({ params, set }) => {
const farm = await db.query.farms.findFirst({ where: eq(farms.id, params.id) });
if (!farm) {
set.status = 404;
return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } };
}
return farm;
})
.use(requireUser)
/**
* Farms in the caller's organisations. The scoping is in the `where`, not
* applied to the result afterwards: a filter that runs after the query is
* one `return` away from being skipped, and it also makes the database read
* rows it is not allowed to show.
*
* A projection rather than `select *`, because the table holds
* `edgeProxyToken`, which trades for an edge JWT - see src/db/projections.ts.
*/
.get(
"/",
async ({ user, query }) => {
const scope = query.orgId
? and(visibleFarms(user.id), eq(farms.orgId, query.orgId))
: visibleFarms(user.id);
return db.query.farms.findMany({
columns: farmProjection.columns,
where: scope,
orderBy: [desc(farms.createdAt)],
...paginate(query),
});
},
{
query: t.Object({
orgId: t.Optional(t.String({ format: "uuid" })),
...PaginationQuery,
}),
detail: { tags: ["farms"], summary: "Farms in the caller's organisations" },
},
)
.get(
"/:id",
async ({ user, params }) => {
const { role } = await requireFarm(user.id, params.id, "viewer");
const farm = await db.query.farms.findFirst({
columns: farmProjection.columns,
where: eq(farms.id, params.id),
});
if (!farm) throw new NotFoundError(`Farm ${params.id} not found`);
return { ...farm, role };
},
{ params: IdParams, detail: { tags: ["farms"] } },
)
.post(
"/",
async ({ body, set }) => {
async ({ user, body, set }) => {
// `orgId` comes from the body and is then authorized, which is the
// right way round: the caller says where the farm should go, and the
// check is whether they own that organisation. What must never come
// from the body is *who the caller is* - that is `user.id`, from a
// verified token, and it is why there is no `ownerId` field here any
// more.
await requireOrg(user.id, body.orgId, "owner", "creating a farm");
// The generated token is deliberately not returned. A farm is created
// far more often than a gateway is provisioned, and a credential that
// comes back from a routine create ends up in logs, browser history and
// screenshots. Fetch it when a gateway actually needs it, below.
const [farm] = await db
.insert(farms)
.values({
name: body.name,
ownerId: body.ownerId,
orgId: body.orgId,
edgeProxyToken: crypto.randomUUID(),
location: body.location,
})
.returning();
.returning(farmProjection.returning);
set.status = 201;
return farm;
},
{
body: t.Object({
name: t.String({ minLength: 1 }),
ownerId: t.String({ format: "uuid" }),
location: t.Optional(t.String()),
name: t.String({ minLength: 1, maxLength: 200 }),
orgId: t.String({ format: "uuid" }),
location: t.Optional(t.String({ maxLength: 500 })),
}),
detail: { tags: ["farms"] },
},
)
.patch(
"/:id",
async ({ user, params, body }) => {
await requireFarm(user.id, params.id, "owner", "editing a farm");
// `orgId` is not patchable here on purpose: moving a farm between
// organisations changes who can see its print history and would need
// the `owner` role in *both*, so it wants its own endpoint rather than
// a field on a general update.
const [farm] = await db
.update(farms)
.set({ ...body, updatedAt: new Date() })
.where(eq(farms.id, params.id))
.returning(farmProjection.returning);
if (!farm) throw new NotFoundError(`Farm ${params.id} not found`);
return farm;
},
{
params: IdParams,
body: t.Object({
name: t.Optional(t.String({ minLength: 1, maxLength: 200 })),
location: t.Optional(t.String({ maxLength: 500 })),
settings: t.Optional(t.Record(t.String(), t.Unknown())),
}),
detail: { tags: ["farms"] },
},
)
.delete(
"/:id",
async ({ user, params }) => {
await requireFarm(user.id, params.id, "owner", "deleting a farm");
// Cascades to the farm's printers and print jobs, per the schema's
// foreign keys. Worth knowing before calling it.
await db.delete(farms).where(eq(farms.id, params.id));
return { deleted: params.id };
},
{ params: IdParams, detail: { tags: ["farms"] } },
)
/**
* Issues a *new* `edgeProxyToken` for this farm and returns it once.
*
* The only route that ever returns that credential, and it rotates on every
* call rather than revealing the stored one: a reveal endpoint returns the
* same secret to everyone who asks and leaves no trace of having been
* called, while a rotate endpoint makes the old value useless, so a
* gateway's token leaking has a bounded blast radius and reprovisioning is
* the fix.
*
* The cost is that rotating locks out the gateway currently using the old
* token until it is reconfigured, which is the right trade for a credential
* that drives physical machines - but it does mean this is not a "show me
* the token" button.
*/
.post(
"/:id/edge-proxy-token",
async ({ user, params }) => {
await requireFarm(user.id, params.id, "owner", "rotating a farm's gateway credential");
const token = crypto.randomUUID();
const [farm] = await db
.update(farms)
.set({ edgeProxyToken: token, updatedAt: new Date() })
.where(eq(farms.id, params.id))
.returning({ id: farms.id });
if (!farm) throw new NotFoundError(`Farm ${params.id} not found`);
return {
farmId: farm.id,
edgeProxyToken: token,
note: "Any gateway still using the previous token must be reconfigured.",
};
},
{
params: IdParams,
detail: {
tags: ["farms"],
summary: "Rotate and return this farm's edge-proxy token (owner only)",
},
},
)
/**
* Printers this farm's gateway can see that no printer row claims.
*
* This exists because of the worst first-run experience in the platform.
* A gateway reports its fleet by the ids in its own `printers.toml`, and
* `persistTelemetry` refuses to create rows for ids it does not recognise -
* correctly, since a typo there would otherwise become a permanent phantom
* printer that nobody can explain. But the refusal used to be silent, so a
* new owner saw a connected gateway, an empty fleet, and no way to find out
* why. The answer was in the server's stderr.
*
* Now it is here. Claim one by creating it with `POST /printers`, passing
* the `gatewayPrinterId` from this list; the next telemetry batch fills in
* everything else and the entry disappears from here.
*
* `viewer`, not `owner`: this reports what hardware exists, which is the
* same class of fact as the printer list itself. Creating the row still
* needs `owner`.
*
* `gatewayOnline` is in the response because an empty list is ambiguous on
* its own - it means either "everything is claimed" or "no gateway has ever
* connected", and those need completely different things done about them.
*/
.get(
"/:id/unclaimed-printers",
async ({ user, params }) => {
await requireFarm(user.id, params.id, "viewer");
return {
farmId: params.id,
gatewayOnline: isGatewayOnline(params.id),
unclaimed: listUnclaimedPrinters(params.id),
};
},
{
params: IdParams,
detail: {
tags: ["farms"],
summary: "Printers the gateway reports that no printer row claims yet",
},
},
)
/**
* Printers the gateway *found* on its own network and USB ports, which is
* a different thing from the list above.
*
* An unclaimed printer is in the gateway's fleet file already: its address
* and credential were written down by hand, and only the printer row is
* missing. A discovered one was merely seen. The point of it is that
* adding a printer should not start with someone walking to a machine to
* read its IP address off the screen.
*
* What a candidate cannot carry is a credential, and no amount of probing
* will change that: a Bambu access code lives on the printer's display and
* a PrusaLink API key in its web UI. So this shortens the job rather than
* removing it - the operator still types one secret, instead of a secret
* and an address and a serial number.
*
* `null` for `discovery` is the normal answer, and means the gateway has
* never reported a scan - almost always because discovery is off, which is
* its default. That is not the same as a report with an empty `printers`,
* which means the gateway looked and found nothing.
*
* `viewer`, like the list above and for the same reason: this reports what
* hardware exists. Creating the row still needs `owner`.
*/
.get(
"/:id/discovered-printers",
async ({ user, params }) => {
await requireFarm(user.id, params.id, "viewer");
const discovery = getDiscoveredPrinters(params.id);
const gatewayOnline = isGatewayOnline(params.id);
if (!discovery) {
return { farmId: params.id, gatewayOnline, discovery: null };
}
// Cross-check against the rows this farm already has, so the console
// can grey out a candidate instead of offering a duplicate. The
// gateway filters its own fleet file before reporting, but it knows
// nothing about printer rows registered in the cloud and never
// claimed, which is exactly the case that produces a confusing
// "add" button for a printer the operator already added.
const existing = await db
.select({
id: printers.id,
serialNumber: printers.serialNumber,
lanAddress: printers.lanAddress,
})
.from(printers)
.where(eq(printers.farmId, params.id));
const bySerial = new Map<string, string>();
const byAddress = new Map<string, string>();
for (const row of existing) {
if (row.serialNumber) bySerial.set(row.serialNumber.toLowerCase(), row.id);
if (row.lanAddress) byAddress.set(row.lanAddress.toLowerCase(), row.id);
}
return {
farmId: params.id,
gatewayOnline,
discovery: {
scannedAt: discovery.scannedAt,
reportedAt: discovery.reportedAt,
gatewayId: discovery.gatewayId,
printers: discovery.printers.map((candidate) => {
const bySerialMatch = candidate.serial
? bySerial.get(candidate.serial.toLowerCase())
: undefined;
const byAddressMatch = byAddress.get(candidate.address.toLowerCase());
return {
...candidate,
/**
* The printer row this candidate looks like, or null.
*
* `matchedOn` says how sure that is, and the two are not
* equally sure. A serial number identifies a machine. An
* address identifies a DHCP lease, so an address match can
* be the printer that held that address last month - which
* is why the basis is reported instead of just a boolean,
* and why the console should warn on `address` rather than
* refuse to add.
*/
existingPrinterId: bySerialMatch ?? byAddressMatch ?? null,
matchedOn: bySerialMatch ? "serial" : byAddressMatch ? "address" : null,
};
}),
},
};
},
{
params: IdParams,
detail: {
tags: ["farms"],
summary: "Printers the gateway found on its network that are not in its fleet",
},
},
);
+84 -17
View File
@@ -1,27 +1,68 @@
import { Elysia, t } from "elysia";
import { eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { ConflictError, NotFoundError } from "@continuum/ts-core";
import { db } from "../db";
import { printJobs } from "../db/schema";
import { getUploadUrl, jobFileKey } from "../s3";
import { requireFarm, requireJob, requireUser, visibleJobs } from "../auth";
import { PaginationQuery, paginate } from "./pagination";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
export const jobRoutes = new Elysia({ prefix: "/jobs" })
.get("/", async ({ query }) => {
if (query.farmId) {
return db.select().from(printJobs).where(eq(printJobs.farmId, query.farmId));
}
return db.select().from(printJobs);
}, { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) })
.get("/:id", async ({ params, set }) => {
.use(requireUser)
.get(
"/",
async ({ user, query }) => {
const scope = query.farmId
? and(visibleJobs(user.id), eq(printJobs.farmId, query.farmId))
: visibleJobs(user.id);
return db.query.printJobs.findMany({
where: scope,
orderBy: [desc(printJobs.createdAt)],
...paginate(query),
});
},
{
query: t.Object({
farmId: t.Optional(t.String({ format: "uuid" })),
...PaginationQuery,
}),
detail: { tags: ["jobs"], summary: "Print jobs in the caller's organisations" },
},
)
.get(
"/:id",
async ({ user, params }) => {
await requireJob(user.id, params.id, "viewer");
const job = await db.query.printJobs.findFirst({ where: eq(printJobs.id, params.id) });
if (!job) {
set.status = 404;
return { error: { code: "NOT_FOUND", message: `Print job ${params.id} not found` } };
}
if (!job) throw new NotFoundError(`Print job ${params.id} not found`);
return job;
})
},
{ params: IdParams, detail: { tags: ["jobs"] } },
)
/**
* Creates the job row and hands back a presigned URL to PUT the file to.
*
* `operator`, matching printer commands: a queued job is a request to move
* a machine.
*
* The row lands as `queued` and the scheduler takes it from there - see
* `src/dispatch`.
*
* Note the ordering this endpoint cannot fix: the caller has not used the
* upload URL yet when this returns, so the row exists before the file
* does. The scheduler therefore checks the bucket before dispatching
* rather than assuming, and a job whose file never arrives simply stays
* queued. That also means there is nothing to nudge here - the file is
* guaranteed absent at this point - so a job starts on the first tick
* after its upload lands.
*/
.post(
"/",
async ({ body, set }) => {
async ({ user, body, set }) => {
await requireFarm(user.id, body.farmId, "operator", "submitting a print job");
const jobId = crypto.randomUUID();
const r2ObjectKey = jobFileKey(body.farmId, jobId, body.fileName);
@@ -32,7 +73,10 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" })
farmId: body.farmId,
fileName: body.fileName,
r2ObjectKey,
submittedBy: body.submittedBy,
// From the verified token, never from the body. It used to be a
// `submittedBy` field the caller filled in, which made the audit
// trail a suggestion rather than a record.
submittedBy: user.id,
})
.returning();
@@ -43,9 +87,32 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" })
{
body: t.Object({
farmId: t.String({ format: "uuid" }),
fileName: t.String({ minLength: 1 }),
fileName: t.String({ minLength: 1, maxLength: 500 }),
contentType: t.Optional(t.String()),
submittedBy: t.String({ format: "uuid" }),
}),
detail: { tags: ["jobs"], summary: "Queue a print job and get an upload URL" },
},
)
.delete(
"/:id",
async ({ user, params }) => {
await requireJob(user.id, params.id, "operator", "deleting a print job");
const job = await db.query.printJobs.findFirst({
columns: { status: true },
where: eq(printJobs.id, params.id),
});
if (!job) throw new NotFoundError(`Print job ${params.id} not found`);
// Only a job that has not reached a printer. Cancelling a running print
// means telling the gateway to stop, which is a command and not a row
// deletion - and deleting the row of a print in progress would leave
// the printer running work nothing is tracking.
if (job.status !== "queued" && job.status !== "uploading") {
throw new ConflictError(`Print job ${params.id} is ${job.status} and cannot be deleted`);
}
await db.delete(printJobs).where(eq(printJobs.id, params.id));
return { deleted: params.id };
},
{ params: IdParams, detail: { tags: ["jobs"], summary: "Delete a job that has not started" } },
);
+202
View File
@@ -0,0 +1,202 @@
import { Elysia, t } from "elysia";
import { and, count, eq, ne } from "drizzle-orm";
import { ConflictError, NotFoundError } from "@continuum/ts-core";
import { db } from "../db";
import { farms, orgMembers, organisations, ORG_ROLES } from "../db/schema";
import { listMemberships, requireOrg, requireUser } from "../auth";
const RoleSchema = t.Union(ORG_ROLES.map((role) => t.Literal(role)));
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
const MemberParams = t.Object({
id: t.String({ format: "uuid" }),
userId: t.String({ format: "uuid" }),
});
/**
* Counts the organisation's owners, used to refuse the one change that cannot
* be undone through the API: removing or demoting its last `owner` leaves
* nobody who can add one back, and the org's farms and printers with it.
*/
async function ownersOtherThan(orgId: string, userId: string): Promise<number> {
const [row] = await db
.select({ n: count() })
.from(orgMembers)
.where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.role, "owner"), ne(orgMembers.userId, userId)));
return Number(row?.n ?? 0);
}
export const orgRoutes = new Elysia({ prefix: "/orgs" })
.use(requireUser)
/**
* Only the caller's own organisations - there is no endpoint that lists all
* of them, by design. An organisation the caller is not a member of should
* be indistinguishable from one that does not exist.
*/
.get("/", async ({ user }) => listMemberships(user.id), {
detail: { tags: ["orgs"], summary: "Organisations the caller belongs to" },
})
.post(
"/",
async ({ user, body, set }) => {
// One transaction: an organisation with no owner is unreachable by
// everyone including its creator, and that is exactly the state a
// failure between these two inserts would leave behind.
const org = await db.transaction(async (tx) => {
const [created] = await tx
.insert(organisations)
.values({ name: body.name })
.returning();
await tx
.insert(orgMembers)
.values({ orgId: created!.id, userId: user.id, role: "owner" });
return created!;
});
set.status = 201;
return { ...org, role: "owner" as const };
},
{
body: t.Object({ name: t.String({ minLength: 1, maxLength: 200 }) }),
detail: { tags: ["orgs"], summary: "Create an organisation, owned by the caller" },
},
)
.get(
"/:id",
async ({ user, params }) => {
const role = await requireOrg(user.id, params.id, "viewer");
const org = await db.query.organisations.findFirst({
where: eq(organisations.id, params.id),
});
// Membership resolved, so the row is there; the throw is for the
// vanishingly small window where it was deleted in between.
if (!org) throw new NotFoundError(`Organisation ${params.id} not found`);
return { ...org, role };
},
{ params: IdParams, detail: { tags: ["orgs"] } },
)
.patch(
"/:id",
async ({ user, params, body }) => {
await requireOrg(user.id, params.id, "owner", "renaming an organisation");
const [org] = await db
.update(organisations)
.set({ ...body, updatedAt: new Date() })
.where(eq(organisations.id, params.id))
.returning();
if (!org) throw new NotFoundError(`Organisation ${params.id} not found`);
return org;
},
{
params: IdParams,
body: t.Object({
name: t.Optional(t.String({ minLength: 1, maxLength: 200 })),
settings: t.Optional(t.Record(t.String(), t.Unknown())),
}),
detail: { tags: ["orgs"] },
},
)
.delete(
"/:id",
async ({ user, params }) => {
await requireOrg(user.id, params.id, "owner", "deleting an organisation");
// Deleting the org cascades to its farms, their printers, every print
// job and every stored frame embedding. That is far too much to lose to
// a mistyped id, so this refuses while any farm is still attached and
// makes the caller delete them one at a time first.
const [attached] = await db
.select({ n: count() })
.from(farms)
.where(eq(farms.orgId, params.id));
if (Number(attached?.n ?? 0) > 0) {
throw new ConflictError(
`Organisation ${params.id} still has ${attached!.n} farm(s); delete them first`,
);
}
await db.delete(organisations).where(eq(organisations.id, params.id));
return { deleted: params.id };
},
{ params: IdParams, detail: { tags: ["orgs"] } },
)
.get(
"/:id/members",
async ({ user, params }) => {
await requireOrg(user.id, params.id, "viewer");
return db
.select({
userId: orgMembers.userId,
role: orgMembers.role,
joinedAt: orgMembers.createdAt,
})
.from(orgMembers)
.where(eq(orgMembers.orgId, params.id));
},
{ params: IdParams, detail: { tags: ["orgs"], summary: "List members and their roles" } },
)
/**
* Add a member or change their role. PUT rather than POST: the caller
* states the role this user should have, and sending it twice is the same
* as sending it once.
*
* There is no invitation flow yet - this takes a Supabase user id directly,
* so adding someone means knowing their id. That is the right shape for an
* API and the wrong shape for a UI, which will need an invite-by-email
* endpoint on top of it.
*/
.put(
"/:id/members/:userId",
async ({ user, params, body }) => {
await requireOrg(user.id, params.id, "owner", "managing membership");
// Demoting the last owner is the same mistake as deleting them, so it
// is refused the same way. Written against "owners other than this
// one" rather than "is this me" so it also covers one owner demoting
// another, which is the same hole from the other side.
if (body.role !== "owner" && (await ownersOtherThan(params.id, params.userId)) === 0) {
throw new ConflictError(
params.userId === user.id
? "You are the only owner of this organisation; promote another owner before demoting yourself"
: "That is the only owner of this organisation; promote another owner first",
);
}
const [member] = await db
.insert(orgMembers)
.values({ orgId: params.id, userId: params.userId, role: body.role })
.onConflictDoUpdate({
target: [orgMembers.orgId, orgMembers.userId],
set: { role: body.role, updatedAt: new Date() },
})
.returning({
userId: orgMembers.userId,
role: orgMembers.role,
joinedAt: orgMembers.createdAt,
});
return member;
},
{
params: MemberParams,
body: t.Object({ role: RoleSchema }),
detail: { tags: ["orgs"], summary: "Add a member or change their role" },
},
)
.delete(
"/:id/members/:userId",
async ({ user, params }) => {
await requireOrg(user.id, params.id, "owner", "managing membership");
if ((await ownersOtherThan(params.id, params.userId)) === 0) {
throw new ConflictError(
"That is the only owner of this organisation; promote another owner first",
);
}
const [removed] = await db
.delete(orgMembers)
.where(and(eq(orgMembers.orgId, params.id), eq(orgMembers.userId, params.userId)))
.returning({ userId: orgMembers.userId });
if (!removed) throw new NotFoundError(`User ${params.userId} is not a member`);
return { removed: removed.userId };
},
{ params: MemberParams, detail: { tags: ["orgs"], summary: "Remove a member" } },
);
+21
View File
@@ -0,0 +1,21 @@
import { t } from "elysia";
/**
* Shared `limit`/`offset` query parameters for the list endpoints.
*
* Every list here is unbounded in principle - a farm accumulates print jobs
* forever - so a default limit is applied whether or not the caller asks for
* one. `maximum` is enforced by the validator rather than silently clamped:
* a client that asks for 10,000 rows should be told no, not handed 200 and
* left to conclude that is all there is.
*/
export const PaginationQuery = {
limit: t.Optional(t.Integer({ minimum: 1, maximum: 200, default: 50 })),
offset: t.Optional(t.Integer({ minimum: 0, default: 0 })),
};
export const DEFAULT_LIMIT = 50;
export function paginate(query: { limit?: number; offset?: number }) {
return { limit: query.limit ?? DEFAULT_LIMIT, offset: query.offset ?? 0 };
}
+284 -23
View File
@@ -1,35 +1,296 @@
import { Elysia, t } from "elysia";
import { eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { NotFoundError } from "@continuum/ts-core";
import { PrinterCommandSchema } from "@continuum/ts-types";
import { db } from "../db";
import { printers } from "../db/schema";
import { PrinterInsertSchema } from "../db/validators";
import { PrinterCreateSchema, PrinterUpdateSchema } from "../db/validators";
import { printerProjection } from "../db/projections";
import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth";
import { PaginationQuery, paginate } from "./pagination";
import { resolveStream } from "../stream";
import { mintIceServers, turnConfigured } from "../camera/ice";
import { isGatewayOnline, negotiateCameraStream, sendPrinterCommand } from "./ws/edge";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
export const printerRoutes = new Elysia({ prefix: "/printers" })
.get("/", async ({ query }) => {
if (query.farmId) {
return db.select().from(printers).where(eq(printers.farmId, query.farmId));
}
return db.select().from(printers);
}, { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) })
.get("/:id", async ({ params, set }) => {
const printer = await db.query.printers.findFirst({ where: eq(printers.id, params.id) });
if (!printer) {
set.status = 404;
return { error: { code: "NOT_FOUND", message: `Printer ${params.id} not found` } };
}
.use(requireUser)
// Scoped through farm -> org membership, and a projection rather than
// `select *`: the projection is an allow-list, so a column added later stays
// out of responses until someone decides it is public. (Printers hold no
// credential today - a printer's LAN access code lives on the proxy, not
// here.) See src/db/projections.ts.
.get(
"/",
async ({ user, query }) => {
const scope = query.farmId
? and(visiblePrinters(user.id), eq(printers.farmId, query.farmId))
: visiblePrinters(user.id);
return db.query.printers.findMany({
columns: printerProjection.columns,
where: scope,
orderBy: [desc(printers.createdAt)],
...paginate(query),
});
},
{
query: t.Object({
farmId: t.Optional(t.String({ format: "uuid" })),
...PaginationQuery,
}),
detail: { tags: ["printers"], summary: "Printers in the caller's organisations" },
},
)
.get(
"/:id",
async ({ user, params }) => {
await requirePrinter(user.id, params.id, "viewer");
const printer = await db.query.printers.findFirst({
columns: printerProjection.columns,
where: eq(printers.id, params.id),
});
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
return printer;
})
},
{ params: IdParams, detail: { tags: ["printers"] } },
)
.post(
"/",
async ({ body, set }) => {
// `body` is already validated + shaped against the Drizzle table by
// PrinterInsertSchema, so it can go straight into `.values()` with no
// hand-mapping of individual fields.
const [printer] = await db.insert(printers).values(body).returning();
async ({ user, body, set }) => {
await requireFarm(user.id, body.farmId, "owner", "adding a printer");
const [printer] = await db
.insert(printers)
.values(body)
.returning(printerProjection.returning);
set.status = 201;
return printer;
},
// Derived from `printers` via drizzle-typebox - see src/db/validators.ts.
// Add or rename a column in the schema and this validator updates itself.
{ body: PrinterInsertSchema },
{ body: PrinterCreateSchema, detail: { tags: ["printers"] } },
)
.patch(
"/:id",
async ({ user, params, body }) => {
await requirePrinter(user.id, params.id, "owner", "editing a printer");
const [printer] = await db
.update(printers)
.set({ ...body, updatedAt: new Date() })
.where(eq(printers.id, params.id))
.returning(printerProjection.returning);
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
return printer;
},
{
params: IdParams,
body: PrinterUpdateSchema,
detail: {
tags: ["printers"],
summary: "Edit a printer's configuration (name, address, credentials, gateway id)",
},
},
)
.delete(
"/:id",
async ({ user, params }) => {
await requirePrinter(user.id, params.id, "owner", "deleting a printer");
await db.delete(printers).where(eq(printers.id, params.id));
return { deleted: params.id };
},
{ params: IdParams, detail: { tags: ["printers"] } },
)
/**
* Sends a command to the printer through its farm's gateway and waits for
* the gateway's answer.
*
* `operator`, not `viewer`: every command in the set moves or heats a
* physical machine, and the blast radius of getting this boundary wrong is
* a ruined print or a hot end driven into a plate. It is the one role
* boundary here that is not about data.
*
* As of 2026-10-11 the set includes `control_print`, whose `stop` is a
* cancel: confirmed on a real P1P, it ends the running print and the
* printer then reports `FAILED`. There is no undo, so a UI must not put it
* where `pause` is expected. Note also that a gateway returning `ok` means
* the printer *accepted* the command - on an idle P1P all three verbs
* answer success - so whether the state changed has to be read back from
* telemetry.
*/
.post(
"/:id/commands",
async ({ user, params, body, set }) => {
await requirePrinter(user.id, params.id, "operator", "sending a printer command");
try {
const result = await sendPrinterCommand(params.id, body);
if (!result.ok) {
// 502, not 400: the request was fine and was delivered; it was the
// printer or its gateway that would not carry it out.
set.status = 502;
return {
error: {
code: "COMMAND_FAILED",
message: result.error ?? "the gateway reported the command failed",
},
commandId: result.commandId,
};
}
return { ok: true, commandId: result.commandId };
} catch (err) {
// `sendPrinterCommand` throws only for things that are true before
// anything is sent: no such printer, no gateway has claimed it, or
// its farm has no gateway connected right now.
set.status = 409;
return {
error: {
code: "PRINTER_UNREACHABLE",
message: err instanceof Error ? err.message : String(err),
},
};
}
},
// The same command set the gateway accepts, straight from the shared
// schema - so this route cannot drift into offering something no gateway
// can execute. Note what is absent: there is still no "start a print"
// command, though no longer because the path is unproven (it was
// confirmed on a real P1P on 2026-10-11) - nothing uploads a sliced file
// to a gateway, and nothing decides which printer runs which job.
{ params: IdParams, body: PrinterCommandSchema, detail: { tags: ["printers"] } },
)
.get(
"/:id/gateway",
async ({ user, params }) => {
await requirePrinter(user.id, params.id, "viewer");
const printer = await db.query.printers.findFirst({
columns: { farmId: true, gatewayPrinterId: true },
where: eq(printers.id, params.id),
});
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
// Whether a command sent right now could even be delivered - worth being
// able to ask before trying, since the answer changes on its own.
return {
claimed: printer.gatewayPrinterId !== null,
gatewayPrinterId: printer.gatewayPrinterId,
gatewayOnline: isGatewayOnline(printer.farmId),
};
},
{ params: IdParams, detail: { tags: ["printers"] } },
)
/**
* What a browser needs to start watching this printer's camera.
*
* Deliberately no URL. A browser cannot play from a gateway's own address:
* go2rtc sends no CORS headers and checks its API password before CORS, so
* an unauthenticated preflight gets a 401 - measured against 1.9.14 - and
* a gateway sits behind a workshop NAT besides. This used to return the
* gateway's LAN address, which could only ever have worked for an operator
* standing on the same network, and offered every other viewer a player
* that silently never loaded.
*
* So the caller gets a camera name to negotiate for and the ICE servers to
* negotiate with, then POSTs an offer to `/:id/webrtc`. Media travels
* browser-to-gateway over the TURN relay, DTLS-SRTP encrypted, so neither
* this process nor the relay can read it.
*
* 404 rather than 200-with-nulls when it cannot be watched: a frontend
* handed `{ cameraId: null }` has to remember to check, and one that
* forgets renders a broken player. The two causes get different messages
* because they are different problems (a P1/A1 that can never stream,
* versus a gateway that is merely unplugged).
*
* `iceServers` is empty when TURN is not configured. That is a supported
* state, not a failure: an operator on the gateway's own LAN connects on
* host candidates alone. It does mean remote viewers will not work until
* CLOUDFLARE_TURN_KEY_ID and CLOUDFLARE_TURN_API_TOKEN are set.
*/
.get(
"/:id/stream",
async ({ user, params }) => {
await requirePrinter(user.id, params.id, "viewer");
const printer = await db.query.printers.findFirst({
columns: { farmId: true, cameraStream: true },
where: eq(printers.id, params.id),
});
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
// The gateway has to be connected, because it is what answers the
// offer - a camera name alone would start a negotiation with nobody.
const result = resolveStream(printer.cameraStream, isGatewayOnline(printer.farmId));
if (!result.ok) throw new NotFoundError(result.message);
// Minted per request and short-lived, so the window in which a leaked
// credential is useful is small. Never cached in the client beyond the
// session it opens.
const iceServers = await mintIceServers();
return { ...result.stream, iceServers: iceServers ?? [], turnConfigured: turnConfigured() };
},
{
params: IdParams,
detail: {
tags: ["printers"],
summary: "What a browser needs to negotiate this printer's camera",
},
},
)
/**
* Relays a browser's WebRTC offer to the printer's gateway and returns
* go2rtc's answer.
*
* **This endpoint carries signalling, not video.** One small request and
* response; the media then flows browser-to-gateway directly over ICE. On
* a workshop LAN it never leaves the building and costs this backend no
* bandwidth, which is the whole reason the relay is built this way rather
* than proxying bytes - that would have put every viewer's video through
* the cloud and added a second of fragment buffering.
*
* It is also the *only* way a browser can play these cameras. go2rtc
* sends no CORS headers and checks its API password before CORS, so a
* browser signalling to a gateway directly gets a 401 on its preflight -
* measured against 1.9.14, not assumed. And a gateway is behind a workshop
* NAT, so this backend cannot dial in either; the gateway's own outbound
* socket is the only path.
*
* Takes the offer as JSON rather than `application/sdp` so it validates
* like every other route here, and because `application/sdp` is not a
* CORS-safelisted content type - which is exactly the trap that makes the
* direct path impossible.
*/
.post(
"/:id/webrtc",
async ({ user, params, body, status }) => {
// "viewer" is deliberate: watching a camera is reading. Anything that
// moves the machine wants "operator".
await requirePrinter(user.id, params.id, "viewer");
const result: { sdp?: string; error?: string } = await negotiateCameraStream(
params.id,
body.sdp,
).catch((err: unknown) => ({ error: err instanceof Error ? err.message : String(err) }));
// 503, not 500: nothing is wrong with this request, and a client that
// gets this should try again later rather than change what it sent.
if (!result.sdp) {
return status(503, {
error: "CAMERA_UNAVAILABLE",
message: result.error ?? "the gateway did not return an answer",
});
}
return { sdp: result.sdp };
},
{
params: IdParams,
body: t.Object({
sdp: t.String({
// An SDP offer from a browser is a few kilobytes. Bounded because
// this is forwarded verbatim over a farm's control socket, and that
// socket also carries telemetry for every printer on it.
minLength: 1,
maxLength: 64_000,
description: "The browser's WebRTC offer SDP",
}),
}),
detail: {
tags: ["printers"],
summary: "Relay a WebRTC offer to this printer's camera (signalling only)",
},
},
);
+791 -46
View File
@@ -1,89 +1,834 @@
import { Elysia, t } from "elysia";
import { Elysia } from "elysia";
import jwt from "@elysiajs/jwt";
import { enqueueFrameIngestion } from "../../queue";
import { and, eq } from "drizzle-orm";
import {
type DiscoveredPrinter,
EdgeClientMessageSchema,
type EdgeServerMessage,
type PrinterCommand,
type PrinterTelemetry,
} from "@continuum/ts-types";
import { db } from "../../db";
import { printers } from "../../db/schema";
import { advanceJobsFrom } from "../../dispatch";
import { enqueueFrameIngestionBatch } from "../../queue";
import { edgeJwtSecret } from "../../auth/edge_secret";
import { farmFromEdgeUpgrade } from "../../auth/edge_upgrade";
import { publish } from "../../telemetry/broadcast";
import { printerProjection } from "../../db/projections";
/**
* Edge-gateway uplink. A continuum-proxy gateway connects here with a
* short-lived edge JWT (minted via POST /auth/edge-token), streams telemetry
* for its whole printer fleet, and receives commands for individual printers
* on the same socket.
*
* ## Where the token goes, and why here
*
* `Authorization: Bearer <edge jwt>` on the upgrade request, verified before
* the socket exists. An unauthenticated peer therefore gets an HTTP 401 and
* no WebSocket at all: no entry in `connections`, no frame ever parsed, no
* state of any kind allocated for it.
*
* The two alternatives were both weaker, and one of them is what this used
* to do:
*
* - **In the first message** (a `token` field on `hello`, which is what this
* route did until now). It worked, but it meant an unauthenticated peer
* got a live socket, an entry in `connections`, and its JSON run through
* `EdgeClientMessageSchema` and this switch statement before proving
* anything. Every message type refused to act without a verified farm, so
* nothing could be *done* with it - but "it cannot do anything" is a
* weaker guarantee than "it does not exist", and it needed a timeout to
* stop anyone holding sockets open for free.
* - **A `?token=` query parameter.** A bearer credential in a URL ends up in
* access logs, proxy logs and error breadcrumbs. This codebase already
* goes to some trouble to avoid exactly that - `PrinterCommand` in the
* proxy has a hand-written `Debug` so a presigned URL prints as
* `<redacted>`. `Authorization` is the header every log tool knows to
* redact; a query string is not.
*
* A browser cannot set headers on a WebSocket, so a future user-facing
* socket cannot copy this exactly. Its equivalent is the subprotocol field
* (`new WebSocket(url, ["bearer", token])`), which is also verified at the
* upgrade - the same property, through the one handshake field a browser
* does control.
*
* The wire format is `schemas/continuum/v1/edge/` in continuum-schemas,
* validated here through its TypeBox mirror in `@continuum/ts-types`. It is
* snake_case, unlike this backend's HTTP routes: the other end is
* serde-derived Rust, and this route previously drifted into a protocol the
* gateway did not speak at all (an `auth` handshake it never sends, one
* printer per telemetry message instead of a batch, UUID printer ids a
* gateway has no way to know, and no command path whatsoever) precisely
* because there was nothing written down for both sides to mirror.
*/
interface PendingCommand {
resolve: (result: { ok: boolean; error?: string }) => void;
timer: ReturnType<typeof setTimeout>;
}
interface PendingWebrtc {
resolve: (result: { sdp?: string; error?: string }) => void;
timer: ReturnType<typeof setTimeout>;
}
interface EdgeConn {
farmId: string;
lastPing: number;
gatewayId: string;
sessionId: string;
lastSeenAt: number;
/**
* Where this gateway serves camera restreams, from its hello. Lives on the
* connection rather than in a DB column or a farm-keyed map: it is a LAN
* address that is only reachable while this gateway is up, so it should
* vanish with the socket instead of outliving it as a URL nothing answers.
* The alternative, a column on `farms`, would keep serving a stale address
* after the gateway disconnected or moved. Undefined means none advertised.
*/
streamBaseUrl?: string;
/**
* Closes this socket if it never identifies itself. Cleared by a
* successful `hello` and on close.
*
* No longer an authentication timeout - the token is verified at the
* upgrade, so every socket here already belongs to a farm. What it bounds
* now is a protocol question: an authenticated socket that never sends
* `hello` has no `gateway_id`, so nothing can be logged about it usefully
* and it is holding a connection it is not using. Kept because a real
* gateway sends `hello` as its first frame, so silence means something is
* wrong rather than slow.
*/
handshakeTimer?: ReturnType<typeof setTimeout>;
send: (message: EdgeServerMessage) => void;
/**
* Gateway printer id -> our `printers.id`. Filled on the first telemetry
* batch and reused after that, so a steady-state connection does not
* re-query the same mapping every few seconds.
*/
printerIds: Map<string, string>;
/** Commands sent but not yet answered, keyed by command_id. */
pending: Map<string, PendingCommand>;
/**
* In-flight camera negotiations, keyed by session id. Separate from
* `pending` because the two resolve with different things and a webrtc
* session is not a command - nothing physical happens, and nothing is
* retried.
*/
webrtc: Map<string, PendingWebrtc>;
}
const connections = new Map<string, EdgeConn>();
const edgeMessage = t.Union([
t.Object({ type: t.Literal("auth"), token: t.String() }),
t.Object({ type: t.Literal("heartbeat") }),
t.Object({
type: t.Literal("frame_batch"),
printerId: t.String({ format: "uuid" }),
printJobId: t.Optional(t.String({ format: "uuid" })),
frames: t.Array(t.Object({ r2ObjectKey: t.String(), capturedAt: t.String() })),
}),
t.Object({
type: t.Literal("telemetry"),
printerId: t.String({ format: "uuid" }),
status: t.String(),
nozzleTempC: t.Optional(t.Number()),
bedTempC: t.Optional(t.Number()),
}),
]);
/**
* farmId -> ws.id. One gateway per farm, so a second connection for the same
* farm replaces the first: that is what a gateway reconnecting after a
* network drop looks like, and the stale entry would otherwise keep receiving
* commands nothing is listening for.
*/
const gatewayByFarm = new Map<string, string>();
/**
* Edge-gateway uplink: continuum-proxy connects here with a short-lived
* edge JWT (minted via POST /auth/edge-token) and streams telemetry +
* camera-frame batches for the lifetime of the connection.
* farmId -> the printers its gateway is reporting that nothing in the cloud
* claims, keyed by the gateway's own id for them.
*
* `persistTelemetry` deliberately refuses to insert a printer row for an id
* it does not recognise - a typo in someone's `printers.toml` would otherwise
* become a permanent phantom printer, and a gateway should not be able to
* create records just by mentioning them. But the previous behaviour was to
* log a warning and drop the snapshot, which made the single worst first-run
* experience in the platform: the gateway connects, reports happily, and the
* fleet stays empty with the only evidence in the server's stderr.
*
* So the refusal stands and the *observation* is kept. A new farm's owner can
* list what their gateway can see and claim it with `POST /printers`, which
* is the one place printer rows are created.
*
* In memory on purpose: this is live gateway state, exactly like
* `connections` and `gatewayByFarm` beside it. It is rebuilt within one
* telemetry interval of a reconnect, and persisting it would mean inventing
* a second, staler source of truth about what a gateway can see.
*/
const unclaimedByFarm = new Map<string, Map<string, UnclaimedPrinter>>();
export interface UnclaimedPrinter {
/** The id in the gateway's `printers.toml` - what to put in `gatewayPrinterId`. */
gatewayPrinterId: string;
/** The gateway's own display name for it, to make a claim prompt legible. */
name: string;
/** Its vendor state, so an owner can tell a live printer from a stale entry. */
vendorState: string | null;
firstSeenAt: string;
lastSeenAt: string;
}
function noteUnclaimed(farmId: string, snapshot: PrinterTelemetry) {
let farm = unclaimedByFarm.get(farmId);
if (!farm) {
farm = new Map();
unclaimedByFarm.set(farmId, farm);
}
const now = new Date().toISOString();
const existing = farm.get(snapshot.printer_id);
farm.set(snapshot.printer_id, {
gatewayPrinterId: snapshot.printer_id,
name: snapshot.name,
vendorState: snapshot.state ?? null,
firstSeenAt: existing?.firstSeenAt ?? now,
lastSeenAt: now,
});
}
/**
* What this farm's gateway is reporting that no printer row claims.
*
* Empty is the healthy answer, and also the answer when no gateway has ever
* connected - a caller that wants to tell those apart should check
* `isGatewayOnline` too.
*/
export function listUnclaimedPrinters(farmId: string): UnclaimedPrinter[] {
return [...(unclaimedByFarm.get(farmId)?.values() ?? [])].sort((a, b) =>
a.gatewayPrinterId.localeCompare(b.gatewayPrinterId),
);
}
/**
* What each farm's gateway last reported finding on its own network and USB
* ports - printers that are *not* in its fleet file, so it cannot drive them.
*
* Distinct from `unclaimedByFarm` above, and the distinction is the whole
* point. An unclaimed printer is already in the gateway's `printers.toml`:
* someone wrote its address and its credential down, the gateway is talking
* to it, and all that is missing is a printer row. A discovered one is a
* machine the gateway merely *saw*. Nobody has 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.
*
* One entry per farm rather than a map keyed by printer, because a report is
* a whole scan result and replaces the last one. Discovery has no stable key
* to merge on anyway: a Bambu printer has a serial, a Klipper host has only
* the address DHCP gave it this week, and merging on that would accumulate
* one phantom candidate per lease.
*
* In memory for the same reason as `unclaimedByFarm`: live gateway state,
* worthless once stale, and rebuilt by the next scan after a reconnect.
* Deliberately *not* cleared when the gateway disconnects - the last known
* list plus `gatewayOnline` and `scannedAt` lets a caller say "found 20
* minutes ago, gateway offline now", which is more use to an operator than
* an empty list that looks like "nothing there".
*/
const discoveredByFarm = new Map<string, FarmDiscovery>();
export interface FarmDiscovery {
printers: DiscoveredPrinterReport[];
/** When the gateway finished the scan, if it said. */
scannedAt: string | null;
/** When this backend received the report, which it always knows. */
reportedAt: string;
gatewayId: string;
}
/**
* camelCase, unlike the wire message it comes from: this is shaped for the
* HTTP route that serves it, and the rest of the backend's REST surface is
* camelCase. The conversion happens once, here, rather than in the route.
*/
export interface DiscoveredPrinterReport {
vendor: string;
address: string;
serial: string | null;
model: string | null;
name: string | null;
detail: string | null;
/** A vendor this could equally be; the operator settles it. */
ambiguousWith: string | null;
source: string;
/** False when the gateway found it but has no working client for it. */
driverReady: boolean;
}
function noteDiscovered(
farmId: string,
gatewayId: string,
printers: readonly DiscoveredPrinter[],
scannedAt: string | null,
) {
discoveredByFarm.set(farmId, {
// Every string in here was chosen by a device on the customer's network,
// so it is display text and nothing more. It is never used to build a
// query, a path or a connection, and the schema caps its length - see
// `DiscoveredPrinterSchema`. Validation already happened: `body:
// EdgeClientMessageSchema` on the route below rejects the frame
// otherwise, which is why this can map without re-checking.
printers: printers.map((printer) => ({
vendor: printer.vendor,
address: printer.address,
// `?? null` collapses the wire's "absent or null" into one shape, the
// same reason the `frame_batch` handler does it below.
serial: printer.serial ?? null,
model: printer.model ?? null,
name: printer.name ?? null,
detail: printer.detail ?? null,
ambiguousWith: printer.ambiguous_with ?? null,
source: printer.source,
driverReady: printer.driver_ready,
})),
scannedAt: scannedAt ?? null,
reportedAt: new Date().toISOString(),
gatewayId,
});
}
/**
* The last scan this farm's gateway reported, or null if it has never
* reported one.
*
* Null is the usual answer, because discovery is off by default in the
* gateway's own config - probing a customer's network is something they have
* to switch on. A caller should treat null as "not enabled or not scanned
* yet", which is different from a report whose `printers` is empty: that one
* means the gateway looked and found nothing.
*/
export function getDiscoveredPrinters(farmId: string): FarmDiscovery | null {
return discoveredByFarm.get(farmId) ?? null;
}
/** How long to wait for a gateway to answer a command before giving up. */
const COMMAND_TIMEOUT_MS = 10_000;
/**
* Longer than a command, because answering means gathering ICE candidates
* and that includes a STUN round trip from the gateway. Measured against
* go2rtc 1.9.14 it is well under this, but the gateway's own request to
* go2rtc is capped at 8s, so this has to outlast that or the cloud would
* give up on an answer that was still coming.
*/
const WEBRTC_TIMEOUT_MS = 12_000;
/**
* How long an authenticated socket may stay open without sending `hello`.
* Generous compared with the round trip it covers - a gateway sends `hello`
* immediately after the socket opens - because the only thing a tight bound
* would buy is dropping gateways on slow links.
*/
const HANDSHAKE_TIMEOUT_MS = 10_000;
/**
* Maps a printer's own state string onto the narrowed `printer_status` enum.
*
* Unrecognised states deliberately fall through to the current value rather
* than to "idle": claiming a printer is idle when we do not understand what
* it said is the one wrong answer here, since idle is what the scheduler
* treats as "safe to send work to". The raw string is kept in
* `vendor_state` either way.
*/
function mapStatus(state: string | null | undefined, printError: number | null | undefined) {
// `!= null` deliberately, not `!== undefined`: these fields arrive as either
// absent or an explicit null (see the Maybe() helper in @continuum/ts-types),
// and treating a null error code as a non-zero one would report every
// printer that has nothing to say as faulted.
if (printError != null && printError !== 0) return "error" as const;
switch (state?.toUpperCase()) {
case "IDLE":
case "FINISH":
return "idle" as const;
case "RUNNING":
case "PREPARE":
case "SLICING":
return "printing" as const;
case "PAUSE":
return "paused" as const;
case "FAILED":
return "error" as const;
default:
return undefined;
}
}
/**
* Writes one telemetry batch to the `printers` table.
*
* Only rows whose `gateway_printer_id` is already claimed by this farm are
* updated - an unknown id is logged and skipped rather than inserted. A
* gateway should not be able to create printer records just by mentioning
* them: that is what POST /printers is for, and auto-inserting here would
* turn a typo in someone's printers.toml into a permanent phantom printer.
*/
async function persistTelemetry(conn: EdgeConn, batch: PrinterTelemetry[]) {
for (const snapshot of batch) {
let printerId = conn.printerIds.get(snapshot.printer_id);
if (!printerId) {
const row = await db.query.printers.findFirst({
columns: { id: true },
where: and(
eq(printers.farmId, conn.farmId),
eq(printers.gatewayPrinterId, snapshot.printer_id),
),
});
if (!row) {
// Still not inserted - see `unclaimedByFarm` for why - but no longer
// only a line in stderr. An owner can now see it and claim it.
noteUnclaimed(conn.farmId, snapshot);
continue;
}
printerId = row.id;
conn.printerIds.set(snapshot.printer_id, printerId);
// It is claimed now, so it is no longer waiting to be.
unclaimedByFarm.get(conn.farmId)?.delete(snapshot.printer_id);
}
const status = mapStatus(snapshot.state, snapshot.print_error);
const [updated] = await db
.update(printers)
.set({
// `?? null` rather than leaving the key out: a field the gateway
// stopped reporting has genuinely gone away (a finished job has no
// remaining minutes), and keeping the last value would leave stale
// numbers on screen forever.
...(status ? { status } : {}),
vendorState: snapshot.state ?? null,
vendorErrorCode: snapshot.print_error ?? null,
nozzleTempC: snapshot.nozzle_temp ?? null,
nozzleTargetTempC: snapshot.nozzle_target ?? null,
bedTempC: snapshot.bed_temp ?? null,
bedTargetTempC: snapshot.bed_target ?? null,
progressPercent: snapshot.percent ?? null,
currentLayer: snapshot.layer ?? null,
totalLayers: snapshot.total_layers ?? null,
remainingMinutes: snapshot.remaining_minutes ?? null,
currentJobName: snapshot.job_name ?? null,
activeAlerts: snapshot.active_alerts ?? null,
// Cleared like the rest when absent: a printer that lost its stream
// must stop being offered one.
cameraStream: snapshot.camera_stream ?? null,
lastSeenAt: new Date(),
updatedAt: new Date(),
})
.where(eq(printers.id, printerId))
// Returned through the same projection the REST routes use, so what a
// watching console is pushed is byte-for-byte what it would have got
// from `GET /printers/:id`. Deriving it from `snapshot` instead would
// be a second translation of the same data, free to drift - and it
// would be missing the columns the gateway does not report at all,
// like `name` and `vendor`.
.returning(printerProjection.returning);
// Push to anyone watching. After the write, so a console cannot be told
// about a state that failed to persist - and `publish` swallows a
// subscriber's error, so one broken browser tab cannot abort a whole
// farm's telemetry batch here.
if (updated) {
publish({
...updated,
// Serialised, because this goes to JSON and a Date would otherwise
// be stringified inconsistently with the REST response.
lastSeenAt: updated.lastSeenAt?.toISOString() ?? null,
});
}
// The printer's row is the latest snapshot; the job's row is the history
// that outlives it. This is the only thing that moves a job out of
// `uploading` - see `advanceJobsFrom` for why the gateway's `ok` on a
// start command is explicitly not trusted to do it.
await advanceJobsFrom(printerId, status, snapshot.percent);
}
}
/**
* Sends a command to the printer's gateway and waits for its result.
*
* Takes our own `printers.id` and translates to the gateway's id on the way
* out, so no caller elsewhere in the backend has to know that gateways use
* different identifiers.
*
* Throws if the printer is unknown, unclaimed, or its farm has no gateway
* connected right now. Resolves `{ ok: false }` if the gateway answered with
* a failure or did not answer within `COMMAND_TIMEOUT_MS` - a command that
* was delivered but never acknowledged is not the same as one that was never
* sent, and callers need to be able to tell those apart.
*/
export async function sendPrinterCommand(
printerId: string,
command: PrinterCommand,
): Promise<{ ok: boolean; error?: string; commandId: string }> {
const printer = await db.query.printers.findFirst({
columns: { id: true, farmId: true, gatewayPrinterId: true },
where: eq(printers.id, printerId),
});
if (!printer) throw new Error(`printer ${printerId} not found`);
if (!printer.gatewayPrinterId) {
throw new Error(`printer ${printerId} has no gateway_printer_id - no gateway has claimed it`);
}
const wsId = gatewayByFarm.get(printer.farmId);
const conn = wsId ? connections.get(wsId) : undefined;
if (!conn) throw new Error(`no gateway connected for farm ${printer.farmId}`);
const commandId = crypto.randomUUID();
const result = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
const timer = setTimeout(() => {
conn.pending.delete(commandId);
resolve({ ok: false, error: `gateway did not answer within ${COMMAND_TIMEOUT_MS}ms` });
}, COMMAND_TIMEOUT_MS);
conn.pending.set(commandId, { resolve, timer });
conn.send({
type: "command",
command_id: commandId,
printer_id: printer.gatewayPrinterId!,
command,
});
});
return { ...result, commandId };
}
/**
* Relays a browser's WebRTC offer to the gateway that owns this printer and
* returns go2rtc's answer.
*
* **Signalling only.** No video passes through this process: once the
* browser has the answer, it and the gateway's go2rtc negotiate media
* directly over ICE. On a workshop LAN that keeps the stream inside the
* building and costs this backend no bandwidth at all, which is why the
* relay carries SDP rather than bytes.
*
* The cloud has to be in the middle because neither end can reach the other
* directly. A browser cannot signal to go2rtc - measured against 1.9.14, it
* sends no CORS headers and checks its password before CORS, so a browser's
* unauthenticated preflight gets a 401 - and a gateway is behind a workshop
* NAT, so this backend cannot dial in either. The gateway's own outbound
* socket is the only path, and it already exists.
*
* Throws for a printer that is unknown, has no camera, or whose farm has no
* gateway connected. Resolves `{ error }` when the gateway answered with a
* failure or did not answer in time - the caller needs to tell "no camera
* here" from "the gateway never replied".
*/
export async function negotiateCameraStream(
printerId: string,
offerSdp: string,
): Promise<{ sdp?: string; error?: string }> {
const printer = await db.query.printers.findFirst({
columns: { id: true, farmId: true, cameraStream: true },
where: eq(printers.id, printerId),
});
if (!printer) throw new Error(`printer ${printerId} not found`);
// Checked here rather than left to go2rtc: the gateway would refuse an
// unknown stream anyway, but 12 seconds later and as a vaguer error.
if (!printer.cameraStream) {
throw new Error(`printer ${printerId} has no camera stream`);
}
const wsId = gatewayByFarm.get(printer.farmId);
const conn = wsId ? connections.get(wsId) : undefined;
if (!conn) throw new Error(`no gateway connected for farm ${printer.farmId}`);
const sessionId = crypto.randomUUID();
return new Promise<{ sdp?: string; error?: string }>((resolve) => {
const timer = setTimeout(() => {
conn.webrtc.delete(sessionId);
resolve({ error: `gateway did not answer within ${WEBRTC_TIMEOUT_MS}ms` });
}, WEBRTC_TIMEOUT_MS);
conn.webrtc.set(sessionId, { resolve, timer });
conn.send({
type: "webrtc_offer",
session_id: sessionId,
camera_stream: printer.cameraStream!,
sdp: offerSdp,
});
});
}
/** Whether a farm currently has a gateway connected. */
export function isGatewayOnline(farmId: string): boolean {
const wsId = gatewayByFarm.get(farmId);
return wsId !== undefined && connections.has(wsId);
}
/**
* The stream base URL the farm's currently connected gateway advertised, or
* undefined if no gateway is connected or it advertised none. Deliberately
* does not distinguish those two: callers only need to know it is unusable.
*/
/**
* No longer part of the playback path, and kept deliberately rather than
* deleted: gateways still report `stream_base_url` in their hello, and it is
* the only record of where a gateway thinks it is reachable - useful when
* diagnosing one. Nothing serves it to a browser any more, because a browser
* cannot use it (no CORS on go2rtc, and a workshop NAT in the way).
*/
export function getStreamBaseUrl(farmId: string): string | undefined {
const wsId = gatewayByFarm.get(farmId);
return wsId ? connections.get(wsId)?.streamBaseUrl : undefined;
}
export const edgeWsRoute = new Elysia()
.use(jwt({ name: "edgeJwt", secret: process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me" }))
.use(jwt({ name: "edgeJwt", secret: edgeJwtSecret }))
/**
* Verifies the edge JWT on the upgrade request, before any socket exists.
*
* Throwing here fails the upgrade with an HTTP status, so a peer without a
* valid token never reaches `open` - which is the whole point of
* authenticating at the handshake rather than in the first frame.
*
* The default `local` scope, NOT `scoped` like `requireUser`, and the
* difference is load-bearing. `requireUser` is `.use()`d inside each route
* file and has to propagate `user` to that file's routes. This resolve
* belongs to the one socket defined just below it, and `scoped` here
* propagates it to the parent app, where it then gates every route
* registered *after* this plugin. Measured: with `scoped`, a second ws
* plugin's own route is refused, because this check runs first and finds
* no edge token. Nothing shipped broken only because /ws/telemetry was the
* first route mounted after this one - and it was refused outright until
* this was changed.
*/
.resolve(async ({ headers, edgeJwt }): Promise<{ edgeFarmId: string }> => ({
edgeFarmId: await farmFromEdgeUpgrade(headers.authorization, (token) => edgeJwt.verify(token)),
}))
.ws("/ws/edge/v1", {
body: edgeMessage,
body: EdgeClientMessageSchema,
open(ws) {
connections.set(ws.id, { farmId: "", lastPing: Date.now() });
// No farmId yet - set by `hello`, and every other message type refuses
// to do anything until it is.
// The farm comes from the verified token, so it is known before the
// first frame. `gatewayId` still waits for `hello`: which gateway of a
// farm's this is, and where it serves camera streams, are things only
// the gateway can say.
const conn: EdgeConn = {
farmId: ws.data.edgeFarmId,
gatewayId: "",
sessionId: crypto.randomUUID(),
lastSeenAt: Date.now(),
send: (message) => ws.send(message),
printerIds: new Map(),
pending: new Map(),
webrtc: new Map(),
};
conn.handshakeTimer = setTimeout(() => {
if (conn.gatewayId) return;
console.warn(`[edge] closing socket ${ws.id}: no hello within ${HANDSHAKE_TIMEOUT_MS}ms`);
ws.close();
}, HANDSHAKE_TIMEOUT_MS);
connections.set(ws.id, conn);
},
async message(ws, message) {
const conn = connections.get(ws.id);
if (!conn) return;
conn.lastSeenAt = Date.now();
switch (message.type) {
case "auth": {
const payload = await ws.data.edgeJwt.verify(message.token);
if (!payload || payload.scope !== "edge") {
ws.send({ type: "auth_error", message: "invalid edge token" });
case "hello": {
// Already authenticated at the upgrade, so there is no token to
// check here. The farm still comes from that token and never from
// the message body: a gateway claiming a farm_id its token was not
// minted for is exactly what the next check is here to stop.
const farmId = conn.farmId;
if (message.farm_id && message.farm_id !== farmId) {
ws.send({ type: "hello_error", message: "farm_id does not match the token's farm" });
ws.close();
return;
}
conn.farmId = payload.farmId as string;
ws.send({ type: "auth_ok", farmId: conn.farmId });
// Identified, so the timer has nothing left to catch. Cleared
// only after the farm matched - a rejected hello leaves it
// running, so a client that keeps sending mismatched farm ids on
// one socket still gets dropped.
clearTimeout(conn.handshakeTimer);
conn.handshakeTimer = undefined;
conn.gatewayId = message.gateway_id;
// `?? undefined`: absent and explicit null both mean "no streams".
conn.streamBaseUrl = message.stream_base_url ?? undefined;
// One gateway per farm: drop whatever was registered before. On a
// reconnect that stale socket is already dead, and leaving it
// registered would send commands into it.
const previous = gatewayByFarm.get(farmId);
if (previous && previous !== ws.id) {
console.warn(
`[edge] farm ${farmId} already had gateway connection ${previous}; ` +
`replacing it with ${ws.id} (gateway ${message.gateway_id})`,
);
}
gatewayByFarm.set(farmId, ws.id);
ws.send({ type: "hello_ack", session_id: conn.sessionId });
console.info(`[edge] gateway ${message.gateway_id} connected for farm ${farmId}`);
break;
}
case "heartbeat": {
conn.lastPing = Date.now();
ws.send({ type: "heartbeat_ack" });
// Echo the sequence number back. The gateway uses it to tell a
// stalled connection from a slow one, which it cannot do against a
// bare ack.
ws.send({ type: "heartbeat_ack", seq: message.seq });
break;
}
case "telemetry": {
if (!conn.farmId) return;
// Fanned out to subscribers of the dashboard telemetry store; boilerplate
// just acknowledges receipt here.
ws.send({ type: "telemetry_ack", printerId: message.printerId });
await persistTelemetry(conn, message.printers);
break;
}
case "webrtc_answer":
case "webrtc_error": {
const waiting = conn.webrtc.get(message.session_id);
if (!waiting) {
// The browser's request already timed out, or this gateway
// answered a session nobody asked about. Either way there is
// nobody left to hand it to.
console.warn(
`[edge] gateway ${conn.gatewayId} answered camera session ` +
`${message.session_id}, which is no longer waiting`,
);
return;
}
clearTimeout(waiting.timer);
conn.webrtc.delete(message.session_id);
waiting.resolve(
message.type === "webrtc_answer"
? { sdp: message.sdp }
: { error: message.message },
);
break;
}
case "command_result": {
const pending = conn.pending.get(message.command_id);
if (!pending) {
// Three ways to get here, and the third is the useful one.
//
// The command timed out; or this gateway answered something we
// never asked; or a long-running command that was acknowledged
// up front has now actually failed, and this is the gateway
// telling us why. A `start_print` is answered the moment it is
// accepted, because the transfer takes minutes and this side
// gives up after ten seconds - so its real outcome can only ever
// arrive late.
//
// The reason is logged rather than dropped. It is the difference
// between an operator seeing "job failed" and seeing which half
// of a download-then-upload failed and what the printer said.
// It does not change the job's fate: the job never reaching
// `printing` is what fails it, and that already works. Recording
// it against the job needs a column print_jobs does not have.
const reason = message.ok ? "reported success" : `failed: ${message.error ?? "no reason given"}`;
console.warn(
`[edge] gateway ${conn.gatewayId} sent a result for unknown, ` +
`expired or already-answered command ${message.command_id} - ${reason}`,
);
return;
}
clearTimeout(pending.timer);
conn.pending.delete(message.command_id);
// `?? undefined` because the wire allows either absent or null for
// "no error" (see the Maybe() helper in @continuum/ts-types) and
// callers should only have to check one of them.
pending.resolve({ ok: message.ok, error: message.error ?? undefined });
break;
}
case "discovered_printers": {
if (!conn.farmId) return;
// No gateway id until `hello`, and the handshake timer closes a
// socket that never sends one, so this cannot be reported by an
// anonymous socket for long. It is recorded rather than demanded
// because a scan result is still true without it.
noteDiscovered(
conn.farmId,
conn.gatewayId,
message.printers,
message.scanned_at ?? null,
);
console.info(
`[edge] gateway ${conn.gatewayId} discovered ${message.printers.length} ` +
`printer(s) not in its fleet (farm ${conn.farmId})`,
);
break;
}
case "frame_batch": {
if (!conn.farmId) return;
for (const frame of message.frames) {
await enqueueFrameIngestion({
farmId: conn.farmId,
printerId: message.printerId,
printJobId: message.printJobId ?? null,
r2ObjectKey: frame.r2ObjectKey,
capturedAt: frame.capturedAt,
});
const printerId = conn.printerIds.get(message.printer_id);
if (!printerId) {
console.warn(
`[edge] frame_batch for unknown printer '${message.printer_id}' from ` +
`gateway ${conn.gatewayId}, dropping ${message.frames.length} frame(s)`,
);
return;
}
ws.send({ type: "frame_batch_ack", count: message.frames.length });
// One round trip for the whole batch, and snake_case field names:
// this is the `stream:camera_frames` contract in
// `schemas/continuum/v1/ai/`, not an HTTP response, and the
// consumer at the other end is serde-derived Rust.
await enqueueFrameIngestionBatch(
message.frames.map((frame) => ({
farm_id: conn.farmId,
printer_id: printerId,
// Omitted rather than null when there is no job: a stream
// entry has no null, and `""` is a value a consumer would
// believe. `?? undefined` collapses the wire's "absent or
// null" into the one the schema describes.
...(message.print_job_id != null ? { print_job_id: message.print_job_id } : {}),
r2_object_key: frame.object_key,
captured_at: frame.captured_at,
})),
);
break;
}
}
},
close(ws) {
const conn = connections.get(ws.id);
if (conn) {
clearTimeout(conn.handshakeTimer);
// Anything still waiting on this gateway will never be answered now.
// Failing them immediately beats making every caller wait out the
// full timeout for a socket that is already gone.
for (const [commandId, pending] of conn.pending) {
clearTimeout(pending.timer);
pending.resolve({ ok: false, error: "gateway disconnected before answering" });
conn.pending.delete(commandId);
}
// Same for camera negotiations, and it matters more here: a browser
// is holding an HTTP request open on each one, so waiting out the
// full timeout for a gateway that has already gone is 12 seconds of
// a spinner that could say "offline" now.
for (const [sessionId, waiting] of conn.webrtc) {
clearTimeout(waiting.timer);
waiting.resolve({ error: "gateway disconnected before answering" });
conn.webrtc.delete(sessionId);
}
if (conn.farmId && gatewayByFarm.get(conn.farmId) === ws.id) {
gatewayByFarm.delete(conn.farmId);
}
if (conn.gatewayId) {
console.info(`[edge] gateway ${conn.gatewayId} disconnected (farm ${conn.farmId})`);
}
}
connections.delete(ws.id);
},
});
+149
View File
@@ -0,0 +1,149 @@
import { Elysia } from "elysia";
import { JwtVerificationError, UnauthorizedError } from "@continuum/ts-core";
import type { AppPrinterState, AppTelemetryMessage } from "@continuum/ts-types";
import { db } from "../../db";
import { farms, printers } from "../../db/schema";
import { visibleFarms, visiblePrinters } from "../../auth";
import { jwtVerifier } from "../../auth/verifier";
import { tokenFromSubprotocol } from "../../auth/ws_subprotocol";
import { maySee, subscribe } from "../../telemetry/broadcast";
/**
* The operator console's live fleet feed.
*
* Read-only and push-only: the browser sends nothing after the handshake,
* and a frame arriving from it is ignored. Everything that changes a printer
* goes over HTTP, where the role checks and the audit trail already live -
* `POST /printers/:id/commands`. A socket that can also issue commands is a
* second authorization surface to get right for no gain.
*
* ## Not /ws/edge/v1
*
* That socket authenticates as a *farm*, with an edge JWT that any gateway
* for that farm holds, and it accepts telemetry. This one authenticates as a
* *user*, with a Supabase access token, and accepts nothing. Pointing a
* browser at the gateway socket would mean handing the browser a credential
* that can drive printers.
*
* ## The wire format
*
* `continuum/v1/app/telemetry_message.schema.json`, mirrored in
* `@continuum/ts-types` as `AppTelemetryMessageSchema`. camelCase, matching
* this backend's HTTP routes rather than the gateway protocol - see that
* file. The payload is the printer resource itself, so a client applies a
* push onto the row it already fetched from `GET /printers`.
*/
/**
* The farms a user can see, resolved once when their socket opens.
*
* Once, not per message: a farm's telemetry arrives every few seconds per
* printer, and re-deriving visibility each time would put a database round
* trip on every push to every viewer. The cost is staleness in one
* direction - someone added to an organisation mid-connection does not see
* its printers until they reconnect, which a page reload does.
*
* Staleness in the *other* direction is the one that would matter, and it is
* bounded by the same thing: someone removed from an organisation keeps
* receiving its telemetry until their socket drops. That is a real gap worth
* knowing about. Closing it properly means invalidating open sockets on a
* membership change, which needs a signal this backend does not have yet; a
* periodic re-resolve would narrow the window but not close it, so it is
* written down rather than half-done. Telemetry is also the least sensitive
* thing in the system - nozzle temperatures, not credentials - and a removed
* member can be cut off immediately by anything that matters, because every
* HTTP route re-checks on each request.
*/
async function visibleFarmIds(userId: string): Promise<Set<string>> {
const rows = await db.select({ id: farms.id }).from(farms).where(visibleFarms(userId));
return new Set(rows.map((row) => row.id));
}
/**
* How many printers this user can see, for the `ready` message.
*
* Counting farms would be the cheaper query and the wrong answer: a farm
* with no printers claimed yet is exactly the first-run state this message
* exists to make legible.
*/
async function countVisiblePrinters(userId: string): Promise<number> {
const rows = await db.select({ id: printers.id }).from(printers).where(visiblePrinters(userId));
return rows.length;
}
/** One unsubscribe per open socket, removed when it closes. */
const unsubscribers = new Map<string | number, () => void>();
export const telemetryWsRoute = new Elysia()
/**
* Verifies the user's access token on the upgrade, before a socket exists.
*
* Throwing fails the upgrade with an HTTP status, so an unauthenticated
* browser never reaches `open`. Same shape as the edge socket's resolve
* next door; the difference is only where the token comes from and which
* verifier checks it.
*
* The default `local` scope deliberately - see the note on the edge
* route's resolve. `scoped` would push this check onto every route
* mounted after this plugin, which would then demand a WebSocket
* subprotocol from plain HTTP requests.
*/
.resolve(async ({ headers }): Promise<{ viewer: { id: string } }> => {
const token = tokenFromSubprotocol(headers["sec-websocket-protocol"]);
try {
const claims = await jwtVerifier.verify(token);
if (!claims.sub) throw new UnauthorizedError("Access token has no subject claim");
return { viewer: { id: claims.sub } };
} catch (err) {
// Never pass the reason through - "expired" versus "bad signature"
// tells a forger which part to fix next. Logged instead, exactly as
// `requireUser` does it.
if (err instanceof JwtVerificationError) {
console.warn(
`[telemetry] token rejected: ${err.cause instanceof Error ? err.cause.message : err.message}`,
);
throw new UnauthorizedError("Invalid or expired access token");
}
throw err;
}
})
.ws("/ws/telemetry", {
async open(ws) {
const { viewer } = ws.data as { viewer: { id: string } };
const allowed = await visibleFarmIds(viewer.id);
const send = (message: AppTelemetryMessage) => ws.send(message);
const unsubscribe = subscribe((printer: AppPrinterState) => {
// The line that keeps one organisation's telemetry out of another's
// console. `maySee` is in `telemetry/broadcast.ts` so it can be
// tested without standing up a socket.
if (!maySee(allowed, printer)) return;
send({ type: "printer.telemetry", printer });
});
// Cleanup is attached to the socket rather than kept in a module-level
// map: there is exactly one unsubscribe per socket and the close
// handler is the only thing that needs it. A leaked subscriber would
// push into a dead socket forever.
unsubscribers.set(ws.id, unsubscribe);
// Sent before any telemetry, so a client knows the difference between
// "nothing to show you" and "not connected yet". Counting farms the
// user can see is not the same as counting printers, so this asks for
// the printers.
send({ type: "ready", visiblePrinters: await countVisiblePrinters(viewer.id) });
},
message(_ws, _message) {
// Deliberately nothing. This socket is push-only; see the module
// comment. Ignoring rather than closing, because a well-meaning client
// sending a keepalive should not be punished for it.
},
close(ws) {
unsubscribers.get(ws.id)?.();
unsubscribers.delete(ws.id);
},
});
+82 -7
View File
@@ -1,16 +1,61 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
/**
* Object storage for job files, configured once at module load so a
* misconfigured deployment fails at boot rather than on the first upload -
* the same shape as `auth/verifier.ts` and `auth/edge_secret.ts`, and for
* the same reason.
*
* It replaces `?? ""` on the access key and secret. An empty credential is
* not a usable one, so that default bought nothing except moving the failure
* somewhere much worse: the process started happily, `POST /jobs` handed a
* client a presigned URL signed with an empty key, and the first sign that
* anything was wrong was a 403 from R2 - either in the client's uploader or,
* worse, inside a gateway that had downloaded R2's XML error document
* instead of a print. Nothing in the chain said "R2 is not configured".
*
* `R2_BUCKET` keeps a default, because a bucket name is not a credential and
* "continuum" is a reasonable one. `R2_ENDPOINT` keeps its derivation from
* the account id, which is how Cloudflare's own URLs are formed.
*/
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`${name} is not set - continuum-backend cannot store or serve job files without it. ` +
"See .env.example; an empty value is not a usable credential.",
);
}
return value;
}
const accountId = process.env.R2_ACCOUNT_ID ?? "";
const bucket = process.env.R2_BUCKET ?? "continuum";
// `||`, so an empty value in the template falls back rather than becoming a
// bucket named "".
const bucket = process.env.R2_BUCKET || "continuum";
// Read before the client is built, so the error names the missing variable
// rather than surfacing later as a signing failure.
const accessKeyId = required("R2_ACCESS_KEY_ID");
const secretAccessKey = required("R2_SECRET_ACCESS_KEY");
// `||` and not `??`: the committed template ships `R2_ENDPOINT=` empty, and
// `??` only catches null and undefined - so an empty value would be taken as
// a real endpoint and the derivation below would never run. This is the same
// trap `auth/edge_secret.ts` documents, and it caught this line too.
const endpoint =
process.env.R2_ENDPOINT || (accountId ? `https://${accountId}.r2.cloudflarestorage.com` : "");
if (!endpoint) {
throw new Error(
"Neither R2_ENDPOINT nor R2_ACCOUNT_ID is set - there is no object storage URL to sign against.",
);
}
export const s3 = new S3Client({
region: "auto",
endpoint: process.env.R2_ENDPOINT ?? `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
},
endpoint,
credentials: { accessKeyId, secretAccessKey },
});
/** Presigned PUT for clients uploading a G-code/3MF file directly to R2. */
@@ -25,6 +70,36 @@ export async function getDownloadUrl(key: string, expiresInSeconds = 900) {
return getSignedUrl(s3, command, { expiresIn: expiresInSeconds });
}
/**
* Whether the object is actually in the bucket, and how big it is.
*
* The scheduler needs this because `POST /jobs` returns a presigned PUT and
* the client has not used it yet: the row exists before the file does. Rather
* than trust a client to tell us it finished - a client that crashes
* mid-upload would never say so, and one that lies would have a gateway
* download an error document - this asks the bucket.
*
* Returns `undefined` for a missing object rather than throwing, because for
* this caller "not uploaded yet" is the ordinary case on every tick between
* queueing a job and the upload finishing.
*/
export async function headObject(key: string): Promise<{ size: number } | undefined> {
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return { size: head.ContentLength ?? 0 };
} catch (err) {
// 404/NotFound is the expected answer for a job whose file has not
// arrived. Anything else - credentials, networking, a bucket that does
// not exist - is a real problem and must not be reported as "no file",
// or every job on a misconfigured deployment would sit queued forever
// with nothing saying why.
const name = (err as { name?: string }).name;
const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
if (name === "NotFound" || name === "NoSuchKey" || status === 404) return undefined;
throw err;
}
}
export function jobFileKey(farmId: string, jobId: string, fileName: string) {
return `farms/${farmId}/jobs/${jobId}/${fileName}`;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Camera stream resolution, on its own with no imports - the same reason as
* `dispatch/job_status.ts`: `../db` opens a connection at import time, and
* the distinct failure messages here are worth testing without one.
*
* This used to hand back the gateway's own LAN address for the browser to
* play from. It cannot: 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.
* So there is no URL to give out. What a viewer needs is a camera name to ask
* for and a gateway that is connected to relay the SDP exchange.
*/
export const NO_CAMERA_MESSAGE = "this printer reports no camera that can be streamed";
export const NO_GATEWAY_MESSAGE =
"this farm's gateway is not connected, so its camera cannot be negotiated";
export type StreamResolution =
| { ok: true; stream: { cameraId: string } }
| { ok: false; reason: "no_camera" | "gateway_offline"; message: string };
/**
* A camera name, or an error naming what is missing. The printer is checked
* first: if it can never stream, saying the gateway is offline would send
* someone off to fix the wrong thing - and a P1/A1 can never stream, so this
* is the common case rather than the unlucky one.
*/
export function resolveStream(
cameraStream: string | null | undefined,
gatewayOnline: boolean,
): StreamResolution {
if (!cameraStream) return { ok: false, reason: "no_camera", message: NO_CAMERA_MESSAGE };
if (!gatewayOnline) {
return { ok: false, reason: "gateway_offline", message: NO_GATEWAY_MESSAGE };
}
return { ok: true, stream: { cameraId: cameraStream } };
}
+81
View File
@@ -0,0 +1,81 @@
import type { AppPrinterState } from "@continuum/ts-types";
/**
* Fan-out from the gateway socket to whoever is watching, with no imports but
* a type - the same reason as `dispatch/job_status.ts`, `stream.ts` and
* `auth/edge_upgrade.ts`: `../db` opens a connection at import time, and the
* part worth testing here is which subscriber is allowed to see what.
*
* ## In-process only, and that is a real limit
*
* A `Set` of callbacks in one Node/Bun process. Two backend instances behind
* a load balancer would each only push the telemetry their own gateways
* happen to be connected to, so an operator would see half a fleet depending
* on which instance their socket landed on.
*
* Not fixed here because the fix is Valkey pub/sub - already a dependency,
* already used by the AI frame queue - and swapping this `Set` for a channel
* subscription is a contained change once there is a second instance to
* justify it. Doing it now would mean testing a message broker to deliver
* events between two objects in the same process. Written down rather than
* discovered: see PLAN.md.
*/
/** What a subscriber is handed. `farmId` is what authorization turns on. */
export type Listener = (printer: AppPrinterState) => void;
const listeners = new Set<Listener>();
/**
* Registers a subscriber and returns its own removal.
*
* Returning the unsubscribe rather than exposing a `remove(listener)` means
* a caller cannot get the identity wrong, and a socket's close handler has
* exactly one thing to call. Leaking a subscriber here would keep pushing
* into a dead socket forever.
*/
export function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/**
* Publishes one printer's new state to every subscriber.
*
* Each listener is called inside its own try/catch. One viewer's socket
* throwing on send - which a socket closing mid-iteration does - must not
* stop the other viewers from getting the same update, and must not
* propagate into `persistTelemetry`, where it would abort a whole farm's
* telemetry batch over one broken browser tab.
*/
export function publish(printer: AppPrinterState): void {
for (const listener of listeners) {
try {
listener(printer);
} catch (err) {
console.warn("[telemetry] a subscriber threw while being notified", err);
}
}
}
/** How many subscribers are attached. For tests and `/health`. */
export function subscriberCount(): number {
return listeners.size;
}
/**
* Whether a user watching `visibleFarms` should be sent this printer.
*
* Separate from the socket so it can be tested without one, because this is
* the function that decides whether one organisation's operator sees
* another's machines. The set is resolved once when a socket opens - see
* `routes/ws/telemetry.ts` for why, and for what that costs.
*/
export function maySee(visibleFarms: ReadonlySet<string>, printer: AppPrinterState): boolean {
// A printer with no farm cannot be matched against anything, so it is not
// sent. There is no such row - `farm_id` is not nullable - but this is the
// one place where being wrong means leaking another org's machine, so it
// does not rely on that staying true.
if (!printer.farmId) return false;
return visibleFarms.has(printer.farmId);
}
+61
View File
@@ -0,0 +1,61 @@
import { FormatRegistry } from "@sinclair/typebox";
/**
* Registers the string formats this codebase uses with TypeBox.
*
* Import this from any test that validates a schema carrying a `format`
* without standing up Elysia. `tests/edge_protocol.test.ts` did not, and so
* passed only because some *other* test file in the same run happened to
* import Elysia first and register the formats globally - run on its own it
* failed, which is the worst way for a contract test to behave.
*
* TypeBox ships no format validators: `format` is a registry lookup, and an
* *unregistered* format does not pass - it fails. So a schema written with
* `format: "uuid"` and checked outside Elysia rejects every value, including
* valid ones. That is not theoretical; it is how
* `src/queue/frame_task.ts`'s compiled check came to refuse every frame task
* with `Unknown format 'uuid'`.
*
* Elysia registers a full set (`uuid`, `date-time` and ~20 more) as a side
* effect of importing it, which is why the HTTP routes validate correctly.
* Modules that deliberately do not import Elysia - anything that must stay
* free of its Valkey/HTTP weight for testing - have to import this instead.
*
* `Has` guards each one so that whichever of the two loads first wins, rather
* than this overriding Elysia's implementation for the route validators. The
* two agree on what these formats accept; `tests/frame_task_contract.test.ts`
* asserts that the active implementation actually rejects a malformed uuid,
* so this cannot quietly become a no-op.
*/
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!FormatRegistry.Has("uuid")) {
FormatRegistry.Set("uuid", (value) => UUID.test(value));
}
if (!FormatRegistry.Has("uri")) {
// `PrinterCommand.start_print.url` is the only user, and it carries a
// presigned object-store GET. Lenient on purpose: anything the platform
// parses as an absolute URL passes. 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 - the command is
// minted by this backend, not submitted by a client.
FormatRegistry.Set("uri", (value) => {
try {
// Rejects a relative reference, which `format: "uri"` is supposed to:
// the two-argument form would happily resolve "../x" against a base.
return new URL(value).protocol.length > 0;
} catch {
return false;
}
});
}
if (!FormatRegistry.Has("date-time")) {
// Deliberately lenient: anything `Date.parse` understands. The producers
// are `new Date().toISOString()` in TypeScript and chrono's RFC 3339 in
// Rust, and rejecting a timestamp a printer actually sent would cost a
// frame for no gain.
FormatRegistry.Set("date-time", (value) => !Number.isNaN(Date.parse(value)));
}
+320
View File
@@ -0,0 +1,320 @@
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { Elysia } from "elysia";
import { toErrorResponse } from "@continuum/ts-core";
import { roleSatisfies, ORG_ROLES } from "../src/auth/roles";
import { startDevAuthIssuer } from "../scripts/dev-auth";
import { Value } from "@sinclair/typebox/value";
import { printers } from "../src/db/schema";
import { farmProjection, printerProjection } from "../src/db/projections";
import { PRINTER_WRITABLE, PrinterCreateSchema } from "../src/db/validators";
const USER = "11111111-1111-1111-1111-111111111111";
describe("the role ladder", () => {
test("is ordered viewer < operator < owner", () => {
expect(ORG_ROLES).toEqual(["viewer", "operator", "owner"]);
});
// Written out in full rather than derived from the ladder: a table that
// computes the expected answer the same way the implementation does agrees
// with a wrong implementation.
const cases: Array<[string, string, boolean]> = [
["viewer", "viewer", true],
["viewer", "operator", false],
["viewer", "owner", false],
["operator", "viewer", true],
["operator", "operator", true],
["operator", "owner", false],
["owner", "viewer", true],
["owner", "operator", true],
["owner", "owner", true],
];
for (const [actual, minimum, expected] of cases) {
test(`'${actual}' ${expected ? "satisfies" : "does not satisfy"} '${minimum}'`, () => {
expect(roleSatisfies(actual as never, minimum as never)).toBe(expected);
});
}
test("a viewer cannot send printer commands", () => {
// The boundary that moves physical machines, called out on its own
// because it is the one that is expensive to get wrong.
expect(roleSatisfies("viewer", "operator")).toBe(false);
});
});
/**
* Exercises the real verification path - `createRemoteJWKSet` against a real
* JWKS endpoint over HTTP - rather than a stubbed verifier. The things that
* actually go wrong here (an unsigned token accepted, a token signed by
* another key accepted, an expired token accepted) are all invisible to a
* test that mocks the verifier out.
*/
describe("requireUser", () => {
let issuer: Awaited<ReturnType<typeof startDevAuthIssuer>>;
let app: Elysia;
beforeAll(async () => {
issuer = await startDevAuthIssuer();
process.env.SUPABASE_URL = issuer.url;
// Imported after SUPABASE_URL is set: the verifier is built at module
// load so a misconfigured deployment fails at boot, not on first request.
const { requireUser } = await import("../src/auth/plugin");
app = new Elysia().onError(toErrorResponse).use(requireUser).get("/whoami", ({ user }) => user);
});
afterAll(() => issuer.stop());
const get = (headers: Record<string, string> = {}) =>
app.handle(new Request("http://localhost/whoami", { headers }));
test("accepts a properly signed token and exposes the subject", async () => {
const token = await issuer.mint({ sub: USER, email: "farm@example.test" });
const response = await get({ authorization: `Bearer ${token}` });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: USER, email: "farm@example.test" });
});
test("rejects a request with no Authorization header", async () => {
const response = await get();
expect(response.status).toBe(401);
expect((await response.json()).error.code).toBe("UNAUTHORIZED");
});
test("rejects a non-Bearer Authorization header", async () => {
const response = await get({ authorization: "Basic dXNlcjpwYXNz" });
expect(response.status).toBe(401);
});
test("rejects a token signed by a key that is not in the JWKS", async () => {
const token = await issuer.mintWithForeignKey({ sub: USER });
const response = await get({ authorization: `Bearer ${token}` });
expect(response.status).toBe(401);
});
test("rejects an expired token", async () => {
const token = await issuer.mint({ sub: USER, expiresIn: -60 });
const response = await get({ authorization: `Bearer ${token}` });
expect(response.status).toBe(401);
});
test("rejects a token issued for another audience", async () => {
// Supabase issues `aud: "authenticated"` for user sessions. Its anon and
// service-role keys are also valid project JWTs with other audiences, so
// without this check the public anon key would authenticate as a user.
const token = await issuer.mint({ sub: USER, audience: "anon" });
const response = await get({ authorization: `Bearer ${token}` });
expect(response.status).toBe(401);
});
test("does not say why a token was rejected", async () => {
// Distinguishing "expired" from "bad signature" tells whoever is probing
// which part of a forged token to fix next.
const expired = await get({
authorization: `Bearer ${await issuer.mint({ sub: USER, expiresIn: -60 })}`,
});
const foreign = await get({
authorization: `Bearer ${await issuer.mintWithForeignKey({ sub: USER })}`,
});
expect((await expired.json()).error.message).toBe((await foreign.json()).error.message);
});
});
describe("configuration", () => {
test("the process refuses to start without SUPABASE_URL", async () => {
// Fail at boot, not on the first authenticated request: a backend that
// starts and then 500s on every login looks like an outage somewhere
// else. Run in a subprocess because the module caches its verifier.
const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/verifier.ts')"], {
cwd: import.meta.dir + "/..",
env: { ...process.env, SUPABASE_URL: "" },
stderr: "pipe",
stdout: "pipe",
});
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain("SUPABASE_URL");
});
// An empty value, not an unset one, because that is what the committed
// `.env.example` ships: the old `process.env.EDGE_JWT_SECRET ?? "dev-..."`
// only caught null and undefined, so copying the template and filling in
// everything else left the edge JWTs signed with "". Anything that knew
// that - or the committed fallback literal - could mint a token for any
// farm_id and open /ws/edge/v1 as that farm's gateway.
test("the process refuses to start with an empty EDGE_JWT_SECRET", async () => {
const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/edge_secret.ts')"], {
cwd: import.meta.dir + "/..",
env: { ...process.env, EDGE_JWT_SECRET: "" },
stderr: "pipe",
stdout: "pipe",
});
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain("EDGE_JWT_SECRET");
});
test("no route holds a hardcoded signing secret", async () => {
// The old fallback was duplicated across two routes, and a third copy
// would be just as exploitable as the first two, so this greps the tree
// rather than the files that happened to have it. Two shapes, because
// either one reintroduces the hole: a literal passed to `jwt()`, and a
// defaulted read of the variable (`?? "..."` / `|| "..."`), which is
// what let an empty `EDGE_JWT_SECRET` through.
const grep = async (pattern: string) => {
const proc = Bun.spawn(["grep", "-rnE", pattern, "src"], {
cwd: import.meta.dir + "/..",
stdout: "pipe",
});
return new Response(proc.stdout).text();
};
expect(await grep('secret:\\s*["\'`]')).toBe("");
expect(await grep('EDGE_JWT_SECRET\\s*(\\?\\?|\\|\\|)')).toBe("");
// And the variable is read in exactly one place, so there is only ever
// one line to get right.
expect(await grep("process\\.env\\.EDGE_JWT_SECRET")).toContain("src/auth/edge_secret.ts");
expect((await grep("process\\.env\\.EDGE_JWT_SECRET")).trim().split("\n")).toHaveLength(1);
});
});
/**
* Object storage is a credential like any other, and used to be the one that
* failed quietly.
*
* `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY` defaulted to `""`, so a
* deployment that copied `.env.example` and filled in everything else
* started happily. `POST /jobs` then handed a client a presigned URL signed
* with an empty key, and the first symptom was a 403 from R2 - in the
* client's uploader, or inside a gateway that had downloaded R2's XML error
* document instead of a print. Nothing in that chain said "R2 is not
* configured".
*/
describe("object storage configuration", () => {
const importS3 = (env: Record<string, string>) =>
Bun.spawn(["bun", "-e", "await import('./src/s3/index.ts')"], {
cwd: import.meta.dir + "/..",
env: { ...process.env, ...env },
stderr: "pipe",
stdout: "pipe",
});
// Every other variable is filled in, so the one under test is the only
// thing missing and the error has to name it. Without this the first
// missing variable in read order wins and the assertion passes for the
// wrong reason.
const complete = {
R2_ACCESS_KEY_ID: "key",
R2_SECRET_ACCESS_KEY: "secret",
R2_ACCOUNT_ID: "acct",
};
test("fails at boot when a credential is empty, naming the variable", async () => {
for (const name of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"]) {
const proc = importS3({ ...complete, [name]: "" });
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain(name);
}
});
/**
* An empty string, not just an absent variable - which is the case that
* actually happens, because the committed template ships these keys with
* no value. `??` would have missed every one of them.
*/
test("treats an empty credential as missing, not as a value", async () => {
const proc = importS3({ ...complete, R2_ACCESS_KEY_ID: "" });
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain("R2_ACCESS_KEY_ID");
});
test("refuses to start with no endpoint and no account id to derive one from", async () => {
const proc = importS3({ ...complete, R2_ENDPOINT: "", R2_ACCOUNT_ID: "" });
expect(await proc.exited).not.toBe(0);
expect(await new Response(proc.stderr).text()).toContain("R2_ENDPOINT");
});
test("starts with a complete configuration", async () => {
// R2_ENDPOINT empty on purpose: it must be derived from the account id,
// which is the `||`-not-`??` case that caught this module out.
const proc = importS3({ ...complete, R2_ENDPOINT: "" });
expect(await proc.exited).toBe(0);
});
test("reads each credential in exactly one place", async () => {
// So there is only ever one line to get right, the same rule
// `EDGE_JWT_SECRET` is held to above.
for (const name of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"]) {
const proc = Bun.spawn(["grep", "-rn", `process.env.${name}`, "src"], {
cwd: import.meta.dir + "/..",
stdout: "pipe",
});
const hits = (await new Response(proc.stdout).text()).trim();
// Read through the `required()` helper, so the literal name appears
// only where it is passed in - not as a `process.env.X` at all.
expect(hits).toBe("");
}
});
});
/**
* The credentials this backend is not allowed to hold or hand out.
*
* `printers.accessCode` used to exist: writable through `POST /printers`,
* excluded from the projection, and never read by any backend code path - a
* printer's LAN password at rest in the cloud for no purpose. It was removed
* (2026-10-19) because the credential belongs on the proxy, the only thing
* that uses it, and the alternative of surfacing it in the UI was considered
* and rejected: useful, but not worth the risk.
*
* These tests exist so that decision is enforced rather than remembered. A
* column is easy to re-add, and the symptom would be silent.
*/
describe("credentials the backend must not hold", () => {
test("a printer has no access-code column to store one in", () => {
// Against the Drizzle table rather than live SQL, so it fails in CI with
// no database - the point is the declaration, not this machine's schema.
expect(Object.keys(printers)).not.toContain("accessCode");
});
test("a client that sends one anyway has it dropped, not stored", () => {
// The create schema is open, so a stray field validates. Elysia cleans
// the body against the schema, which is what actually discards it - so
// the honest description is "ignored", not "rejected". Worth pinning:
// if the field ever became retained, nothing else here would notice.
const body = {
farmId: "3f2b1c5e-0000-4000-8000-000000000000",
name: "P1P",
vendor: "bambu" as const,
accessCode: "SECRET123",
};
const cleaned = Value.Clean(PrinterCreateSchema, structuredClone(body)) as Record<string, unknown>;
expect(cleaned).not.toHaveProperty("accessCode");
expect(cleaned.name).toBe("P1P");
});
test("the farm's edge proxy token is still kept out of responses", () => {
// The credential that does remain. `POST /auth/edge-token` trades it for
// an edge JWT, so with it and a farm id anyone can connect as that
// farm's gateway and drive its printers.
expect(Object.keys(farmProjection.columns)).not.toContain("edgeProxyToken");
});
test("printer metadata is neither writable nor served, so it cannot carry a secret", () => {
// `metadata` was the way around the access-code decision: a free-form
// jsonb column that was both writable and in the projection, so a client
// could stash a credential in it and read it back. It is now closed at
// both ends. Pinned as three assertions because either end reopening it
// alone (a writable field nobody can read, or a readable one nobody can
// write) is still half of the hole.
expect(Object.keys(printerProjection.columns)).not.toContain("metadata");
const cleaned = Value.Clean(PrinterCreateSchema, {
farmId: "3f2b1c5e-0000-4000-8000-000000000000",
name: "P1P",
vendor: "bambu" as const,
metadata: { accessCode: "SECRET123" },
}) as Record<string, unknown>;
expect(cleaned).not.toHaveProperty("metadata");
expect(cleaned.name).toBe("P1P");
expect(PRINTER_WRITABLE as readonly string[]).not.toContain("metadata");
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "bun:test";
import { dirname, join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
/**
* Guards the one dependency mistake that broke the shared schemas, because
* nothing else catches it and the symptom points somewhere else entirely.
*
* `@continuum/ts-types` is linked from continuum-schemas and resolves its own
* dependencies from that repo's node_modules. It had TypeBox 0.32.37 while
* this backend had 0.33.24, and two *different* TypeBox versions mean two
* structurally incompatible `TSchema` types - so Elysia rejected every schema
* from there, with a message that pointed somewhere else entirely.
*
* The misleading part: Elysia's validator accepts
* `TSchema | StandardSchemaV1Like`, so when the TSchema branch failed
* TypeScript reported the *other* branch's failure, `'~standard' is missing`.
* That reads like Elysia wants a Standard Schema validator and TypeBox is the
* wrong tool, which is not true at all - Elysia is TypeBox-native (`t` is a
* TypeBox JavaScriptTypeBuilder, and the dist `require`s @sinclair/typebox at
* runtime without even declaring it as a dependency).
*
* Note what this does *not* check: that both resolve to the same file. A
* `file:` dependency keeps its own node_modules, so there are legitimately two
* copies on disk. Two copies of the *same version* are interchangeable here,
* because TypeBox's types are structural and its kind markers use
* `Symbol.for()`, which is global across copies. It is the version skew that
* breaks, not the duplication.
*/
/** The installed version of `name`, resolved as `from` would resolve it. */
function installedVersion(name: string, from: string): string {
let dir = dirname(Bun.resolveSync(name, from));
// Walk up from the entry file to the package root - resolveSync returns e.g.
// build/esm/index.mjs, and package.json is not always an allowed export.
for (let i = 0; i < 10; i++) {
const candidate = join(dir, "package.json");
if (existsSync(candidate)) {
const pkg = JSON.parse(readFileSync(candidate, "utf8"));
if (pkg.name === name) return pkg.version;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`could not find the installed package.json for ${name} from ${from}`);
}
describe("@sinclair/typebox version is identical everywhere", () => {
it("matches between this backend and @continuum/ts-types", () => {
const here = installedVersion("@sinclair/typebox", import.meta.dir);
const tsTypesDir = dirname(Bun.resolveSync("@continuum/ts-types", import.meta.dir));
const there = installedVersion("@sinclair/typebox", tsTypesDir);
expect(there).toBe(here);
});
it("is pinned exactly, not as a range", () => {
// A caret range is what let the two drift apart in the first place: this
// backend and ts-types each resolved their own newest match.
const pkg = JSON.parse(readFileSync(`${import.meta.dir}/../package.json`, "utf8"));
const pinned = pkg.dependencies["@sinclair/typebox"];
expect(pinned).not.toMatch(/^[\^~]/);
expect(installedVersion("@sinclair/typebox", import.meta.dir)).toBe(pinned);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { nextJobStatus } from "../src/dispatch/job_status";
/**
* The job-status mapping, tested exhaustively.
*
* This is the logic that decides whether a print happened. It exists because
* a gateway accepting `start_print` says nothing about the outcome: a real
* end-to-end run on a P1P (2026-10-15) was accepted, uploaded byte-perfect,
* reached RUNNING and then the printer paused itself on a physical
* broken-filament fault. Job state is therefore derived from the telemetry
* that follows, and getting this table wrong means the API reports prints
* that never ran - the one failure nobody would catch by looking.
*/
describe("nextJobStatus", () => {
const currents = ["uploading", "printing", "paused"] as const;
const printerStates = ["idle", "printing", "paused", "error"] as const;
// Written out rather than computed: a table derived the same way the
// implementation derives it agrees with a wrong implementation.
const expected: Record<string, string | undefined> = {
"uploading/idle": undefined,
"uploading/printing": "printing",
"uploading/paused": "paused",
"uploading/error": "failed",
"printing/idle": "completed",
"printing/printing": "printing",
"printing/paused": "paused",
"printing/error": "failed",
"paused/idle": "completed",
"paused/printing": "printing",
"paused/paused": "paused",
"paused/error": "failed",
};
for (const current of currents) {
for (const printerState of printerStates) {
const key = `${current}/${printerState}`;
test(`${key} -> ${expected[key] ?? "unchanged"}`, () => {
expect(nextJobStatus(current, printerState)).toBe(expected[key] as never);
});
}
}
test("an idle printer does NOT complete a job that was still uploading", () => {
// The case worth its own test. A printer is idle for the entire transfer
// - minutes of it, at the 0.2 MiB/s measured over FTPS - so "idle" there
// means "has not started", not "finished". Reading it as completion
// would mark every job successful seconds after dispatch, before the
// file had even reached the machine.
expect(nextJobStatus("uploading", "idle")).toBeUndefined();
});
test("a printer error fails the job from any state, including mid-upload", () => {
// The P1P's self-pause arrived while the job was still `uploading` by
// the cloud's reckoning, because the printer faulted during its
// pre-print stages. If `uploading` were excluded here, that job would
// have sat claiming to upload until the reaper got it twenty minutes
// later.
for (const current of currents) {
expect(nextJobStatus(current, "error")).toBe("failed");
}
});
test("completion is never reported from a state the print had not begun in", () => {
// Guards the invariant rather than the table: the only route to
// "completed" is through a state that means the printer had actually
// been printing.
const completable = currents.filter((c) => nextJobStatus(c, "idle") === "completed");
expect(completable).toEqual(["printing", "paused"]);
});
});
describe("the dispatch switch", () => {
test("is on by default and off only for the exact string false", async () => {
const { dispatchEnabled } = await import("../src/dispatch/enabled");
expect(dispatchEnabled({})).toBe(true);
expect(dispatchEnabled({ DISPATCH_ENABLED: "true" })).toBe(true);
expect(dispatchEnabled({ DISPATCH_ENABLED: "false" })).toBe(false);
});
test("a value that looks like an attempt to disable it but does not is reported", async () => {
const { dispatchEnabled, dispatchSwitchWarning } = await import("../src/dispatch/enabled");
for (const typo of ["0", "off", "False", "no"]) {
expect(dispatchEnabled({ DISPATCH_ENABLED: typo })).toBe(true);
expect(dispatchSwitchWarning({ DISPATCH_ENABLED: typo })).toContain("WILL be dispatched");
}
expect(dispatchSwitchWarning({})).toBeUndefined();
expect(dispatchSwitchWarning({ DISPATCH_ENABLED: "false" })).toBeUndefined();
});
});
+402
View File
@@ -0,0 +1,402 @@
import { describe, expect, it } from "bun:test";
import { Value } from "@sinclair/typebox/value";
import { EdgeClientMessageSchema, EdgeServerMessageSchema } from "@continuum/ts-types";
// Registers `uuid` and `uri`. Not optional: TypeBox treats an unregistered
// format as a failure, so without this every `start_print` in this file is
// rejected for its job_id and url. This file used to rely on another test
// file importing Elysia first, which made it pass in a full run and fail on
// its own.
import "../src/validation/formats";
import currentCapture from "./fixtures/gateway_frames.json";
import legacyCapture from "./fixtures/gateway_frames_legacy_nulls.json";
import discoveryCapture from "./fixtures/gateway_discovery_frame.json";
/**
* A contract test, not a unit test: it checks that what continuum-proxy
* actually puts on the wire is what this backend's validator accepts.
*
* The fixtures are real captures from a live gateway talking to a real P1P and
* H2C, not hand-written examples - which is the whole point. The two ends of
* /ws/edge/v1 had silently drifted into completely different protocols
* (different handshake, different telemetry shape, printer ids the gateway
* could not know), and every hand-written example agreed with whichever side
* wrote it. Re-capture with scripts/capture-gateway-frames.js when the protocol
* changes, rather than editing these by hand.
*/
function check(schema: Parameters<typeof Value.Check>[0], value: unknown) {
const ok = Value.Check(schema, value);
if (!ok) {
// Surface *why*, or a failure here is just "false !== true".
const errors = [...Value.Errors(schema, value)].map(
(e) => `${e.path || "/"}: ${e.message} (got ${JSON.stringify(e.value)})`,
);
throw new Error(`validation failed:\n ${errors.join("\n ")}`);
}
}
describe("gateway -> backend frames", () => {
for (const frame of currentCapture.frames) {
it(`accepts a real '${frame.type}' frame`, () => {
check(EdgeClientMessageSchema, frame);
});
}
it("accepts the pre-fix capture, where empty fields were explicit nulls", () => {
// Liberal reader: the gateway now omits these, but a serde-derived
// producer emits null by default and one such field must not cost us a
// whole farm's telemetry batch.
for (const frame of legacyCapture.frames) {
check(EdgeClientMessageSchema, frame);
}
});
it("carries the live telemetry this was captured from", () => {
const telemetry = currentCapture.frames.find((f) => f.type === "telemetry");
expect(telemetry).toBeDefined();
const printers = (telemetry as { printers: Array<{ printer_id: string }> }).printers;
expect(printers.length).toBeGreaterThan(0);
// Guards against a fixture that validates because it is empty.
expect(printers.every((p) => typeof p.printer_id === "string" && p.printer_id.length > 0)).toBe(true);
});
it("accepts a stage, including stage 0", () => {
// `stage` is Bambu's `stg_cur`: what the printer is doing when it is not
// laying down plastic. 0 means printing, and a schema or serialiser that
// treats 0 as absent loses the distinction that makes the field worth
// having - a printer at 14% and layer 0 is mid-bed-levelling at stage 54
// and genuinely stuck at stage 0.
for (const stage of [0, 13, 54]) {
check(EdgeClientMessageSchema, {
type: "telemetry",
printers: [{ printer_id: "p1p", name: "P1P", percent: 14, layer: 0, stage }],
});
}
// The assertion that makes the loop above mean anything. This schema
// permits unknown properties, so `{ stage: 54 }` validates whether or
// not `stage` is declared - verified, it does. Rejecting a *wrongly
// typed* stage is what proves the field is really in the schema, so this
// is what fails if someone removes it.
expect(
Value.Check(EdgeClientMessageSchema, {
type: "telemetry",
printers: [{ printer_id: "p1p", name: "P1P", stage: "54" }],
}),
).toBe(false);
});
it("still accepts telemetry with no stage at all", () => {
// The field is additive: the committed live capture predates it, and a
// gateway that has not been updated yet simply omits it.
check(EdgeClientMessageSchema, {
type: "telemetry",
printers: [{ printer_id: "p1p", name: "P1P", percent: 0 }],
});
});
it("rejects a telemetry batch whose printer has no id", () => {
// printer_id and name are the only required fields; everything else is
// genuinely unknowable for a printer that has only just connected.
check(EdgeClientMessageSchema, { type: "telemetry", printers: [{ printer_id: "p1p", name: "P1P" }] });
expect(Value.Check(EdgeClientMessageSchema, { type: "telemetry", printers: [{ name: "P1P" }] })).toBe(false);
});
it("rejects an unknown message type", () => {
// The old protocol's handshake, which no gateway ever sent.
expect(Value.Check(EdgeClientMessageSchema, { type: "auth", token: "x" })).toBe(false);
});
/**
* The hello carries no credential any more - the edge JWT goes in an
* `Authorization: Bearer` header on the upgrade, verified before the
* socket exists (see tests/edge_upgrade.test.ts). This asserts the schema
* does not require the `token` field it used to.
*
* Note the committed captures predate that change and still contain
* `token: "dev-token"`, which validates because this schema is a liberal
* reader. That is deliberate and harmless: the field is now ignored, and
* an old gateway sending it is refused at the upgrade for having no
* header, so there is no path by which a token in a frame authenticates
* anything.
*/
it("accepts a hello with no token at all", () => {
check(EdgeClientMessageSchema, {
type: "hello",
gateway_id: "dev-gw",
farm_id: "dev-farm",
});
check(EdgeClientMessageSchema, {
type: "hello",
gateway_id: "dev-gw",
farm_id: "dev-farm",
stream_base_url: "http://gateway.local:1984",
});
});
it("still requires the fields a hello cannot work without", () => {
// `farm_id` is checked against the token's own claim rather than
// trusted, which is only possible if it is actually sent.
expect(Value.Check(EdgeClientMessageSchema, { type: "hello", gateway_id: "g" })).toBe(false);
expect(Value.Check(EdgeClientMessageSchema, { type: "hello", farm_id: "f" })).toBe(false);
});
describe("discovered_printers", () => {
const candidate = {
vendor: "bambu",
address: "192.168.0.20",
serial: "00M09A382100000",
model: "C11",
name: "P1P",
source: "certificate",
driver_ready: true,
};
it("accepts the real scan captured from a live LAN", () => {
// The one case a hand-written example cannot cover. Everything below
// agrees with how this test file imagines the gateway behaves; this
// agrees with what the gateway actually emitted, through its own serde
// enum, about two real printers. The two halves of this message are
// hand-synced, so a capture is the only thing that catches a drift.
for (const frame of discoveryCapture.frames) {
check(EdgeClientMessageSchema, frame);
}
// Guards against a fixture that validates because it is empty.
const printers = discoveryCapture.frames[0].printers;
expect(printers.length).toBeGreaterThan(0);
// And against one that validates because every field is absent: the
// address is the entire reason this message exists.
expect(printers.every((p) => p.address.length > 0 && p.vendor.length > 0)).toBe(true);
});
it("accepts a scan result", () => {
check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [candidate],
scanned_at: "2026-10-19T12:00:00Z",
});
});
it("accepts a gateway that looked and found nothing", () => {
// Distinct from never having reported, which is the off-by-default
// case. An empty list is a real answer and must validate.
check(EdgeClientMessageSchema, { type: "discovered_printers", printers: [] });
});
it("accepts a candidate with nothing but where and what it is", () => {
// The honest minimum for a Klipper host: Moonraker answers, so the
// address and the vendor are known, and nothing else is.
check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ vendor: "klipper", address: "192.168.0.31", source: "http_api", driver_ready: false }],
});
});
it("accepts the prusa_link / octoprint ambiguity", () => {
check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [
{
vendor: "octoprint",
address: "192.168.0.42",
ambiguous_with: "prusa_link",
source: "http_api",
driver_ready: false,
},
],
});
});
it("rejects a name longer than the cap", () => {
// The point of the cap, and the reason this variant is closed where
// its siblings are open: every string in a candidate was chosen by a
// device on the customer's network. Anything that answers an SSDP
// query names itself, so an unbounded `name` is LAN-supplied text on
// its way to a database column and a React tree.
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ ...candidate, name: "x".repeat(129) }],
}),
).toBe(false);
});
it("rejects a vendor nothing can drive and a source nothing can produce", () => {
for (const bad of [{ vendor: "makerbot" }, { source: "telepathy" }]) {
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ ...candidate, ...bad }],
}),
).toBe(false);
}
});
it("rejects a candidate carrying a credential", () => {
// Not a hypothetical tidiness check. A probe cannot learn an access
// code, so a candidate that has one got it from somewhere else, and
// the closed object is what stops this message quietly becoming a
// channel for secrets nobody meant to send to the cloud.
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ ...candidate, access_code: "12345678" }],
}),
).toBe(false);
});
it("requires an address, because a candidate without one is unusable", () => {
const { address: _address, ...addressless } = candidate;
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [addressless],
}),
).toBe(false);
// And an empty one is no better than a missing one.
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ ...candidate, address: "" }],
}),
).toBe(false);
});
});
});
describe("backend -> gateway frames", () => {
// Exactly what src/routes/ws/edge.ts sends. If one of these stops
// validating, the gateway's serde enum will have stopped accepting it too -
// see the mirror of this test in continuum-proxy/tests/uplink_protocol.rs.
const outbound = [
{ type: "hello_ack", session_id: "3f2b1c5e-0000-4000-8000-000000000000" },
{ type: "hello_error", message: "invalid edge token" },
{ type: "heartbeat_ack", seq: 7 },
{
type: "command",
command_id: "9a7d2f10-0000-4000-8000-000000000000",
printer_id: "p1p",
command: { action: "set_fan_speed", percent: 40 },
},
{
type: "command",
command_id: "9a7d2f11-0000-4000-8000-000000000000",
printer_id: "p1p",
command: { action: "send_gcode_line", line: "M106 S0" },
},
{
type: "command",
command_id: "9a7d2f12-0000-4000-8000-000000000000",
printer_id: "p1p",
command: { action: "control_print", control: "pause" },
},
{
type: "command",
command_id: "9a7d2f13-0000-4000-8000-000000000000",
printer_id: "p1p",
command: { action: "control_print", control: "resume" },
},
{
type: "command",
command_id: "9a7d2f14-0000-4000-8000-000000000000",
printer_id: "p1p",
command: { action: "control_print", control: "stop" },
},
{
type: "command",
command_id: "9a7d2f15-0000-4000-8000-000000000000",
printer_id: "p1p",
command: {
action: "start_print",
job_id: "7c9e6679-0000-4000-8000-000000000000",
file_name: "cube.gcode.3mf",
// Exactly what src/dispatch sends: a presigned GET, minted at
// dispatch time rather than at job creation.
url: "https://example.r2.cloudflarestorage.com/farms/f/jobs/j/cube.gcode.3mf?X-Amz-Signature=x",
},
},
];
for (const frame of outbound) {
it(`emits a valid '${frame.type}' (${(frame as { command?: { action: string } }).command?.action ?? "no command"})`, () => {
check(EdgeServerMessageSchema, frame);
});
}
it("rejects a print control verb the gateway does not implement", () => {
// "cancel" is the trap: it is what Moonraker calls this, and the Klipper
// implementation behind `control_print` really does send a cancel - but
// the wire word is "stop". A verb that only one vendor recognises must
// not validate here, or it reaches a gateway that cannot map it.
for (const control of ["cancel", "abort", "Pause", ""]) {
expect(
Value.Check(EdgeServerMessageSchema, {
type: "command",
command_id: "x",
printer_id: "p1p",
command: { action: "control_print", control },
}),
).toBe(false);
}
});
it("rejects a start_print missing any of its three required fields", () => {
// The variant exists now - the job scheduler in src/dispatch sends it.
// What this guards is that none of its fields is optional. Each one is
// load-bearing in a different way: without `url` the gateway has nothing
// to fetch, without `file_name` it has nowhere to put it, and without
// `job_id` nothing can line a gateway's logs up against a job. An
// optional field here would fail at the gateway, minutes later, on a
// machine nobody is watching.
const complete = {
action: "start_print",
job_id: "7c9e6679-0000-4000-8000-000000000000",
file_name: "plate_1.3mf",
url: "https://example.test/signed",
};
check(EdgeServerMessageSchema, {
type: "command",
command_id: "x",
printer_id: "p1p",
command: complete,
});
for (const omit of ["job_id", "file_name", "url"] as const) {
const partial: Record<string, unknown> = { ...complete };
delete partial[omit];
expect(
Value.Check(EdgeServerMessageSchema, {
type: "command",
command_id: "x",
printer_id: "p1p",
command: partial,
}),
).toBe(false);
}
});
it("will not carry a filament or AMS mapping on a start_print", () => {
// Deliberately absent from the contract, and the reason is expensive:
// hardcoding the filament choice instead of reading it from the project
// archive made a real P1P heat to 250C and pause itself at 1%. The
// gateway derives it from the `.3mf`'s own slice_info.config, which is
// the only thing that knows. A field here would invite a caller to guess
// again - and because this schema is a closed union, an extra property
// makes the whole command fail to validate rather than being ignored.
expect(
Value.Check(EdgeServerMessageSchema, {
type: "command",
command_id: "x",
printer_id: "p1p",
command: {
action: "start_print",
job_id: "7c9e6679-0000-4000-8000-000000000000",
file_name: "plate_1.3mf",
url: "https://example.test/signed",
ams_mapping: [3, -1, -1, -1, -1],
},
}),
).toBe(false);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from "bun:test";
import { Elysia } from "elysia";
import jwt from "@elysiajs/jwt";
import { UnauthorizedError } from "@continuum/ts-core";
import { farmFromEdgeUpgrade } from "../src/auth/edge_upgrade";
/**
* The gateway protocol's authentication boundary.
*
* The edge JWT travels in `Authorization: Bearer` on the upgrade request and
* is verified before any socket exists. It used to be a `token` field on the
* `hello` message, which meant an unauthenticated peer got a live socket and
* a parsed frame before proving anything. These tests are what stops that
* coming back.
*/
describe("farmFromEdgeUpgrade", () => {
const verifyOk = async (token: string) =>
token === "good" ? { scope: "edge", farmId: "farm-1" } : false;
it("returns the farm from a valid edge token", async () => {
expect(await farmFromEdgeUpgrade("Bearer good", verifyOk)).toBe("farm-1");
});
it("refuses a missing or malformed Authorization header", async () => {
for (const header of [undefined, null, "", "good", "Basic good", "Bearer"]) {
await expect(farmFromEdgeUpgrade(header, verifyOk)).rejects.toThrow(UnauthorizedError);
}
});
it("refuses a token the verifier rejects", async () => {
await expect(farmFromEdgeUpgrade("Bearer forged", verifyOk)).rejects.toThrow(UnauthorizedError);
});
/**
* A signature alone is not authorisation. The same secret could be made to
* sign something else one day, and such a token must not open a farm's
* gateway socket.
*/
it("refuses a correctly signed token minted for a different scope", async () => {
const verify = async () => ({ scope: "user", farmId: "farm-1" });
await expect(farmFromEdgeUpgrade("Bearer good", verify)).rejects.toThrow(UnauthorizedError);
});
/**
* Without this, the connection's `farmId` would be `undefined`, which to
* anything doing a lookup reads as "every farm" rather than as an error.
*/
it("refuses a token with no usable farm claim", async () => {
for (const farmId of [undefined, null, "", 42, {}]) {
const verify = async () => ({ scope: "edge", farmId }) as Record<string, unknown>;
await expect(farmFromEdgeUpgrade("Bearer good", verify)).rejects.toThrow(UnauthorizedError);
}
});
/**
* The messages must not say which part failed - that tells the holder of a
* forged token what to fix next. Asserted rather than assumed, because the
* natural way to write this code is one specific message per branch.
*/
it("says the same thing however the token fails", async () => {
const messages = new Set<string>();
for (const verify of [
async () => false as const,
async () => ({ scope: "user", farmId: "farm-1" }),
async () => ({ scope: "edge" }) as Record<string, unknown>,
]) {
await farmFromEdgeUpgrade("Bearer x", verify).catch((err: Error) =>
messages.add(err.message),
);
}
expect(messages.size).toBe(1);
});
});
/**
* That the check above actually gates the upgrade, which is the property the
* whole design rests on and is a framework behaviour rather than ours: a
* `resolve` that throws must fail the HTTP upgrade so `open` never runs.
*
* This builds its own Elysia app rather than importing `edgeWsRoute`, which
* pulls in `../../db` and so a live Postgres. What it proves is that this
* composition refuses a socket and that a resolved value reaches `ws.data`;
* what it cannot prove is that `edge.ts` is wired this way. `scripts/
* e2e-edge.sh` runs the real route against a real database and a real
* gateway, and is the test for that.
*/
describe("a resolve that throws gates the websocket upgrade", () => {
const SECRET = "edge-upgrade-test-secret";
const app = new Elysia()
.use(jwt({ name: "edgeJwt", secret: SECRET }))
.get("/mint", ({ edgeJwt, query }) =>
edgeJwt.sign({ scope: query.scope ?? "edge", farmId: "farm-1" }),
)
.resolve({ as: "scoped" }, async ({ headers, edgeJwt }): Promise<{ edgeFarmId: string }> => ({
edgeFarmId: await farmFromEdgeUpgrade(headers.authorization, (t) => edgeJwt.verify(t)),
}))
.ws("/ws/edge/v1", {
open(ws) {
ws.send(JSON.stringify({ farm: (ws.data as { edgeFarmId: string }).edgeFarmId }));
},
})
.listen(0);
const base = `localhost:${app.server!.port}`;
function attempt(headers: Record<string, string>): Promise<string> {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://${base}/ws/edge/v1`, { headers } as never);
const done = (r: string) => {
try {
ws.close();
} catch {
// Already closed by the server; nothing to do.
}
resolve(r);
};
ws.onmessage = (event) => done(`opened:${event.data}`);
ws.onerror = () => done("refused");
ws.onclose = () => done("refused");
setTimeout(() => done("timed out"), 5_000);
});
}
it("opens for a valid edge token and exposes its farm", async () => {
const token = await (await fetch(`http://${base}/mint`)).text();
expect(await attempt({ Authorization: `Bearer ${token}` })).toBe('opened:{"farm":"farm-1"}');
});
it("never opens a socket without a valid edge token", async () => {
const wrongScope = await (await fetch(`http://${base}/mint?scope=user`)).text();
expect(await attempt({})).toBe("refused");
expect(await attempt({ Authorization: "Bearer not-a-jwt" })).toBe("refused");
expect(await attempt({ Authorization: `Bearer ${wrongScope}` })).toBe("refused");
});
});
+25
View File
@@ -0,0 +1,25 @@
{
"_note": "A real `discovered_printers` frame, captured on 2026-10-19 by running `cargo run --example discover_printers -- wire 192.168.0.0/24` against a live LAN with a Bambu P1P and H2C on it, with the gateway's fleet file pointed at an empty file so nothing was filtered out. The addresses and the message shape are exactly as captured. The two `serial` values are PLACEHOLDERS of the captured shape (15 alphanumeric characters) rather than the real ones: the serials identify someone's physical hardware, nothing in this test depends on their value, and no other fixture here carries one. Note what the frame does not contain, which is the point of the format: no access code, no API key. Re-capture with the `wire` subcommand above when the message changes.",
"frames": [
{
"type": "discovered_printers",
"printers": [
{
"vendor": "bambu",
"address": "192.168.0.20",
"serial": "01S00A000000000",
"source": "certificate",
"driver_ready": true
},
{
"vendor": "bambu",
"address": "192.168.0.21",
"serial": "31B0BP0A0000000",
"source": "certificate",
"driver_ready": true
}
],
"scanned_at": "2026-10-19T08:20:03Z"
}
]
}
+56
View File
@@ -0,0 +1,56 @@
{
"_note": "Real frames captured from a continuum-proxy gateway on 2026-10-03, talking to a live Bambu P1P (idle) and H2C (mid-print). One of each message type. This is the current wire format: fields with no value are omitted, not null.",
"frames": [
{
"type": "hello",
"gateway_id": "dev-gw",
"farm_id": "dev-farm",
"token": "dev-token"
},
{
"type": "heartbeat",
"seq": 2
},
{
"type": "telemetry",
"printers": [
{
"printer_id": "p1p",
"name": "P1P",
"state": "IDLE",
"percent": 0,
"layer": 0,
"total_layers": 0,
"remaining_minutes": 0,
"nozzle_temp": 23.375,
"nozzle_target": 0.0,
"bed_temp": 22.40625,
"bed_target": 0.0,
"print_error": 0,
"active_alerts": 0
},
{
"printer_id": "h2c",
"name": "H2C",
"state": "RUNNING",
"percent": 99,
"layer": 50,
"total_layers": 50,
"remaining_minutes": 1,
"job_name": "świeczka-24.stl",
"nozzle_temp": 230.0,
"nozzle_target": 230.0,
"bed_temp": 35.0,
"bed_target": 35.0,
"print_error": 0,
"active_alerts": 3
}
]
},
{
"type": "command_result",
"command_id": "cap-1",
"ok": true
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"_note": "The same capture from before the gateway was taught to omit empty fields, kept deliberately: it is what a serde-derived producer emits by default, and the validator must keep accepting it. Note p1p's \"job_name\": null - an idle printer with no job - which used to fail validation and take the whole telemetry batch down with it.",
"frames": [
{
"type": "hello",
"gateway_id": "dev-gw",
"farm_id": "dev-farm",
"token": "dev-token"
},
{
"type": "heartbeat",
"seq": 2
},
{
"type": "telemetry",
"printers": [
{
"printer_id": "p1p",
"name": "P1P",
"state": "IDLE",
"percent": 0,
"layer": 0,
"total_layers": 0,
"remaining_minutes": 0,
"job_name": null,
"nozzle_temp": 23.40625,
"nozzle_target": 0.0,
"bed_temp": 22.4375,
"bed_target": 0.0,
"print_error": 0,
"active_alerts": 0
},
{
"printer_id": "h2c",
"name": "H2C",
"state": "RUNNING",
"percent": 97,
"layer": 50,
"total_layers": 50,
"remaining_minutes": 3,
"job_name": "świeczka-24.stl",
"nozzle_temp": 230.0,
"nozzle_target": 230.0,
"bed_temp": 35.0,
"bed_target": 35.0,
"print_error": 0,
"active_alerts": 3
}
]
},
{
"type": "command_result",
"command_id": "cap-1",
"ok": true,
"error": null
}
]
}
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, test } from "bun:test";
import { Value } from "@sinclair/typebox/value";
import { FrameTaskSchema, PrintAlertSchema, type FrameTask } from "@continuum/ts-types";
import { checkFrameTask, frameTaskToStreamFields } from "../src/queue/frame_task";
/**
* The mirror of `continuum-ai-worker/src/task.rs`'s test module. Both sides
* assert the same field names against the same canonical schema
* (`schemas/continuum/v1/ai/frame_task.schema.json`), because the bug this
* replaced was invisible from either side alone: the producer wrote
* `printerId` and `r2ObjectKey`, the consumer read `printer_id` and nothing
* else, every task was delivered and acknowledged, and every frame was
* attributed to a printer called "unknown".
*
* A stream entry is a flat string map, so there is nothing to parse and
* nothing to fail - which is exactly why the field names need a test rather
* than a type.
*/
const FARM = "11111111-1111-1111-1111-111111111111";
const PRINTER = "22222222-2222-2222-2222-222222222222";
const JOB = "33333333-3333-3333-3333-333333333333";
const task: FrameTask = {
farm_id: FARM,
printer_id: PRINTER,
r2_object_key: "farms/x/frames/1.jpg",
captured_at: "2026-10-11T12:00:00Z",
};
/** The flat list XADD takes, as the `{field: value}` map a consumer sees. */
function asMap(fields: string[]): Record<string, string> {
const map: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) map[fields[i]!] = fields[i + 1]!;
return map;
}
describe("the stream:camera_frames wire form", () => {
test("is snake_case, and exactly the keys the schema names", () => {
// Written out rather than derived from the schema: a test that reads the
// field names from the same place the implementation does cannot catch
// them both being wrong.
expect(Object.keys(asMap(frameTaskToStreamFields(task))).sort()).toEqual([
"captured_at",
"farm_id",
"printer_id",
"r2_object_key",
]);
});
test("carries the values through unchanged", () => {
expect(asMap(frameTaskToStreamFields(task))).toEqual({
farm_id: FARM,
printer_id: PRINTER,
r2_object_key: "farms/x/frames/1.jpg",
captured_at: "2026-10-11T12:00:00Z",
});
});
test("includes print_job_id when there is one", () => {
const fields = asMap(frameTaskToStreamFields({ ...task, print_job_id: JOB }));
expect(fields.print_job_id).toBe(JOB);
});
/**
* The counterpart of the Rust `an_empty_job_id_is_no_job_id` test. The old
* producer wrote `""`; a stream entry has no null, so the only way to say
* "no job" is to leave the field out.
*/
test("omits print_job_id entirely rather than writing an empty string", () => {
const fields = frameTaskToStreamFields(task);
expect(fields).not.toContain("print_job_id");
expect(fields).not.toContain("");
expect("print_job_id" in asMap(fields)).toBe(false);
});
test("emits an even number of field/value entries", () => {
// XADD takes pairs; an odd count is a wrong-arity error at the server
// rather than at the type level.
expect(frameTaskToStreamFields(task).length % 2).toBe(0);
expect(frameTaskToStreamFields({ ...task, print_job_id: JOB }).length % 2).toBe(0);
});
test("never emits a camelCase key", () => {
// The actual regression. Both field name sets, side by side, so the
// failure message names the one that came back.
const keys = Object.keys(asMap(frameTaskToStreamFields({ ...task, print_job_id: JOB })));
for (const camel of ["farmId", "printerId", "printJobId", "r2ObjectKey", "capturedAt"]) {
expect(keys).not.toContain(camel);
}
});
});
describe("the producer's schema check", () => {
test("accepts a well-formed task", () => {
expect(checkFrameTask.Check(task)).toBe(true);
});
test("rejects a task with a camelCase field instead of the real one", () => {
const { printer_id, ...rest } = task;
expect(checkFrameTask.Check({ ...rest, printerId: printer_id })).toBe(false);
});
test("rejects a task missing any required field", () => {
for (const key of Object.keys(task) as Array<keyof FrameTask>) {
const partial = { ...task };
delete partial[key];
expect(checkFrameTask.Check(partial)).toBe(false);
}
});
test("rejects an empty object key", () => {
// `minLength: 1`: an empty key would send the worker looking for the
// object named "".
expect(checkFrameTask.Check({ ...task, r2_object_key: "" })).toBe(false);
});
/**
* Guards `src/validation/formats.ts`. TypeBox treats an unregistered
* format as a failure, not as "skip", so this check started life rejecting
* every task with `Unknown format 'uuid'`. These two assertions fail if
* the registration is lost (everything rejected) *or* if it became a no-op
* that accepts anything (nothing rejected).
*/
test("the uuid format is actually enforced, not merely declared", () => {
expect(checkFrameTask.Check(task)).toBe(true);
expect(checkFrameTask.Check({ ...task, printer_id: "unknown" })).toBe(false);
expect(checkFrameTask.Check({ ...task, farm_id: "11111111-1111-1111-1111-11111111111" })).toBe(
false,
);
});
test("the date-time format is actually enforced", () => {
expect(checkFrameTask.Check({ ...task, captured_at: "yesterday" })).toBe(false);
});
test("rejects a print_job_id that is an empty string", () => {
// Belt and braces with the omit-don't-empty rule: if a caller does pass
// `""`, the producer refuses rather than writing it.
expect(checkFrameTask.Check({ ...task, print_job_id: "" })).toBe(false);
});
});
describe("the channel:print_alerts payload", () => {
// Asserted here as well as in the worker because the backend is the side
// that will have to parse these once the fan-out route exists, and this is
// where that expectation should be recorded.
const alert = {
printer_id: PRINTER,
label: "spaghetti",
confidence: 0.91,
captured_at: "2026-10-11T12:00:00Z",
r2_object_key: "farms/x/frames/1.jpg",
};
test("the shape the worker publishes validates", () => {
expect(Value.Check(PrintAlertSchema, alert)).toBe(true);
});
test("print_job_id is optional, and null is not the same as absent", () => {
expect(Value.Check(PrintAlertSchema, { ...alert, print_job_id: JOB })).toBe(true);
expect(Value.Check(PrintAlertSchema, { ...alert, print_job_id: null })).toBe(false);
});
test("confidence outside 0..1 is refused", () => {
expect(Value.Check(PrintAlertSchema, { ...alert, confidence: 1.5 })).toBe(false);
expect(Value.Check(PrintAlertSchema, { ...alert, confidence: -0.1 })).toBe(false);
});
test("an alert carries the frame it came from, not just a label", () => {
// Without `r2_object_key` an operator gets "printer X is failing" and no
// way to look at the evidence.
const { r2_object_key, ...withoutFrame } = alert;
expect(Value.Check(PrintAlertSchema, withoutFrame)).toBe(false);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { Value } from "@sinclair/typebox/value";
import { EdgeClientMessageSchema } from "@continuum/ts-types";
import { NO_CAMERA_MESSAGE, NO_GATEWAY_MESSAGE, resolveStream } from "../src/stream";
const hello = { type: "hello", gateway_id: "gw-1", farm_id: "f1", token: "t" };
describe("hello stream_base_url", () => {
test("is accepted when present", () => {
const msg = { ...hello, stream_base_url: "http://gateway.local:1984" };
expect(Value.Check(EdgeClientMessageSchema, msg)).toBe(true);
});
test("is accepted when absent, the normal case", () => {
expect(Value.Check(EdgeClientMessageSchema, hello)).toBe(true);
});
test("tolerates an explicit null from a serde producer", () => {
expect(Value.Check(EdgeClientMessageSchema, { ...hello, stream_base_url: null })).toBe(true);
});
});
describe("telemetry camera_stream", () => {
const telemetry = (extra: object) => ({
type: "telemetry",
printers: [{ printer_id: "p1", name: "X1", ...extra }],
});
// The persistence step itself is `snapshot.camera_stream ?? null` inside
// persistTelemetry, which needs a database; what is checked here is that
// the value survives the validator that guards that path.
test("survives validation with its value intact", () => {
const msg = telemetry({ camera_stream: "p1" });
expect(Value.Check(EdgeClientMessageSchema, msg)).toBe(true);
expect((msg.printers[0] as { camera_stream?: string }).camera_stream).toBe("p1");
});
test("is optional", () => {
expect(Value.Check(EdgeClientMessageSchema, telemetry({}))).toBe(true);
});
});
describe("resolveStream", () => {
// No streamUrl any more, and its absence is the point: a browser cannot
// play from a gateway's own address (no CORS, and a workshop NAT besides),
// so handing one out only invited a player that could never load. What is
// left is the camera name to negotiate for.
test("returns the endpoint's success shape", () => {
expect(resolveStream("p1", true)).toEqual({ ok: true, stream: { cameraId: "p1" } });
});
test("the two failures are distinguishable", () => {
const noCamera = resolveStream(null, true);
const offline = resolveStream("p1", false);
if (noCamera.ok || offline.ok) throw new Error("expected both to fail");
expect(noCamera.reason).toBe("no_camera");
expect(offline.reason).toBe("gateway_offline");
expect(noCamera.message).toBe(NO_CAMERA_MESSAGE);
expect(offline.message).toBe(NO_GATEWAY_MESSAGE);
// Different problems for whoever is debugging: a P1/A1 that can never
// stream, versus a gateway that is merely unplugged.
expect(noCamera.message).not.toBe(offline.message);
});
test("a printer with no camera is blamed first, even if the gateway is also absent", () => {
const both = resolveStream(undefined, false);
expect(!both.ok && both.reason).toBe("no_camera");
});
});
+283
View File
@@ -0,0 +1,283 @@
import { describe, expect, it } from "bun:test";
import { Elysia } from "elysia";
import { FormatRegistry } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import { AppTelemetryMessageSchema, type AppPrinterState } from "@continuum/ts-types";
import { UnauthorizedError } from "@continuum/ts-core";
import { maySee, publish, subscribe, subscriberCount } from "../src/telemetry/broadcast";
import { tokenFromSubprotocol } from "../src/auth/ws_subprotocol";
/**
* TypeBox knows no string formats until something registers them. Elysia
* does that for itself at runtime, which is why `format: "uuid"` works on
* the HTTP routes - but a bare `Value.Check` here does not, and an
* unregistered format fails every value rather than being ignored. So the
* schema keeps its format and the test learns the format, instead of the
* schema being weakened to suit the test.
*/
FormatRegistry.Set("uuid", (value) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value),
);
const printer = (over: Partial<AppPrinterState> = {}): AppPrinterState => ({
id: "3f2b1c5e-0000-4000-8000-000000000001",
farmId: "3f2b1c5e-0000-4000-8000-0000000000aa",
name: "H2C",
vendor: "bambu",
status: "idle",
vendorState: "FINISH",
vendorErrorCode: 0,
nozzleTempC: 30,
nozzleTargetTempC: 0,
bedTempC: 23,
bedTargetTempC: 0,
progressPercent: 100,
currentLayer: 134,
totalLayers: 134,
remainingMinutes: 0,
currentJobName: "a-real-job",
activeAlerts: 6,
cameraStream: "h2c",
lastSeenAt: "2026-10-18T13:40:00.000Z",
...over,
});
/**
* The function that decides whether one organisation's operator sees
* another's machines. It lives in its own module precisely so this test does
* not need a socket or a database.
*/
describe("maySee", () => {
const mine = "3f2b1c5e-0000-4000-8000-0000000000aa";
const theirs = "3f2b1c5e-0000-4000-8000-0000000000bb";
it("sends a printer in a farm the viewer can see", () => {
expect(maySee(new Set([mine]), printer({ farmId: mine }))).toBe(true);
});
it("does not send a printer from another organisation's farm", () => {
expect(maySee(new Set([mine]), printer({ farmId: theirs }))).toBe(false);
});
it("sends nothing to a viewer who can see no farms", () => {
expect(maySee(new Set(), printer())).toBe(false);
});
/**
* `farm_id` is not nullable, so there is no such row today. Asserted
* anyway: this is the one function where being wrong leaks another
* organisation's machines, so it should not depend on a schema constraint
* staying as it is.
*/
it("refuses a printer with no farm rather than matching it against anything", () => {
expect(maySee(new Set([mine]), printer({ farmId: undefined as never }))).toBe(false);
expect(maySee(new Set([""]), printer({ farmId: "" as never }))).toBe(false);
});
});
describe("the broadcast fan-out", () => {
it("delivers to every subscriber and stops on unsubscribe", () => {
const seen: string[] = [];
const off1 = subscribe((p) => seen.push(`a:${p.name}`));
const off2 = subscribe((p) => seen.push(`b:${p.name}`));
publish(printer({ name: "first" }));
expect(seen).toEqual(["a:first", "b:first"]);
off1();
publish(printer({ name: "second" }));
expect(seen).toEqual(["a:first", "b:first", "b:second"]);
off2();
publish(printer({ name: "third" }));
expect(seen).toEqual(["a:first", "b:first", "b:second"]);
expect(subscriberCount()).toBe(0);
});
/**
* A socket closing mid-iteration makes its `send` throw. If that
* propagated, it would come out of `persistTelemetry` and abort a whole
* farm's telemetry batch - one dead browser tab taking out the fleet.
*/
it("keeps delivering after a subscriber throws, and does not propagate", () => {
const seen: string[] = [];
const off1 = subscribe(() => {
throw new Error("socket already closed");
});
const off2 = subscribe((p) => seen.push(p.name));
expect(() => publish(printer({ name: "survivor" }))).not.toThrow();
expect(seen).toEqual(["survivor"]);
off1();
off2();
});
});
/**
* A browser cannot set headers on a WebSocket, so the token rides in the
* subprotocol field rather than a query parameter - which would put a bearer
* credential into access and proxy logs.
*/
describe("tokenFromSubprotocol", () => {
it("takes the token from a well-formed offer", () => {
expect(tokenFromSubprotocol("bearer, tok-abc")).toBe("tok-abc");
// Whatever whitespace the runtime joins the list with.
expect(tokenFromSubprotocol("bearer,tok-abc")).toBe("tok-abc");
});
it("refuses anything else", () => {
for (const header of [
undefined,
null,
"",
"bearer",
"tok-abc",
// Reversed: the scheme must be first, because the runtime echoes the
// first offered value back and the token must not be what comes back.
"tok-abc, bearer",
"basic, tok-abc",
"bearer, tok-abc, extra",
]) {
expect(() => tokenFromSubprotocol(header)).toThrow(UnauthorizedError);
}
});
});
/**
* That what the backend actually pushes matches the published contract.
*
* The payload is built from `printerProjection`, and the schema lives in
* continuum-schemas - two places that can drift. This is the test that
* notices. It is also the reason the frontend can be written against the
* schema rather than against a guess, which is how the last version of that
* store ended up consuming fields no backend ever sent.
*/
describe("the pushed messages match continuum/v1/app", () => {
function check(value: unknown) {
const ok = Value.Check(AppTelemetryMessageSchema, value);
if (!ok) {
const errors = [...Value.Errors(AppTelemetryMessageSchema, value)].map(
(e) => `${e.path || "/"}: ${e.message} (got ${JSON.stringify(e.value)})`,
);
throw new Error(`validation failed:\n ${errors.join("\n ")}`);
}
}
it("accepts a full telemetry push", () => {
check({ type: "printer.telemetry", printer: printer() });
});
/**
* The state the schema's nullability exists for: a claimed printer whose
* gateway has never reported. Every reading is null, and a client that
* renders `?? 0` would draw this as a cold printer rather than an unknown
* one.
*/
it("accepts a printer that has never reported anything", () => {
check({
type: "printer.telemetry",
printer: {
id: "3f2b1c5e-0000-4000-8000-000000000002",
farmId: "3f2b1c5e-0000-4000-8000-0000000000aa",
name: "never seen",
vendor: "bambu",
status: null,
vendorState: null,
nozzleTempC: null,
bedTempC: null,
progressPercent: null,
cameraStream: null,
lastSeenAt: null,
},
});
});
it("accepts the ready message", () => {
check({ type: "ready", visiblePrinters: 0 });
check({ type: "ready", visiblePrinters: 12 });
});
it("rejects the shape the old frontend invented", () => {
// Every field of this was made up against nothing: a `data` envelope,
// `printerId`, `nozzleTempC` at the top level, `progressPct`. Asserted
// so nobody reintroduces it by writing the client first.
expect(
Value.Check(AppTelemetryMessageSchema, {
type: "printer.telemetry",
data: { printerId: "p1p", nozzleTempC: 30, progressPct: 50 },
}),
).toBe(false);
});
it("rejects a push with no printer and an unknown message type", () => {
expect(Value.Check(AppTelemetryMessageSchema, { type: "printer.telemetry" })).toBe(false);
expect(Value.Check(AppTelemetryMessageSchema, { type: "printer.update", printer: printer() })).toBe(false);
});
});
/**
* That the subprotocol check actually gates the upgrade, so an
* unauthenticated browser never gets a socket.
*
* Mirrors the real route's composition rather than importing it, because
* `routes/ws/telemetry.ts` pulls in `../../db` and so a live Postgres - the
* same constraint as `tests/edge_upgrade.test.ts`. What this pins is the
* framework behaviour the design rests on: that a throwing `resolve` fails
* the HTTP upgrade, and that the runtime echoes back the *first* offered
* subprotocol, which is why "bearer" is first and the token second. If it
* echoed the token instead, the credential would come back in a response
* header. `scripts/e2e-edge.sh` drives the real route.
*/
describe("the subprotocol gates the websocket upgrade", () => {
const app = new Elysia()
.resolve({ as: "scoped" }, ({ headers }): { viewer: string } => {
const token = tokenFromSubprotocol(headers["sec-websocket-protocol"]);
if (token !== "good-token") throw new UnauthorizedError("Invalid or expired access token");
return { viewer: "user-1" };
})
.ws("/ws/telemetry", {
open(ws) {
ws.send(JSON.stringify({ viewer: (ws.data as { viewer: string }).viewer }));
},
})
.listen(0);
const base = `localhost:${app.server!.port}`;
function attempt(protocols?: string[]): Promise<{ result: string; negotiated?: string }> {
return new Promise((resolve) => {
const ws = protocols
? new WebSocket(`ws://${base}/ws/telemetry`, protocols)
: new WebSocket(`ws://${base}/ws/telemetry`);
const done = (result: string) => {
const negotiated = ws.protocol;
try {
ws.close();
} catch {
// Already closed by the server.
}
resolve({ result, negotiated });
};
ws.onmessage = (event) => done(`opened:${event.data}`);
ws.onerror = () => done("refused");
ws.onclose = () => done("refused");
setTimeout(() => done("timed out"), 5_000);
});
}
it("opens for a valid token and echoes back the scheme, not the token", async () => {
const { result, negotiated } = await attempt(["bearer", "good-token"]);
expect(result).toBe('opened:{"viewer":"user-1"}');
// The credential must not be what comes back in the response header.
expect(negotiated).toBe("bearer");
});
it("never opens a socket without a valid token", async () => {
expect((await attempt()).result).toBe("refused");
expect((await attempt(["bearer", "forged"])).result).toBe("refused");
expect((await attempt(["bearer"])).result).toBe("refused");
// Scheme second: rejected, because the first value is what gets echoed.
expect((await attempt(["good-token", "bearer"])).result).toBe("refused");
});
});