e4940f9404c972a0e4f8b81e05a92e4cca28f997
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
c8167804c2
|
Fix bun-types resolution; fix possibly-undefined health-check row
Same bun-types-vs-@types/bun fix as continuum-common. Also fixes a real noUncheckedIndexedAccess violation in the /health route: db.execute()'s result is T[], so rows[0] is 'T | undefined', not T — destructuring assumed it always existed. Verified with a clean 'bunx tsc --noEmit'. |
||
|
|
3f0a51194b
|
Derive Elysia request validators from Drizzle tables via drizzle-typebox | ||
|
|
fad0a52690
|
Initial boilerplate scaffold for continuum-backend
Bun + ElysiaJS control plane: Drizzle/Postgres schema (printers, farms, print_jobs, pgvector frame_embeddings), R2 presigned URLs, Redis Streams frame ingestion queue, REST routes, and the /ws/edge/v1 edge-gateway WebSocket uplink. |