Commit Graph
7 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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