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