Compare commits

...
6 Commits
Author SHA1 Message Date
octoturge cd34f7b597 Prove the whole print loop with every machine but the printer real
e2e-job-flow.sh uploads a file through the presigned URL to the real object
store, lets the real scheduler dispatch it over /ws/edge/v1 to a stand-in
gateway, which downloads it through the presigned GET and hashes it, then
reports the print running and finished. The job must pass through printing
to completed and the gateway must have received exactly the uploaded bytes.

Dispatch is on in this one, which is only safe because the stand-in is the
only gateway the backend instance under test can have; the script says so.
2026-10-29 15:16:39 +00:00
octoturge 9e4b3e5315 Drive the first-run experience in a real browser
e2e-setup-browser.sh signs a user with no organisation into the real web
console, creates a farm, copies the gateway settings the page shows,
connects a stand-in gateway configured from exactly those settings, adds a
printer through the form, gets a failed-print alert and dismisses it, then
removes the printer. Afterwards it searches every table and the backend log
for the access code the browser typed. No printer is touched and dispatch
is off. The e2e scripts that start the real proxy also give it its own store
file in the scratch directory.
2026-10-29 15:15:06 +00:00
octoturge 519ada57f3 Give the real proxy the farm's proxy token, as the console will
The e2e scripts handed the proxy a pre-minted edge JWT, which hid that the
proxy could not exchange its configured token for one. It now does, so the
scripts configure it the way a real gateway is configured: with the farm's
edge proxy token.
2026-10-29 15:10:27 +00:00
octoturge 9036d23e6b Prove provisioning with the real proxy, and a real printer when one is named
e2e-provision.sh used a stand-in gateway; this drives the real
continuum-proxy over the real socket, which is what catches the two ends
disagreeing about the provision_printer frame. With no printer named it
proves the failure path (a useful error returns, no row, nothing stored on
the gateway, the code in neither process's log) and that the proxy starts
and connects with no printers file at all. With REAL_HOST/REAL_SERIAL/
REAL_ACCESS_CODE it also proves the success path - telemetry under the new
id, an owner-only store file, and the printer returning after a restart
from the store alone. It only connects and listens; it sends no command.
2026-10-29 15:07:44 +00:00
octoturge 46bfecf3e7 Add a printer through the gateway without the cloud keeping its credential
POST /farms/:id/printers (owner) is the console's add-printer call. It needs
a connected gateway and relays the printer's access code to it in a single
provision_printer frame; the gateway connects to the printer to prove the
code, saves it in its own database, and only then does the cloud create the
printer row. A refused or timed-out add leaves no row, and a row insert that
fails after the gateway said yes tells the gateway to forget the printer.

The access code is held in the request and the one frame and nowhere else:
not in any table, not in a log line, not in a response or an error (the
gateway's error text is scrubbed of it as a second line of defence).
Deleting a printer now also tells the gateway to forget it and its stored
credential, best effort, so an unplugged gateway cannot block an owner.
Only Bambu is accepted, because it is the only vendor with a working
driver; the rest are found by discovery but cannot yet be driven.

e2e-provision.sh drives a stand-in gateway over the real socket and, after
adding a printer, searches every table of the database and the backend's
log for the code (checked to fail when the code is logged).
2026-10-29 15:06:10 +00:00
octoturge 847f7cb686 Raise alerts: failed or paused prints, and AI detections
Nothing subscribed to channel:print_alerts and nothing watched a printer
fail, so an operator away from the screen found out when they came back to
a ruined plate. Alerts are now rows in printer_alerts, listed at
GET /alerts (open=true for what still needs a person) and cleared with
POST /alerts/:id/acknowledge, which is operator-only and keeps the first
acknowledger on the record.

Two sources. Telemetry: a change into error is a failed print if the printer
was printing or paused, otherwise a printer error; a print the printer
paused itself (how an AMS fault looks) is announced too. Transitions, not
levels - a printer sitting in error is one alert, not one per frame - and a
connection's first sight of a printer raises nothing, so a gateway that
reconnects does not re-announce known faults. AI: the worker's Valkey
publications are stored when confidence is at least ALERT_MIN_CONFIDENCE
(default 0.6); the worker deliberately does not threshold, so the policy
lives here. A bad message costs one message, not the listener.

e2e-authz.sh gains the scoping/ack cases; new e2e-alerts.sh drives a stand-in
gateway over the real /ws/edge/v1 and the real Valkey channel (checked to
fail when the derivation is broken).
2026-10-29 14:56:24 +00:00
29 changed files with 2928 additions and 10 deletions
+32
View File
@@ -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");
+841
View File
@@ -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": {}
}
}
+7
View File
@@ -29,6 +29,13 @@
"when": 1792399397819, "when": 1792399397819,
"tag": "0003_colorful_electro", "tag": "0003_colorful_electro",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1793285676878,
"tag": "0004_printer_alerts",
"breakpoints": true
} }
] ]
} }
+216
View File
@@ -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
+22
View File
@@ -305,6 +305,28 @@ CODE="$(req POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":
expect 401 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"not-the-token\"}" \ expect 401 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"not-the-token\"}" \
-- "a wrong proxy token is refused" -- "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 -------------- # --- 8. the guards that stop an org becoming unadministrable --------------
echo "==> an organisation cannot be left without an owner" echo "==> an organisation cannot be left without an owner"
+2 -1
View File
@@ -268,12 +268,13 @@ echo "==> starting the real continuum-proxy, which spawns the real go2rtc"
env -C "$PROXY_DIR" \ env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=e2e-cam-gw \ CONTINUUM_GATEWAY_ID=e2e-cam-gw \
CONTINUUM_FARM_ID="$FARM_ID" \ CONTINUUM_FARM_ID="$FARM_ID" \
CONTINUUM_GATEWAY_TOKEN="$TOKEN" \ CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \ CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" \ CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" \
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_STREAM_BASE_URL="http://e2e-gateway.invalid:1984" \ CONTINUUM_STREAM_BASE_URL="http://e2e-gateway.invalid:1984" \
CONTINUUM_PRINTERS_PATH=printers.toml \ CONTINUUM_PRINTERS_PATH=printers.toml \
CONTINUUM_STORE_PATH="$WORK/continuum.db" \
RUST_LOG=info \ RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 & "$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$! PROXY_PID=$!
+2 -1
View File
@@ -209,7 +209,7 @@ STREAM_BASE_URL="http://e2e-gateway.invalid:1984"
env -C "$PROXY_DIR" \ env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=e2e-gw \ CONTINUUM_GATEWAY_ID=e2e-gw \
CONTINUUM_FARM_ID="$FARM_ID" \ CONTINUUM_FARM_ID="$FARM_ID" \
CONTINUUM_GATEWAY_TOKEN="$TOKEN" \ CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \ CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
`# Deliberately absent, not an oversight: what is under test is the config` \ `# Deliberately absent, not an oversight: what is under test is the config` \
`# the gateway writes and the camera_stream it advertises, both of which` \ `# the gateway writes and the camera_stream it advertises, both of which` \
@@ -220,6 +220,7 @@ env -C "$PROXY_DIR" \
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_STREAM_BASE_URL="$STREAM_BASE_URL" \ CONTINUUM_STREAM_BASE_URL="$STREAM_BASE_URL" \
CONTINUUM_PRINTERS_PATH=printers.toml \ CONTINUUM_PRINTERS_PATH=printers.toml \
CONTINUUM_STORE_PATH="$WORK/continuum.db" \
RUST_LOG=info \ RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 & "$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$! PROXY_PID=$!
+242
View File
@@ -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
+277
View File
@@ -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
+246
View File
@@ -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
+137
View File
@@ -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."
+158
View File
@@ -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);
+84
View File
@@ -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);
+36
View File
@@ -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);
};
+55
View File
@@ -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;
}
+49
View File
@@ -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);
}
});
}
+22
View File
@@ -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;
}
+2
View File
@@ -2,10 +2,12 @@ export { requireUser, type AuthenticatedUser } from "./plugin";
export { roleSatisfies, ORG_ROLES, type OrgRole } from "./roles"; export { roleSatisfies, ORG_ROLES, type OrgRole } from "./roles";
export { export {
listMemberships, listMemberships,
requireAlert,
requireFarm, requireFarm,
requireJob, requireJob,
requireOrg, requireOrg,
requirePrinter, requirePrinter,
visibleAlerts,
visibleFarms, visibleFarms,
visibleJobs, visibleJobs,
visiblePrinters, visiblePrinters,
+31 -1
View File
@@ -1,7 +1,7 @@
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { ForbiddenError, NotFoundError } from "@continuum/ts-core"; import { ForbiddenError, NotFoundError } from "@continuum/ts-core";
import { db } from "../db"; import { db } from "../db";
import { farms, orgMembers, printJobs, printers } from "../db/schema"; import { farms, orgMembers, printJobs, printerAlerts, printers } from "../db/schema";
import { roleSatisfies, type OrgRole } from "./roles"; import { roleSatisfies, type OrgRole } from "./roles";
/** /**
@@ -64,6 +64,14 @@ export function visibleJobs(userId: string) {
); );
} }
/** `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 { function check(role: OrgRole | null, minimum: OrgRole, missing: string, action: string): OrgRole {
if (role === null) throw new NotFoundError(missing); if (role === null) throw new NotFoundError(missing);
if (!roleSatisfies(role, minimum)) { if (!roleSatisfies(role, minimum)) {
@@ -156,3 +164,25 @@ export async function requireJob(
const role = check(row?.role ?? null, minimum, `Print job ${jobId} not found`, action); const role = check(row?.role ?? null, minimum, `Print job ${jobId} not found`, action);
return { farmId: row!.farmId, orgId: row!.orgId, role }; 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 };
}
+45
View File
@@ -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;
+1
View File
@@ -3,3 +3,4 @@ export * from "./farms";
export * from "./printers"; export * from "./printers";
export * from "./print_jobs"; export * from "./print_jobs";
export * from "./embeddings"; export * from "./embeddings";
export * from "./alerts";
+5
View File
@@ -10,6 +10,8 @@ import { orgRoutes } from "./routes/orgs";
import { farmRoutes } from "./routes/farms"; import { farmRoutes } from "./routes/farms";
import { printerRoutes } from "./routes/printers"; import { printerRoutes } from "./routes/printers";
import { jobRoutes } from "./routes/jobs"; import { jobRoutes } from "./routes/jobs";
import { alertRoutes } from "./routes/alerts";
import { startAiAlertListener } from "./alerts/listener";
import { edgeWsRoute } from "./routes/ws/edge"; import { edgeWsRoute } from "./routes/ws/edge";
import { telemetryWsRoute } from "./routes/ws/telemetry"; import { telemetryWsRoute } from "./routes/ws/telemetry";
import { startDispatcher } from "./dispatch"; import { startDispatcher } from "./dispatch";
@@ -27,6 +29,7 @@ const app = new Elysia()
{ name: "farms", description: "Sites, each belonging to one organisation" }, { name: "farms", description: "Sites, each belonging to one organisation" },
{ name: "printers", description: "Machines, each belonging to one farm" }, { name: "printers", description: "Machines, each belonging to one farm" },
{ name: "jobs", description: "Print jobs" }, { name: "jobs", description: "Print jobs" },
{ name: "alerts", description: "Things an operator should look at" },
], ],
components: { components: {
securitySchemes: { securitySchemes: {
@@ -62,6 +65,7 @@ const app = new Elysia()
.use(farmRoutes) .use(farmRoutes)
.use(printerRoutes) .use(printerRoutes)
.use(jobRoutes) .use(jobRoutes)
.use(alertRoutes)
.use(edgeWsRoute) .use(edgeWsRoute)
.use(telemetryWsRoute) .use(telemetryWsRoute)
.listen(Number(process.env.PORT ?? 3000)); .listen(Number(process.env.PORT ?? 3000));
@@ -72,5 +76,6 @@ console.log(`continuum-backend listening on :${app.server?.port}`);
// only ever sends work to farms with a connected gateway, so on a fresh boot // only ever sends work to farms with a connected gateway, so on a fresh boot
// it does nothing until one connects. // it does nothing until one connects.
startDispatcher(); startDispatcher();
startAiAlertListener();
export type App = typeof app; export type App = typeof app;
+40
View File
@@ -0,0 +1,40 @@
/**
* 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 actually drive today. Everything else is found by discovery but not yet controllable. */
export const PROVISIONABLE_VENDORS = ["bambu"] as const;
/**
* 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;
}
+59
View File
@@ -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" } },
);
+115 -3
View File
@@ -1,12 +1,13 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { NotFoundError } from "@continuum/ts-core"; import { ConflictError, NotFoundError } from "@continuum/ts-core";
import { db } from "../db"; import { db } from "../db";
import { farms, printers } from "../db/schema"; import { farms, printers } from "../db/schema";
import { farmProjection } from "../db/projections"; import { farmProjection, printerProjection } from "../db/projections";
import { PROVISIONABLE_VENDORS, gatewayIdFor, safeGatewayError } from "../provision/plan";
import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth"; import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth";
import { PaginationQuery, paginate } from "./pagination"; import { PaginationQuery, paginate } from "./pagination";
import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters } from "./ws/edge"; import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters, requestGateway } from "./ws/edge";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
@@ -193,6 +194,117 @@ export const farmRoutes = new Elysia({ prefix: "/farms" })
* its own - it means either "everything is claimed" or "no gateway has ever * its own - it means either "everything is claimed" or "no gateway has ever
* connected", and those need completely different things done about them. * 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 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`,
},
};
}
if (!isGatewayOnline(params.id)) {
throw new ConflictError(
"this farm's gateway is not connected, so it cannot be given the printer's access code - start it and try again",
);
}
const gatewayPrinterId = gatewayIdFor(body.name);
const secrets = [body.accessCode];
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,
},
})).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,
// Narrowed by the PROVISIONABLE_VENDORS check above.
vendor: body.vendor as (typeof PROVISIONABLE_VENDORS)[number],
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.:_-]+$" }),
accessCode: t.String({ minLength: 1, maxLength: 128 }),
// Bambu's MQTT topics are addressed by serial number.
serialNumber: t.String({ minLength: 5, maxLength: 64, pattern: "^[A-Za-z0-9]+$" }),
}),
detail: {
tags: ["farms"],
summary: "Add a printer through the farm's gateway (the access code is relayed, never stored)",
},
},
)
.get( .get(
"/:id/unclaimed-printers", "/:id/unclaimed-printers",
async ({ user, params }) => { async ({ user, params }) => {
+23 -3
View File
@@ -10,7 +10,7 @@ import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../au
import { PaginationQuery, paginate } from "./pagination"; import { PaginationQuery, paginate } from "./pagination";
import { resolveStream } from "../stream"; import { resolveStream } from "../stream";
import { mintIceServers, turnConfigured } from "../camera/ice"; import { mintIceServers, turnConfigured } from "../camera/ice";
import { isGatewayOnline, negotiateCameraStream, sendPrinterCommand } from "./ws/edge"; import { isGatewayOnline, negotiateCameraStream, requestGateway, sendPrinterCommand } from "./ws/edge";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
@@ -93,9 +93,29 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
.delete( .delete(
"/:id", "/:id",
async ({ user, params }) => { async ({ user, params }) => {
await requirePrinter(user.id, params.id, "owner", "deleting a printer"); 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)); await db.delete(printers).where(eq(printers.id, params.id));
return { deleted: 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"] } }, { params: IdParams, detail: { tags: ["printers"] } },
) )
+64 -1
View File
@@ -9,7 +9,8 @@ import {
type PrinterTelemetry, type PrinterTelemetry,
} from "@continuum/ts-types"; } from "@continuum/ts-types";
import { db } from "../../db"; import { db } from "../../db";
import { printers } from "../../db/schema"; import { printerAlerts, printers } from "../../db/schema";
import { deriveAlert, type Status } from "../../alerts/derive";
import { advanceJobsFrom } from "../../dispatch"; import { advanceJobsFrom } from "../../dispatch";
import { enqueueFrameIngestionBatch } from "../../queue"; import { enqueueFrameIngestionBatch } from "../../queue";
import { edgeJwtSecret } from "../../auth/edge_secret"; import { edgeJwtSecret } from "../../auth/edge_secret";
@@ -108,6 +109,12 @@ interface EdgeConn {
* re-query the same mapping every few seconds. * re-query the same mapping every few seconds.
*/ */
printerIds: Map<string, string>; printerIds: Map<string, string>;
/**
* The last status seen per printer on THIS connection, to tell a change
* from a level when deciding whether to raise an alert. Per connection on
* purpose: after a reconnect nothing is known, so nothing is raised.
*/
lastStatus: Map<string, Status>;
/** Commands sent but not yet answered, keyed by command_id. */ /** Commands sent but not yet answered, keyed by command_id. */
pending: Map<string, PendingCommand>; pending: Map<string, PendingCommand>;
/** /**
@@ -432,6 +439,25 @@ async function persistTelemetry(conn: EdgeConn, batch: PrinterTelemetry[]) {
}); });
} }
// After the write, and never allowed to break the batch: an alert that
// fails to save is a lost notification, not a reason to stop recording
// the rest of the farm's telemetry.
if (updated) {
const alert = deriveAlert(
conn.lastStatus.get(printerId),
status,
updated.name,
snapshot.state,
);
if (status) conn.lastStatus.set(printerId, status);
if (alert) {
await db
.insert(printerAlerts)
.values({ farmId: conn.farmId, printerId, kind: alert.kind, message: alert.message })
.catch((err: unknown) => console.warn("[alerts] could not record an alert", err));
}
}
// The printer's row is the latest snapshot; the job's row is the history // The printer's row is the latest snapshot; the job's row is the history
// that outlives it. This is the only thing that moves a job out of // that outlives it. This is the only thing that moves a job out of
// `uploading` - see `advanceJobsFrom` for why the gateway's `ok` on a // `uploading` - see `advanceJobsFrom` for why the gateway's `ok` on a
@@ -492,6 +518,42 @@ export async function sendPrinterCommand(
return { ...result, commandId }; return { ...result, commandId };
} }
/**
* Longer than a command: the gateway connects to the new printer before it
* answers, and its own connect timeout is 30s.
*/
const PROVISION_TIMEOUT_MS = 40_000;
/**
* Asks a farm's gateway to add or forget a printer, and waits for its answer.
*
* **The message carries a printer credential.** It is built by the caller,
* handed straight to the socket, and kept nowhere: not in `pending` (which
* holds only the resolver), not in a log line, not in an error. A gateway
* that is not connected is a thrown error before anything is built into a
* frame, so there is no queue it could sit in either - the console tells the
* operator to retry rather than this process holding a secret for later.
*/
export async function requestGateway(
farmId: string,
build: (requestId: string) => EdgeServerMessage,
timeoutMs = PROVISION_TIMEOUT_MS,
): Promise<{ ok: boolean; error?: string }> {
const wsId = gatewayByFarm.get(farmId);
const conn = wsId ? connections.get(wsId) : undefined;
if (!conn) throw new Error(`no gateway is connected for this farm`);
const requestId = crypto.randomUUID();
return new Promise((resolve) => {
const timer = setTimeout(() => {
conn.pending.delete(requestId);
resolve({ ok: false, error: `the gateway did not answer within ${Math.round(timeoutMs / 1000)}s` });
}, timeoutMs);
conn.pending.set(requestId, { resolve, timer });
conn.send(build(requestId));
});
}
/** /**
* Relays a browser's WebRTC offer to the gateway that owns this printer and * Relays a browser's WebRTC offer to the gateway that owns this printer and
* returns go2rtc's answer. * returns go2rtc's answer.
@@ -616,6 +678,7 @@ export const edgeWsRoute = new Elysia()
lastSeenAt: Date.now(), lastSeenAt: Date.now(),
send: (message) => ws.send(message), send: (message) => ws.send(message),
printerIds: new Map(), printerIds: new Map(),
lastStatus: new Map(),
pending: new Map(), pending: new Map(),
webrtc: new Map(), webrtc: new Map(),
}; };
+75
View File
@@ -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");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { gatewayIdFor, safeGatewayError } from "../src/provision/plan";
describe("gatewayIdFor", () => {
const accepted = /^[A-Za-z0-9_-]{1,64}$/;
test("makes a readable id the gateway will accept", () => {
expect(gatewayIdFor("Bench P1P", () => 0)).toBe("bench-p1p-0000");
expect(gatewayIdFor("Bench P1P", () => 0.999999)).toMatch(accepted);
});
test("survives names with nothing usable in them", () => {
expect(gatewayIdFor("***", () => 0.5)).toMatch(/^printer-[0-9a-f]{4}$/);
expect(gatewayIdFor("Принтер", () => 0.5)).toMatch(accepted);
});
test("never exceeds what the gateway accepts, whatever the name", () => {
expect(gatewayIdFor("x".repeat(500))).toMatch(accepted);
expect(gatewayIdFor("../../etc/passwd")).toMatch(accepted);
});
test("two printers with the same name get different ids", () => {
const ids = new Set(Array.from({ length: 50 }, () => gatewayIdFor("Same name")));
expect(ids.size).toBeGreaterThan(30);
});
});
describe("safeGatewayError", () => {
test("passes an ordinary message through", () => {
expect(safeGatewayError("could not connect to the printer", ["12345678"])).toBe(
"could not connect to the printer",
);
});
test("removes a credential that somehow got into the message", () => {
expect(safeGatewayError("bad login 12345678 for host", ["12345678"])).toBe("bad login <hidden> for host");
});
test("has an answer when the gateway gave none", () => {
expect(safeGatewayError(undefined, [])).toBe("the gateway did not say why");
});
});