Compare commits

..
50 Commits
Author SHA1 Message Date
octoturge fc8cf1d509 Add organisation invitations and one-time-code gateway enrolment
Invitations: token shown once, only its hash stored, 7-day expiry, the
accepting account's email must match. Enrolment codes: 15 minutes,
single use, hashed; POST /auth/enrol returns the farm's existing gateway
token without rotating it and is rate-limited per IP. No email is sent;
the inviter shares the link. Migration 0007, unit tests and
scripts/e2e-onboarding.sh.
2026-10-30 08:13:00 +00:00
octoturge b431ee4030 Apply job outcomes a gateway kept while the cloud was away
A job_event from a gateway moves a job that is still in flight (uploading,
printing, paused) to printing, completed or failed, and is acknowledged
whether or not it changed anything so the gateway stops sending it. A job
the cloud has cancelled, completed or put back in the queue is left alone,
and a gateway can only touch jobs of its own farm. If the database is
unreachable it is not acknowledged, so it is sent again.

scripts/e2e-vendors.sh gains a phase that takes the backend away for the
whole of a fake print and checks the job completes on reconnect from the
proxy's kept record.
2026-10-30 08:05:05 +00:00
octoturge 64a43593b2 Fail a job when the gateway reports it could not start it
A start_print is acknowledged as soon as the gateway accepts it, and the
download and transfer that follow can still fail. That late result was only
logged, so the job sat in uploading until the stall reaper gave up twenty
minutes later.

The dispatcher now remembers which job each start command belonged to. A
late failure for it fails the job with the gateway's own reason in
metadata.lastDispatchError - but only while the job is still uploading, so a
job the printer has since started is left to its telemetry.

scripts/e2e-vendors.sh gains a phase where the proxy refuses the download
and the job must fail at once with that reason.
2026-10-30 07:52:44 +00:00
octoturge d53c22b641 Add Klipper, OctoPrint and PrusaLink printers from the API
POST /farms/:id/printers now takes any of the four vendors the gateway can
drive. Credentials depend on the vendor: Bambu needs an access code and
serial, OctoPrint and PrusaLink an API key, Klipper nothing. A missing one
is a 422 naming what is needed. The API key is relayed to the gateway and
kept out of logs and errors like the access code.

The printer_vendor enum gains octoprint (migration 0006); PrusaLink is
stored as prusa, the name the enum already had.

scripts/e2e-vendors.sh adds each kind through the API against a fake REST
printer (scripts/fake-http-printer.ts) and runs a job through the real
scheduler to completion. The fake shares its author's reading of the docs
with the driver, so this proves the plumbing, not the protocols.
2026-10-30 07:49:31 +00:00
octoturge 69b6efea9b Check the P1/A1 camera in real Chrome
BROWSER=1 on e2e-camera-snapshot.sh signs in with the real Supabase test
user, opens the fleet page and checks the snapshot view decodes, refreshes
and has picture content.
2026-10-30 07:37:41 +00:00
octoturge fcde6f5155 Let a job choose its AMS slot; soak test for telemetry
POST /jobs takes an optional amsSlot (stored in the job metadata) which
dispatch passes to the gateway. The camera e2e script gains SOAK_S.
2026-10-30 06:56:24 +00:00
octoturge 58d5b013bc Let the real-print script use a LAN object store 2026-10-29 22:13:47 +00:00
octoturge 1f9fd08b40 Add a gated script for the first real print
scripts/real-print-p1p.sh queues a job in a throwaway one-printer farm and
watches the machine print it. Refuses without I_AUTHORISE_A_REAL_PRINT=yes.
2026-10-29 16:20:04 +00:00
octoturge a190c896da Relay P1/A1 camera frames to viewers
GET /printers/:id/snapshot asks the farm's gateway for one JPEG frame
(camera_snapshot_request) and returns it no-store. Concurrent viewers
share one read and a frame under 800 ms old is reused, because the
printer's camera serves one client. /stream answers kind "snapshot" for
these printers; printers.camera_snapshot is set from telemetry.

scripts/e2e-camera-snapshot.sh reads real frames from the P1P through
the real proxy and backend.
2026-10-29 16:11:10 +00:00
octoturge cd34f7b597 Prove the whole print loop with every machine but the printer real
e2e-job-flow.sh uploads a file through the presigned URL to the real object
store, lets the real scheduler dispatch it over /ws/edge/v1 to a stand-in
gateway, which downloads it through the presigned GET and hashes it, then
reports the print running and finished. The job must pass through printing
to completed and the gateway must have received exactly the uploaded bytes.

Dispatch is on in this one, which is only safe because the stand-in is the
only gateway the backend instance under test can have; the script says so.
2026-10-29 15:16:39 +00:00
octoturge 9e4b3e5315 Drive the first-run experience in a real browser
e2e-setup-browser.sh signs a user with no organisation into the real web
console, creates a farm, copies the gateway settings the page shows,
connects a stand-in gateway configured from exactly those settings, adds a
printer through the form, gets a failed-print alert and dismisses it, then
removes the printer. Afterwards it searches every table and the backend log
for the access code the browser typed. No printer is touched and dispatch
is off. The e2e scripts that start the real proxy also give it its own store
file in the scratch directory.
2026-10-29 15:15:06 +00:00
octoturge 519ada57f3 Give the real proxy the farm's proxy token, as the console will
The e2e scripts handed the proxy a pre-minted edge JWT, which hid that the
proxy could not exchange its configured token for one. It now does, so the
scripts configure it the way a real gateway is configured: with the farm's
edge proxy token.
2026-10-29 15:10:27 +00:00
octoturge 9036d23e6b Prove provisioning with the real proxy, and a real printer when one is named
e2e-provision.sh used a stand-in gateway; this drives the real
continuum-proxy over the real socket, which is what catches the two ends
disagreeing about the provision_printer frame. With no printer named it
proves the failure path (a useful error returns, no row, nothing stored on
the gateway, the code in neither process's log) and that the proxy starts
and connects with no printers file at all. With REAL_HOST/REAL_SERIAL/
REAL_ACCESS_CODE it also proves the success path - telemetry under the new
id, an owner-only store file, and the printer returning after a restart
from the store alone. It only connects and listens; it sends no command.
2026-10-29 15:07:44 +00:00
octoturge 46bfecf3e7 Add a printer through the gateway without the cloud keeping its credential
POST /farms/:id/printers (owner) is the console's add-printer call. It needs
a connected gateway and relays the printer's access code to it in a single
provision_printer frame; the gateway connects to the printer to prove the
code, saves it in its own database, and only then does the cloud create the
printer row. A refused or timed-out add leaves no row, and a row insert that
fails after the gateway said yes tells the gateway to forget the printer.

The access code is held in the request and the one frame and nowhere else:
not in any table, not in a log line, not in a response or an error (the
gateway's error text is scrubbed of it as a second line of defence).
Deleting a printer now also tells the gateway to forget it and its stored
credential, best effort, so an unplugged gateway cannot block an owner.
Only Bambu is accepted, because it is the only vendor with a working
driver; the rest are found by discovery but cannot yet be driven.

e2e-provision.sh drives a stand-in gateway over the real socket and, after
adding a printer, searches every table of the database and the backend's
log for the code (checked to fail when the code is logged).
2026-10-29 15:06:10 +00:00
octoturge 847f7cb686 Raise alerts: failed or paused prints, and AI detections
Nothing subscribed to channel:print_alerts and nothing watched a printer
fail, so an operator away from the screen found out when they came back to
a ruined plate. Alerts are now rows in printer_alerts, listed at
GET /alerts (open=true for what still needs a person) and cleared with
POST /alerts/:id/acknowledge, which is operator-only and keeps the first
acknowledger on the record.

Two sources. Telemetry: a change into error is a failed print if the printer
was printing or paused, otherwise a printer error; a print the printer
paused itself (how an AMS fault looks) is announced too. Transitions, not
levels - a printer sitting in error is one alert, not one per frame - and a
connection's first sight of a printer raises nothing, so a gateway that
reconnects does not re-announce known faults. AI: the worker's Valkey
publications are stored when confidence is at least ALERT_MIN_CONFIDENCE
(default 0.6); the worker deliberately does not threshold, so the policy
lives here. A bad message costs one message, not the listener.

e2e-authz.sh gains the scoping/ack cases; new e2e-alerts.sh drives a stand-in
gateway over the real /ws/edge/v1 and the real Valkey channel (checked to
fail when the derivation is broken).
2026-10-29 14:56:24 +00:00
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
108 changed files with 18485 additions and 241 deletions
+46 -8
View File
@@ -1,22 +1,60 @@
PORT=3000 PORT=3000
# Postgres, routed through pgCat/PgBouncer # Postgres, directly - no pooler for the MVP (see src/db/index.ts)
DATABASE_URL=postgres://continuum:continuum@localhost:6432/continuum DATABASE_URL=postgres://continuum:continuum@localhost:5432/continuum
DATABASE_POOL_MAX=10 DATABASE_POOL_MAX=10
# Redis (state + streams) # Valkey (state + streams), NOT Redis - Redis 7.4+ is RSALv2/SSPL and 8.0+
REDIS_URL=redis://localhost:6379 # 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_ACCOUNT_ID=
R2_BUCKET=continuum R2_BUCKET=continuum
R2_ENDPOINT= R2_ENDPOINT=
R2_ACCESS_KEY_ID= R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY= R2_SECRET_ACCESS_KEY=
# Supabase Auth # Cloudflare Realtime TURN, for relaying printer camera video.
SUPABASE_JWT_SECRET= #
# 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= 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= 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 bun.lock
.env .env
.env.local .env.local
drizzle/
*.log *.log
.DS_Store .DS_Store
+67 -11
View File
@@ -1,30 +1,86 @@
# continuum-backend # continuum-backend
Cloud control plane for Continuum. Bun + ElysiaJS, PostgreSQL (via pgCat/PgBouncer, Cloud control plane for Continuum. Bun + ElysiaJS, PostgreSQL (with pgvector for frame embeddings), Valkey Streams, and Cloudflare R2 storage.
with pgvector for frame embeddings), Redis Streams, and Cloudflare R2 storage.
## Run ## Run
```sh ```sh
bun install bun install
cp .env.example .env # fill in DATABASE_URL, REDIS_URL, R2_*, SUPABASE_JWT_SECRET scripts/dev-env.sh up # Valkey + Postgres/pgvector, and print the URLs
bun run db:generate && bun run db:migrate 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 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 ## Layout
- `src/db/` - Drizzle schema (`printers`, `farms`, `print_jobs`, `frame_embeddings`) - `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`, request validators straight from the Drizzle tables via `drizzle-typebox`,
so the Postgres schema and the API validators come from one definition so the Postgres schema and the API validators come from one definition
instead of two hand-synced ones. instead of two hand-synced ones.
- `src/s3/` - R2 presigned URL helpers (`PUT` for client uploads, `GET` for the - `src/s3/` - R2 presigned URL helpers (`PUT` for client uploads, `GET` for the
edge proxy). edge proxy).
- `src/queue/` - Redis client + `XADD` helper pushing onto `stream:camera_frames` - `src/queue/` - Valkey client (`ioredis`) + `XADD` helper pushing onto
for `continuum-ai-worker`. `stream:camera_frames` for `continuum-ai-worker`.
- `src/routes/` - `/auth`, `/farms`, `/printers`, `/jobs`, and the edge-gateway
WebSocket at `/ws/edge/v1`.
`frame_embeddings.embedding` requires the `vector` extension: Valkey, not Redis: Redis 7.4+ is RSALv2/SSPL and 8.0+ adds AGPLv3, none of
`CREATE EXTENSION IF NOT EXISTS vector;` 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", out: "./drizzle",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { 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, verbose: true,
strict: 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";
+32
View File
@@ -0,0 +1,32 @@
DO $$ BEGIN
CREATE TYPE "public"."alert_kind" AS ENUM('print_failed', 'print_paused', 'printer_error', 'ai_detection');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "printer_alerts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"farm_id" uuid NOT NULL,
"printer_id" uuid,
"kind" "alert_kind" NOT NULL,
"message" text NOT NULL,
"confidence" real,
"object_key" text,
"acknowledged_by" uuid,
"acknowledged_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "printer_alerts" ADD CONSTRAINT "printer_alerts_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 "printer_alerts" ADD CONSTRAINT "printer_alerts_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
CREATE INDEX IF NOT EXISTS "printer_alerts_farm_open_idx" ON "printer_alerts" USING btree ("farm_id","acknowledged_at");
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "printers" ADD COLUMN "camera_snapshot" boolean DEFAULT false NOT NULL;
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "printer_vendor" ADD VALUE 'octoprint';
+40
View File
@@ -0,0 +1,40 @@
CREATE TABLE IF NOT EXISTS "enrolment_codes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"farm_id" uuid NOT NULL,
"code_hash" text NOT NULL,
"created_by" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
CONSTRAINT "enrolment_codes_code_hash_unique" UNIQUE("code_hash")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "org_invitations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"org_id" uuid NOT NULL,
"email" text NOT NULL,
"role" "org_role" NOT NULL,
"token_hash" text NOT NULL,
"invited_by" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"accepted_at" timestamp with time zone,
"accepted_by" uuid,
CONSTRAINT "org_invitations_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
ALTER TABLE "org_members" ADD COLUMN "email" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "enrolment_codes" ADD CONSTRAINT "enrolment_codes_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 "org_invitations" ADD CONSTRAINT "org_invitations_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 "enrolment_codes_farm_idx" ON "enrolment_codes" USING btree ("farm_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "org_invitations_org_idx" ON "org_invitations" USING btree ("org_id");
+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": {}
}
}
+841
View File
@@ -0,0 +1,841 @@
{
"id": "ee6e6f93-7aec-431b-955d-75c485352ea7",
"prevId": "e4751490-d3cf-44ca-8807-f7dba03415ea",
"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": {}
},
"public.printer_alerts": {
"name": "printer_alerts",
"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
},
"kind": {
"name": "kind",
"type": "alert_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": false
},
"object_key": {
"name": "object_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"acknowledged_by": {
"name": "acknowledged_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"acknowledged_at": {
"name": "acknowledged_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()"
}
},
"indexes": {
"printer_alerts_farm_open_idx": {
"name": "printer_alerts_farm_open_idx",
"columns": [
{
"expression": "farm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "acknowledged_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"printer_alerts_farm_id_farms_id_fk": {
"name": "printer_alerts_farm_id_farms_id_fk",
"tableFrom": "printer_alerts",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"printer_alerts_printer_id_printers_id_fk": {
"name": "printer_alerts_printer_id_printers_id_fk",
"tableFrom": "printer_alerts",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"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"
]
},
"public.alert_kind": {
"name": "alert_kind",
"schema": "public",
"values": [
"print_failed",
"print_paused",
"printer_error",
"ai_detection"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+848
View File
@@ -0,0 +1,848 @@
{
"id": "aef1c8fd-6a0a-48b5-9957-26b3630990f3",
"prevId": "ee6e6f93-7aec-431b-955d-75c485352ea7",
"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
},
"camera_snapshot": {
"name": "camera_snapshot",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": 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": {}
},
"public.printer_alerts": {
"name": "printer_alerts",
"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
},
"kind": {
"name": "kind",
"type": "alert_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": false
},
"object_key": {
"name": "object_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"acknowledged_by": {
"name": "acknowledged_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"acknowledged_at": {
"name": "acknowledged_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()"
}
},
"indexes": {
"printer_alerts_farm_open_idx": {
"name": "printer_alerts_farm_open_idx",
"columns": [
{
"expression": "farm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "acknowledged_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"printer_alerts_farm_id_farms_id_fk": {
"name": "printer_alerts_farm_id_farms_id_fk",
"tableFrom": "printer_alerts",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"printer_alerts_printer_id_printers_id_fk": {
"name": "printer_alerts_printer_id_printers_id_fk",
"tableFrom": "printer_alerts",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"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"
]
},
"public.alert_kind": {
"name": "alert_kind",
"schema": "public",
"values": [
"print_failed",
"print_paused",
"printer_error",
"ai_detection"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+849
View File
@@ -0,0 +1,849 @@
{
"id": "97bbd615-092b-4859-9b89-de9c6295a4f9",
"prevId": "aef1c8fd-6a0a-48b5-9957-26b3630990f3",
"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
},
"camera_snapshot": {
"name": "camera_snapshot",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": 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": {}
},
"public.printer_alerts": {
"name": "printer_alerts",
"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
},
"kind": {
"name": "kind",
"type": "alert_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": false
},
"object_key": {
"name": "object_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"acknowledged_by": {
"name": "acknowledged_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"acknowledged_at": {
"name": "acknowledged_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()"
}
},
"indexes": {
"printer_alerts_farm_open_idx": {
"name": "printer_alerts_farm_open_idx",
"columns": [
{
"expression": "farm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "acknowledged_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"printer_alerts_farm_id_farms_id_fk": {
"name": "printer_alerts_farm_id_farms_id_fk",
"tableFrom": "printer_alerts",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"printer_alerts_printer_id_printers_id_fk": {
"name": "printer_alerts_printer_id_printers_id_fk",
"tableFrom": "printer_alerts",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"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",
"octoprint"
]
},
"public.print_job_status": {
"name": "print_job_status",
"schema": "public",
"values": [
"queued",
"uploading",
"printing",
"paused",
"completed",
"failed",
"cancelled"
]
},
"public.alert_kind": {
"name": "alert_kind",
"schema": "public",
"values": [
"print_failed",
"print_paused",
"printer_error",
"ai_detection"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
{
"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
},
{
"idx": 4,
"version": "7",
"when": 1793285676878,
"tag": "0004_printer_alerts",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1793289367708,
"tag": "0005_bouncy_cannonball",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1793346305671,
"tag": "0006_octoprint_vendor",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1793347631289,
"tag": "0007_onboarding",
"breakpoints": true
}
]
}
+15 -9
View File
@@ -1,5 +1,6 @@
{ {
"name": "@continuum/backend", "name": "@continuum/backend",
"license": "UNLICENSED",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -9,24 +10,29 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"test": "bun test",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"elysia": "^1.1.26", "@continuum/ts-types": "file:../continuum-schemas/packages/ts-types",
"@elysiajs/cors": "^1.1.1", "@continuum/ts-core": "file:../continuum-common/packages/ts-core",
"@elysiajs/jwt": "^1.1.1", "elysia": "~1.4.30",
"@elysiajs/swagger": "^1.1.6", "@elysiajs/cors": "~1.4.2",
"@elysiajs/jwt": "~1.4.2",
"@elysiajs/swagger": "~1.3.1",
"@aws-sdk/client-s3": "^3.658.1", "@aws-sdk/client-s3": "^3.658.1",
"@aws-sdk/s3-request-presigner": "^3.658.1", "@aws-sdk/s3-request-presigner": "^3.658.1",
"drizzle-orm": "^0.33.0", "drizzle-orm": "0.33.0",
"drizzle-typebox": "^0.2.1", "drizzle-typebox": "0.1.1",
"@sinclair/typebox": "^0.33.12", "@sinclair/typebox": "0.34.52",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"ioredis": "^5.4.1" "ioredis": "^5.4.1"
}, },
"devDependencies": { "devDependencies": {
"drizzle-kit": "^0.24.2",
"bun-types": "^1.1.10", "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
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# End-to-end test of alerts: a stand-in gateway reports a printer going
# from printing to failed over the real /ws/edge/v1, and an AI detection is
# published on the real Valkey channel; both must become rows an operator can
# list and acknowledge.
#
# 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:-3030}"
AUTH_PORT="${AUTH_PORT:-3031}"
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"])'; }
echo "==> starting continuum-backend on :$PORT"
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 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
ok "backend healthy"
# --- 2. a farm with one claimed printer ---------------------------------
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
expect 201 POST /orgs "$OWNER" '{"name":"e2e alerts org"}' -- "create an org"
ORG_A="$(json 'd["id"]')"
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e alerts farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
FARM_A="$(json 'd["id"]')"
expect 201 POST /printers "$OWNER" \
"{\"farmId\":\"$FARM_A\",\"name\":\"Fake P1\",\"vendor\":\"bambu\",\"gatewayPrinterId\":\"fake1\"}" -- "claim a printer"
PRINTER_A="$(json 'd["id"]')"
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
EDGE_JWT="$(json 'd["token"]')"
[ -n "$EDGE_JWT" ] || fail "no edge token in the response"
# --- 3. telemetry -> alert ------------------------------------------------
echo "==> a print that fails raises exactly one alert"
# The first frame is only an observation (nothing is known before it), so
# IDLE,RUNNING,RUNNING,FAILED,FAILED has one transition worth raising.
bun scripts/fake-gateway.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" fake1 IDLE,RUNNING,RUNNING,FAILED,FAILED \
|| fail "the fake gateway could not talk to the backend"
N="$(psql_q "SELECT count(*) FROM printer_alerts WHERE farm_id='$FARM_A' AND kind='print_failed'")"
[ "$N" = "1" ] && ok "one print_failed alert from a printing -> failed transition" || bad "expected 1 print_failed alert, found $N"
TOTAL="$(psql_q "SELECT count(*) FROM printer_alerts WHERE farm_id='$FARM_A'")"
[ "$TOTAL" = "1" ] && ok "and nothing else: staying failed and idle -> running are quiet" || bad "expected 1 alert in total, found $TOTAL"
echo "==> a reconnect does not re-announce what was already known"
bun scripts/fake-gateway.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" fake1 FAILED,FAILED || fail "reconnect failed"
TOTAL="$(psql_q "SELECT count(*) FROM printer_alerts WHERE farm_id='$FARM_A'")"
[ "$TOTAL" = "1" ] && ok "still one alert after reconnecting into the same state" || bad "reconnect raised alerts: $TOTAL"
expect 200 GET "/alerts?open=true&farmId=$FARM_A" "$OWNER" -- "the owner lists open alerts"
body_has "the print failed"
body_has "Fake P1"
# --- 4. AI detection -> alert --------------------------------------------
echo "==> an AI detection on the Valkey channel becomes a row (above the floor only)"
publish() { valkey-cli -u "$VALKEY_URL" PUBLISH channel:print_alerts "$1" >/dev/null; }
mk() { printf '{"printer_id":"%s","label":"spaghetti","confidence":%s,"captured_at":"2026-10-29T10:00:00Z","r2_object_key":"frames/e2e.jpg"}' "$PRINTER_A" "$1"; }
publish "$(mk 0.95)"
publish "$(mk 0.10)"
publish 'this is not json'
sleep 1
AI="$(psql_q "SELECT count(*) FROM printer_alerts WHERE farm_id='$FARM_A' AND kind='ai_detection'")"
[ "$AI" = "1" ] && ok "the 0.95 detection was recorded; the 0.10 one and the junk were not" || bad "expected 1 ai_detection alert, found $AI"
CONF="$(psql_q "SELECT confidence FROM printer_alerts WHERE farm_id='$FARM_A' AND kind='ai_detection'")"
[ "$CONF" = "0.95" ] && ok "confidence stored as published" || bad "confidence is '$CONF'"
echo
if [ "$FAILURES" -eq 0 ]; then
echo "PASS - telemetry transitions and AI detections both become alerts."
else
echo "FAIL - $FAILURES assertion(s) failed." >&2
exit 1
fi
+356
View File
@@ -0,0 +1,356 @@
#!/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"
# --- 7b. alerts ---------------------------------------------------------
echo "==> alerts are scoped, and acknowledging is an operator's decision"
ALERT_A="$(new_uuid)"
psql_q "INSERT INTO printer_alerts (id, farm_id, printer_id, kind, message) VALUES ('$ALERT_A', '$FARM_A', '$PRINTER_A', 'print_failed', 'e2e: the print failed')" >/dev/null
expect 200 GET "/alerts?open=true" "$VIEWER_A" -- "a viewer lists open alerts"
body_has "$ALERT_A"
expect 200 GET "/alerts?open=true" "$OUTSIDER" -- "the outsider lists open alerts"
body_lacks "$ALERT_A"
ok "the outsider's alert list does not contain farm A's alert"
expect 404 POST "/alerts/$ALERT_A/acknowledge" "$OUTSIDER" -- "the outsider cannot acknowledge another org's alert"
expect 403 POST "/alerts/$ALERT_A/acknowledge" "$VIEWER_A" -- "a viewer cannot acknowledge"
expect 200 POST "/alerts/$ALERT_A/acknowledge" "$OPERATOR_A" -- "an operator acknowledges"
WHO="$(json 'd["acknowledgedBy"]')"
[ "$WHO" = "$OPERATOR_A_ID" ] && ok "acknowledgedBy came from the token" || bad "acknowledgedBy is '$WHO'"
expect 200 POST "/alerts/$ALERT_A/acknowledge" "$OWNER_A" -- "acknowledging again is harmless"
WHO2="$(json 'd["acknowledgedBy"]')"
[ "$WHO2" = "$OPERATOR_A_ID" ] && ok "the first acknowledger stays on the record" || bad "second ack overwrote the record: '$WHO2'"
expect 200 GET "/alerts?open=true" "$VIEWER_A" -- "an acknowledged alert leaves the open list"
body_lacks "$ALERT_A"
expect 401 GET /alerts - -- "GET /alerts rejects anonymous"
# --- 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);
+116
View File
@@ -0,0 +1,116 @@
/**
* The P1/A1 camera in the real web console, in a real Chrome, for
* scripts/e2e-camera-snapshot.sh (BROWSER=1). Signs in the same way as
* e2e-camera-browser.ts. Reads pixel statistics and keeps no screenshot: the
* frame is the inside of someone's workshop.
*
* Results are `ok<TAB>label` / `FAILED<TAB>label`.
* 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,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const page = await browser.newPage();
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(String((err as Error).message ?? err).slice(0, 160)));
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");
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");
const shown = await page
.waitForFunction(() => document.querySelector(".snapshot-view__status.is-connected") !== null, { timeout: 60_000 })
.then(() => true)
.catch(() => false);
const status = await page.evaluate(
() => document.querySelector(".snapshot-view__status")?.textContent?.trim() ?? "no snapshot view rendered",
);
say(shown, "the snapshot view reports connected", `status: ${status}`);
say(
(await page.$(".webrtc-view")) === null,
"no WebRTC player is rendered for a snapshot camera",
);
if (shown) {
// Two different frames, a few seconds apart: the picture is refreshing.
const result = await page.evaluate(async () => {
const img = document.querySelector<HTMLImageElement>(".snapshot-view img")!;
const first = img.src;
const t0 = Date.now();
while (img.src === first && Date.now() - t0 < 15_000) await new Promise((r) => setTimeout(r, 250));
const refreshed = img.src !== first;
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 36;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 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 {
refreshed,
width: img.naturalWidth,
height: img.naturalHeight,
mean,
stdev: Math.sqrt(Math.max(0, sumSq / n - mean * mean)),
};
});
say(result.width > 0, "Chrome decoded the JPEG", `${result.width}x${result.height}`);
say(result.refreshed, "the picture refreshes by itself");
say(result.stdev > 2, "the frame has picture content, not black or one flat colour",
`mean luma ${result.mean.toFixed(0)}, stdev ${result.stdev.toFixed(1)}`);
}
say(pageErrors.length === 0, "the page raised no uncaught errors", pageErrors.slice(0, 2).join(" | "));
} finally {
await browser.close();
}
process.exit(failed ? 1 : 0);
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# A P1/A1 camera, end to end: the real proxy reads a JPEG frame from the real
# printer's port 6000 (read-only - it only watches), the real backend relays
# it, and an authenticated viewer fetches it.
#
# Needs a printer in ../continuum-proxy/printers.toml whose camera is the
# P1/A1 kind (CAMERA_ID, default p1p) and that is switched on. Touches the
# printer's camera and MQTT status, nothing else; no command is sent.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
CAMERA_ID="${CAMERA_ID:-p1p}"
GO2RTC_BIN="${GO2RTC_BIN:-$HOME/.local/bin/go2rtc}"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
PORT="${PORT:-3050}"
AUTH_PORT="${AUTH_PORT:-3051}"
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}"
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 R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
# Where to keep the last frame for a human to look at. Unset: not kept.
SAVE_FRAME="${SAVE_FRAME:-}"
# BROWSER=1: also open the real web console in real Chrome (needs the real
# Supabase test user, so source ../dev-secrets.sh).
BROWSER="${BROWSER:-0}"
APP_HOST="${APP_HOST:-127.0.0.1}"
APP_DIR="${APP_DIR:-$REPO_ROOT/../continuum-app}"
APP_PORT="${APP_PORT:-3052}"
CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome}"
APP_PID=""
WORK="$(mktemp -d)"
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
USER_ID="$(cat /proc/sys/kernel/random/uuid)"
cleanup() {
local rc=$?
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done
[ -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
[ -n "${ORG_ID:-}" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
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"
sleep 0.5
if ps -eo comm | grep -qx go2rtc; then
echo "WARNING: a go2rtc process is still running after cleanup" >&2; [ $rc -eq 0 ] && rc=1
fi
exit $rc
}
trap cleanup EXIT
echo "==> checking preconditions"
psql_q "SELECT camera_snapshot FROM printers LIMIT 0" >/dev/null 2>&1 \
|| fail "database lacks printers.camera_snapshot - run scripts/dev-env.sh schema"
[ -x "$GO2RTC_BIN" ] || fail "no go2rtc at $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 '$CAMERA_ID'"
ps -eo comm | grep -qx go2rtc && fail "a go2rtc is already running"
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/6000" 2>/dev/null || fail "$CAMERA_ID ($CAM_HOST) refuses TCP 6000: no snapshot camera, or it is off"
ok "$CAMERA_ID accepts connections on 6000"
fi
if [ "$BROWSER" = "1" ]; then
for name in SUPABASE_URL SUPABASE_SECRET_KEY SUPABASE_PUBLISHABLE_KEY; do
[ -n "${!name:-}" ] || fail "$name is not set - source ../dev-secrets.sh (BROWSER=1)"
done
[ -x "$CHROME_BIN" ] || fail "no Chrome at $CHROME_BIN"
[ -d "$APP_DIR/apps/web/node_modules" ] || fail "$APP_DIR has no installed web app"
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"
fi
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 snapshot org') RETURNING id")"
psql_q "INSERT INTO org_members (org_id, user_id, role) VALUES ('$ORG_ID', '$USER_ID', 'viewer')" >/dev/null
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token) VALUES ('e2e snapshot farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id) VALUES ('$FARM_ID', 'snapshot under test', 'bambu', '$CAMERA_ID')" >/dev/null
CAM_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID'")"
echo "==> starting the auth issuer and the backend"
if [ "$BROWSER" != "1" ]; then
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
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"])')"
fi
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"
echo "==> starting the real continuum-proxy"
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=e2e-snap-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_PRINTERS_PATH=printers.toml CONTINUUM_STORE_PATH="$WORK/continuum.db" RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
echo "==> waiting for the camera to be advertised as a snapshot camera"
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"
printf '%s' "$STREAM" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["kind"]=="snapshot" and "iceServers" not in d, d' \
|| fail "the stream answer is not a snapshot answer"
ok "the stream endpoint says kind=snapshot (no WebRTC session offered)"
echo "==> fetching frames"
check_frame() {
local file="$1" label="$2" size
size="$(stat -c %s "$file")"
[ "$size" -gt 1000 ] || fail "$label: only $size bytes"
[ "$(head -c2 "$file" | od -An -tx1 | tr -d ' \n')" = "ffd8" ] || fail "$label: does not start with a JPEG marker"
[ "$(tail -c2 "$file" | od -An -tx1 | tr -d ' \n')" = "ffd9" ] || fail "$label: does not end with a JPEG marker"
ok "$label: a $size-byte JPEG"
}
fetch() { curl -s -o "$1" -D "$1.h" -w '%{http_code}' -H "authorization: Bearer $USER_TOKEN" "$BASE/printers/$CAM_UUID/snapshot"; }
code="$(fetch "$WORK/f1.jpg")"
[ "$code" = 200 ] || fail "first frame: HTTP $code: $(head -c 300 "$WORK/f1.jpg")"
grep -qi '^content-type: image/jpeg' "$WORK/f1.jpg.h" || fail "wrong content-type"
grep -qi '^cache-control: no-store' "$WORK/f1.jpg.h" || fail "frames must be no-store"
check_frame "$WORK/f1.jpg" "first frame"
sleep 1.2
code="$(fetch "$WORK/f2.jpg")"; [ "$code" = 200 ] || fail "second frame: HTTP $code"
check_frame "$WORK/f2.jpg" "second frame (a fresh read after the cache window)"
cmp -s "$WORK/f1.jpg" "$WORK/f2.jpg" && fail "two frames a second apart are byte-identical - is the cache serving stale pictures?"
ok "consecutive frames differ"
echo "==> five viewers at once share the camera"
viewers=()
for i in 1 2 3 4 5; do fetch "$WORK/c$i.jpg" > "$WORK/c$i.code" & viewers+=($!); done
# Not a bare `wait`: that would also wait for the backend and the proxy.
for pid in "${viewers[@]}"; do wait "$pid"; done
for i in 1 2 3 4 5; do [ "$(cat "$WORK/c$i.code")" = 200 ] || fail "viewer $i: HTTP $(cat "$WORK/c$i.code")"; check_frame "$WORK/c$i.jpg" "viewer $i"; done
ok "all five got a picture"
echo "==> access control"
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/printers/$CAM_UUID/snapshot")" = 401 ] || fail "no token must be 401"
ok "no token: 401"
# The code is never read out of printers.toml here; the camera login happens
# on the gateway, and the only thing that should cross is the picture.
if grep -qi 'access_code\|accessCode' "$WORK/backend.log"; then
fail "the backend log mentions an access code"
fi
ok "the backend log never mentions an access code"
# Optional soak: the first real dispatch froze the proxy ~4.5 min in (an
# MQTT publish that never completed while the fleet lock was held). Staying
# connected well past that and still receiving telemetry is the regression test.
if [ -n "${SOAK_S:-}" ]; then
echo "==> soaking for ${SOAK_S}s: telemetry must keep arriving"
sleep "$SOAK_S"
AGE="$(psql_q "SELECT extract(epoch FROM now() - last_seen_at)::int FROM printers WHERE id='$CAM_UUID'")"
[ -n "$AGE" ] && [ "$AGE" -lt 20 ] || fail "telemetry is ${AGE:-never}s old after the soak - the proxy froze"
ok "telemetry still fresh (${AGE}s old) after ${SOAK_S}s"
code="$(fetch "$WORK/after.jpg")"; [ "$code" = 200 ] || fail "no camera frame after the soak: HTTP $code"
ok "camera still answers after the soak"
fi
if [ "$BROWSER" = "1" ]; then
echo "==> the real console in real Chrome"
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"; }
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-snapshot-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' "$OUT" | grep -E "^(ok|FAILED)$(printf '\t')")
if [ "$seen" -lt 8 ] && [ "$bad" -eq 0 ]; then bad=1; echo " FAILED: only $seen of 8 browser results" >&2; fi
if [ "$bad" -ne 0 ]; then
printf '%s\n' "$OUT" | sed 's/^/ | /' >&2; tail -15 "$WORK/app.log" >&2
fail "the real browser did not show the snapshot camera"
fi
fi
[ -n "$SAVE_FRAME" ] && cp "$WORK/f2.jpg" "$SAVE_FRAME" && ok "kept a frame at $SAVE_FRAME"
echo
echo "PASS - a P1/A1 camera frame travelled printer -> proxy -> backend -> an authenticated viewer."
+417
View File
@@ -0,0 +1,417 @@
#!/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="$EDGE_PROXY_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 \
CONTINUUM_STORE_PATH="$WORK/continuum.db" \
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
+462
View File
@@ -0,0 +1,462 @@
#!/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="$EDGE_PROXY_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 \
CONTINUUM_STORE_PATH="$WORK/continuum.db" \
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
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# End-to-end test of the print loop, with every machine faked: upload a file to
# the REAL object store through the presigned URL the API hands out, let the
# REAL scheduler dispatch it over the REAL /ws/edge/v1 to a stand-in gateway,
# which downloads it from the REAL presigned GET and hashes it, then reports
# the print running and finished. The job must be completed, and the bytes the
# gateway received must be the bytes uploaded.
#
# SAFETY: dispatch is ON here, deliberately - so this is only safe because the
# only gateway this backend instance can have is the stand-in (a real proxy
# would have to be pointed at this instance's random port). No printer is
# touched.
#
# Needs the real object-store settings: set -a; . ../dev-secrets.sh; set +a
#
# 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:-3037}"
AUTH_PORT="${AUTH_PORT:-3038}"
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:-}"
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-}"
R2_ENDPOINT="${R2_ENDPOINT:-}"
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
echo "--- fake gateway log ---"; cat "$WORK/gw.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"])'; }
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. a farm with one idle printer and a stand-in gateway ----------------
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
expect 201 POST /orgs "$OWNER" '{"name":"e2e job-flow org"}' -- "create an org"
ORG_A="$(json 'd["id"]')"
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e job-flow farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
FARM_A="$(json 'd["id"]')"
expect 201 POST /printers "$OWNER" \
"{\"farmId\":\"$FARM_A\",\"name\":\"Fake P1\",\"vendor\":\"bambu\",\"gatewayPrinterId\":\"fakep\"}" -- "claim a printer"
PRINTER_A="$(json 'd["id"]')"
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
EDGE_JWT="$(json 'd["token"]')"
FRAMES="$WORK/frames.jsonl"; : > "$FRAMES"
FARM_ID="$FARM_A" REPORT_IDLE=fakep bun scripts/fake-gateway-serve.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" 90 "$FRAMES" > "$WORK/gw.log" 2>&1 &
GW_PID=$!
for _ in $(seq 1 40); do grep -q ready "$WORK/gw.log" 2>/dev/null && break; sleep 0.25; done
grep -q ready "$WORK/gw.log" || fail "the stand-in gateway did not connect: $(cat "$WORK/gw.log")"
for _ in $(seq 1 40); do
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRINTER_A'")" = "idle" ] && break
sleep 0.25
done
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRINTER_A'")" = "idle" ] && ok "the printer reports idle" || fail "the printer never reported idle"
# --- 3. upload, dispatch, print --------------------------------------------
echo "==> upload a file through the presigned URL"
head -c 200000 /dev/urandom > "$WORK/cube.3mf"
WANT="$(sha256sum "$WORK/cube.3mf" | cut -d' ' -f1)"
expect 201 POST /jobs "$OWNER" "{\"farmId\":\"$FARM_A\",\"fileName\":\"cube.3mf\",\"contentType\":\"application/octet-stream\"}" -- "queue a job"
JOB_A="$(json 'd["job"]["id"]')"
OBJECT_KEY="$(json 'd["job"]["r2ObjectKey"]')"
UPLOAD_URL="$(json 'd["uploadUrl"]')"
cleanup_object() { [ -n "${OBJECT_KEY:-}" ] && bun -e "
import { s3 } from './src/s3';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] }));
" "$OBJECT_KEY" >/dev/null 2>&1 || true; }
trap 'cleanup_object; cleanup' EXIT
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/cube.3mf" "$UPLOAD_URL")"
[ "$CODE" = "200" ] && ok "the file reached the object store (HTTP 200)" || fail "the upload returned HTTP $CODE"
echo "==> the scheduler dispatches it and the print runs to completion"
SEEN=""
for _ in $(seq 1 80); do
STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_A'")"
case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac
[ "$STATUS" = "completed" ] && break
[ "$STATUS" = "failed" ] && break
sleep 1
done
[ "$STATUS" = "completed" ] && ok "the job completed (saw:$SEEN)" || bad "the job ended as '$STATUS' (saw:$SEEN)"
case "$SEEN" in *printing*) ok "it was seen printing on the way" ;; *) bad "the job was never printing: $SEEN" ;; esac
python3 - "$FRAMES" "$WANT" <<'PYCHK' && ok "the gateway got start_print for this job, and downloaded exactly the bytes that were uploaded" || bad "the gateway's view does not match: $(cat "$FRAMES")"
import json,sys
frames=[json.loads(l) for l in open(sys.argv[1])]
start=[f for f in frames if f["type"]=="start_print"]
dl=[f for f in frames if f["type"]=="downloaded"]
assert len(start)==1, f"expected one start_print, got {len(start)}"
assert start[0]["file_name"]=="cube.3mf" and start[0]["printer_id"]=="fakep", start[0]
assert len(dl)==1 and dl[0]["sha256"]==sys.argv[2] and dl[0]["bytes"]==200000, dl
PYCHK
kill "$GW_PID" 2>/dev/null || true
echo
if [ "$FAILURES" -eq 0 ]; then
echo "PASS - a file went from upload to a finished print, byte for byte, through every real component but the printer."
else
echo "FAIL - $FAILURES assertion(s) failed." >&2
exit 1
fi
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Onboarding end to end, against the real routes and database: inviting
# someone by email, and connecting a gateway with a one-time code instead of
# copying a token. With the proxy built, the last step runs the real
# `continuum-proxy enrol` against the live backend and starts the proxy on
# what it wrote.
#
# Nothing here sends an email: the invitation link is returned to the inviter
# (see the invitations route).
#
# scripts/dev-env.sh up && scripts/dev-env.sh schema
# scripts/e2e-onboarding.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PORT="${PORT:-3064}"; AUTH_PORT="${AUTH_PORT:-3065}"
BASE="http://localhost:$PORT"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
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}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
export 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}"
WORK="$(mktemp -d)"
BACKEND_PID=""; AUTH_PID=""; PROXY_PID=""; ORG_ID=""; FAILURES=0
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
new_uuid() { cat /proc/sys/kernel/random/uuid; }
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
cleanup() {
local rc=$?
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done
sleep 0.5
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done
[ -z "${KEEP_DATA:-}" ] && [ -n "$ORG_ID" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
if [ $rc -ne 0 ]; then echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true; fi
rm -rf "$WORK"; exit $rc
}
trap cleanup EXIT
req() { # method path token [json] -> status; body in $WORK/last.json
local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$1" "$BASE$2")
[ "$3" != "-" ] && args+=(-H "authorization: Bearer $3")
[ -n "${4:-}" ] && args+=(-H 'content-type: application/json' -d "$4")
curl "${args[@]}"
}
expect() { # code method path token [body] -- what
local want="$1" method="$2" path="$3" token="$4"; shift 4
local body=""; [ "${1:-}" != "--" ] && { body="$1"; shift; }; shift
local got; got="$(req "$method" "$path" "$token" "$body")"
[ "$got" = "$want" ] && ok "$* ($got)" || bad "$*: expected $want, got $got - $(head -c 250 "$WORK/last.json")"
}
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; }
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
export SUPABASE_URL="$AUTH_URL"
mint() { curl -sf "$AUTH_URL/token?sub=$1&email=$2" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; }
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"
OWNER_ID="$(new_uuid)"; GUEST_ID="$(new_uuid)"; STRANGER_ID="$(new_uuid)"
TAG="$(new_uuid | cut -c1-8)"
OWNER="$(mint "$OWNER_ID" "owner-$TAG@example.test")"
GUEST="$(mint "$GUEST_ID" "Guest-$TAG@Example.Test")"
STRANGER="$(mint "$STRANGER_ID" "stranger-$TAG@example.test")"
echo "==> an organisation and a farm"
expect 201 POST /orgs "$OWNER" '{"name":"e2e onboarding org"}' -- "owner creates an org"
ORG_ID="$(json 'd["id"]')"
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e onboarding farm\",\"orgId\":\"$ORG_ID\"}" -- "owner creates a farm"
FARM_ID="$(json 'd["id"]')"
echo "==> inviting by email"
expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"guest-$TAG@example.test\",\"role\":\"operator\"}" -- "owner invites an address"
TOKEN="$(json 'd["token"]')"; INV_ID="$(json 'd["id"]')"
grep -q "$TOKEN" <<<"$(psql_q "SELECT token_hash FROM org_invitations WHERE id='$INV_ID'")" && bad "the token is stored in the clear" || ok "only a hash of the token is stored"
expect 404 POST "/orgs/$ORG_ID/invitations" "$GUEST" "{\"email\":\"x-$TAG@example.test\",\"role\":\"viewer\"}" -- "a non-member cannot invite (the org does not exist for them)"
expect 200 GET "/orgs/$ORG_ID/invitations" "$OWNER" -- "owner lists pending invitations"
[ "$(json 'len(d)')" = 1 ] && ok "one pending" || bad "pending list: $(cat "$WORK/last.json")"
grep -q "$TOKEN" "$WORK/last.json" && bad "the list leaks the token" || ok "the list does not show the token"
echo "==> accepting"
expect 403 POST /invitations/accept "$STRANGER" "{\"token\":\"$TOKEN\"}" -- "someone signed in with another address cannot use the link"
expect 404 POST /invitations/accept "$GUEST" '{"token":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}' -- "a made-up token is refused"
expect 200 POST /invitations/accept "$GUEST" "{\"token\":\"$TOKEN\"}" -- "the invited address accepts (case-insensitively)"
[ "$(json 'd["role"]')" = operator ] && ok "joined as operator" || bad "role: $(cat "$WORK/last.json")"
expect 404 POST /invitations/accept "$GUEST" "{\"token\":\"$TOKEN\"}" -- "the link works once"
expect 200 GET "/orgs/$ORG_ID/members" "$OWNER" -- "owner lists members"
grep -q "guest-$TAG@example.test" "$WORK/last.json" && ok "the member list shows the email" || bad "no email in $(cat "$WORK/last.json")"
expect 200 GET "/orgs/$ORG_ID" "$GUEST" -- "the new member can see the org"
echo "==> expiry and revocation"
expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"stranger-$TAG@example.test\",\"role\":\"viewer\"}" -- "a second invitation"
T2="$(json 'd["token"]')"; I2="$(json 'd["id"]')"
psql_q "UPDATE org_invitations SET expires_at = now() - interval '1 minute' WHERE id='$I2'" >/dev/null
expect 404 POST /invitations/accept "$STRANGER" "{\"token\":\"$T2\"}" -- "an expired invitation is refused"
expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"stranger-$TAG@example.test\",\"role\":\"viewer\"}" -- "re-inviting issues a fresh one"
T3="$(json 'd["token"]')"; I3="$(json 'd["id"]')"
expect 200 DELETE "/orgs/$ORG_ID/invitations/$I3" "$OWNER" -- "owner revokes it"
expect 404 POST /invitations/accept "$STRANGER" "{\"token\":\"$T3\"}" -- "a revoked invitation is refused"
echo "==> enrolling a gateway with a one-time code"
expect 403 POST "/farms/$FARM_ID/enrolment-codes" "$GUEST" -- "an operator cannot make an enrolment code"
expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "owner makes a code"
CODE="$(json 'd["code"]')"
[ "$(psql_q "SELECT count(*) FROM enrolment_codes WHERE code_hash = '$CODE'")" = 0 ] && ok "the code is not stored in the clear" || bad "code stored in the clear"
TYPED="$(echo "$CODE" | tr 'A-Z' 'a-z' | tr -d '-')"
expect 200 POST /auth/enrol - "{\"code\":\"$TYPED\"}" -- "the gateway trades the code (typed sloppily)"
GW_TOKEN="$(json 'd["gatewayToken"]')"
[ "$(json 'd["farmId"]')" = "$FARM_ID" ] && ok "it is told which farm" || bad "farm: $(cat "$WORK/last.json")"
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_ID\",\"edgeProxyToken\":\"$GW_TOKEN\"}" -- "the credential it got works"
expect 401 POST /auth/enrol - "{\"code\":\"$CODE\"}" -- "a code works once"
expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a second code"
C2="$(json 'd["code"]')"
expect 200 POST /auth/enrol - "{\"code\":\"$C2\"}" -- "enrolling a second machine"
[ "$(json 'd["gatewayToken"]')" = "$GW_TOKEN" ] && ok "the farm's token was not rotated, so the first gateway still works" || bad "the token changed"
expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a third code, to expire"
C3="$(json 'd["code"]')"
psql_q "UPDATE enrolment_codes SET expires_at = now() - interval '1 minute' WHERE farm_id='$FARM_ID' AND used_at IS NULL" >/dev/null
expect 401 POST /auth/enrol - "{\"code\":\"$C3\"}" -- "an expired code is refused"
if [ -x "$PROXY_DIR/target/debug/continuum-proxy" ] && "$PROXY_DIR/target/debug/continuum-proxy" enrol --help >/dev/null 2>&1; then
echo "==> the real proxy enrols itself"
expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a code for the proxy"
PCODE="$(json 'd["code"]')"
CONF="$WORK/proxy.toml"
( cd "$WORK" && CONTINUUM_CONFIG_PATH="$CONF" "$PROXY_DIR/target/debug/continuum-proxy" enrol "$BASE" "$PCODE" > "$WORK/enrol.log" 2>&1 ) || { cat "$WORK/enrol.log"; fail "proxy enrol failed"; }
[ "$(stat -c %a "$CONF")" = 600 ] && ok "the config it wrote is owner-only" || bad "config mode $(stat -c %a "$CONF")"
grep -q "$PCODE" "$WORK/enrol.log" && bad "the code is in the proxy output" || ok "the code is not echoed"
grep -q "gateway_token" "$CONF" && grep -q "farm_id = \"$FARM_ID\"" "$CONF" && ok "farm and token are in the config" || bad "config: $(sed 's/gateway_token.*/gateway_token = <hidden>/' "$CONF")"
env -C "$PROXY_DIR" CONTINUUM_CONFIG_PATH="$CONF" CONTINUUM_GO2RTC_BIN=/nonexistent CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_PRINTERS_PATH="$WORK/none.toml" CONTINUUM_STORE_PATH="$WORK/store/continuum.db" RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
for _ in $(seq 1 40); do grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && break; sleep 0.5; done
grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && ok "it started on that config alone and the cloud accepted it" || bad "the enrolled proxy never connected: $(tail -5 "$WORK/proxy.log")"
else
echo " (skipping the proxy step: build continuum-proxy with the enrol command first)"
fi
# Last, because it blocks this address from enrolling for ten minutes.
echo "==> guessing"
for i in $(seq 1 12); do req POST /auth/enrol - '{"code":"ZZZZZ-ZZZZZ"}' >/dev/null; done
expect 429 POST /auth/enrol - '{"code":"ZZZZZ-ZZZZZ"}' -- "guessing is rate-limited"
echo
[ "$FAILURES" -eq 0 ] && echo "PASS - invitations and one-time-code enrolment." || { echo "FAIL - $FAILURES assertion(s)." >&2; exit 1; }
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
# End-to-end test of adding a printer with the REAL continuum-proxy as the
# gateway (e2e-provision.sh uses a stand-in): the provision frame must parse on
# the proxy, the proxy must try the printer, and the answer must come back.
#
# Without REAL_HOST/REAL_SERIAL/REAL_ACCESS_CODE it points the add at a closed
# port on this machine and proves the failure path across the real wire: a
# useful error comes back, no printer row is created, no store entry is made,
# and the proxy's log never contains the access code.
#
# WITH them (a printer you are willing to connect to - this only READS, it
# sends no command) it proves the success path as well: 201, telemetry flows
# under the new id, the proxy's store file is owner-only, and after the proxy
# is restarted with NO printers file the printer comes back from the store.
# REAL_HOST=192.168.0.20 REAL_SERIAL=... REAL_ACCESS_CODE=... scripts/e2e-provision-proxy.sh
#
# 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:-3034}"
AUTH_PORT="${AUTH_PORT:-3035}"
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=""
PROXY_PID=""
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
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")
[ -n "$PROXY_PID" ] && pids+=("$PROXY_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
echo "--- proxy log (tail, credentials are never logged) ---"; 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: $*"; }
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"])'; }
echo "==> starting continuum-backend on :$PORT"
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 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
ok "backend healthy"
# --- 2. a farm and a real gateway ----------------------------------------
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
expect 201 POST /orgs "$OWNER" '{"name":"e2e provision-proxy org"}' -- "create an org"
ORG_A="$(json 'd["id"]')"
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e provision-proxy farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
FARM_A="$(json 'd["id"]')"
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
EDGE_JWT="$(json 'd["token"]')"
echo "==> building and starting the real continuum-proxy with NO printers"
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
STORE="$WORK/proxy-store/continuum.db"
: > "$WORK/proxy.log"
start_proxy() {
# env -C so $! is the daemon itself and cleanup does not orphan it.
env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=e2e-gw \
CONTINUUM_FARM_ID="$FARM_A" \
CONTINUUM_GATEWAY_TOKEN="$PROXY_TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc \
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_PRINTERS_PATH="$WORK/no-such-printers.toml" \
CONTINUUM_STORE_PATH="$STORE" \
RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" >> "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
}
stop_proxy() {
[ -n "$PROXY_PID" ] && kill "$PROXY_PID" 2>/dev/null || true
sleep 1
[ -n "$PROXY_PID" ] && kill -9 "$PROXY_PID" 2>/dev/null || true
PROXY_PID=""
}
wait_for_gateway() {
for _ in $(seq 1 60); do
grep -q "gateway e2e-gw connected" "$WORK/backend.log" && return 0
sleep 0.5
done
return 1
}
start_proxy
wait_for_gateway || fail "the proxy did not connect to the backend: $(tail -5 "$WORK/proxy.log")"
ok "the proxy connected with no printers file at all, from its store alone"
[ -f "$STORE" ] && ok "and created its store at $STORE" || bad "no store file was created"
# --- 3. the failure path across the real wire ------------------------------
echo "==> adding a printer that cannot be reached"
DEAD_CODE="DEADCODE-$(new_uuid | cut -c1-8)"
expect 502 POST "/farms/$FARM_A/printers" "$OWNER" \
"{\"name\":\"Nowhere\",\"vendor\":\"bambu\",\"host\":\"127.0.0.1\",\"accessCode\":\"$DEAD_CODE\",\"serialNumber\":\"01P00A000000099\"}" \
-- "the proxy reports it could not connect"
body_has GATEWAY_REFUSED
body_has "could not connect"
body_lacks "$DEAD_CODE"
N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")"
[ "$N" = "0" ] && ok "no printer row was created" || bad "a row exists after a failed add: $N"
grep -q "$DEAD_CODE" "$WORK/proxy.log" "$WORK/backend.log" && bad "the access code is in a log" || ok "the code is in neither the proxy's nor the backend's log"
if command -v sqlite3 >/dev/null 2>&1; then
[ "$(sqlite3 "$STORE" 'select count(*) from printers')" = "0" ] && ok "nothing was stored on the gateway" || bad "the proxy stored a printer it could not reach"
fi
# --- 4. the success path, only with a printer you name ---------------------
if [ -n "${REAL_HOST:-}" ] && [ -n "${REAL_SERIAL:-}" ] && [ -n "${REAL_ACCESS_CODE:-}" ]; then
echo "==> adding the real printer at $REAL_HOST (read-only: connect and listen)"
expect 201 POST "/farms/$FARM_A/printers" "$OWNER" \
"{\"name\":\"Real printer\",\"vendor\":\"bambu\",\"host\":\"$REAL_HOST\",\"accessCode\":\"$REAL_ACCESS_CODE\",\"serialNumber\":\"$REAL_SERIAL\"}" \
-- "the proxy connected to the printer and the cloud created the row"
PRINTER_ID="$(json 'd["id"]')"
body_lacks "$REAL_ACCESS_CODE"
MODE="$(stat -c %a "$STORE")"
[ "$MODE" = "600" ] && ok "the store file is owner-only (600)" || bad "the store file mode is $MODE"
wait_telemetry() {
for _ in $(seq 1 40); do
[ -n "$(psql_q "SELECT vendor_state FROM printers WHERE id='$PRINTER_ID' AND vendor_state IS NOT NULL")" ] && return 0
sleep 0.5
done
return 1
}
wait_telemetry && ok "telemetry arrives under the new id" || bad "no telemetry for the added printer"
echo "==> restart the proxy with no printers file: the printer must come back from the store"
psql_q "UPDATE printers SET vendor_state=NULL, last_seen_at=NULL WHERE id='$PRINTER_ID'" >/dev/null
stop_proxy
# The edge JWT is short-lived but outlives this test.
start_proxy
wait_telemetry && ok "after a restart the printer reports again, from the store alone" || bad "the printer did not come back after a restart"
grep -q "$REAL_ACCESS_CODE" "$WORK/proxy.log" "$WORK/backend.log" && bad "the access code is in a log" || ok "the access code is in no log"
else
echo " (skipping the success path: set REAL_HOST, REAL_SERIAL and REAL_ACCESS_CODE to run it against a real printer)"
fi
echo
if [ "$FAILURES" -eq 0 ]; then
echo "PASS - the real proxy took provisioning requests over the real wire."
else
echo "FAIL - $FAILURES assertion(s) failed." >&2
exit 1
fi
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
# End-to-end test of adding a printer from the console: the access code must
# reach the gateway over the real /ws/edge/v1 and be kept nowhere in the cloud
# - not in any table, not in the backend's log, not in a response.
#
# 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:-3032}"
AUTH_PORT="${AUTH_PORT:-3033}"
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
echo "--- fake gateway log ---"; cat "$WORK/gw.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"])'; }
echo "==> starting continuum-backend on :$PORT"
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 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
ok "backend healthy"
# --- 2. a farm, an owner, an operator -----------------------------------
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
OPERATOR_ID="$(new_uuid)"; OPERATOR="$(mint "$OPERATOR_ID")"
OUTSIDER_ID="$(new_uuid)"; OUTSIDER="$(mint "$OUTSIDER_ID")"
expect 201 POST /orgs "$OWNER" '{"name":"e2e provision org"}' -- "create an org"
ORG_A="$(json 'd["id"]')"
expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_ID" "$OWNER" '{"role":"operator"}' -- "add an operator"
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e provision farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
FARM_A="$(json 'd["id"]')"
CODE="SECRETCODE-$(new_uuid | cut -c1-8)"
BODY="{\"name\":\"Bench P1P\",\"vendor\":\"bambu\",\"host\":\"192.168.0.50\",\"accessCode\":\"$CODE\",\"serialNumber\":\"01P00A000000001\"}"
echo "==> who may add a printer, and what happens with no gateway"
expect 403 POST "/farms/$FARM_A/printers" "$OPERATOR" "$BODY" -- "an operator cannot add a printer"
expect 404 POST "/farms/$FARM_A/printers" "$OUTSIDER" "$BODY" -- "an outsider cannot see the farm exists"
expect 401 POST "/farms/$FARM_A/printers" - "$BODY" -- "anonymous is refused"
expect 409 POST "/farms/$FARM_A/printers" "$OWNER" "$BODY" -- "no gateway connected: refused, nothing queued"
body_lacks "$CODE"
N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")"
[ "$N" = "0" ] && ok "no printer row was created" || bad "a row exists after a refused add: $N"
# --- 3. with a gateway -----------------------------------------------------
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
EDGE_JWT="$(json 'd["token"]')"
FRAMES="$WORK/frames.jsonl"; : > "$FRAMES"
REFUSED="REFUSEME-$(new_uuid | cut -c1-8)"
FARM_ID="$FARM_A" bun scripts/fake-gateway-serve.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" 40 "$FRAMES" "$REFUSED" > "$WORK/gw.log" 2>&1 &
GW_PID=$!
for _ in $(seq 1 40); do grep -q ready "$WORK/gw.log" 2>/dev/null && break; sleep 0.25; done
grep -q ready "$WORK/gw.log" || fail "the fake gateway did not connect: $(cat "$WORK/gw.log")"
sleep 0.5
echo "==> adding a printer through the gateway"
expect 422 POST "/farms/$FARM_A/printers" "$OWNER" "${BODY/bambu/klipper}" -- "a vendor with no working driver is refused"
expect 422 POST "/farms/$FARM_A/printers" "$OWNER" "${BODY/192.168.0.50/not a host}" -- "a malformed address is refused"
expect 201 POST "/farms/$FARM_A/printers" "$OWNER" "$BODY" -- "the owner adds a printer"
PRINTER_ID="$(json 'd["id"]')"
GWID="$(json 'd["gatewayPrinterId"]')"
body_lacks "$CODE"
ok "the response does not contain the access code (printer $PRINTER_ID, gateway id $GWID)"
[ "$(json 'd["lanAddress"]')" = "192.168.0.50" ] && ok "address recorded" || bad "lanAddress wrong"
GOT="$(python3 -c "
import json,sys
for l in open('$FRAMES'):
m=json.loads(l)
if m['type']=='provision_printer': print(m['printer']['access_code'], m['printer']['id'], m['printer']['sn'])
")"
[ "$GOT" = "$CODE $GWID 01P00A000000001" ] && ok "the gateway received the access code, its id, and the serial" || bad "gateway frame was: '$GOT'"
echo "==> the cloud kept the credential nowhere"
LEAK_ROWS="$(psql_q "SELECT count(*) FROM printers WHERE row_to_json(printers)::text LIKE '%$CODE%'")"
[ "$LEAK_ROWS" = "0" ] && ok "not in the printers table" || bad "the access code is in the printers table"
# Every table, so a future column or table holding it cannot hide.
ALL_LEAK="$(psql "$DATABASE_URL" -tAq -c "SELECT string_agg(table_name, ',') FROM information_schema.tables WHERE table_schema='public'" \
| tr ',' '\n' | while read -r tbl; do [ -n "$tbl" ] && psql_q "SELECT count(*) FROM \"$tbl\" t WHERE row_to_json(t)::text LIKE '%$CODE%'" | sed "s/^/$tbl /"; done | awk '$2>0')"
[ -z "$ALL_LEAK" ] && ok "not in any table of the database" || bad "found in: $ALL_LEAK"
grep -q "$CODE" "$WORK/backend.log" && bad "the access code is in the backend log" || ok "not in the backend's log"
echo "==> a gateway that refuses leaves nothing behind"
REFUSE_BODY="${BODY/$CODE/$REFUSED}"
expect 502 POST "/farms/$FARM_A/printers" "$OWNER" "$REFUSE_BODY" -- "the printer rejected the code"
body_has GATEWAY_REFUSED
body_has "access code was rejected"
body_lacks "$REFUSED"
N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")"
[ "$N" = "1" ] && ok "still exactly one printer row" || bad "expected 1 printer row, found $N"
grep -q "$REFUSED" "$WORK/backend.log" && bad "the refused code is in the backend log" || ok "nor was the refused code logged"
echo "==> removing a printer tells the gateway"
expect 200 DELETE "/printers/$PRINTER_ID" "$OWNER" -- "the owner deletes the printer"
[ "$(json 'd["gatewayForgot"]')" = "True" ] && ok "the gateway confirmed it forgot the printer" || bad "gatewayForgot: $(cat "$WORK/last.json")"
grep -q "\"remove_printer\".*\"$GWID\"" "$FRAMES" && ok "the gateway received remove_printer for $GWID" || bad "no remove_printer frame for $GWID"
kill "$GW_PID" 2>/dev/null || true
echo
if [ "$FAILURES" -eq 0 ]; then
echo "PASS - the access code reaches the gateway and rests nowhere in the cloud."
else
echo "FAIL - $FAILURES assertion(s) failed." >&2
exit 1
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
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# The first-run experience in the real web console, in a real Chrome: a user
# with no organisation signs in, creates a farm, copies the gateway settings,
# connects a gateway configured from exactly those settings, adds a printer
# through the form, gets an alert when it fails, and removes it.
#
# The gateway is a stand-in (scripts/fake-gateway-serve.ts), so NO printer and
# no network device is touched; e2e-provision-proxy.sh covers the real proxy.
#
# SAFETY: nothing here talks to a real printer, and dispatch is off.
#
# Needs: source ../dev-secrets.sh first (real Supabase, as e2e-camera.sh's
# BROWSER mode), a migrated database, Chrome, ../continuum-app installed, and
# port 3042 free. Uses the throwaway Supabase user continuum-e2e@example.invalid,
# which must have no organisation (it refuses to start otherwise, so it cannot
# trample a SERVE=1 stack) and removes what it creates.
#
# Usage:
# set -a; . ../dev-secrets.sh; set +a
# scripts/e2e-setup-browser.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PORT="${PORT:-3036}"
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}"
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 R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
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=""
APP_PID=""
USER_ID=""
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
cleanup() {
local rc=$?
[ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true
[ -n "$APP_PID" ] && kill -- "-$APP_PID" 2>/dev/null || true
sleep 0.5
[ -n "$BACKEND_PID" ] && kill -9 "$BACKEND_PID" 2>/dev/null || true
[ -n "$APP_PID" ] && kill -9 -- "-$APP_PID" 2>/dev/null || true
# What this run created, and only that: the user began with no organisation.
if [ -n "$USER_ID" ] && [ -z "${KEEP_DATA:-}" ]; then
psql_q "DELETE FROM organisations WHERE id IN (SELECT org_id FROM org_members WHERE user_id='$USER_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 "--- web console log (tail) ---"; tail -15 "$WORK/app.log" 2>/dev/null || true
fi
rm -rf "$WORK"
exit $rc
}
trap cleanup EXIT
for name in SUPABASE_URL SUPABASE_SECRET_KEY SUPABASE_PUBLISHABLE_KEY; do
[ -n "${!name:-}" ] || fail "$name is not set - source ../dev-secrets.sh"
done
psql_q "SELECT 1 FROM printer_alerts LIMIT 0" >/dev/null 2>&1 || fail "no migrated database - run scripts/dev-env.sh up && schema"
[ -x "$CHROME_BIN" ] || fail "no Chrome at $CHROME_BIN"
[ -d "$APP_DIR/apps/web/node_modules" ] || fail "$APP_DIR has no installed web app"
# bash's own /dev/tcp rather than `ss`, which is not installed everywhere.
if (exec 3<>"/dev/tcp/127.0.0.1/$APP_PORT") 2>/dev/null; then fail "port $APP_PORT is already in use (a SERVE=1 stack running?)"; fi
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)"; SB_EMAIL="$(sb_field email)"; SB_PASSWORD="$(sb_field password)"
unset SB_USER
[ "$(psql_q "SELECT count(*) FROM org_members WHERE user_id='$USER_ID'")" = "0" ] \
|| fail "the test user already belongs to an organisation - stop any SERVE=1 stack first"
ok "a Supabase user with no organisation"
echo "==> starting the backend on :$PORT"
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"
ok "backend healthy"
echo "==> starting the web console on :$APP_PORT"
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 127.0.0.1 --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" || fail "the web console did not start"
ok "the web console is up"
ACCESS_CODE="BROWSERCODE$(cat /proc/sys/kernel/random/uuid | cut -c1-6)"
FRAMES="$WORK/frames.jsonl"; : > "$FRAMES"
echo "==> first run, in real Chrome"
OUT="$(env APP_URL="http://127.0.0.1:$APP_PORT" API_BASE="$BASE" WS_BASE="ws://localhost:$PORT" \
SUPABASE_E2E_EMAIL="$SB_EMAIL" SUPABASE_E2E_PASSWORD="$SB_PASSWORD" CHROME_BIN="$CHROME_BIN" \
FRAMES_FILE="$FRAMES" ACCESS_CODE="$ACCESS_CODE" \
timeout 280 bun scripts/e2e-setup-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' "$OUT" | grep -E "^(ok|FAILED)$(printf '\t')")
if [ "$seen" -lt 18 ] && [ "$bad" -eq 0 ]; then
bad=1; echo " FAILED: the browser run produced only $seen of 18 results" >&2
fi
if [ "$bad" -ne 0 ]; then
printf '%s\n' "$OUT" | sed 's/^/ | /' >&2
fail "the first-run experience did not work"
fi
# The credential the browser typed must rest nowhere in the cloud.
grep -q "$ACCESS_CODE" "$WORK/backend.log" && fail "the access code is in the backend log"
LEAK="$(psql "$DATABASE_URL" -tAq -c "SELECT string_agg(table_name, ' ') FROM information_schema.tables WHERE table_schema='public'" \
| tr ' ' '\n' | while read -r tbl; do [ -n "$tbl" ] && psql_q "SELECT count(*) FROM \"$tbl\" t WHERE row_to_json(t)::text LIKE '%$ACCESS_CODE%'" | sed "s/^/$tbl /"; done | awk '$2>0')"
[ -z "$LEAK" ] || fail "the access code is stored in: $LEAK"
ok "the access code is in no table and no log"
echo
echo "PASS - a new user went from nothing to a farm, a gateway, a printer, an alert and back."
+158
View File
@@ -0,0 +1,158 @@
/**
* Drives the console's first-run experience in a real Chrome, for
* scripts/e2e-setup-browser.sh: a brand-new user with nothing signs in, creates
* a farm, gets gateway settings, connects a (stand-in) gateway with them, adds
* a printer through the form, sees an alert when it fails, dismisses it, and
* removes the printer.
*
* Results are `ok<TAB>label` / `FAILED<TAB>label`. The password, the gateway
* token and the printer's access code never reach a log line.
*
* Environment: APP_URL, API_BASE, WS_BASE, SUPABASE_E2E_EMAIL,
* SUPABASE_E2E_PASSWORD, FRAMES_FILE, ACCESS_CODE, CHROME_BIN.
*/
import { readFileSync } from "node:fs";
import puppeteer from "puppeteer-core";
const { APP_URL, API_BASE, WS_BASE, FRAMES_FILE, ACCESS_CODE } = process.env as Record<string, string>;
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,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
let gateway: ReturnType<typeof Bun.spawn> | undefined;
try {
const page = await browser.newPage();
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(String((err as Error).message ?? err).slice(0, 160)));
const waitText = (selector: string, text: string, timeout = 30_000) =>
page
.waitForFunction(
(sel: string, want: string) => document.querySelector(sel)?.textContent?.includes(want) ?? false,
{ timeout },
selector,
text,
)
.then(() => true)
.catch(() => false);
await page.goto(`${APP_URL}/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, "a brand-new user signs in", signInError ?? "");
if (signInError) process.exit(1);
// 1. nothing yet -> pointed at Setup
await page.goto(`${APP_URL}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
say(await waitText(".fleet", "You have no farm yet"), "the empty fleet page points a new user to Setup");
// 2. onboarding
await page.goto(`${APP_URL}/setup`, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForSelector(".onboarding input", { timeout: 30_000 });
await page.type(".onboarding input", "Browser Farm");
await page.click(".onboarding button[type=submit]");
say(await waitText(".setup__farm h2", "Browser Farm"), "creating a farm from nothing works (org and farm)");
// 3. gateway settings
await page.waitForSelector(".gateway-setup button", { timeout: 15_000 });
say(
await page.$eval(".gateway-setup", (el) => el.textContent?.includes("No gateway is connected") ?? false),
"setup says no gateway is connected yet",
);
await page.click(".gateway-setup button");
await page.waitForSelector(".gateway-setup__settings", { timeout: 15_000 });
const settings = await page.$eval(".gateway-setup__settings", (el) => el.textContent ?? "");
const farmId = /farm_id = "([^"]+)"/.exec(settings)?.[1] ?? "";
const proxyToken = /gateway_token = "([^"]+)"/.exec(settings)?.[1] ?? "";
const uplink = /uplink_url = "([^"]+)"/.exec(settings)?.[1] ?? "";
say(Boolean(farmId && proxyToken && uplink.endsWith("/ws/edge/v1")), "the page shows complete gateway settings");
// 4. a gateway configured from exactly what the page showed
const exchanged = await fetch(`${API_BASE}/auth/edge-token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ farmId, edgeProxyToken: proxyToken }),
});
say(exchanged.ok, "the token the page showed is accepted by the cloud");
const jwt = ((await exchanged.json()) as { token: string }).token;
gateway = Bun.spawn(["bun", "scripts/fake-gateway-serve.ts", `${WS_BASE}/ws/edge/v1`, jwt, "120", FRAMES_FILE], {
env: { ...process.env, FARM_ID: farmId, TELEMETRY_AFTER_PROVISION: "1" },
stdout: "ignore",
stderr: "ignore",
});
say(await waitText(".gateway-setup", "A gateway is connected", 30_000), "the page notices the gateway connecting");
await page.click(".gateway-setup button:last-of-type"); // Done: the token leaves the screen
say(
!(await page.$eval("body", (el, token) => el.textContent?.includes(token) ?? false, proxyToken)),
"dismissing the settings removes the token from the page",
);
// 5. add a printer
const fields = await page.$$(".add-printer form input");
await fields[0]!.type("Bench P1P");
await fields[1]!.type("192.168.0.77");
await fields[2]!.type("01P00A000000007");
await fields[3]!.type(ACCESS_CODE);
await page.click(".add-printer button[type=submit]");
say(await waitText(".add-printer", "Added Bench P1P", 45_000), "adding a printer through the form succeeds");
say(await waitText(".managed", "Bench P1P"), "the printer appears in the list");
const leftover = await page.$eval(".add-printer input[type=password]", (el) => (el as HTMLInputElement).value);
say(leftover === "", "the access code is cleared from the form after sending");
const frames = readFileSync(FRAMES_FILE, "utf8");
say(frames.includes(ACCESS_CODE), "the gateway received the access code");
// 6. the printer fails -> an alert on the fleet page
await page.goto(`${APP_URL}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
say(await waitText(".alert-list", "the print failed", 60_000), "a failed print raises an alert on the fleet page");
await page.click(".alert-list button");
const gone = await page
.waitForFunction(() => document.querySelector(".alert-list") === null, { timeout: 20_000 })
.then(() => true)
.catch(() => false);
say(gone, "dismissing the alert removes it");
// 7. remove
await page.goto(`${APP_URL}/setup`, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForSelector(".managed button", { timeout: 20_000 });
await page.click(".managed button");
say(await waitText(".managed", "forget its access code"), "removing asks first, and says what it deletes");
const buttons = await page.$$(".managed button");
await buttons[0]!.click(); // "Yes, remove"
const removed = await page
.waitForFunction(() => document.querySelector(".managed") === null, { timeout: 30_000 })
.then(() => true)
.catch(() => false);
say(removed, "the printer is removed");
say(readFileSync(FRAMES_FILE, "utf8").includes("remove_printer"), "the gateway was told to forget it");
say(pageErrors.length === 0, "no uncaught page errors", pageErrors.slice(0, 2).join(" | "));
} finally {
gateway?.kill();
await browser.close();
}
process.exit(failed ? 1 : 0);
+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 }));
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# The non-Bambu vendors end to end, against FAKE printers: add each one through
# the API (so the provision frame, the real proxy's driver and its store are
# all exercised), then run a job through the real scheduler and watch it go
# queued -> uploading -> printing -> completed from the fake's own telemetry.
#
# What this proves is the plumbing around the drivers. It does NOT prove the
# drivers speak the real Klipper / OctoPrint / PrusaLink protocols: the fake
# (scripts/fake-http-printer.ts) is written from the same reading of the docs
# as the driver, so a shared misreading passes here and fails on hardware.
#
# Needs the dev stack (scripts/dev-env.sh up && schema) and the object store
# credentials in ../dev-secrets.sh (the job file goes through the real
# presigned URLs). No real printer is touched.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
[ -f "$REPO_ROOT/../dev-secrets.sh" ] && . "$REPO_ROOT/../dev-secrets.sh"
KINDS="${KINDS:-klipper octoprint prusa_link}"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
PORT="${PORT:-3062}"; AUTH_PORT="${AUTH_PORT:-3063}"; FAKE_PORT="${FAKE_PORT:-7141}"
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}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
WORK="$(mktemp -d)"
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""; FAKE_PID=""; ORGS=""
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; }
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; }
stop() { for pid in "$@"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done; sleep 0.5; for pid in "$@"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done; }
cleanup() {
local rc=$?
stop "$PROXY_PID" "$FAKE_PID" "$BACKEND_PID" "$AUTH_PID"
for org in $ORGS; do psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true; done
if [ $rc -ne 0 ]; then
echo "--- backend log (tail) ---"; tail -25 "$WORK/backend.log" 2>/dev/null || true
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
echo "--- fake printer log (tail) ---"; tail -15 "$WORK/fake.log" 2>/dev/null || true
fi
[ -n "${KEEP_LOGS:-}" ] && cp "$WORK"/*.log "$KEEP_LOGS"/ 2>/dev/null || true
rm -rf "$WORK"; exit $rc
}
trap cleanup EXIT
[ -n "${R2_ENDPOINT:-}" ] || fail "no object store credentials - ../dev-secrets.sh"
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
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
export SUPABASE_URL="$AUTH_URL"
USER_ID="$(new_uuid)"
TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | json 'd["access_token"]')"
auth=(-H "authorization: Bearer $TOKEN")
start_backend() {
DISPATCH_ENABLED=true 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 && return 0; sleep 0.5; done
fail "backend did not become healthy"
}
start_backend
# run_kind <kind> <allow insecure downloads: true|false> [outage]
# With downloads refused the proxy accepts the start and then fails it, which
# is the late-failure path: the job must fail with the proxy's reason.
run_kind() {
local KIND="$1" ALLOW="$2" OUTAGE="${3:-}"
echo "==> $KIND (insecure local downloads: $ALLOW${OUTAGE:+, cloud outage mid-job})"
KEY="key-$(new_uuid | cut -c1-8)"
env ${OUTAGE:+RUN_S=14} bun scripts/fake-http-printer.ts "$KIND" "$FAKE_PORT" "$KEY" > "$WORK/fake.log" 2>&1 &
FAKE_PID=$!
for _ in $(seq 1 40); do grep -q listening "$WORK/fake.log" && break; sleep 0.25; done
ORG_ID="$(curl -sf "${auth[@]}" -H 'content-type: application/json' -d "{\"name\":\"e2e vendors $KIND\"}" "$BASE/orgs" | json 'd["id"]')"
ORGS="$ORGS $ORG_ID"
FARM_ID="$(curl -sf "${auth[@]}" -H 'content-type: application/json' -d "{\"name\":\"farm $KIND\",\"orgId\":\"$ORG_ID\"}" "$BASE/farms" | json 'd["id"]')"
GW_TOKEN="$(curl -sf "${auth[@]}" -X POST "$BASE/farms/$FARM_ID/edge-proxy-token" | json 'd["edgeProxyToken"]')"
env -C "$PROXY_DIR" \
CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS="$ALLOW" CONTINUUM_GATEWAY_ID=e2e-vendors-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$GW_TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_PRINTERS_PATH="$WORK/no-such-printers.toml" CONTINUUM_STORE_PATH="$WORK/store-$KIND/continuum.db" RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
# This farm's own connect line: the log is shared across the loop.
for _ in $(seq 1 60); do grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && break; sleep 0.5; done
grep -q "connected for farm $FARM_ID" "$WORK/backend.log" || fail "the proxy never connected"
# Klipper needs no credential; the others take an API key.
CRED=""; [ "$KIND" != klipper ] && CRED=",\"apiKey\":\"$KEY\""
CODE="$(curl -s -o "$WORK/add.json" -w '%{http_code}' "${auth[@]}" -H 'content-type: application/json' \
-d "{\"name\":\"Fake $KIND\",\"vendor\":\"$KIND\",\"host\":\"127.0.0.1:$FAKE_PORT\"$CRED}" "$BASE/farms/$FARM_ID/printers")"
[ "$CODE" = 201 ] || fail "adding the $KIND printer returned $CODE: $(head -c 300 "$WORK/add.json")"
PRN_UUID="$(json 'd["id"]' < "$WORK/add.json")"
ok "added through the API and connected by the proxy"
grep -q "$KEY" "$WORK/proxy.log" "$WORK/backend.log" && fail "the API key is in a log"
ok "the API key is in neither log"
if [ "$KIND" != klipper ]; then
CODE="$(curl -s -o /dev/null -w '%{http_code}' "${auth[@]}" -H 'content-type: application/json' \
-d "{\"name\":\"No key\",\"vendor\":\"$KIND\",\"host\":\"127.0.0.1:$FAKE_PORT\"}" "$BASE/farms/$FARM_ID/printers")"
[ "$CODE" = 422 ] && ok "a missing API key is refused (422)" || fail "a keyless add returned $CODE"
fi
for _ in $(seq 1 30); do [ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = idle ] && break; sleep 1; done
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = idle ] || fail "the cloud never saw the printer idle"
ok "the cloud sees it idle (telemetry from the driver's poller)"
J="$(curl -sf "${auth[@]}" -H 'content-type: application/json' \
-d "{\"farmId\":\"$FARM_ID\",\"fileName\":\"e2e-part.gcode\",\"contentType\":\"application/octet-stream\"}" "$BASE/jobs")"
JOB_ID="$(printf '%s' "$J" | json 'd["job"]["id"]')"
OBJECT_KEY="$(printf '%s' "$J" | json 'd["job"]["r2ObjectKey"]')"
UPLOAD_URL="$(printf '%s' "$J" | json 'd["uploadUrl"]')"
printf 'G28\nG1 X10 Y10 F3000\nM104 S0\n' > "$WORK/part.gcode"
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/part.gcode" "$UPLOAD_URL")"
[ "$CODE" = 200 ] || fail "job file upload returned $CODE"
if [ -n "$OUTAGE" ]; then
# Take the cloud away the moment the job is dispatched - before any
# telemetry can have told it the print began - and bring it back only
# after the fake print is over. Telemetry alone would leave the job
# `uploading` (idle printer, never seen printing); only the gateway's
# own kept record can complete it.
for _ in $(seq 1 300); do [ "$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")" = uploading ] && break; sleep 0.1; done
[ "$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")" = uploading ] || fail "the job was not dispatched"
stop "$BACKEND_PID"; BACKEND_PID=""
echo " (cloud down; the fake print runs 14s without it)"
sleep 20
start_backend
fi
SEEN=""; START=$SECONDS; STATUS=""
while [ $((SECONDS - START)) -lt 60 ]; do
STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")"
case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac
[ "$STATUS" = completed ] || [ "$STATUS" = failed ] && break
sleep 1
done
echo " job status history:$SEEN"
if [ -n "$OUTAGE" ]; then
[ "$STATUS" = completed ] || fail "after the outage the $KIND job is '$STATUS', not completed"
grep -Eq "gateway reported (started|finished)" "$WORK/backend.log" || fail "it completed, but not from the gateway's kept record"
ok "the cloud was away for the whole print; the proxy's kept record completed the job on reconnect"
elif [ "$ALLOW" = true ]; then
[ "$STATUS" = completed ] || fail "the $KIND job ended as '$STATUS'"
ok "$KIND: queued, scheduled, uploaded, printed and completed (fake printer)"
else
[ "$STATUS" = failed ] || fail "the refused download left the job '$STATUS', not failed"
REASON="$(psql_q "SELECT metadata->>'lastDispatchError' FROM print_jobs WHERE id='$JOB_ID'")"
case "$REASON" in *"non-HTTPS"*) ok "the job failed at once with the proxy's reason: $REASON" ;; *) fail "the job failed without the proxy's reason: '$REASON'" ;; esac
fi
bun -e "
import { s3 } from './src/s3';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] }));
" "$OBJECT_KEY" >/dev/null 2>&1 || true
stop "$PROXY_PID" "$FAKE_PID"; PROXY_PID=""; FAKE_PID=""
}
for KIND in $KINDS; do run_kind "$KIND" true; done
run_kind klipper false
run_kind klipper true outage
echo "PASS - $KINDS: added from the API, scheduled and completed against fake printers; a refused download fails the job with its reason; a job finished while the cloud was away completes on reconnect."
+84
View File
@@ -0,0 +1,84 @@
/**
* A stand-in gateway that stays connected and answers printer provisioning,
* for scripts/e2e-provision.sh.
*
* With TELEMETRY_AFTER_PROVISION=1 it also reports each provisioned printer
* (running, then failed a few seconds later), so the console has something to
* alert on.
*
* With REPORT_IDLE=<gateway-printer-id> it reports that printer idle on
* connect, so the scheduler treats it as free. A `start_print` command is
* accepted, the file is downloaded from its presigned URL and hashed (the
* hash is appended to the frames file, never the URL), and the printer then
* reports running and finished, like a real print that goes well.
*
* Needs FARM_ID in the environment (the hello must name the token's farm).
*
* Usage: bun scripts/fake-gateway-serve.ts <ws-url> <edge-jwt> <seconds> <frames-file> [refuse-access-code]
*
* Every provision_printer / remove_printer frame it receives is appended to
* <frames-file> as one JSON line, so the script can prove the credential
* arrived here. A provision whose access_code equals <refuse-access-code> is
* answered as a failure, like a printer that rejected the login.
*/
import { appendFileSync } from "node:fs";
const [url, jwt, seconds, framesFile, refuseCode] = process.argv.slice(2);
if (!url || !jwt || !seconds || !framesFile) {
console.error("usage: fake-gateway-serve.ts <ws-url> <edge-jwt> <seconds> <frames-file> [refuse-code]");
process.exit(2);
}
const ws = new WebSocket(url, { headers: { authorization: `Bearer ${jwt}` } } as never);
ws.onerror = () => {
console.error("socket error");
process.exit(1);
};
ws.onopen = () => {
// farm_id is required by the hello schema; the backend checks it against the token's farm.
ws.send(JSON.stringify({ type: "hello", gateway_id: "fake-gw", farm_id: process.env.FARM_ID }));
if (process.env.REPORT_IDLE) {
ws.send(JSON.stringify({ type: "telemetry", printers: [{ printer_id: process.env.REPORT_IDLE, name: "Fake", state: "IDLE", percent: 0, print_error: 0 }] }));
}
console.log("ready");
};
async function printFile(message: { command_id: string; printer_id: string; command: { job_id: string; file_name: string; url: string } }) {
const report = (state: string, percent: number) =>
ws.send(JSON.stringify({ type: "telemetry", printers: [{ printer_id: message.printer_id, name: "Fake", state, percent, print_error: 0 }] }));
const body = new Uint8Array(await (await fetch(message.command.url)).arrayBuffer());
const digest = new Bun.CryptoHasher("sha256").update(body).digest("hex");
appendFileSync(framesFile, JSON.stringify({ type: "downloaded", file_name: message.command.file_name, bytes: body.length, sha256: digest }) + "\n");
report("RUNNING", 20);
setTimeout(() => report("RUNNING", 60), 1500);
setTimeout(() => report("FINISH", 100), 3000);
}
ws.onmessage = (event) => {
const message = JSON.parse(String(event.data));
if (message.type === "command" && message.command?.action === "start_print") {
appendFileSync(framesFile, JSON.stringify({ type: "start_print", printer_id: message.printer_id, file_name: message.command.file_name, job_id: message.command.job_id }) + "\n");
ws.send(JSON.stringify({ type: "command_result", command_id: message.command_id, ok: true }));
void printFile(message);
return;
}
if (message.type !== "provision_printer" && message.type !== "remove_printer") return;
appendFileSync(framesFile, JSON.stringify(message) + "\n");
if (message.type === "provision_printer" && process.env.TELEMETRY_AFTER_PROVISION === "1") {
const id = message.printer.id;
const report = (state: string) =>
ws.send(JSON.stringify({ type: "telemetry", printers: [{ printer_id: id, name: message.printer.name, state, percent: 40, print_error: 0 }] }));
setTimeout(() => report("RUNNING"), 1500);
setTimeout(() => report("RUNNING"), 3000);
setTimeout(() => report("FAILED"), 4500);
}
const refused = message.type === "provision_printer" && refuseCode && message.printer.access_code === refuseCode;
ws.send(
JSON.stringify({
type: "command_result",
command_id: message.request_id,
ok: !refused,
...(refused ? { error: "could not connect to the printer: the access code was rejected" } : {}),
}),
);
};
setTimeout(() => process.exit(0), Number(seconds) * 1000);
+36
View File
@@ -0,0 +1,36 @@
/**
* A stand-in gateway for scripts/e2e-alerts.sh: connects to /ws/edge/v1 with
* an edge JWT, says hello, then reports one printer through a sequence of
* states, one telemetry frame each, and waits for the backend to take them.
*
* Usage: bun scripts/fake-gateway.ts <ws-url> <edge-jwt> <printer-id> STATE[,STATE...]
* A state is a Bambu gcode_state word (IDLE, RUNNING, PAUSE, FAILED).
*/
const [url, jwt, printerId, states] = process.argv.slice(2);
if (!url || !jwt || !printerId || !states) {
console.error("usage: fake-gateway.ts <ws-url> <edge-jwt> <printer-id> STATE[,STATE...]");
process.exit(2);
}
const ws = new WebSocket(url, { headers: { authorization: `Bearer ${jwt}` } } as never);
ws.onerror = () => {
console.error("socket error");
process.exit(1);
};
ws.onopen = async () => {
ws.send(JSON.stringify({ type: "hello", gateway_id: "fake-gw" }));
for (const state of states.split(",")) {
ws.send(
JSON.stringify({
type: "telemetry",
printers: [{ printer_id: printerId, name: "Fake", state, percent: 10, print_error: 0 }],
}),
);
// Telemetry is persisted in order per socket; a pause keeps the frames
// from being coalesced into one observation.
await Bun.sleep(400);
}
await Bun.sleep(500);
ws.close();
process.exit(0);
};
+103
View File
@@ -0,0 +1,103 @@
// A stand-in for the REST printers (Klipper's Moonraker, OctoPrint, PrusaLink)
// so the real proxy can be driven end to end without owning one. It is only as
// good as my reading of those APIs - it proves the plumbing, not the hardware.
//
// bun scripts/fake-http-printer.ts <klipper|octoprint|prusa_link> <port> [apiKey]
//
// A file uploaded with "print" set starts a fake print that runs for RUN_S
// seconds (default 8) and then finishes. Every request is logged to stdout.
const [kind, portArg, apiKey = "e2e-key"] = process.argv.slice(2);
if (!["klipper", "octoprint", "prusa_link"].includes(kind ?? "")) {
console.error("usage: fake-http-printer.ts <klipper|octoprint|prusa_link> <port> [apiKey]");
process.exit(2);
}
const RUN_S = Number(process.env.RUN_S ?? 8);
let file: string | undefined;
let startedAt: number | undefined;
let cancelled = false;
const progress = () => (startedAt === undefined ? 0 : cancelled ? 0 : Math.min(1, (Date.now() - startedAt) / (RUN_S * 1000)));
const phase = () => (startedAt === undefined ? "idle" : cancelled ? "cancelled" : progress() >= 1 ? "done" : "printing");
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
function startFrom(body: string, name?: string) {
file = name ?? /filename="([^"]+)"/.exec(body)?.[1] ?? "unknown";
startedAt = Date.now();
cancelled = false;
}
Bun.serve({
port: Number(portArg),
async fetch(req) {
const url = new URL(req.url);
const body = req.method === "GET" ? "" : new TextDecoder().decode(await req.arrayBuffer());
console.log(`${req.method} ${url.pathname} ${body.length}b`);
const route = `${req.method} ${url.pathname}`;
if (kind === "klipper") {
if (route === "GET /printer/info") return json({ result: { state: "ready" } });
if (route === "GET /printer/objects/query") {
const p = progress();
const state = { idle: "standby", printing: "printing", done: "complete", cancelled: "cancelled" }[phase()];
return json({
result: {
status: {
print_stats: { state, filename: file ?? "", print_duration: p * RUN_S, info: { current_layer: Math.ceil(p * 10), total_layer: 10 } },
virtual_sdcard: { progress: p },
extruder: { temperature: phase() === "printing" ? 210 : 24, target: phase() === "printing" ? 210 : 0 },
heater_bed: { temperature: phase() === "printing" ? 60 : 23, target: phase() === "printing" ? 60 : 0 },
},
},
});
}
if (route === "POST /server/files/upload") {
if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400);
startFrom(body);
return json({ print_started: true }, 201);
}
if (route === "POST /printer/print/cancel") { cancelled = true; return json({ result: "ok" }); }
if (route === "POST /printer/gcode/script") return json({ result: "ok" });
} else {
if (req.headers.get("x-api-key") !== apiKey) return json({ error: "bad key" }, 403);
const ph = phase();
if (kind === "octoprint") {
if (route === "GET /api/job") {
return json({
state: ph === "printing" ? "Printing" : "Operational",
job: { file: { name: file ?? null, display: file ?? null } },
progress: { completion: ph === "idle" || ph === "cancelled" ? null : progress() * 100, printTimeLeft: Math.round((1 - progress()) * RUN_S) },
});
}
if (route === "GET /api/printer") {
return json({ temperature: { tool0: { actual: ph === "printing" ? 210 : 24, target: ph === "printing" ? 210 : 0 }, bed: { actual: 60, target: ph === "printing" ? 60 : 0 } } });
}
if (route === "POST /api/files/local") {
if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400);
startFrom(body);
return json({ done: true }, 201);
}
if (route === "POST /api/job") { if (body.includes("cancel")) cancelled = true; return new Response(null, { status: 204 }); }
if (route === "POST /api/printer/command") return new Response(null, { status: 204 });
} else {
const state = { idle: "IDLE", printing: "PRINTING", done: "FINISHED", cancelled: "STOPPED" }[ph];
if (route === "GET /api/v1/status") {
return json({
printer: { state, temp_nozzle: ph === "printing" ? 215 : 25, target_nozzle: ph === "printing" ? 215 : 0, temp_bed: 60, target_bed: 0 },
...(ph === "printing" ? { job: { id: 1, progress: progress() * 100, time_remaining: Math.round((1 - progress()) * RUN_S) } } : {}),
});
}
if (route === "GET /api/v1/job") return json({ id: 1, file: { display_name: file } });
if (req.method === "PUT" && url.pathname.startsWith("/api/v1/files/local/")) {
if (req.headers.get("print-after-upload") !== "?1") return json({ error: "print flag missing" }, 400);
startFrom(body, decodeURIComponent(url.pathname.split("/").pop() ?? ""));
return new Response(null, { status: 201 });
}
if (route === "DELETE /api/v1/job/1") { cancelled = true; return new Response(null, { status: 204 }); }
}
}
return json({ error: `no route ${route}` }, 404);
},
});
console.log(`fake ${kind} listening on :${portArg}`);
+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"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# THE FIRST REAL DISPATCH: queue a job in a throwaway farm that holds only the
# P1P, let the real scheduler send it through the real proxy, and watch the
# machine print it.
#
# **This starts a physical print.** It refuses to run without
# I_AUTHORISE_A_REAL_PRINT=yes, and only ever targets PRINTER_ID's printer
# (default p1p) because the throwaway farm contains nothing else. The file is
# read from the printer's own SD card root (FILE, default
# auto-test-001.gcode.3mf), uploaded to the object store, and dispatched like
# any customer job; the proxy then downloads it, FTPS-uploads it to the card
# and starts it, mapping filament from the archive itself.
#
# Nothing here cancels the print. To stop it: the printer's own screen, or
# the console's Stop control.
set -euo pipefail
[ "${I_AUTHORISE_A_REAL_PRINT:-}" = "yes" ] || { echo "refusing: set I_AUTHORISE_A_REAL_PRINT=yes to start a real print" >&2; exit 2; }
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PRINTER_ID="${PRINTER_ID:-p1p}"
FILE="${FILE:-auto-test-001.gcode.3mf}"
# 0-based across all AMS units (AMS1 slot 4 is 3). Unset: map by the project's filament.
AMS_SLOT="${AMS_SLOT:-}"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
GO2RTC_BIN="${GO2RTC_BIN:-$HOME/.local/bin/go2rtc}"
PORT="${PORT:-3060}"; AUTH_PORT="${AUTH_PORT:-3061}"
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}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
MAX_WAIT_S="${MAX_WAIT_S:-5400}"
WORK="$(mktemp -d)"
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""; OBJECT_KEY=""
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
USER_ID="$(cat /proc/sys/kernel/random/uuid)"
cleanup() {
local rc=$?
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done
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 "$OBJECT_KEY" ] && bun -e "
import { s3 } from './src/s3';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] }));
" "$OBJECT_KEY" >/dev/null 2>&1 || true
[ -z "${KEEP_DATA:-}" ] && [ -n "${ORG_ID:-}" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
if [ $rc -ne 0 ]; then
echo "--- backend log (tail) ---"; tail -25 "$WORK/backend.log" 2>/dev/null || true
echo "--- proxy log (tail) ---"; tail -40 "$WORK/proxy.log" 2>/dev/null || true
fi
rm -rf "$WORK"
exit $rc
}
trap cleanup EXIT
[ -f "$PROXY_DIR/printers.toml" ] || fail "no printers.toml"
grep -q "^id *= *\"$PRINTER_ID\"" "$PROXY_DIR/printers.toml" || fail "printers.toml has no printer '$PRINTER_ID'"
ps -eo comm | grep -qx go2rtc && fail "a go2rtc is already running"
echo "==> reading $FILE from $PRINTER_ID's SD card (read-only)"
( cd "$PROXY_DIR" && cargo run -q --example bambu_mapping_check -- "$PRINTER_ID" "$FILE" "$WORK/job.3mf" ) > "$WORK/mapping.txt" 2>&1 || { cat "$WORK/mapping.txt"; fail "could not read the file"; }
grep -E 'gcode_state|project filament|mapping' "$WORK/mapping.txt" | sed 's/^/ /'
grep -q 'gcode_state: Some("IDLE")' "$WORK/mapping.txt" || grep -q 'gcode_state: Some("FINISH")' "$WORK/mapping.txt" || fail "the printer is not idle"
grep -q 'mapping the production path would use' "$WORK/mapping.txt" || fail "the filament would not map; not starting"
WANT="$(sha256sum "$WORK/job.3mf" | cut -d' ' -f1)"
echo "==> seeding a throwaway farm holding only $PRINTER_ID"
EDGE_PROXY_TOKEN="real-$(date +%s)"
ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('real print org') RETURNING id")"
psql_q "INSERT INTO org_members (org_id, user_id, role) VALUES ('$ORG_ID', '$USER_ID', 'owner')" >/dev/null
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token) VALUES ('real print farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
PRN_NAME="$(awk -F'"' -v id="$PRINTER_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', '${PRN_NAME:-P1P}', 'bambu', '$PRINTER_ID')" >/dev/null
PRN_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID'")"
[ "$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_ID'")" = 1 ] || fail "the farm must hold exactly one printer"
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
export SUPABASE_URL="$AUTH_URL"
TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
echo "==> starting the backend WITH dispatch on"
DISPATCH_ENABLED=true 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"
echo "==> starting the real proxy"
env -C "$PROXY_DIR" \
CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true CONTINUUM_GATEWAY_ID=real-print-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_PRINTERS_PATH=printers.toml CONTINUUM_STORE_PATH="$WORK/continuum.db" RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
for _ in $(seq 1 60); do
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = "idle" ] && break
sleep 1
done
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = "idle" ] || fail "the cloud never saw $PRINTER_ID idle"
ok "the cloud sees $PRINTER_ID idle"
echo "==> queueing the job"
J="$(curl -sf -X POST "$BASE/jobs" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"farmId\":\"$FARM_ID\",\"fileName\":\"$FILE\",\"contentType\":\"application/octet-stream\"${AMS_SLOT:+,\"amsSlot\":$AMS_SLOT}}")"
JOB_ID="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["job"]["id"])')"
OBJECT_KEY="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["job"]["r2ObjectKey"])')"
UPLOAD_URL="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["uploadUrl"])')"
# The job is queued the moment it exists; dispatch needs the bytes, so the
# window between the two is a race the scheduler can only lose (the proxy's
# download then fails and the job fails), which is why this is done at once.
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/job.3mf" "$UPLOAD_URL")"
[ "$CODE" = 200 ] || fail "upload returned HTTP $CODE"
ok "job $JOB_ID queued, $(stat -c %s "$WORK/job.3mf") bytes uploaded"
echo "==> watching (up to ${MAX_WAIT_S}s)"
SEEN=""; LAST=""; START=$SECONDS
while [ $((SECONDS - START)) -lt "$MAX_WAIT_S" ]; do
STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")"
PRN="$(psql_q "SELECT coalesce(vendor_state,'-') || ' ' || coalesce(progress_percent::text,'-') || '% layer ' || coalesce(current_layer::text,'-') || '/' || coalesce(total_layers::text,'-') || ' nozzle ' || coalesce(nozzle_temp_c::text,'-') FROM printers WHERE id='$PRN_UUID'")"
NOW="job=$STATUS printer=$PRN"
[ "$NOW" != "$LAST" ] && { echo " [$((SECONDS - START))s] $NOW"; LAST="$NOW"; }
case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac
[ "$STATUS" = completed ] && break
[ "$STATUS" = failed ] && break
sleep 5
done
echo
echo "job status history:$SEEN"
if [ "$STATUS" = completed ]; then
grep -E 'uploaded a project file|mapped the project|start' "$WORK/proxy.log" | sed 's/^/ proxy: /' | head -8
echo "PASS - the first real dispatch: queued in the cloud, scheduled, uploaded to the P1P, printed and completed."
else
fail "the job ended as '$STATUS'"
fi
+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);
+55
View File
@@ -0,0 +1,55 @@
/**
* Which telemetry changes deserve an operator's attention, on their own with
* no imports - same reason as `dispatch/job_status.ts`: `../db` opens a
* connection at import time, and this is the decision worth testing without
* one.
*/
export type AlertKind = "print_failed" | "print_paused" | "printer_error";
export type Status = "offline" | "idle" | "printing" | "paused" | "error" | "maintenance";
/**
* What changed, if anything, that is worth raising.
*
* Transitions and not levels: a printer that sits in `error` for an hour is
* one alert, not 720. `previous` undefined means this connection has not seen
* the printer before, which raises nothing - a gateway reconnecting would
* otherwise re-announce every fault that was already known.
*
* A fault while printing is a failed print; a fault while idle is the printer
* needing attention, which is a different thing to tell someone.
*/
export function deriveAlert(
previous: Status | undefined,
next: Status | undefined,
printerName: string,
vendorState?: string | null,
): { kind: AlertKind; message: string } | undefined {
if (previous === undefined || next === undefined || previous === next) return undefined;
const detail = vendorState ? ` (printer reports ${vendorState})` : "";
if (next === "error") {
return previous === "printing" || previous === "paused"
? { kind: "print_failed", message: `${printerName}: the print failed${detail}` }
: { kind: "printer_error", message: `${printerName} reported an error${detail}` };
}
// Paused by the printer itself is how an AMS fault shows up - the print is
// still on the bed and nobody is going to notice without being told. A
// pause the operator asked for is also announced; telling them what they
// just did is cheap, and the console cannot tell the two apart.
if (next === "paused" && previous === "printing") {
return { kind: "print_paused", message: `${printerName}: the print is paused${detail}` };
}
return undefined;
}
/** Whether an AI detection is worth a row; the worker deliberately does not threshold. */
export function worthRaising(confidence: number, minimum: number): boolean {
return confidence >= minimum;
}
/** The floor for AI detections, overridable because it is a taste, not a fact. */
export function minAiConfidence(env: Record<string, string | undefined>): number {
const parsed = Number(env.ALERT_MIN_CONFIDENCE);
return env.ALERT_MIN_CONFIDENCE !== undefined && parsed >= 0 && parsed <= 1 ? parsed : 0.6;
}
+49
View File
@@ -0,0 +1,49 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import { printerAlerts, printers } from "../db/schema";
import { valkey } from "../queue";
import { minAiConfidence, worthRaising } from "./derive";
import { parsePrintAlert } from "./parse";
const CHANNEL = "channel:print_alerts";
/**
* Turns the AI worker's fire-and-forget publications into rows.
*
* PUBLISH keeps nothing, so a detection published while this process is down
* is gone - the same limit the schema documents. Everything that arrives
* while it is up is kept, which is the difference between an alert and a
* flicker.
*
* A subscribed connection can do nothing else in RESP, hence `duplicate()`
* rather than reusing the shared client.
*/
export function startAiAlertListener(): void {
const floor = minAiConfidence(process.env);
const subscriber = valkey.duplicate();
subscriber.on("error", (err) => console.warn("[alerts] listener connection", err.message));
subscriber.subscribe(CHANNEL).catch((err: unknown) => console.warn("[alerts] cannot subscribe", err));
subscriber.on("message", async (_channel, raw) => {
const alert = parsePrintAlert(raw);
if (!alert || !worthRaising(alert.confidence, floor)) return;
try {
const printer = await db.query.printers.findFirst({
columns: { id: true, farmId: true, name: true },
where: eq(printers.id, alert.printer_id),
});
// An alert for a printer we do not have has no farm to show it to.
if (!printer) return;
await db.insert(printerAlerts).values({
farmId: printer.farmId,
printerId: printer.id,
kind: "ai_detection",
message: `${printer.name}: looks like ${alert.label} (${Math.round(alert.confidence * 100)}%)`,
confidence: alert.confidence,
objectKey: alert.r2_object_key,
});
} catch (err) {
console.warn("[alerts] could not record an AI alert", err);
}
});
}
+22
View File
@@ -0,0 +1,22 @@
import { TypeCompiler } from "@sinclair/typebox/compiler";
import { PrintAlertSchema, type PrintAlert } from "@continuum/ts-types";
// Must come first - see queue/frame_task.ts: an unregistered format fails.
import "../validation/formats";
const check = TypeCompiler.Compile(PrintAlertSchema);
/**
* Parses a `channel:print_alerts` message, or returns undefined for anything
* that is not a valid `PrintAlert`. A subscriber must never throw on what
* arrives: the channel is shared and a bad publisher should cost one message,
* not the listener.
*/
export function parsePrintAlert(raw: string): PrintAlert | undefined {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return undefined;
}
return check.Check(value) ? (value as PrintAlert) : undefined;
}
+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;
}
+14
View File
@@ -0,0 +1,14 @@
export { requireUser, type AuthenticatedUser } from "./plugin";
export { roleSatisfies, ORG_ROLES, type OrgRole } from "./roles";
export {
listMemberships,
requireAlert,
requireFarm,
requireJob,
requireOrg,
requirePrinter,
visibleAlerts,
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 };
+188
View File
@@ -0,0 +1,188 @@
import { and, eq, inArray } from "drizzle-orm";
import { ForbiddenError, NotFoundError } from "@continuum/ts-core";
import { db } from "../db";
import { farms, orgMembers, printJobs, printerAlerts, 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)),
);
}
/** `where` condition limiting `printer_alerts` to those the user can see. */
export function visibleAlerts(userId: string) {
return inArray(
printerAlerts.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 };
}
/** As `requireFarm`, resolved through the alert's farm. */
export async function requireAlert(
userId: string,
alertId: string,
minimum: OrgRole,
action = "this operation",
): Promise<{ farmId: string; orgId: string; role: OrgRole }> {
const [row] = await db
.select({ farmId: printerAlerts.farmId, orgId: farms.orgId, role: orgMembers.role })
.from(printerAlerts)
.innerJoin(farms, eq(farms.id, printerAlerts.farmId))
.leftJoin(
orgMembers,
and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)),
)
.where(eq(printerAlerts.id, alertId))
.limit(1);
const role = check(row?.role ?? null, minimum, `Alert ${alertId} 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 postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";
// Points at pgCat/PgBouncer, not Postgres directly, so keep prepared // No pooler in front of this for the MVP: DATABASE_URL points straight at
// statements disabled and cap the pool per-instance connections low. // 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; const connectionString = process.env.DATABASE_URL;
if (!connectionString) { if (!connectionString) {
throw new Error("DATABASE_URL is not set"); throw new Error("DATABASE_URL is not set");
+90
View File
@@ -0,0 +1,90 @@
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",
"cameraSnapshot",
"vendorState",
"vendorErrorCode",
"lastSeenAt",
"createdAt",
"updatedAt",
] as const);
+45
View File
@@ -0,0 +1,45 @@
import { pgTable, uuid, text, timestamp, real, pgEnum, index } from "drizzle-orm/pg-core";
import { farms } from "./farms";
import { printers } from "./printers";
export const alertKindEnum = pgEnum("alert_kind", [
"print_failed",
"print_paused",
"printer_error",
"ai_detection",
]);
/**
* The permanent record of something an operator should look at.
*
* A table and not a channel: Valkey PUBLISH keeps nothing, so an alert raised
* while no console was open would otherwise be gone for good. The console
* lists open (unacknowledged) rows, which is also what makes "I was away,
* what happened" answerable.
*/
export const printerAlerts = pgTable(
"printer_alerts",
{
id: uuid("id").primaryKey().defaultRandom(),
farmId: uuid("farm_id")
.notNull()
.references(() => farms.id, { onDelete: "cascade" }),
// Set null rather than cascade: the history of why a deleted printer was
// flagged is still worth having, and the farm cascade bounds it anyway.
printerId: uuid("printer_id").references(() => printers.id, { onDelete: "set null" }),
kind: alertKindEnum("kind").notNull(),
message: text("message").notNull(),
// Only for ai_detection; null is "not applicable", not zero confidence.
confidence: real("confidence"),
// Where the frame the detection came from lives, for ai_detection.
objectKey: text("object_key"),
acknowledgedBy: uuid("acknowledged_by"),
acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
farmOpen: index("printer_alerts_farm_open_idx").on(table.farmId, table.acknowledgedAt),
}),
);
export type PrinterAlert = typeof printerAlerts.$inferSelect;
+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(), id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(), 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(), edgeProxyToken: text("edge_proxy_token").notNull(),
location: text("location"), location: text("location"),
settings: jsonb("settings").$type<Record<string, unknown>>().default({}), settings: jsonb("settings").$type<Record<string, unknown>>().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_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 Farm = typeof farms.$inferSelect;
export type NewFarm = typeof farms.$inferInsert; export type NewFarm = typeof farms.$inferInsert;
+3
View File
@@ -1,4 +1,7 @@
export * from "./orgs";
export * from "./farms"; export * from "./farms";
export * from "./printers"; export * from "./printers";
export * from "./print_jobs"; export * from "./print_jobs";
export * from "./embeddings"; export * from "./embeddings";
export * from "./alerts";
export * from "./onboarding";
+51
View File
@@ -0,0 +1,51 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { orgRoleEnum, organisations } from "./orgs";
import { farms } from "./farms";
/**
* An invitation to join an organisation, addressed to an email.
*
* Only a hash of the token is stored: the token is the whole credential, and
* the table is the kind of thing that ends up in a backup or a screenshot.
* It is shown to the inviter once, when the invitation is made.
*/
export const orgInvitations = pgTable(
"org_invitations",
{
id: uuid("id").primaryKey().defaultRandom(),
orgId: uuid("org_id")
.notNull()
.references(() => organisations.id, { onDelete: "cascade" }),
/** Lower-cased: addresses are compared case-insensitively. */
email: text("email").notNull(),
role: orgRoleEnum("role").notNull(),
tokenHash: text("token_hash").notNull().unique(),
invitedBy: uuid("invited_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
acceptedBy: uuid("accepted_by"),
},
(table) => ({ orgIdx: index("org_invitations_org_idx").on(table.orgId) }),
);
/**
* A short-lived, single-use code that lets a gateway fetch its farm's
* credentials without anyone copying a token around. Hashed like an
* invitation token, and for the same reason.
*/
export const enrolmentCodes = pgTable(
"enrolment_codes",
{
id: uuid("id").primaryKey().defaultRandom(),
farmId: uuid("farm_id")
.notNull()
.references(() => farms.id, { onDelete: "cascade" }),
codeHash: text("code_hash").notNull().unique(),
createdBy: uuid("created_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp("used_at", { withTimezone: true }),
},
(table) => ({ farmIdx: index("enrolment_codes_farm_idx").on(table.farmId) }),
);
+71
View File
@@ -0,0 +1,71 @@
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"),
/**
* Shown in member lists, which would otherwise be bare ids. Taken from
* the token's `email` claim when someone creates an organisation or
* accepts an invitation; null for a member added by id.
*/
email: text("email"),
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;
+75 -5
View File
@@ -1,7 +1,7 @@
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real } from "drizzle-orm/pg-core"; import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real, integer, boolean, uniqueIndex } from "drizzle-orm/pg-core";
import { farms } from "./farms"; import { farms } from "./farms";
export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper"]); export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper", "octoprint"]);
export const printerStatusEnum = pgEnum("printer_status", [ export const printerStatusEnum = pgEnum("printer_status", [
"offline", "offline",
"idle", "idle",
@@ -11,7 +11,9 @@ export const printerStatusEnum = pgEnum("printer_status", [
"maintenance", "maintenance",
]); ]);
export const printers = pgTable("printers", { export const printers = pgTable(
"printers",
{
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
farmId: uuid("farm_id") farmId: uuid("farm_id")
.notNull() .notNull()
@@ -21,14 +23,82 @@ export const printers = pgTable("printers", {
status: printerStatusEnum("status").notNull().default("offline"), status: printerStatusEnum("status").notNull().default("offline"),
lanAddress: text("lan_address"), lanAddress: text("lan_address"),
serialNumber: text("serial_number"), 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"), nozzleTempC: real("nozzle_temp_c"),
nozzleTargetTempC: real("nozzle_target_temp_c"),
bedTempC: real("bed_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 camera can only be read as single JPEG frames (a P1/A1), which the
* gateway serves on request. Mutually exclusive with `cameraStream`.
*/
cameraSnapshot: boolean("camera_snapshot").notNull().default(false),
/**
* 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({}), metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_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 Printer = typeof printers.$inferSelect;
export type NewPrinter = typeof printers.$inferInsert; 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 // The second argument overrides fields Drizzle can't infer correctly for an
// insert - `id` is DB-generated (defaultRandom()) so it must be optional on // insert - `id` is DB-generated (defaultRandom()) so it must be optional on
// the way in, even though it's required on the way out. // 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, { export const PrinterInsertSchema = createInsertSchema(printers, {
id: t.Optional(t.String({ format: "uuid" })), 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 PrinterSelectSchema = createSelectSchema(printers);
export const PrintJobInsertSchema = createInsertSchema(printJobs, { export const PrintJobInsertSchema = createInsertSchema(printJobs, {
id: t.Optional(t.String({ format: "uuid" })), id: t.Optional(t.String({ format: "uuid" })),
metadata: JsonObject,
}); });
export const PrintJobSelectSchema = createSelectSchema(printJobs); 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`;
}
+476
View File
@@ -0,0 +1,476 @@
import { and, asc, eq, inArray, isNotNull, isNull, lt, sql } 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 { jobStatusFromGatewayEvent, nextJobStatus } from "./job_status";
import { rememberStart, takeStart } from "./late_failure";
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,
// Chosen by whoever queued the job; absent means "by the project's filament".
...(typeof job.metadata?.amsSlot === "number" ? { ams_slot: job.metadata.amsSlot } : {}),
});
// Accepted or not, a late result for this command can name the job.
rememberStart(result.commandId, job.id);
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;
}
/**
* A gateway that had accepted a `start_print` reports that the download or
* transfer then failed. Fails the job with that reason - but only while it is
* still `uploading`: one that has since reached `printing` is being driven by
* the printer's own telemetry, and a late message must not overrule it.
*
* Failed rather than requeued, for the reason `reapStalledUploads` gives.
*/
export async function failJobFromGateway(commandId: string, error: string): Promise<void> {
const jobId = takeStart(commandId);
if (!jobId) return;
const failed = await db
.update(printJobs)
.set({
status: "failed",
completedAt: new Date(),
updatedAt: new Date(),
metadata: sql`coalesce(${printJobs.metadata}, '{}'::jsonb) || ${JSON.stringify({
lastDispatchError: `the gateway could not start it: ${error}`,
lastDispatchErrorAt: new Date().toISOString(),
})}::jsonb`,
})
.where(and(eq(printJobs.id, jobId), eq(printJobs.status, "uploading")))
.returning({ id: printJobs.id });
if (failed.length > 0) console.warn(`[dispatch] job ${jobId} failed: the gateway could not start it: ${error}`);
}
/**
* Applies an outcome a gateway kept for us. Throws only when the database
* could not be reached - the one case where the gateway must NOT be told it
* was handled, so that it sends it again. A job that is unknown, belongs to
* another farm, or is no longer in flight is not an error: there is nothing
* to change, and the gateway should stop sending it.
*/
export async function applyGatewayJobEvent(
farmId: string,
event: { job_id: string; event: "started" | "finished" | "failed"; detail?: string | null; at_ms: number },
): Promise<"applied" | "ignored"> {
// The farm in the `where` is the authorisation: a gateway can only move
// jobs of the farm it is connected for.
const job = await db.query.printJobs.findFirst({
columns: { id: true, status: true },
where: and(eq(printJobs.id, event.job_id), eq(printJobs.farmId, farmId)),
});
if (!job) return "ignored";
const next = jobStatusFromGatewayEvent(job.status, event.event);
if (!next) return "ignored";
const at = new Date(event.at_ms);
const terminal = next === "completed" || next === "failed";
const updated = await db
.update(printJobs)
.set({
status: next,
updatedAt: new Date(),
...(next === "completed" ? { progressPct: 100 } : {}),
...(terminal ? { completedAt: at } : {}),
...(next === "failed"
? {
metadata: sql`coalesce(${printJobs.metadata}, '{}'::jsonb) || ${JSON.stringify({
lastDispatchError: event.detail ?? "the gateway reported the job failed",
lastDispatchErrorAt: at.toISOString(),
})}::jsonb`,
}
: {}),
})
// Re-checked in the write: the status may have moved since the read.
.where(and(eq(printJobs.id, job.id), eq(printJobs.status, job.status)))
.returning({ id: printJobs.id });
if (updated.length === 0) return "ignored";
console.log(`[dispatch] job ${job.id} ${job.status} -> ${next} (gateway reported ${event.event})`);
return "applied";
}
/**
* 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 (a failure it reports promptly fails the job sooner)`,
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})`);
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 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;
}
}
/**
* What a job's status becomes when its gateway reports an outcome it kept
* for us (`job_event`) - possibly long after it happened, because the
* gateway holds each one until the cloud acknowledges it.
*
* Only jobs still in flight move. A job the cloud has cancelled, failed or
* completed meanwhile is left exactly as it is, which is what makes a stale
* report from a gateway harmless; so is `queued`, a job the cloud took back
* and may already have given to another printer. `undefined` means leave it.
*/
export function jobStatusFromGatewayEvent(
current: string,
event: "started" | "finished" | "failed",
): "printing" | "completed" | "failed" | undefined {
const inFlight = current === "uploading" || current === "printing" || current === "paused";
if (!inFlight) return undefined;
switch (event) {
case "started":
// Only the first step; a job already printing or paused is further on.
return current === "uploading" ? "printing" : undefined;
case "finished":
return "completed";
case "failed":
return "failed";
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* A `start_print` is answered the moment the gateway accepts it, because the
* download and transfer take minutes and this side stops waiting after ten
* seconds. If that work then fails, the gateway sends a second `command_result`
* for the same command id - by which time nothing here is waiting for it.
*
* This remembers which job each start belongs to, so that second result can
* fail the job with the gateway's own reason instead of the job sitting in
* `uploading` until the stall reaper gives up on it twenty minutes later.
*
* In memory on purpose: after a restart the reaper is still there to catch
* what this forgot.
*/
const MAX_REMEMBERED = 1000;
const REMEMBER_MS = 60 * 60 * 1000;
const starts = new Map<string, { jobId: string; at: number }>();
export function rememberStart(commandId: string, jobId: string, now = Date.now()): void {
starts.set(commandId, { jobId, at: now });
// Maps iterate in insertion order, so the oldest entries come first.
for (const [id, entry] of starts) {
if (starts.size <= MAX_REMEMBERED && now - entry.at < REMEMBER_MS) break;
starts.delete(id);
}
}
/** The job a start command was for, forgetting it: a command is only answered late once. */
export function takeStart(commandId: string): string | undefined {
const entry = starts.get(commandId);
starts.delete(commandId);
return entry?.jobId;
}
+56 -4
View File
@@ -2,29 +2,81 @@ import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors"; import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger"; import { swagger } from "@elysiajs/swagger";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { toErrorResponse } from "@continuum/ts-core";
import { db } from "./db"; import { db } from "./db";
import { redis } from "./queue"; import { valkey } from "./queue";
import { authRoutes } from "./routes/auth"; import { authRoutes } from "./routes/auth";
import { invitationRoutes, orgRoutes } from "./routes/orgs";
import { farmRoutes } from "./routes/farms"; import { farmRoutes } from "./routes/farms";
import { printerRoutes } from "./routes/printers"; import { printerRoutes } from "./routes/printers";
import { jobRoutes } from "./routes/jobs"; import { jobRoutes } from "./routes/jobs";
import { alertRoutes } from "./routes/alerts";
import { startAiAlertListener } from "./alerts/listener";
import { edgeWsRoute } from "./routes/ws/edge"; import { edgeWsRoute } from "./routes/ws/edge";
import { telemetryWsRoute } from "./routes/ws/telemetry";
import { startDispatcher } from "./dispatch";
const app = new Elysia() const app = new Elysia()
.use(cors()) .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" },
{ name: "alerts", description: "Things an operator should look at" },
],
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 () => { .get("/health", async () => {
const rows = await db.execute<{ ok: number }>(sql`select 1 as ok`); const rows = await db.execute<{ ok: number }>(sql`select 1 as ok`);
const redisPing = await redis.ping(); const valkeyPing = await valkey.ping();
return { status: "ok", db: rows[0]?.ok === 1, redis: redisPing === "PONG" }; return { status: "ok", db: rows[0]?.ok === 1, valkey: valkeyPing === "PONG" };
}) })
.use(authRoutes) .use(authRoutes)
.use(orgRoutes)
.use(invitationRoutes)
.use(farmRoutes) .use(farmRoutes)
.use(printerRoutes) .use(printerRoutes)
.use(jobRoutes) .use(jobRoutes)
.use(alertRoutes)
.use(edgeWsRoute) .use(edgeWsRoute)
.use(telemetryWsRoute)
.listen(Number(process.env.PORT ?? 3000)); .listen(Number(process.env.PORT ?? 3000));
console.log(`continuum-backend listening on :${app.server?.port}`); 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();
startAiAlertListener();
export type App = typeof app; export type App = typeof app;
+62
View File
@@ -0,0 +1,62 @@
/**
* Invitation tokens and gateway enrolment codes. Pure, so the parts worth
* getting exactly right are testable without a database.
*/
import { createHash, randomBytes, randomInt } from "node:crypto";
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export const ENROL_TTL_MS = 15 * 60 * 1000;
/** What is stored: a hash, never the secret. SHA-256 is enough for 256-bit random input. */
export const hashSecret = (secret: string) => createHash("sha256").update(secret).digest("hex");
/** 256 bits, URL-safe: goes in a link. */
export const newInviteToken = () => randomBytes(32).toString("base64url");
// Crockford base32: no I, L, O or U, so a code read off a screen and typed
// into a terminal survives the usual mix-ups.
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const CODE_LENGTH = 10;
/** `ABCDE-FGHJK`: 50 bits, single-use and valid for fifteen minutes. */
export function newEnrolmentCode(): string {
let code = "";
for (let i = 0; i < CODE_LENGTH; i++) code += ALPHABET[randomInt(ALPHABET.length)];
return `${code.slice(0, 5)}-${code.slice(5)}`;
}
/** What was typed, reduced to what was issued: case, dashes and spaces ignored, look-alikes folded. */
export function normaliseCode(input: string): string {
return input
.toUpperCase()
.replace(/[^0-9A-Z]/g, "")
.replace(/O/g, "0")
.replace(/[IL]/g, "1");
}
/** The form that is hashed for storage and lookup. */
export const codeKey = (input: string) => hashSecret(normaliseCode(input));
/**
* Counts failures per key inside a sliding window, so guessing an enrolment
* code from one address stops being worth trying. In memory: it resets on
* restart, which only ever helps an attacker by a window's worth of guesses.
*/
export class FailureLimiter {
private failures = new Map<string, number[]>();
constructor(
private readonly max: number,
private readonly windowMs: number,
) {}
blocked(key: string, now = Date.now()): boolean {
const recent = (this.failures.get(key) ?? []).filter((t) => now - t < this.windowMs);
if (recent.length === 0) this.failures.delete(key);
else this.failures.set(key, recent);
return recent.length >= this.max;
}
fail(key: string, now = Date.now()): void {
this.failures.set(key, [...(this.failures.get(key) ?? []), now]);
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Pure helpers for adding a printer through a gateway, with no imports so
* they test without a database - same reasoning as `dispatch/job_status.ts`.
*/
/**
* Vendors a gateway can drive, by the name the gateway knows them as, and
* what each needs to be told. `prusa_link` is stored as `prusa` and
* `octoprint` as itself - the database enum predates the gateway's names.
*
* Bambu is verified against real printers. The other three are written from
* their public APIs and tested against mock servers only.
*/
export const PROVISIONABLE_VENDORS = ["bambu", "klipper", "octoprint", "prusa_link"] as const;
export type ProvisionableVendor = (typeof PROVISIONABLE_VENDORS)[number];
const NEEDS: Record<ProvisionableVendor, { accessCode?: true; serialNumber?: true; apiKey?: true; dbVendor: "bambu" | "klipper" | "octoprint" | "prusa" }> = {
bambu: { accessCode: true, serialNumber: true, dbVendor: "bambu" },
klipper: { dbVendor: "klipper" },
octoprint: { apiKey: true, dbVendor: "octoprint" },
prusa_link: { apiKey: true, dbVendor: "prusa" },
};
export const dbVendorFor = (vendor: ProvisionableVendor) => NEEDS[vendor].dbVendor;
/** The first credential this vendor needs that was not supplied, named for the operator. */
export function missingCredential(
vendor: ProvisionableVendor,
given: { accessCode?: string; serialNumber?: string; apiKey?: string },
): string | undefined {
const needs = NEEDS[vendor];
if (needs.accessCode && !given.accessCode?.trim()) return "an access code";
if (needs.serialNumber && !given.serialNumber?.trim()) return "a serial number";
if (needs.apiKey && !given.apiKey?.trim()) return "an API key";
return undefined;
}
/**
* The id the gateway will know this printer by, chosen here so telemetry
* arrives under an id the cloud already holds. A readable slug of the name
* plus a random suffix: readable because it shows up in the gateway's logs
* and certificate-pin file names, random because two printers may share a
* name. Constrained to what the gateway accepts (`^[A-Za-z0-9_-]{1,64}$`).
*/
export function gatewayIdFor(name: string, random: () => number = Math.random): string {
const slug =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "printer";
const suffix = Math.floor(random() * 0xffff)
.toString(16)
.padStart(4, "0");
return `${slug}-${suffix}`;
}
/**
* What to tell the operator when the gateway says no, without ever repeating
* the credential back. The gateway's messages do not contain it by design,
* and this is the second line of that defence: anything that does is dropped.
*/
export function safeGatewayError(error: string | undefined, secrets: string[]): string {
let message = error ?? "the gateway did not say why";
for (const secret of secrets) {
if (secret) message = message.split(secret).join("<hidden>");
}
return message;
}
+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 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, 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 { /** Pushes one frame-ingestion task for continuum-ai-worker to pick up. */
farmId: string; export async function enqueueFrameIngestion(task: FrameTask): Promise<string> {
printerId: string; const [id] = await enqueueFrameIngestionBatch([task]);
printJobId: string | null; return id!;
r2ObjectKey: string;
capturedAt: string;
} }
/** Pushes a frame-ingestion task for continuum-ai-worker to pick up via XREADGROUP. */ /**
export async function enqueueFrameIngestion(task: FrameIngestionTask): Promise<string> { * Pushes a whole batch in one round trip.
return redis.xadd( *
CAMERA_FRAMES_STREAM, * 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.
"farmId", * Enqueuing them one at a time, as this used to, costs a round trip each
task.farmId, * while holding up the WebSocket message handler.
"printerId", */
task.printerId, export async function enqueueFrameIngestionBatch(tasks: FrameTask[]): Promise<string[]> {
"printJobId", if (tasks.length === 0) return [];
task.printJobId ?? "",
"r2ObjectKey", const pipeline = valkey.pipeline();
task.r2ObjectKey, for (const task of tasks) {
"capturedAt", if (!checkFrameTask.Check(task)) {
task.capturedAt, const first = [...checkFrameTask.Errors(task)][0];
) as Promise<string>; 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;
});
} }
+59
View File
@@ -0,0 +1,59 @@
import { Elysia, t } from "elysia";
import { and, desc, eq, isNull } from "drizzle-orm";
import { NotFoundError } from "@continuum/ts-core";
import { db } from "../db";
import { printerAlerts } from "../db/schema";
import { requireAlert, requireUser, visibleAlerts } from "../auth";
import { PaginationQuery, paginate } from "./pagination";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
export const alertRoutes = new Elysia({ prefix: "/alerts" })
.use(requireUser)
.get(
"/",
async ({ user, query }) => {
const filters = [visibleAlerts(user.id)];
if (query.farmId) filters.push(eq(printerAlerts.farmId, query.farmId));
// Open means not acknowledged: what is still waiting on a person.
if (query.open) filters.push(isNull(printerAlerts.acknowledgedAt));
return db.query.printerAlerts.findMany({
where: and(...filters),
orderBy: [desc(printerAlerts.createdAt)],
...paginate(query),
});
},
{
query: t.Object({
farmId: t.Optional(t.String({ format: "uuid" })),
open: t.Optional(t.BooleanString()),
...PaginationQuery,
}),
detail: { tags: ["alerts"], summary: "Alerts in the caller's organisations" },
},
)
/**
* `operator`, not `viewer`: acknowledging clears it from everyone's list, so
* it is a decision about the farm and not a read.
*
* Idempotent on purpose - the second person to click gets the same answer,
* and the first acknowledger stays on the record.
*/
.post(
"/:id/acknowledge",
async ({ user, params }) => {
await requireAlert(user.id, params.id, "operator", "acknowledging an alert");
const [row] = await db
.update(printerAlerts)
.set({ acknowledgedBy: user.id, acknowledgedAt: new Date() })
.where(and(eq(printerAlerts.id, params.id), isNull(printerAlerts.acknowledgedAt)))
.returning();
if (row) return row;
const existing = await db.query.printerAlerts.findFirst({
where: eq(printerAlerts.id, params.id),
});
if (!existing) throw new NotFoundError(`Alert ${params.id} not found`);
return existing;
},
{ params: IdParams, detail: { tags: ["alerts"], summary: "Mark an alert as dealt with" } },
);
+114 -37
View File
@@ -1,61 +1,138 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import jwt from "@elysiajs/jwt"; import jwt from "@elysiajs/jwt";
import { and, eq, gt, isNull } from "drizzle-orm";
import { UnauthorizedError } from "@continuum/ts-core";
import { db } from "../db"; import { db } from "../db";
import { farms } from "../db/schema"; import { enrolmentCodes, farms } from "../db/schema";
import { eq } from "drizzle-orm"; import { listMemberships, requireUser } from "../auth";
import { edgeJwtSecret } from "../auth/edge_secret";
import { FailureLimiter, codeKey } from "../onboarding/tokens";
/** Ten wrong codes from one address in ten minutes and it is told to wait. */
const enrolFailures = new FailureLimiter(10, 10 * 60 * 1000);
const EDGE_TOKEN_TTL_SECONDS = 3600;
/** /**
* Verifies Supabase-issued JWTs (HS256, shared secret) and issues short-lived * Two different kinds of authentication meet here, and they are not the same
* edge-gateway tokens scoped to a single farm for the /ws/edge/v1 handshake. * 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" }) export const authRoutes = new Elysia({ prefix: "/auth" })
.use(
jwt({
name: "supabaseJwt",
secret: process.env.SUPABASE_JWT_SECRET ?? "dev-secret-change-me",
}),
)
.use( .use(
jwt({ jwt({
name: "edgeJwt", 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( .post(
"/edge-token", "/edge-token",
async ({ edgeJwt, body, set }) => { async ({ edgeJwt, body }) => {
const farm = await db.query.farms.findFirst({ where: eq(farms.id, body.farmId) }); 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) { if (!farm || farm.edgeProxyToken !== body.edgeProxyToken) {
set.status = 401; throw new UnauthorizedError("Unknown farm or bad proxy token");
return { error: { code: "UNAUTHORIZED", message: "Unknown farm or bad proxy token" } };
} }
const token = await edgeJwt.sign({ farmId: farm.id, scope: "edge" }); const token = await edgeJwt.sign({
return { token, expiresInSeconds: 3600 }; 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({ body: t.Object({
farmId: t.String({ format: "uuid" }), 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" },
}, },
)
/**
* A gateway trades a one-time code (`POST /farms/:id/enrolment-codes`) for
* its farm's credentials. Not behind `requireUser` for the same reason as
* `/edge-token`: the gateway has no account, and the code is the credential.
*
* The farm's existing token is returned, not rotated, so enrolling a second
* machine cannot lock out the first. A farm created before tokens were
* minted at creation gets one here.
*/
.post(
"/enrol",
async ({ body, server, request, set }) => {
const who = server?.requestIP(request)?.address ?? "unknown";
if (enrolFailures.blocked(who)) {
set.status = 429;
return { error: { code: "TOO_MANY_ATTEMPTS", message: "Too many wrong codes; wait a few minutes and try again" } };
}
// Claimed in one statement: of two gateways presenting the same code at
// once, exactly one gets a row back.
const [claimed] = await db
.update(enrolmentCodes)
.set({ usedAt: new Date() })
.where(
and(
eq(enrolmentCodes.codeHash, codeKey(body.code)),
isNull(enrolmentCodes.usedAt),
gt(enrolmentCodes.expiresAt, new Date()),
),
)
.returning({ farmId: enrolmentCodes.farmId });
if (!claimed) {
enrolFailures.fail(who);
throw new UnauthorizedError("That code is wrong, expired or already used");
}
const farm = await db.query.farms.findFirst({
columns: { id: true, edgeProxyToken: true },
where: eq(farms.id, claimed.farmId),
});
if (!farm) throw new UnauthorizedError("That code is wrong, expired or already used");
let token = farm.edgeProxyToken;
if (!token) {
token = crypto.randomUUID();
await db.update(farms).set({ edgeProxyToken: token, updatedAt: new Date() }).where(eq(farms.id, farm.id));
}
return { farmId: farm.id, gatewayToken: token };
},
{
body: t.Object({ code: t.String({ minLength: 4, maxLength: 64 }) }),
detail: { tags: ["auth"], summary: "Trade a one-time enrolment code for a gateway's credentials" },
},
)
.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" } },
); );
+446 -17
View File
@@ -1,38 +1,467 @@
import { Elysia, t } from "elysia"; 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 { db } from "../db";
import { farms } from "../db/schema"; import { enrolmentCodes, farms, printers } from "../db/schema";
import { farmProjection, printerProjection } from "../db/projections";
import {
PROVISIONABLE_VENDORS,
dbVendorFor,
gatewayIdFor,
missingCredential,
safeGatewayError,
type ProvisionableVendor,
} from "../provision/plan";
import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth";
import { ENROL_TTL_MS, codeKey, newEnrolmentCode } from "../onboarding/tokens";
import { PaginationQuery, paginate } from "./pagination";
import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters, requestGateway } from "./ws/edge";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
export const farmRoutes = new Elysia({ prefix: "/farms" }) export const farmRoutes = new Elysia({ prefix: "/farms" })
.get("/", async () => db.select().from(farms)) .use(requireUser)
.get("/:id", async ({ params, set }) => { /**
const farm = await db.query.farms.findFirst({ where: eq(farms.id, params.id) }); * Farms in the caller's organisations. The scoping is in the `where`, not
if (!farm) { * applied to the result afterwards: a filter that runs after the query is
set.status = 404; * one `return` away from being skipped, and it also makes the database read
return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } }; * rows it is not allowed to show.
} *
return farm; * 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( .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 const [farm] = await db
.insert(farms) .insert(farms)
.values({ .values({
name: body.name, name: body.name,
ownerId: body.ownerId, orgId: body.orgId,
edgeProxyToken: crypto.randomUUID(), edgeProxyToken: crypto.randomUUID(),
location: body.location, location: body.location,
}) })
.returning(); .returning(farmProjection.returning);
set.status = 201; set.status = 201;
return farm; return farm;
}, },
{ {
body: t.Object({ body: t.Object({
name: t.String({ minLength: 1 }), name: t.String({ minLength: 1, maxLength: 200 }),
ownerId: t.String({ format: "uuid" }), orgId: t.String({ format: "uuid" }),
location: t.Optional(t.String()), 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"] } },
)
/**
* A one-time code for connecting a gateway to this farm. The gateway
* trades it at `POST /auth/enrol` for the farm's credentials, so nobody has
* to copy a token between machines.
*
* Unlike rotating the token, this leaves the farm's existing credential
* alone: enrolling a gateway cannot lock out the one already running.
* Fifteen minutes, one use, and only its hash is kept.
*/
.post(
"/:id/enrolment-codes",
async ({ user, params, set }) => {
await requireFarm(user.id, params.id, "owner", "enrolling a gateway");
const code = newEnrolmentCode();
const expiresAt = new Date(Date.now() + ENROL_TTL_MS);
await db.insert(enrolmentCodes).values({
farmId: params.id,
codeHash: codeKey(code),
createdBy: user.id,
expiresAt,
});
set.status = 201;
return { code, expiresAt };
},
{
params: IdParams,
detail: { tags: ["farms"], summary: "A one-time code to enrol a gateway (owner only)" },
},
)
/**
* 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.
*/
/**
* Adds a printer to a farm: tells the farm's gateway to connect to it, and
* creates the printer row once it has.
*
* **The access code (or API key) passes through this process and is kept nowhere.** It
* is read from the body, placed in one frame to the gateway, and dropped:
* not stored, not logged, not echoed in a response or an error. The
* gateway keeps it in its own database. That is the platform's rule for
* printer credentials, and it is why this is a request to a *connected*
* gateway rather than a row that a gateway picks up later - there is no
* place in the cloud for the secret to wait.
*
* The gateway is asked first and the row created second. The other order
* would show a printer in the console that the gateway then refuses
* (wrong code, wrong address) and leave cleaning up to us; this way a
* failure leaves nothing behind. If the row insert fails after the
* gateway said yes, the gateway is told to forget the printer again.
*
* `owner`, like creating a printer any other way.
*/
.post(
"/:id/printers",
async ({ user, params, body, set }) => {
await requireFarm(user.id, params.id, "owner", "adding a printer");
if (!(PROVISIONABLE_VENDORS as readonly string[]).includes(body.vendor)) {
set.status = 422;
return {
error: {
code: "VENDOR_NOT_SUPPORTED",
message: `only ${PROVISIONABLE_VENDORS.join(", ")} printers can be added and driven today`,
},
};
}
const vendor = body.vendor as ProvisionableVendor;
const missing = missingCredential(vendor, body);
if (missing) {
set.status = 422;
return { error: { code: "CREDENTIAL_REQUIRED", message: `a ${vendor} printer needs ${missing}` } };
}
if (!isGatewayOnline(params.id)) {
throw new ConflictError(
"this farm's gateway is not connected, so it cannot be given the printer's credentials - start it and try again",
);
}
const gatewayPrinterId = gatewayIdFor(body.name);
const secrets = [body.accessCode ?? "", body.apiKey ?? ""];
const answer = await requestGateway(params.id, (requestId) => ({
type: "provision_printer",
request_id: requestId,
printer: {
id: gatewayPrinterId,
name: body.name,
vendor: body.vendor,
host: body.host,
access_code: body.accessCode,
sn: body.serialNumber,
api_key: body.apiKey,
},
})).catch((err: unknown) => ({
ok: false,
error: err instanceof Error ? err.message : String(err),
}));
if (!answer.ok) {
set.status = 502;
return {
error: {
code: "GATEWAY_REFUSED",
message: safeGatewayError(answer.error, secrets),
},
};
}
try {
const [printer] = await db
.insert(printers)
.values({
farmId: params.id,
name: body.name,
vendor: dbVendorFor(vendor),
lanAddress: body.host,
serialNumber: body.serialNumber,
gatewayPrinterId,
})
.returning(printerProjection.returning);
set.status = 201;
return printer;
} catch (err) {
// The gateway now holds a printer the cloud has no row for. Best
// effort to take it back out; either way the caller is told.
await requestGateway(
params.id,
(requestId) => ({ type: "remove_printer", request_id: requestId, printer_id: gatewayPrinterId }),
10_000,
).catch(() => undefined);
throw err;
}
},
{
params: IdParams,
body: t.Object({
name: t.String({ minLength: 1, maxLength: 200 }),
vendor: t.String({ minLength: 1, maxLength: 32 }),
// An address or hostname: letters, digits, dots, dashes, colons (IPv6).
host: t.String({ minLength: 1, maxLength: 255, pattern: "^[A-Za-z0-9.:_-]+$" }),
// Which of these a vendor needs is checked above (`missingCredential`).
accessCode: t.Optional(t.String({ minLength: 1, maxLength: 128 })),
// Bambu's MQTT topics are addressed by serial number.
serialNumber: t.Optional(t.String({ minLength: 5, maxLength: 64, pattern: "^[A-Za-z0-9]+$" })),
apiKey: t.Optional(t.String({ minLength: 1, maxLength: 256 })),
}),
detail: {
tags: ["farms"],
summary: "Add a printer through the farm's gateway (the access code is relayed, never stored)",
},
},
)
.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",
},
},
); );
+88 -17
View File
@@ -1,27 +1,68 @@
import { Elysia, t } from "elysia"; 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 { db } from "../db";
import { printJobs } from "../db/schema"; import { printJobs } from "../db/schema";
import { getUploadUrl, jobFileKey } from "../s3"; 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" }) export const jobRoutes = new Elysia({ prefix: "/jobs" })
.get("/", async ({ query }) => { .use(requireUser)
if (query.farmId) { .get(
return db.select().from(printJobs).where(eq(printJobs.farmId, query.farmId)); "/",
} async ({ user, query }) => {
return db.select().from(printJobs); const scope = query.farmId
}, { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) }) ? and(visibleJobs(user.id), eq(printJobs.farmId, query.farmId))
.get("/:id", async ({ params, set }) => { : 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) }); const job = await db.query.printJobs.findFirst({ where: eq(printJobs.id, params.id) });
if (!job) { if (!job) throw new NotFoundError(`Print job ${params.id} not found`);
set.status = 404;
return { error: { code: "NOT_FOUND", message: `Print job ${params.id} not found` } };
}
return job; 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( .post(
"/", "/",
async ({ body, set }) => { async ({ user, body, set }) => {
await requireFarm(user.id, body.farmId, "operator", "submitting a print job");
const jobId = crypto.randomUUID(); const jobId = crypto.randomUUID();
const r2ObjectKey = jobFileKey(body.farmId, jobId, body.fileName); const r2ObjectKey = jobFileKey(body.farmId, jobId, body.fileName);
@@ -32,7 +73,11 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" })
farmId: body.farmId, farmId: body.farmId,
fileName: body.fileName, fileName: body.fileName,
r2ObjectKey, r2ObjectKey,
submittedBy: body.submittedBy, metadata: body.amsSlot === undefined ? {} : { amsSlot: body.amsSlot },
// 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(); .returning();
@@ -43,9 +88,35 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" })
{ {
body: t.Object({ body: t.Object({
farmId: t.String({ format: "uuid" }), farmId: t.String({ format: "uuid" }),
fileName: t.String({ minLength: 1 }), fileName: t.String({ minLength: 1, maxLength: 500 }),
contentType: t.Optional(t.String()), contentType: t.Optional(t.String()),
submittedBy: t.String({ format: "uuid" }), // 0-based across all AMS units (AMS1 slot 4 is 3). Absent: the
// gateway maps by the project's own filament.
amsSlot: t.Optional(t.Integer({ minimum: 0, maximum: 15 })),
}), }),
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" } },
); );
+363
View File
@@ -0,0 +1,363 @@
import { Elysia, t } from "elysia";
import { and, count, eq, gt, isNull, ne } from "drizzle-orm";
import { ConflictError, ForbiddenError, NotFoundError } from "@continuum/ts-core";
import { db } from "../db";
import { farms, orgInvitations, orgMembers, organisations, ORG_ROLES } from "../db/schema";
import { listMemberships, requireOrg, requireUser } from "../auth";
import { INVITE_TTL_MS, hashSecret, newInviteToken } from "../onboarding/tokens";
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", email: user.email?.toLowerCase() });
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,
email: orgMembers.email,
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.
*
* This takes a Supabase user id directly, which is the right shape for an
* API and the wrong one for a person: they use the invitations below.
*/
.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" },
},
)
/**
* Invites someone by email. They join when they sign in with that address
* and open the link; nobody has to know their user id.
*
* **No email is sent from here.** The token is returned once, in the
* response, for the inviter to pass on (the console builds the link). A
* mail provider is a deployment decision this service does not make; until
* one is wired up, the inviter is the courier. The address still matters:
* only an account with that address can use the link, so a link that ends
* up somewhere else is useless to whoever finds it.
*
* A second invitation to the same address replaces the first, so there is
* only ever one live link per person per organisation.
*/
.post(
"/:id/invitations",
async ({ user, params, body, set }) => {
await requireOrg(user.id, params.id, "owner", "inviting someone");
const email = body.email.trim().toLowerCase();
const token = newInviteToken();
const expiresAt = new Date(Date.now() + INVITE_TTL_MS);
const invitation = await db.transaction(async (tx) => {
await tx
.delete(orgInvitations)
.where(
and(eq(orgInvitations.orgId, params.id), eq(orgInvitations.email, email), isNull(orgInvitations.acceptedAt)),
);
const [created] = await tx
.insert(orgInvitations)
.values({
orgId: params.id,
email,
role: body.role,
tokenHash: hashSecret(token),
invitedBy: user.id,
expiresAt,
})
.returning({
id: orgInvitations.id,
email: orgInvitations.email,
role: orgInvitations.role,
expiresAt: orgInvitations.expiresAt,
});
return created!;
});
set.status = 201;
return { ...invitation, token, acceptPath: `/invite?token=${token}` };
},
{
params: IdParams,
body: t.Object({ email: t.String({ format: "email", maxLength: 320 }), role: RoleSchema }),
detail: { tags: ["orgs"], summary: "Invite someone by email (the link is returned once)" },
},
)
.get(
"/:id/invitations",
async ({ user, params }) => {
await requireOrg(user.id, params.id, "owner", "viewing invitations");
return db
.select({
id: orgInvitations.id,
email: orgInvitations.email,
role: orgInvitations.role,
createdAt: orgInvitations.createdAt,
expiresAt: orgInvitations.expiresAt,
})
.from(orgInvitations)
.where(
and(
eq(orgInvitations.orgId, params.id),
isNull(orgInvitations.acceptedAt),
gt(orgInvitations.expiresAt, new Date()),
),
);
},
{ params: IdParams, detail: { tags: ["orgs"], summary: "Invitations not yet accepted" } },
)
.delete(
"/:id/invitations/:invitationId",
async ({ user, params }) => {
await requireOrg(user.id, params.id, "owner", "revoking an invitation");
const [removed] = await db
.delete(orgInvitations)
.where(
and(
eq(orgInvitations.id, params.invitationId),
eq(orgInvitations.orgId, params.id),
isNull(orgInvitations.acceptedAt),
),
)
.returning({ id: orgInvitations.id });
if (!removed) throw new NotFoundError("No such pending invitation");
return { revoked: removed.id };
},
{
params: t.Object({ id: t.String({ format: "uuid" }), invitationId: t.String({ format: "uuid" }) }),
detail: { tags: ["orgs"], summary: "Revoke an invitation" },
},
)
.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" } },
);
/**
* Accepting an invitation. Separate from `/orgs` because the caller is not
* yet a member of anything it could be scoped to.
*/
export const invitationRoutes = new Elysia({ prefix: "/invitations" })
.use(requireUser)
.post(
"/accept",
async ({ user, body }) => {
const invitation = await db.query.orgInvitations.findFirst({
where: and(
eq(orgInvitations.tokenHash, hashSecret(body.token)),
isNull(orgInvitations.acceptedAt),
gt(orgInvitations.expiresAt, new Date()),
),
});
// One answer for unknown, used, revoked and expired: which of those it
// was is not for someone holding a guessed or stale link to learn.
if (!invitation) throw new NotFoundError("This invitation is invalid, expired or already used");
if (!user.email) {
throw new ForbiddenError("Your sign-in carries no email address, so it cannot be matched to an invitation");
}
if (user.email.toLowerCase() !== invitation.email) {
throw new ForbiddenError(`This invitation was sent to a different address than the one you are signed in with`);
}
const rank = (role: string) => ORG_ROLES.indexOf(role as (typeof ORG_ROLES)[number]);
await db.transaction(async (tx) => {
// Claimed first and conditionally: two tabs opening the link at once
// cannot both succeed.
const [claimed] = await tx
.update(orgInvitations)
.set({ acceptedAt: new Date(), acceptedBy: user.id })
.where(and(eq(orgInvitations.id, invitation.id), isNull(orgInvitations.acceptedAt)))
.returning({ id: orgInvitations.id });
if (!claimed) throw new ConflictError("This invitation was just used");
const existing = await tx.query.orgMembers.findFirst({
where: and(eq(orgMembers.orgId, invitation.orgId), eq(orgMembers.userId, user.id)),
});
// An invitation never lowers a role someone already has.
const role = existing && rank(existing.role) > rank(invitation.role) ? existing.role : invitation.role;
await tx
.insert(orgMembers)
.values({ orgId: invitation.orgId, userId: user.id, role, email: invitation.email })
.onConflictDoUpdate({
target: [orgMembers.orgId, orgMembers.userId],
set: { role, email: invitation.email, updatedAt: new Date() },
});
});
const org = await db.query.organisations.findFirst({ where: eq(organisations.id, invitation.orgId) });
return { orgId: invitation.orgId, orgName: org?.name ?? "", role: invitation.role };
},
{
body: t.Object({ token: t.String({ minLength: 20, maxLength: 200 }) }),
detail: { tags: ["orgs"], summary: "Accept an invitation with the token from its link" },
},
);
+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 };
}
+341 -23
View File
@@ -1,35 +1,353 @@
import { Elysia, t } from "elysia"; 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 { db } from "../db";
import { printers } from "../db/schema"; 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 { resolveSnapshot, resolveStream } from "../stream";
import { mintIceServers, turnConfigured } from "../camera/ice";
import { isGatewayOnline, negotiateCameraStream, requestCameraSnapshot, requestGateway, sendPrinterCommand } from "./ws/edge";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
export const printerRoutes = new Elysia({ prefix: "/printers" }) export const printerRoutes = new Elysia({ prefix: "/printers" })
.get("/", async ({ query }) => { .use(requireUser)
if (query.farmId) { // Scoped through farm -> org membership, and a projection rather than
return db.select().from(printers).where(eq(printers.farmId, query.farmId)); // `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
return db.select().from(printers); // credential today - a printer's LAN access code lives on the proxy, not
}, { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) }) // here.) See src/db/projections.ts.
.get("/:id", async ({ params, set }) => { .get(
const printer = await db.query.printers.findFirst({ where: eq(printers.id, params.id) }); "/",
if (!printer) { async ({ user, query }) => {
set.status = 404; const scope = query.farmId
return { error: { code: "NOT_FOUND", message: `Printer ${params.id} not found` } }; ? 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; return printer;
}) },
{ params: IdParams, detail: { tags: ["printers"] } },
)
.post( .post(
"/", "/",
async ({ body, set }) => { async ({ user, body, set }) => {
// `body` is already validated + shaped against the Drizzle table by await requireFarm(user.id, body.farmId, "owner", "adding a printer");
// PrinterInsertSchema, so it can go straight into `.values()` with no const [printer] = await db
// hand-mapping of individual fields. .insert(printers)
const [printer] = await db.insert(printers).values(body).returning(); .values(body)
.returning(printerProjection.returning);
set.status = 201; set.status = 201;
return printer; return printer;
}, },
// Derived from `printers` via drizzle-typebox - see src/db/validators.ts. { body: PrinterCreateSchema, detail: { tags: ["printers"] } },
// Add or rename a column in the schema and this validator updates itself. )
{ body: PrinterInsertSchema }, .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 }) => {
const { farmId } = await requirePrinter(user.id, params.id, "owner", "deleting a printer");
const printer = await db.query.printers.findFirst({
columns: { gatewayPrinterId: true },
where: eq(printers.id, params.id),
});
await db.delete(printers).where(eq(printers.id, params.id));
// Tell the gateway to forget it - and its stored credential - when it
// can be reached. Best effort and after the row is gone: an owner
// deleting a printer must not be blocked by an unplugged gateway. If
// the gateway misses this, the printer keeps being reported and shows
// up as unclaimed, which is visible rather than silently stale.
let gatewayForgot: boolean | null = null;
const gatewayPrinterId = printer?.gatewayPrinterId;
if (gatewayPrinterId && isGatewayOnline(farmId)) {
const answer = await requestGateway(
farmId,
(requestId) => ({ type: "remove_printer", request_id: requestId, printer_id: gatewayPrinterId }),
10_000,
).catch(() => ({ ok: false }));
gatewayForgot = answer.ok;
}
return { deleted: params.id, gatewayForgot };
},
{ 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, cameraSnapshot: true },
where: eq(printers.id, params.id),
});
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
if (!printer.cameraStream && printer.cameraSnapshot) {
const snap = resolveSnapshot(true, isGatewayOnline(printer.farmId));
if (!snap.ok) throw new NotFoundError(snap.message);
return {
kind: "snapshot" as const,
snapshotPath: `/printers/${params.id}/snapshot`,
intervalMs: 1000,
};
}
// 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 { kind: "webrtc" as const, ...result.stream, iceServers: iceServers ?? [], turnConfigured: turnConfigured() };
},
{
params: IdParams,
detail: {
tags: ["printers"],
summary: "What a browser needs to negotiate this printer's camera",
},
},
)
/**
* One JPEG frame from a printer whose camera is not RTSP (Bambu P1/A1).
*
* Viewer-level like the stream. The picture crosses the cloud, which the
* WebRTC path avoids; see `requestCameraSnapshot` for why that is bounded.
* `no-store` because each fetch must be the next frame, not a cached one.
*/
.get(
"/:id/snapshot",
async ({ user, params, set, status }) => {
await requirePrinter(user.id, params.id, "viewer");
let result: { jpeg?: Buffer; error?: string };
try {
result = await requestCameraSnapshot(params.id);
} catch (err) {
return status(404, { error: "NO_SNAPSHOT_CAMERA", message: err instanceof Error ? err.message : String(err) });
}
if (!result.jpeg) {
return status(503, { error: "CAMERA_UNAVAILABLE", message: result.error ?? "no picture" });
}
set.headers["content-type"] = "image/jpeg";
set.headers["cache-control"] = "no-store";
return new Response(result.jpeg, { headers: { "content-type": "image/jpeg", "cache-control": "no-store" } });
},
{ params: IdParams, detail: { tags: ["printers"], summary: "One camera frame (P1/A1)" } },
)
/**
* 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)",
},
},
); );
+953 -46
View File
File diff suppressed because it is too large Load Diff
+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"; 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 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({ export const s3 = new S3Client({
region: "auto", region: "auto",
endpoint: process.env.R2_ENDPOINT ?? `https://${accountId}.r2.cloudflarestorage.com`, endpoint,
credentials: { credentials: { accessKeyId, secretAccessKey },
accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
},
}); });
/** Presigned PUT for clients uploading a G-code/3MF file directly to R2. */ /** 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 }); 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) { export function jobFileKey(farmId: string, jobId: string, fileName: string) {
return `farms/${farmId}/jobs/${jobId}/${fileName}`; return `farms/${farmId}/jobs/${jobId}/${fileName}`;
} }
+52
View File
@@ -0,0 +1,52 @@
/**
* 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 } };
}
export const NO_SNAPSHOT_MESSAGE = "this printer's camera cannot be read as pictures";
/**
* For a P1/A1-style camera: no stream to negotiate, a picture to fetch
* repeatedly. Same ordering rule as `resolveStream` - the printer first.
*/
export function resolveSnapshot(
cameraSnapshot: boolean | null | undefined,
gatewayOnline: boolean,
): { ok: true } | { ok: false; reason: "no_camera" | "gateway_offline"; message: string } {
if (!cameraSnapshot) return { ok: false, reason: "no_camera", message: NO_SNAPSHOT_MESSAGE };
if (!gatewayOnline) return { ok: false, reason: "gateway_offline", message: NO_GATEWAY_MESSAGE };
return { ok: true };
}
+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)));
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import { deriveAlert, minAiConfidence, worthRaising } from "../src/alerts/derive";
describe("deriveAlert", () => {
test("a fault during a print is a failed print", () => {
expect(deriveAlert("printing", "error", "H2C")?.kind).toBe("print_failed");
expect(deriveAlert("paused", "error", "H2C")?.kind).toBe("print_failed");
});
test("a fault on an idle printer is a printer error, not a failed print", () => {
expect(deriveAlert("idle", "error", "P1P")?.kind).toBe("printer_error");
expect(deriveAlert("offline", "error", "P1P")?.kind).toBe("printer_error");
});
test("the printer pausing itself is announced, with its own words", () => {
const alert = deriveAlert("printing", "paused", "H2C", "PAUSE");
expect(alert?.kind).toBe("print_paused");
expect(alert?.message).toBe("H2C: the print is paused (printer reports PAUSE)");
});
test("a level is not an alert: staying in error raises nothing", () => {
expect(deriveAlert("error", "error", "H2C")).toBeUndefined();
});
test("a printer first seen on this connection raises nothing", () => {
expect(deriveAlert(undefined, "error", "H2C")).toBeUndefined();
});
test("an unmapped status never raises", () => {
expect(deriveAlert("printing", undefined, "H2C")).toBeUndefined();
});
test("ordinary transitions are quiet", () => {
expect(deriveAlert("idle", "printing", "H2C")).toBeUndefined();
expect(deriveAlert("printing", "idle", "H2C")).toBeUndefined();
expect(deriveAlert("paused", "printing", "H2C")).toBeUndefined();
});
});
describe("AI detection floor", () => {
test("defaults to 0.6 and respects the environment", () => {
expect(minAiConfidence({})).toBe(0.6);
expect(minAiConfidence({ ALERT_MIN_CONFIDENCE: "0.9" })).toBe(0.9);
});
test("an out-of-range or non-numeric floor falls back rather than disabling alerts", () => {
expect(minAiConfidence({ ALERT_MIN_CONFIDENCE: "7" })).toBe(0.6);
expect(minAiConfidence({ ALERT_MIN_CONFIDENCE: "lots" })).toBe(0.6);
});
test("worthRaising is inclusive at the floor", () => {
expect(worthRaising(0.6, 0.6)).toBe(true);
expect(worthRaising(0.59, 0.6)).toBe(false);
});
});
import { parsePrintAlert } from "../src/alerts/parse";
describe("parsePrintAlert", () => {
const good = {
printer_id: "0b6d3b4e-8f58-4a3b-9c69-9f3a2f6a0a11",
label: "spaghetti",
confidence: 0.91,
captured_at: "2026-10-29T10:00:00Z",
r2_object_key: "frames/x.jpg",
};
test("accepts exactly the published shape", () => {
expect(parsePrintAlert(JSON.stringify(good))?.label).toBe("spaghetti");
});
test("rejects garbage, missing fields and out-of-range confidence instead of throwing", () => {
expect(parsePrintAlert("not json")).toBeUndefined();
expect(parsePrintAlert(JSON.stringify({ ...good, printer_id: undefined }))).toBeUndefined();
expect(parsePrintAlert(JSON.stringify({ ...good, confidence: 3 }))).toBeUndefined();
// Tolerant of extra fields: a newer worker adding one must not blind this consumer.
expect(parsePrintAlert(JSON.stringify({ ...good, extra: 1 }))?.label).toBe("spaghetti");
});
});
+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"
}
]
}

Some files were not shown because too many files have changed in this diff Show More