519ada57f32882f914f6a170d985635a5519854b
4
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|