Compare commits
50
Commits
6d9db5adc1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc8cf1d509
|
||
|
|
b431ee4030
|
||
|
|
64a43593b2
|
||
|
|
d53c22b641
|
||
|
|
69b6efea9b
|
||
|
|
fcde6f5155
|
||
|
|
58d5b013bc
|
||
|
|
1f9fd08b40
|
||
|
|
a190c896da
|
||
|
|
cd34f7b597
|
||
|
|
9e4b3e5315
|
||
|
|
519ada57f3
|
||
|
|
9036d23e6b
|
||
|
|
46bfecf3e7
|
||
|
|
847f7cb686
|
||
|
|
6a2457315e
|
||
|
|
705336b422
|
||
|
|
26d382b82d
|
||
|
|
c72bfe71d6
|
||
|
|
e4940f9404
|
||
|
|
ac484a99ba
|
||
|
|
c312e37b4a
|
||
|
|
a9aec03d47
|
||
|
|
0e6a4aaa93
|
||
|
|
adf77fe0ef
|
||
|
|
51e1827f5a
|
||
|
|
818f28ea5d
|
||
|
|
cd7aed0028
|
||
|
|
dddfff6800
|
||
|
|
f02c9897c9
|
||
|
|
0d87c8cbce
|
||
|
|
8848e396cf
|
||
|
|
b7792ad335
|
||
|
|
24142d4603
|
||
|
|
e9083cc9e3
|
||
|
|
57af710b1a
|
||
|
|
7a0e8ba6ef
|
||
|
|
4de0d86d43
|
||
|
|
564d01816b
|
||
|
|
1db96caa91
|
||
|
|
b5382ab47c
|
||
|
|
398a739edd
|
||
|
|
b42cbac299
|
||
|
|
d8f8653dee
|
||
|
|
01bcc73996
|
||
|
|
d827b465cf
|
||
|
|
42d4b3820a
|
||
|
|
e2c1e0abe2
|
||
|
|
ab093f3136
|
||
|
|
349c4073cd
|
+46
-8
@@ -1,22 +1,60 @@
|
||||
PORT=3000
|
||||
|
||||
# Postgres, routed through pgCat/PgBouncer
|
||||
DATABASE_URL=postgres://continuum:continuum@localhost:6432/continuum
|
||||
# Postgres, directly - no pooler for the MVP (see src/db/index.ts)
|
||||
DATABASE_URL=postgres://continuum:continuum@localhost:5432/continuum
|
||||
DATABASE_POOL_MAX=10
|
||||
|
||||
# Redis (state + streams)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# Valkey (state + streams), NOT Redis - Redis 7.4+ is RSALv2/SSPL and 8.0+
|
||||
# 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_BUCKET=continuum
|
||||
R2_ENDPOINT=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
|
||||
# Supabase Auth
|
||||
SUPABASE_JWT_SECRET=
|
||||
# Cloudflare Realtime TURN, for relaying printer camera video.
|
||||
#
|
||||
# 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=
|
||||
|
||||
# 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=
|
||||
|
||||
# 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
|
||||
|
||||
@@ -4,6 +4,5 @@ bun.lockb
|
||||
bun.lock
|
||||
.env
|
||||
.env.local
|
||||
drizzle/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -1,30 +1,86 @@
|
||||
# continuum-backend
|
||||
|
||||
Cloud control plane for Continuum. Bun + ElysiaJS, PostgreSQL (via pgCat/PgBouncer,
|
||||
with pgvector for frame embeddings), Redis Streams, and Cloudflare R2 storage.
|
||||
Cloud control plane for Continuum. Bun + ElysiaJS, PostgreSQL (with pgvector for frame embeddings), Valkey Streams, and Cloudflare R2 storage.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
bun install
|
||||
cp .env.example .env # fill in DATABASE_URL, REDIS_URL, R2_*, SUPABASE_JWT_SECRET
|
||||
bun run db:generate && bun run db:migrate
|
||||
scripts/dev-env.sh up # Valkey + Postgres/pgvector, and print the URLs
|
||||
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
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
- `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`,
|
||||
so the Postgres schema and the API validators come from one definition
|
||||
instead of two hand-synced ones.
|
||||
- `src/s3/` - R2 presigned URL helpers (`PUT` for client uploads, `GET` for the
|
||||
edge proxy).
|
||||
- `src/queue/` - Redis client + `XADD` helper pushing onto `stream:camera_frames`
|
||||
for `continuum-ai-worker`.
|
||||
- `src/routes/` - `/auth`, `/farms`, `/printers`, `/jobs`, and the edge-gateway
|
||||
WebSocket at `/ws/edge/v1`.
|
||||
- `src/queue/` - Valkey client (`ioredis`) + `XADD` helper pushing onto
|
||||
`stream:camera_frames` for `continuum-ai-worker`.
|
||||
|
||||
`frame_embeddings.embedding` requires the `vector` extension:
|
||||
`CREATE EXTENSION IF NOT EXISTS vector;`
|
||||
Valkey, not Redis: Redis 7.4+ is RSALv2/SSPL and 8.0+ adds AGPLv3, none of
|
||||
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.
|
||||
|
||||
@@ -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
@@ -5,7 +5,10 @@ export default defineConfig({
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
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,
|
||||
strict: true,
|
||||
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "printers" ADD COLUMN "camera_stream" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "printers" DROP COLUMN IF EXISTS "access_code";
|
||||
@@ -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");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "printers" ADD COLUMN "camera_snapshot" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "printer_vendor" ADD VALUE 'octoprint';
|
||||
@@ -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");
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "@continuum/backend",
|
||||
"license": "UNLICENSED",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -9,24 +10,29 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"elysia": "^1.1.26",
|
||||
"@elysiajs/cors": "^1.1.1",
|
||||
"@elysiajs/jwt": "^1.1.1",
|
||||
"@elysiajs/swagger": "^1.1.6",
|
||||
"@continuum/ts-types": "file:../continuum-schemas/packages/ts-types",
|
||||
"@continuum/ts-core": "file:../continuum-common/packages/ts-core",
|
||||
"elysia": "~1.4.30",
|
||||
"@elysiajs/cors": "~1.4.2",
|
||||
"@elysiajs/jwt": "~1.4.2",
|
||||
"@elysiajs/swagger": "~1.3.1",
|
||||
"@aws-sdk/client-s3": "^3.658.1",
|
||||
"@aws-sdk/s3-request-presigner": "^3.658.1",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"drizzle-typebox": "^0.2.1",
|
||||
"@sinclair/typebox": "^0.33.12",
|
||||
"drizzle-orm": "0.33.0",
|
||||
"drizzle-typebox": "0.1.1",
|
||||
"@sinclair/typebox": "0.34.52",
|
||||
"postgres": "^3.4.4",
|
||||
"ioredis": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.24.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
Executable
+237
@@ -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
|
||||
Executable
+248
@@ -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
|
||||
Executable
+216
@@ -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
|
||||
Executable
+356
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Executable
+215
@@ -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."
|
||||
Executable
+417
@@ -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
|
||||
Executable
+462
@@ -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
|
||||
Executable
+242
@@ -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
|
||||
Executable
+154
@@ -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; }
|
||||
Executable
+277
@@ -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
|
||||
Executable
+246
@@ -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
|
||||
Executable
+237
@@ -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
|
||||
Executable
+137
@@ -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."
|
||||
@@ -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);
|
||||
@@ -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 }));
|
||||
Executable
+171
@@ -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."
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}`);
|
||||
Executable
+44
@@ -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"
|
||||
Executable
+142
@@ -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
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 } };
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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]!;
|
||||
}
|
||||
@@ -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
@@ -2,8 +2,17 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Points at pgCat/PgBouncer, not Postgres directly, so keep prepared
|
||||
// statements disabled and cap the pool per-instance connections low.
|
||||
// No pooler in front of this for the MVP: DATABASE_URL points straight at
|
||||
// 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;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
+26
-11
@@ -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", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
ownerId: uuid("owner_id").notNull(),
|
||||
edgeProxyToken: text("edge_proxy_token").notNull(),
|
||||
location: text("location"),
|
||||
settings: jsonb("settings").$type<Record<string, unknown>>().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const farms = pgTable(
|
||||
"farms",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").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(),
|
||||
location: text("location"),
|
||||
settings: jsonb("settings").$type<Record<string, unknown>>().default({}),
|
||||
createdAt: timestamp("created_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 NewFarm = typeof farms.$inferInsert;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export * from "./orgs";
|
||||
export * from "./farms";
|
||||
export * from "./printers";
|
||||
export * from "./print_jobs";
|
||||
export * from "./embeddings";
|
||||
export * from "./alerts";
|
||||
export * from "./onboarding";
|
||||
|
||||
@@ -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) }),
|
||||
);
|
||||
@@ -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;
|
||||
+90
-20
@@ -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";
|
||||
|
||||
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", [
|
||||
"offline",
|
||||
"idle",
|
||||
@@ -11,24 +11,94 @@ export const printerStatusEnum = pgEnum("printer_status", [
|
||||
"maintenance",
|
||||
]);
|
||||
|
||||
export const printers = pgTable("printers", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
farmId: uuid("farm_id")
|
||||
.notNull()
|
||||
.references(() => farms.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
vendor: printerVendorEnum("vendor").notNull(),
|
||||
status: printerStatusEnum("status").notNull().default("offline"),
|
||||
lanAddress: text("lan_address"),
|
||||
serialNumber: text("serial_number"),
|
||||
accessCode: text("access_code"),
|
||||
nozzleTempC: real("nozzle_temp_c"),
|
||||
bedTempC: real("bed_temp_c"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
||||
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const printers = pgTable(
|
||||
"printers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
farmId: uuid("farm_id")
|
||||
.notNull()
|
||||
.references(() => farms.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
vendor: printerVendorEnum("vendor").notNull(),
|
||||
status: printerStatusEnum("status").notNull().default("offline"),
|
||||
lanAddress: text("lan_address"),
|
||||
serialNumber: text("serial_number"),
|
||||
|
||||
/**
|
||||
* 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"),
|
||||
nozzleTargetTempC: real("nozzle_target_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({}),
|
||||
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_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 NewPrinter = typeof printers.$inferInsert;
|
||||
|
||||
@@ -11,12 +11,63 @@ import { printers, printJobs } from "./schema";
|
||||
// The second argument overrides fields Drizzle can't infer correctly for an
|
||||
// insert - `id` is DB-generated (defaultRandom()) so it must be optional on
|
||||
// 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, {
|
||||
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 PrintJobInsertSchema = createInsertSchema(printJobs, {
|
||||
id: t.Optional(t.String({ format: "uuid" })),
|
||||
metadata: JsonObject,
|
||||
});
|
||||
export const PrintJobSelectSchema = createSelectSchema(printJobs);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -2,29 +2,81 @@ import { Elysia } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { toErrorResponse } from "@continuum/ts-core";
|
||||
import { db } from "./db";
|
||||
import { redis } from "./queue";
|
||||
import { valkey } from "./queue";
|
||||
import { authRoutes } from "./routes/auth";
|
||||
import { invitationRoutes, orgRoutes } from "./routes/orgs";
|
||||
import { farmRoutes } from "./routes/farms";
|
||||
import { printerRoutes } from "./routes/printers";
|
||||
import { jobRoutes } from "./routes/jobs";
|
||||
import { alertRoutes } from "./routes/alerts";
|
||||
import { startAiAlertListener } from "./alerts/listener";
|
||||
import { edgeWsRoute } from "./routes/ws/edge";
|
||||
import { telemetryWsRoute } from "./routes/ws/telemetry";
|
||||
import { startDispatcher } from "./dispatch";
|
||||
|
||||
const app = new Elysia()
|
||||
.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 () => {
|
||||
const rows = await db.execute<{ ok: number }>(sql`select 1 as ok`);
|
||||
const redisPing = await redis.ping();
|
||||
return { status: "ok", db: rows[0]?.ok === 1, redis: redisPing === "PONG" };
|
||||
const valkeyPing = await valkey.ping();
|
||||
return { status: "ok", db: rows[0]?.ok === 1, valkey: valkeyPing === "PONG" };
|
||||
})
|
||||
.use(authRoutes)
|
||||
.use(orgRoutes)
|
||||
.use(invitationRoutes)
|
||||
.use(farmRoutes)
|
||||
.use(printerRoutes)
|
||||
.use(jobRoutes)
|
||||
.use(alertRoutes)
|
||||
.use(edgeWsRoute)
|
||||
.use(telemetryWsRoute)
|
||||
.listen(Number(process.env.PORT ?? 3000));
|
||||
|
||||
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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 { 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,
|
||||
});
|
||||
|
||||
export const CAMERA_FRAMES_STREAM = "stream:camera_frames";
|
||||
export { CAMERA_FRAMES_STREAM };
|
||||
export type { FrameTask };
|
||||
export { frameTaskToStreamFields } from "./frame_task";
|
||||
|
||||
export interface FrameIngestionTask {
|
||||
farmId: string;
|
||||
printerId: string;
|
||||
printJobId: string | null;
|
||||
r2ObjectKey: string;
|
||||
capturedAt: string;
|
||||
/** Pushes one frame-ingestion task for continuum-ai-worker to pick up. */
|
||||
export async function enqueueFrameIngestion(task: FrameTask): Promise<string> {
|
||||
const [id] = await enqueueFrameIngestionBatch([task]);
|
||||
return id!;
|
||||
}
|
||||
|
||||
/** Pushes a frame-ingestion task for continuum-ai-worker to pick up via XREADGROUP. */
|
||||
export async function enqueueFrameIngestion(task: FrameIngestionTask): Promise<string> {
|
||||
return redis.xadd(
|
||||
CAMERA_FRAMES_STREAM,
|
||||
"*",
|
||||
"farmId",
|
||||
task.farmId,
|
||||
"printerId",
|
||||
task.printerId,
|
||||
"printJobId",
|
||||
task.printJobId ?? "",
|
||||
"r2ObjectKey",
|
||||
task.r2ObjectKey,
|
||||
"capturedAt",
|
||||
task.capturedAt,
|
||||
) as Promise<string>;
|
||||
/**
|
||||
* Pushes a whole batch in one round trip.
|
||||
*
|
||||
* 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.
|
||||
* Enqueuing them one at a time, as this used to, costs a round trip each
|
||||
* while holding up the WebSocket message handler.
|
||||
*/
|
||||
export async function enqueueFrameIngestionBatch(tasks: FrameTask[]): Promise<string[]> {
|
||||
if (tasks.length === 0) return [];
|
||||
|
||||
const pipeline = valkey.pipeline();
|
||||
for (const task of tasks) {
|
||||
if (!checkFrameTask.Check(task)) {
|
||||
const first = [...checkFrameTask.Errors(task)][0];
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,61 +1,138 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import jwt from "@elysiajs/jwt";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { UnauthorizedError } from "@continuum/ts-core";
|
||||
import { db } from "../db";
|
||||
import { farms } from "../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { enrolmentCodes, farms } from "../db/schema";
|
||||
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
|
||||
* edge-gateway tokens scoped to a single farm for the /ws/edge/v1 handshake.
|
||||
* Two different kinds of authentication meet here, and they are not the same
|
||||
* 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" })
|
||||
.use(
|
||||
jwt({
|
||||
name: "supabaseJwt",
|
||||
secret: process.env.SUPABASE_JWT_SECRET ?? "dev-secret-change-me",
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
jwt({
|
||||
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(
|
||||
"/edge-token",
|
||||
async ({ edgeJwt, body, set }) => {
|
||||
const farm = await db.query.farms.findFirst({ where: eq(farms.id, body.farmId) });
|
||||
async ({ edgeJwt, body }) => {
|
||||
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) {
|
||||
set.status = 401;
|
||||
return { error: { code: "UNAUTHORIZED", message: "Unknown farm or bad proxy token" } };
|
||||
throw new UnauthorizedError("Unknown farm or bad proxy token");
|
||||
}
|
||||
|
||||
const token = await edgeJwt.sign({ farmId: farm.id, scope: "edge" });
|
||||
return { token, expiresInSeconds: 3600 };
|
||||
const token = await edgeJwt.sign({
|
||||
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({
|
||||
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
@@ -1,38 +1,467 @@
|
||||
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 { 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" })
|
||||
.get("/", async () => db.select().from(farms))
|
||||
.get("/:id", async ({ params, set }) => {
|
||||
const farm = await db.query.farms.findFirst({ where: eq(farms.id, params.id) });
|
||||
if (!farm) {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } };
|
||||
}
|
||||
return farm;
|
||||
})
|
||||
.use(requireUser)
|
||||
/**
|
||||
* Farms in the caller's organisations. The scoping is in the `where`, not
|
||||
* applied to the result afterwards: a filter that runs after the query is
|
||||
* one `return` away from being skipped, and it also makes the database read
|
||||
* rows it is not allowed to show.
|
||||
*
|
||||
* 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(
|
||||
"/",
|
||||
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
|
||||
.insert(farms)
|
||||
.values({
|
||||
name: body.name,
|
||||
ownerId: body.ownerId,
|
||||
orgId: body.orgId,
|
||||
edgeProxyToken: crypto.randomUUID(),
|
||||
location: body.location,
|
||||
})
|
||||
.returning();
|
||||
.returning(farmProjection.returning);
|
||||
set.status = 201;
|
||||
return farm;
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
ownerId: t.String({ format: "uuid" }),
|
||||
location: t.Optional(t.String()),
|
||||
name: t.String({ minLength: 1, maxLength: 200 }),
|
||||
orgId: t.String({ format: "uuid" }),
|
||||
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",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+90
-19
@@ -1,27 +1,68 @@
|
||||
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 { printJobs } from "../db/schema";
|
||||
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" })
|
||||
.get("/", async ({ query }) => {
|
||||
if (query.farmId) {
|
||||
return db.select().from(printJobs).where(eq(printJobs.farmId, query.farmId));
|
||||
}
|
||||
return db.select().from(printJobs);
|
||||
}, { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) })
|
||||
.get("/:id", async ({ params, set }) => {
|
||||
const job = await db.query.printJobs.findFirst({ where: eq(printJobs.id, params.id) });
|
||||
if (!job) {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: `Print job ${params.id} not found` } };
|
||||
}
|
||||
return job;
|
||||
})
|
||||
.use(requireUser)
|
||||
.get(
|
||||
"/",
|
||||
async ({ user, query }) => {
|
||||
const scope = query.farmId
|
||||
? and(visibleJobs(user.id), eq(printJobs.farmId, query.farmId))
|
||||
: 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) });
|
||||
if (!job) throw new NotFoundError(`Print job ${params.id} not found`);
|
||||
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(
|
||||
"/",
|
||||
async ({ body, set }) => {
|
||||
async ({ user, body, set }) => {
|
||||
await requireFarm(user.id, body.farmId, "operator", "submitting a print job");
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
const r2ObjectKey = jobFileKey(body.farmId, jobId, body.fileName);
|
||||
|
||||
@@ -32,7 +73,11 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" })
|
||||
farmId: body.farmId,
|
||||
fileName: body.fileName,
|
||||
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();
|
||||
|
||||
@@ -43,9 +88,35 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" })
|
||||
{
|
||||
body: t.Object({
|
||||
farmId: t.String({ format: "uuid" }),
|
||||
fileName: t.String({ minLength: 1 }),
|
||||
fileName: t.String({ minLength: 1, maxLength: 500 }),
|
||||
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" } },
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
+342
-24
@@ -1,35 +1,353 @@
|
||||
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 { 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" })
|
||||
.get("/", async ({ query }) => {
|
||||
if (query.farmId) {
|
||||
return db.select().from(printers).where(eq(printers.farmId, query.farmId));
|
||||
}
|
||||
return db.select().from(printers);
|
||||
}, { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) })
|
||||
.get("/:id", async ({ params, set }) => {
|
||||
const printer = await db.query.printers.findFirst({ where: eq(printers.id, params.id) });
|
||||
if (!printer) {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: `Printer ${params.id} not found` } };
|
||||
}
|
||||
return printer;
|
||||
})
|
||||
.use(requireUser)
|
||||
// Scoped through farm -> org membership, and a projection rather than
|
||||
// `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
|
||||
// credential today - a printer's LAN access code lives on the proxy, not
|
||||
// here.) See src/db/projections.ts.
|
||||
.get(
|
||||
"/",
|
||||
async ({ user, query }) => {
|
||||
const scope = query.farmId
|
||||
? 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;
|
||||
},
|
||||
{ params: IdParams, detail: { tags: ["printers"] } },
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, set }) => {
|
||||
// `body` is already validated + shaped against the Drizzle table by
|
||||
// PrinterInsertSchema, so it can go straight into `.values()` with no
|
||||
// hand-mapping of individual fields.
|
||||
const [printer] = await db.insert(printers).values(body).returning();
|
||||
async ({ user, body, set }) => {
|
||||
await requireFarm(user.id, body.farmId, "owner", "adding a printer");
|
||||
const [printer] = await db
|
||||
.insert(printers)
|
||||
.values(body)
|
||||
.returning(printerProjection.returning);
|
||||
set.status = 201;
|
||||
return printer;
|
||||
},
|
||||
// Derived from `printers` via drizzle-typebox - see src/db/validators.ts.
|
||||
// Add or rename a column in the schema and this validator updates itself.
|
||||
{ body: PrinterInsertSchema },
|
||||
{ body: PrinterCreateSchema, detail: { tags: ["printers"] } },
|
||||
)
|
||||
.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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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";
|
||||
|
||||
/**
|
||||
* 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 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({
|
||||
region: "auto",
|
||||
endpoint: process.env.R2_ENDPOINT ?? `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
|
||||
},
|
||||
endpoint,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
});
|
||||
|
||||
/** 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return `farms/${farmId}/jobs/${jobId}/${fileName}`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user