diff --git a/.env.example b/.env.example index 732a6b5..de660f7 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,8 @@ R2_ENDPOINT= R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= -# Supabase Auth -SUPABASE_JWT_SECRET= +# Supabase Auth. Access tokens are verified against this project's published +# JWKS (RS256), so no signing secret is needed - or wanted - here. SUPABASE_URL= # Minted for continuum-proxy edge gateways via POST /auth/edge-token diff --git a/drizzle/0001_luxuriant_jane_foster.sql b/drizzle/0001_luxuriant_jane_foster.sql new file mode 100644 index 0000000..f094886 --- /dev/null +++ b/drizzle/0001_luxuriant_jane_foster.sql @@ -0,0 +1,81 @@ +-- Hand-edited after generation. drizzle-kit emitted +-- ALTER TABLE "farms" ADD COLUMN "org_id" uuid NOT NULL; +-- which cannot apply to a farms table that already has rows, and it emitted +-- the DROP of "owner_id" without moving the ownership it encodes anywhere - +-- so an existing database would either refuse the migration or silently lose +-- who owned each farm. +-- +-- The order below is the fix: add the column nullable, derive one +-- organisation per distinct owner and make that owner its `owner` member, +-- point each farm at it, and only then tighten the column and drop +-- "owner_id". Edited statements are marked; the rest is generated output. +-- +-- Safe to keep across regeneration: drizzle only appends new migration files +-- and diffs against meta/0001_snapshot.json, not against this SQL. +DO $$ BEGIN + CREATE TYPE "public"."org_role" AS ENUM('viewer', 'operator', 'owner'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organisations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "org_members" ( + "org_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "role" "org_role" DEFAULT 'viewer' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "org_members_org_id_user_id_pk" PRIMARY KEY("org_id","user_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "org_members" ADD CONSTRAINT "org_members_org_id_organisations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "org_members_user_id_idx" ON "org_members" USING btree ("user_id");--> statement-breakpoint +-- EDITED: generated as `uuid NOT NULL`. Nullable for now; tightened below, +-- once every row has a value. +ALTER TABLE "farms" ADD COLUMN "org_id" uuid;--> statement-breakpoint +-- EDITED (added): backfill. One organisation per distinct farms.owner_id, +-- with that user as its sole `owner` member. Farms that shared an owner end +-- up in one organisation together, which is what the old column meant. +-- +-- A DO block rather than a temporary table so the whole thing is one +-- statement with no state outlasting it, and so RETURNING ... INTO can carry +-- each generated organisation id straight to the rows that need it. +DO $$ +DECLARE + legacy_owner record; + new_org_id uuid; +BEGIN + FOR legacy_owner IN SELECT DISTINCT "owner_id" FROM "farms" LOOP + INSERT INTO "organisations" ("name") + VALUES ('Organisation ' || left(legacy_owner."owner_id"::text, 8)) + RETURNING "id" INTO new_org_id; + + INSERT INTO "org_members" ("org_id", "user_id", "role") + VALUES (new_org_id, legacy_owner."owner_id", 'owner'); + + UPDATE "farms" SET "org_id" = new_org_id WHERE "owner_id" = legacy_owner."owner_id"; + END LOOP; +END $$; +--> statement-breakpoint +-- EDITED (added): now that the backfill has run, enforce what the schema says. +ALTER TABLE "farms" ALTER COLUMN "org_id" SET NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "farms" ADD CONSTRAINT "farms_org_id_organisations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "farms_org_id_idx" ON "farms" USING btree ("org_id");--> statement-breakpoint +ALTER TABLE "farms" DROP COLUMN IF EXISTS "owner_id"; diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..86c46bd --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,709 @@ +{ + "id": "55f32571-d0c7-46a5-b763-ea7bef84faeb", + "prevId": "f60fed84-9b69-403f-a789-9e29cee970f1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.org_members": { + "name": "org_members", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_members_user_id_idx": { + "name": "org_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_members_org_id_organisations_id_fk": { + "name": "org_members_org_id_organisations_id_fk", + "tableFrom": "org_members", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_members_org_id_user_id_pk": { + "name": "org_members_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organisations": { + "name": "organisations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.farms": { + "name": "farms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "edge_proxy_token": { + "name": "edge_proxy_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "farms_org_id_idx": { + "name": "farms_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "farms_org_id_organisations_id_fk": { + "name": "farms_org_id_organisations_id_fk", + "tableFrom": "farms", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.printers": { + "name": "printers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "printer_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "printer_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "lan_address": { + "name": "lan_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serial_number": { + "name": "serial_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_code": { + "name": "access_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_printer_id": { + "name": "gateway_printer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nozzle_temp_c": { + "name": "nozzle_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "nozzle_target_temp_c": { + "name": "nozzle_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_temp_c": { + "name": "bed_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_target_temp_c": { + "name": "bed_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "progress_percent": { + "name": "progress_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_layer": { + "name": "current_layer", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_layers": { + "name": "total_layers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining_minutes": { + "name": "remaining_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_job_name": { + "name": "current_job_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_alerts": { + "name": "active_alerts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vendor_state": { + "name": "vendor_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_error_code": { + "name": "vendor_error_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "printers_farm_gateway_printer_id_idx": { + "name": "printers_farm_gateway_printer_id_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_printer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "printers_farm_id_farms_id_fk": { + "name": "printers_farm_id_farms_id_fk", + "tableFrom": "printers", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.print_jobs": { + "name": "print_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "print_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "progress_pct": { + "name": "progress_pct", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_time_s": { + "name": "estimated_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "elapsed_time_s": { + "name": "elapsed_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "print_jobs_farm_id_farms_id_fk": { + "name": "print_jobs_farm_id_farms_id_fk", + "tableFrom": "print_jobs", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "print_jobs_printer_id_printers_id_fk": { + "name": "print_jobs_printer_id_printers_id_fk", + "tableFrom": "print_jobs", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.frame_embeddings": { + "name": "frame_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "print_job_id": { + "name": "print_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(512)", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "frame_embeddings_embedding_ivfflat_idx": { + "name": "frame_embeddings_embedding_ivfflat_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "ivfflat", + "with": { + "lists": 100 + } + } + }, + "foreignKeys": { + "frame_embeddings_print_job_id_print_jobs_id_fk": { + "name": "frame_embeddings_print_job_id_print_jobs_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "print_jobs", + "columnsFrom": [ + "print_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frame_embeddings_printer_id_printers_id_fk": { + "name": "frame_embeddings_printer_id_printers_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "viewer", + "operator", + "owner" + ] + }, + "public.printer_status": { + "name": "printer_status", + "schema": "public", + "values": [ + "offline", + "idle", + "printing", + "paused", + "error", + "maintenance" + ] + }, + "public.printer_vendor": { + "name": "printer_vendor", + "schema": "public", + "values": [ + "bambu", + "prusa", + "klipper" + ] + }, + "public.print_job_status": { + "name": "print_job_status", + "schema": "public", + "values": [ + "queued", + "uploading", + "printing", + "paused", + "completed", + "failed", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9a8b390..7643a1f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1791029521420, "tag": "0000_watery_firedrake", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791041832172, + "tag": "0001_luxuriant_jane_foster", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 1ec749e..06868d7 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@continuum/ts-types": "file:../continuum-schemas/packages/ts-types", + "@continuum/ts-core": "file:../continuum-common/packages/ts-core", "elysia": "~1.4.30", "@elysiajs/cors": "~1.4.2", "@elysiajs/jwt": "~1.4.2", diff --git a/scripts/dev-auth.ts b/scripts/dev-auth.ts new file mode 100644 index 0000000..5bdd26f --- /dev/null +++ b/scripts/dev-auth.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +/** + * A stand-in for Supabase Auth: an RS256 issuer that publishes a JWKS at the + * same path a Supabase project does, and mints access tokens on request. + * + * This exists so the *real* verification path is testable. The backend + * verifies tokens with `createRemoteJWKSet` against + * `$SUPABASE_URL/auth/v1/.well-known/jwks.json`, and the alternative to + * standing that endpoint up locally is a test-only bypass in the backend - + * an env var that makes it trust an unsigned token. That bypass would be a + * permanent hole one misconfiguration away from production, and it would mean + * the code paths under test are not the code paths that ship. + * + * Point the backend at it with `SUPABASE_URL=http://localhost:`. + * + * Usage as a server: + * bun scripts/dev-auth.ts [--port 4545] + * GET /auth/v1/.well-known/jwks.json the public keys + * GET /token?sub=&email= a signed access token + * + * Usage from a test: + * const issuer = await startDevAuthIssuer(); + * const token = await issuer.mint({ sub: userId }); + * issuer.stop(); + */ +import { SignJWT, exportJWK, generateKeyPair, calculateJwkThumbprint } from "jose"; + +export interface MintOptions { + sub: string; + email?: string; + /** Seconds until expiry. Negative values mint an already-expired token. */ + expiresIn?: number; + /** Overrides the `aud` claim; the backend requires "authenticated". */ + audience?: string; +} + +export async function startDevAuthIssuer(port = 0) { + const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true }); + const publicJwk = await exportJWK(publicKey); + const kid = await calculateJwkThumbprint(publicJwk); + const jwks = { keys: [{ ...publicJwk, kid, alg: "RS256", use: "sig" }] }; + + async function mint(options: MintOptions): Promise { + const expiresIn = options.expiresIn ?? 3600; + return new SignJWT({ sub: options.sub, email: options.email, role: "authenticated" }) + .setProtectedHeader({ alg: "RS256", kid }) + .setIssuedAt() + .setAudience(options.audience ?? "authenticated") + .setExpirationTime(Math.floor(Date.now() / 1000) + expiresIn) + .sign(privateKey); + } + + const server = Bun.serve({ + port, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/auth/v1/.well-known/jwks.json") { + return Response.json(jwks); + } + if (url.pathname === "/token") { + const sub = url.searchParams.get("sub"); + if (!sub) return new Response("?sub= is required", { status: 400 }); + const expiresIn = url.searchParams.get("expiresIn"); + return Response.json({ + access_token: await mint({ + sub, + email: url.searchParams.get("email") ?? undefined, + expiresIn: expiresIn ? Number(expiresIn) : undefined, + audience: url.searchParams.get("audience") ?? undefined, + }), + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + return { + url: `http://localhost:${server.port}`, + port: server.port, + kid, + mint, + /** A token signed by a *different* key, for testing rejection. */ + async mintWithForeignKey(options: MintOptions): Promise { + const other = await generateKeyPair("RS256", { extractable: true }); + const otherJwk = await exportJWK(other.publicKey); + return new SignJWT({ sub: options.sub, role: "authenticated" }) + .setProtectedHeader({ alg: "RS256", kid: await calculateJwkThumbprint(otherJwk) }) + .setIssuedAt() + .setAudience(options.audience ?? "authenticated") + .setExpirationTime(Math.floor(Date.now() / 1000) + (options.expiresIn ?? 3600)) + .sign(other.privateKey); + }, + stop: () => server.stop(true), + }; +} + +if (import.meta.main) { + const portFlag = process.argv.indexOf("--port"); + const port = portFlag === -1 ? 4545 : Number(process.argv[portFlag + 1]); + const issuer = await startDevAuthIssuer(port); + console.log(`dev-auth issuer on ${issuer.url} (kid ${issuer.kid})`); + console.log(` SUPABASE_URL=${issuer.url}`); +} diff --git a/scripts/e2e-authz.sh b/scripts/e2e-authz.sh new file mode 100755 index 0000000..f33e06d --- /dev/null +++ b/scripts/e2e-authz.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# End-to-end test of authentication and the org -> farm -> printer +# authorization chain, against a real database and the real HTTP routes. +# +# This is the test that the unit tests cannot be. tests/authz.test.ts proves +# tokens are verified and that the role ladder orders correctly; only this +# proves that each route actually asks, that scoping is applied in the query +# rather than after it, and that credentials stay out of responses - the three +# things that were wrong when every route was unauthenticated. +# +# Four users, two organisations, no printers or gateways needed: +# owner_a, operator_a, viewer_a in org A, which owns farm A and printer A +# outsider_b in org B, which owns nothing +# +# Usage: +# scripts/dev-env.sh up && scripts/dev-env.sh schema +# scripts/e2e-authz.sh +# +# KEEP_DATA=1 leave the seeded organisations behind for inspection +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +PORT="${PORT:-3020}" +AUTH_PORT="${AUTH_PORT:-3021}" +BASE="http://localhost:$PORT" +DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}" +VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}" +EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}" +# Presigning a PUT is local HMAC with no network call, so POST /jobs works +# offline - but the SDK refuses to sign with empty credentials. +R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}" +R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}" +R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}" +export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT +export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT + +WORK="$(mktemp -d)" +BACKEND_PID="" +AUTH_PID="" +FAILURES=0 + +# `uuidgen` is not installed everywhere; the kernel's generator always is. +new_uuid() { + if [ -r /proc/sys/kernel/random/uuid ]; then + cat /proc/sys/kernel/random/uuid + else + python3 -c 'import uuid; print(uuid.uuid4())' + fi +} + +psql_q() { PGPASSWORD=continuum psql -h localhost -U continuum -d continuum -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 [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 [body] -- ` +expect() { + local want="$1"; shift + local method="$1" path="$2" token="$3"; shift 3 + local body="" + if [ "${1:-}" != "--" ]; then body="$1"; shift; fi + shift # the -- + local what="$*" + local got; got="$(req "$method" "$path" "$token" "$body")" + if [ "$got" = "$want" ]; then + ok "$what ($got)" + else + bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")" + fi +} + +body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; } +body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; } +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; } + +# --- 0. preconditions ------------------------------------------------------ + +echo "==> checking the database is up and migrated" +psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up" +psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \ + || fail "farms.org_id is missing - run scripts/dev-env.sh schema" +psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \ + || fail "org_members is missing - run scripts/dev-env.sh schema" +ok "schema has the org layer" + +# --- 1. the issuer and the backend ---------------------------------------- + +echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT" +bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 & +AUTH_PID=$! +AUTH_URL="http://localhost:$AUTH_PORT" +for _ in $(seq 1 40); do + curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break + sleep 0.25 +done +curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \ + || fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")" +export SUPABASE_URL="$AUTH_URL" +ok "issuer up, SUPABASE_URL=$SUPABASE_URL" + +mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; } + +OWNER_A_ID="$(new_uuid)"; OWNER_A="$(mint "$OWNER_A_ID")" +OPERATOR_A_ID="$(new_uuid)"; OPERATOR_A="$(mint "$OPERATOR_A_ID")" +VIEWER_A_ID="$(new_uuid)"; VIEWER_A="$(mint "$VIEWER_A_ID")" +OUTSIDER_ID="$(new_uuid)"; OUTSIDER="$(mint "$OUTSIDER_ID")" +ok "minted four access tokens" + +echo "==> starting continuum-backend on :$PORT" +bun run src/index.ts > "$WORK/backend.log" 2>&1 & +BACKEND_PID=$! +for _ in $(seq 1 60); do + curl -sf "$BASE/health" >/dev/null 2>&1 && break + sleep 0.5 +done +curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")" +ok "backend healthy" + +# --- 2. anonymous access -------------------------------------------------- + +echo "==> nothing works without a token" +expect 200 GET /health - -- "GET /health is public" +expect 401 GET /orgs - -- "GET /orgs rejects anonymous" +expect 401 GET /farms - -- "GET /farms rejects anonymous" +expect 401 GET /printers - -- "GET /printers rejects anonymous" +expect 401 GET /jobs - -- "GET /jobs rejects anonymous" +expect 401 GET /auth/session - -- "GET /auth/session rejects anonymous" +expect 401 POST /farms - '{"name":"x","orgId":"00000000-0000-0000-0000-000000000000"}' -- "POST /farms rejects anonymous" +expect 401 GET /farms -H.invalid.token -- "a malformed token is rejected" + +# --- 3. the seeded world -------------------------------------------------- + +echo "==> building org A (via the API, as owner_a)" +expect 201 POST /orgs "$OWNER_A" '{"name":"e2e org A"}' -- "owner_a creates org A" +ORG_A="$(json 'd["id"]')" +body_has '"role":"owner"' +ok "org A = $ORG_A, creator is its owner" + +expect 200 GET /auth/session "$OWNER_A" -- "owner_a reads their session" +body_has "$ORG_A" + +expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_A_ID" "$OWNER_A" '{"role":"operator"}' -- "owner_a adds operator_a" +expect 200 PUT "/orgs/$ORG_A/members/$VIEWER_A_ID" "$OWNER_A" '{"role":"viewer"}' -- "owner_a adds viewer_a" + +expect 201 POST /farms "$OWNER_A" "{\"name\":\"e2e farm A\",\"orgId\":\"$ORG_A\"}" -- "owner_a creates farm A" +FARM_A="$(json 'd["id"]')" +body_lacks edgeProxyToken +ok "farm A = $FARM_A, and the create response carries no proxy token" + +expect 201 POST /printers "$OWNER_A" \ + "{\"farmId\":\"$FARM_A\",\"name\":\"e2e printer A\",\"vendor\":\"bambu\",\"accessCode\":\"SECRET123\",\"serialNumber\":\"SN-SECRET\"}" \ + -- "owner_a adds printer A with an access code" +PRINTER_A="$(json 'd["id"]')" +body_lacks SECRET123 +ok "printer A = $PRINTER_A, and the create response carries no access code" + +echo "==> building org B (via the API, as the outsider)" +expect 201 POST /orgs "$OUTSIDER" '{"name":"e2e org B"}' -- "outsider creates their own org B" +ORG_B="$(json 'd["id"]')" + +# --- 4. scoping: a list shows only your own ------------------------------ + +echo "==> lists are scoped to the caller's organisations" +expect 200 GET /farms "$VIEWER_A" -- "viewer_a lists farms" +body_has "$FARM_A" +expect 200 GET /farms "$OUTSIDER" -- "the outsider lists farms" +body_lacks "$FARM_A" +ok "the outsider's farm list does not contain farm A" + +expect 200 GET /printers "$OUTSIDER" -- "the outsider lists printers" +body_lacks "$PRINTER_A" +ok "the outsider's printer list does not contain printer A" + +expect 200 GET /orgs "$OUTSIDER" -- "the outsider lists organisations" +body_lacks "$ORG_A" +ok "the outsider cannot see org A exists" + +# --- 5. a non-member gets 404, not 403 ----------------------------------- + +echo "==> a non-member cannot distinguish 'forbidden' from 'does not exist'" +expect 404 GET "/farms/$FARM_A" "$OUTSIDER" -- "GET another org's farm" +expect 404 GET "/printers/$PRINTER_A" "$OUTSIDER" -- "GET another org's printer" +expect 404 GET "/orgs/$ORG_A" "$OUTSIDER" -- "GET another org" +expect 404 GET "/orgs/$ORG_A/members" "$OUTSIDER" -- "GET another org's members" +expect 404 DELETE "/farms/$FARM_A" "$OUTSIDER" -- "DELETE another org's farm" +expect 404 PATCH "/printers/$PRINTER_A" "$OUTSIDER" '{"name":"hijacked"}' -- "PATCH another org's printer" +expect 404 PUT "/orgs/$ORG_A/members/$OUTSIDER_ID" "$OUTSIDER" '{"role":"owner"}' -- "add themselves to another org" +expect 404 POST "/farms/$FARM_A/edge-proxy-token" "$OUTSIDER" -- "rotate another farm's gateway credential" +expect 404 POST "/printers/$PRINTER_A/commands" "$OUTSIDER" '{"action":"set_fan_speed","percent":100}' \ + -- "command another org's printer" +# The same id, as a member: proves the 404s above are about membership and not +# about the farm being missing or the route being broken. +expect 200 GET "/farms/$FARM_A" "$VIEWER_A" -- "the same farm id, as a member of its org" + +# --- 6. the role ladder, over HTTP --------------------------------------- + +echo "==> roles gate what a member can do" +expect 200 GET "/printers/$PRINTER_A" "$VIEWER_A" -- "a viewer reads a printer" +expect 403 POST "/printers/$PRINTER_A/commands" "$VIEWER_A" '{"action":"set_fan_speed","percent":100}' \ + -- "a viewer cannot command a printer" +body_has FORBIDDEN +# 409 PRINTER_UNREACHABLE, not 403: no gateway is connected in this test, so +# authorization passing is exactly what reaching that error means. +expect 409 POST "/printers/$PRINTER_A/commands" "$OPERATOR_A" '{"action":"set_fan_speed","percent":100}' \ + -- "an operator passes authorization and fails on delivery" +body_has PRINTER_UNREACHABLE + +expect 403 POST "/jobs" "$VIEWER_A" "{\"farmId\":\"$FARM_A\",\"fileName\":\"v.3mf\"}" -- "a viewer cannot submit a job" +expect 201 POST "/jobs" "$OPERATOR_A" "{\"farmId\":\"$FARM_A\",\"fileName\":\"o.3mf\"}" -- "an operator submits a job" +JOB_A="$(json 'd["job"]["id"]')" +SUBMITTER="$(json 'd["job"]["submittedBy"]')" +[ "$SUBMITTER" = "$OPERATOR_A_ID" ] \ + && ok "submittedBy came from the token, not the body ($SUBMITTER)" \ + || bad "submittedBy is '$SUBMITTER', expected '$OPERATOR_A_ID'" + +# The old route took `submittedBy` from the body. Sending it now must not +# change the recorded submitter - an ignored extra field, not an override. +expect 201 POST "/jobs" "$OPERATOR_A" \ + "{\"farmId\":\"$FARM_A\",\"fileName\":\"spoof.3mf\",\"submittedBy\":\"$OWNER_A_ID\"}" \ + -- "a job body claiming another submitter" +SPOOFED="$(json 'd["job"]["submittedBy"]')" +[ "$SPOOFED" = "$OPERATOR_A_ID" ] \ + && ok "a spoofed submittedBy in the body is ignored" \ + || bad "the body overrode submittedBy: got '$SPOOFED'" + +expect 403 PATCH "/farms/$FARM_A" "$OPERATOR_A" '{"name":"renamed by operator"}' -- "an operator cannot rename a farm" +expect 200 PATCH "/farms/$FARM_A" "$OWNER_A" '{"name":"e2e farm A renamed"}' -- "an owner can rename a farm" +expect 403 POST "/printers" "$OPERATOR_A" "{\"farmId\":\"$FARM_A\",\"name\":\"nope\",\"vendor\":\"bambu\"}" \ + -- "an operator cannot add a printer" + +# --- 7. credentials stay on the server ------------------------------------ + +echo "==> credentials never appear in an ordinary response" +expect 200 GET "/farms/$FARM_A" "$OWNER_A" -- "an owner fetches their own farm" +body_lacks edgeProxyToken +expect 200 GET /farms "$OWNER_A" -- "an owner lists their farms" +body_lacks edgeProxyToken +expect 200 GET "/printers/$PRINTER_A" "$OWNER_A" -- "an owner fetches their own printer" +body_lacks SECRET123 +body_lacks accessCode +ok "no proxy token and no access code in any read" + +echo "==> rotation is owner-only, and returns the new value exactly once" +expect 403 POST "/farms/$FARM_A/edge-proxy-token" "$OPERATOR_A" -- "an operator cannot rotate" +expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER_A" -- "an owner rotates" +ROTATED="$(json 'd["edgeProxyToken"]')" +[ -n "$ROTATED" ] || bad "rotation returned no token" +STORED="$(psql_q "SELECT edge_proxy_token FROM farms WHERE id='$FARM_A'")" +[ "$ROTATED" = "$STORED" ] && ok "the returned token is the one now stored" || bad "rotation did not persist" + +# And it still works for what it is for. +CODE="$(req POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$ROTATED\"}")" +[ "$CODE" = "200" ] && ok "the rotated token still mints an edge JWT (no user token needed)" \ + || bad "POST /auth/edge-token returned $CODE for the rotated token" +expect 401 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"not-the-token\"}" \ + -- "a wrong proxy token is refused" + +# --- 8. the guards that stop an org becoming unadministrable -------------- + +echo "==> an organisation cannot be left without an owner" +expect 409 DELETE "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" -- "the only owner cannot remove themselves" +expect 409 PUT "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" '{"role":"viewer"}' -- "the only owner cannot demote themselves" +expect 409 DELETE "/orgs/$ORG_A" "$OWNER_A" -- "an org with farms attached cannot be deleted" +body_has CONFLICT +expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_A_ID" "$OWNER_A" '{"role":"owner"}' -- "promote a second owner" +expect 200 DELETE "/orgs/$ORG_A/members/$OWNER_A_ID" "$OWNER_A" -- "now the first owner may leave" +expect 404 GET "/orgs/$ORG_A" "$OWNER_A" -- "and immediately loses access to the org" + +# --- 9. pagination is bounded -------------------------------------------- + +echo "==> list endpoints are bounded" +expect 422 GET "/jobs?limit=10000" "$OPERATOR_A" -- "an oversized limit is refused, not silently clamped" +expect 200 GET "/jobs?limit=1" "$OPERATOR_A" -- "a limit of 1 is honoured" +COUNT="$(json 'len(d)')" +[ "$COUNT" = "1" ] && ok "one row returned" || bad "limit=1 returned $COUNT rows" + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "PASS - authentication, org scoping, the role ladder and credential" + echo " containment all verified against a real database." +else + echo "FAIL - $FAILURES assertion(s) failed." >&2 + exit 1 +fi diff --git a/scripts/e2e-edge.sh b/scripts/e2e-edge.sh index 446efad..c17d9e0 100755 --- a/scripts/e2e-edge.sh +++ b/scripts/e2e-edge.sh @@ -21,6 +21,11 @@ # PRINTER_ID=p1p which printer to send the fan command to # PROXY_DIR=../continuum-proxy # KEEP_DATA=1 leave the seeded farm behind for inspection +# +# The HTTP routes now require a verified Supabase access token, so this starts +# scripts/dev-auth.ts - a local RS256 issuer publishing a JWKS at the path the +# backend fetches - and mints a token for a seeded org `operator`. That is the +# real verification path, not a bypass: see the header of dev-auth.ts. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -29,6 +34,7 @@ cd "$REPO_ROOT" PRINTER_ID="${PRINTER_ID:-p1p}" PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}" PORT="${PORT:-3010}" +AUTH_PORT="${AUTH_PORT:-3011}" BASE="http://localhost:$PORT" DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}" VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}" @@ -38,30 +44,50 @@ export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT WORK="$(mktemp -d)" BACKEND_PID="" PROXY_PID="" +AUTH_PID="" + +# `uuidgen` is not installed everywhere; the kernel's generator always is. +new_uuid() { + if [ -r /proc/sys/kernel/random/uuid ]; then + cat /proc/sys/kernel/random/uuid + else + python3 -c 'import uuid; print(uuid.uuid4())' + fi +} psql_q() { PGPASSWORD=continuum psql -h localhost -U continuum -d continuum -tAq -c "$1"; } +# The farm's operator. A real Supabase user id would come from the project; +# here it only has to be a uuid that the token's `sub` and the org_members row +# agree on. +USER_ID="$(new_uuid)" + cleanup() { local rc=$? - for pid in "$PROXY_PID" "$BACKEND_PID"; do + for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -z "$pid" ] && continue kill "$pid" 2>/dev/null || true done # A gateway that outlives this script keeps reconnecting with a token for a # farm that is about to be deleted, and pollutes the next run's log. for _ in $(seq 1 10); do - kill -0 "${PROXY_PID:-0}" 2>/dev/null || break + [ -n "$PROXY_PID" ] || break + kill -0 "$PROXY_PID" 2>/dev/null || break sleep 0.3 done - kill -9 "${PROXY_PID:-0}" 2>/dev/null || true - kill -9 "${BACKEND_PID:-0}" 2>/dev/null || true - if [ -z "${KEEP_DATA:-}" ] && [ -n "${FARM_ID:-}" ]; then - # Printers cascade from farms. - psql_q "DELETE FROM farms WHERE id = '$FARM_ID'" >/dev/null 2>&1 || true + # Never `kill $PID` with an unset PID: `kill 0` signals the whole process + # group, which includes this script and whatever launched it. + for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do + [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true + done + if [ -z "${KEEP_DATA:-}" ] && [ -n "${ORG_ID:-}" ]; then + # Farms cascade from the organisation, printers from the farm. + psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true fi if [ $rc -ne 0 ]; then echo echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true + echo "--- dev-auth log (tail) ---"; tail -10 "$WORK/auth.log" 2>/dev/null || true echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true fi rm -rf "$WORK" @@ -78,18 +104,27 @@ echo "==> checking the database is up and migrated" psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up" psql_q "SELECT gateway_printer_id FROM printers LIMIT 0" >/dev/null 2>&1 \ || fail "printers.gateway_printer_id is missing - run scripts/dev-env.sh schema" -ok "schema has the edge columns" +psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \ + || fail "farms.org_id is missing - run scripts/dev-env.sh schema" +ok "schema has the edge columns and the org layer" [ -f "$PROXY_DIR/printers.toml" ] || fail "no $PROXY_DIR/printers.toml - the proxy needs real printers" # --- 1. seed --------------------------------------------------------------- -echo "==> seeding a farm and claiming its printers" +echo "==> seeding an organisation, a farm and claiming its printers" EDGE_PROXY_TOKEN="e2e-$(date +%s)" -FARM_ID="$(psql_q "INSERT INTO farms (name, owner_id, edge_proxy_token) - VALUES ('e2e farm', gen_random_uuid(), '$EDGE_PROXY_TOKEN') RETURNING id")" +ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('e2e org') RETURNING id")" +[ -n "$ORG_ID" ] || fail "could not create an organisation" +# `operator`, not `owner`: the command route requires operator, so seeding the +# weakest role that should work proves the boundary is where it claims to be +# rather than passing because the test user can do everything. +psql_q "INSERT INTO org_members (org_id, user_id, role) + VALUES ('$ORG_ID', '$USER_ID', 'operator')" >/dev/null +FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token) + VALUES ('e2e farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")" [ -n "$FARM_ID" ] || fail "could not create a farm" -ok "farm $FARM_ID" +ok "org $ORG_ID, farm $FARM_ID, operator $USER_ID" # gateway_printer_id is the whole point: it maps the gateway's own ids from # printers.toml onto these rows. Telemetry for an id nothing claims is skipped. @@ -102,6 +137,25 @@ done < <(awk -F'"' '/^id *=/ {id=$2} /^name *=/ {print id, $2}' "$PROXY_DIR/prin # --- 2. backend ------------------------------------------------------------ +echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT" +bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 & +AUTH_PID=$! +AUTH_URL="http://localhost:$AUTH_PORT" +for _ in $(seq 1 40); do + curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break + sleep 0.25 +done +curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \ + || fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")" +export SUPABASE_URL="$AUTH_URL" +ok "issuer up, SUPABASE_URL=$SUPABASE_URL" + +USER_TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')" +[ -n "$USER_TOKEN" ] || fail "could not mint a user token" +AUTH_HEADER="authorization: Bearer $USER_TOKEN" +ok "minted an access token for the operator" + echo "==> starting continuum-backend on :$PORT" bun run src/index.ts > "$WORK/backend.log" 2>&1 & BACKEND_PID=$! @@ -197,7 +251,7 @@ ok "rows carry real vendor state and temperatures" TARGET_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID' AND gateway_printer_id='$PRINTER_ID'")" [ -n "$TARGET_UUID" ] || fail "no printer claimed as '$PRINTER_ID'" -GW="$(curl -sf "$BASE/printers/$TARGET_UUID/gateway")" +GW="$(curl -sf -H "$AUTH_HEADER" "$BASE/printers/$TARGET_UUID/gateway")" echo "$GW" | grep -q '"gatewayOnline":true' || fail "the gateway is not registered as online: $GW" ok "gateway reports online for $PRINTER_ID" @@ -210,14 +264,14 @@ else echo "==> sending set_fan_speed to $PRINTER_ID ($STATE) over HTTP" RESP="$(curl -s -o "$WORK/cmd.json" -w '%{http_code}' -X POST \ "$BASE/printers/$TARGET_UUID/commands" -H 'content-type: application/json' \ - -d '{"action":"set_fan_speed","percent":40}')" + -H "$AUTH_HEADER" -d '{"action":"set_fan_speed","percent":40}')" [ "$RESP" = "200" ] || fail "command returned HTTP $RESP: $(cat "$WORK/cmd.json")" grep -q '"ok":true' "$WORK/cmd.json" || fail "command was not acknowledged: $(cat "$WORK/cmd.json")" ok "command acknowledged by the printer: $(cat "$WORK/cmd.json")" # Put it back, so this leaves the printer as it found it. curl -s -X POST "$BASE/printers/$TARGET_UUID/commands" \ - -H 'content-type: application/json' \ + -H 'content-type: application/json' -H "$AUTH_HEADER" \ -d '{"action":"set_fan_speed","percent":0}' > /dev/null ok "fan returned to 0" COMMAND_TESTED=yes @@ -230,7 +284,7 @@ ORPHAN="$(psql_q "INSERT INTO printers (farm_id, name, vendor) VALUES ('$FARM_ID', 'unclaimed', 'bambu') RETURNING id")" CODE="$(curl -s -o "$WORK/orphan.json" -w '%{http_code}' -X POST \ "$BASE/printers/$ORPHAN/commands" -H 'content-type: application/json' \ - -d '{"action":"set_fan_speed","percent":0}')" + -H "$AUTH_HEADER" -d '{"action":"set_fan_speed","percent":0}')" [ "$CODE" = "409" ] || fail "expected HTTP 409 for an unclaimed printer, got $CODE" grep -q 'PRINTER_UNREACHABLE' "$WORK/orphan.json" || fail "wrong error: $(cat "$WORK/orphan.json")" ok "unclaimed printer refused with 409 PRINTER_UNREACHABLE" diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..71000d6 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,12 @@ +export { requireUser, type AuthenticatedUser } from "./plugin"; +export { roleSatisfies, ORG_ROLES, type OrgRole } from "./roles"; +export { + listMemberships, + requireFarm, + requireJob, + requireOrg, + requirePrinter, + visibleFarms, + visibleJobs, + visiblePrinters, +} from "./scope"; diff --git a/src/auth/plugin.ts b/src/auth/plugin.ts new file mode 100644 index 0000000..4176233 --- /dev/null +++ b/src/auth/plugin.ts @@ -0,0 +1,51 @@ +import { Elysia } from "elysia"; +import { JwtVerificationError, UnauthorizedError, extractBearerToken } from "@continuum/ts-core"; +import { jwtVerifier } from "./verifier"; + +export interface AuthenticatedUser { + id: string; + email?: string; +} + +/** + * Requires a verified Supabase access token and exposes the caller as `user`. + * + * Applied per route group with `.use(requireUser)` rather than globally, + * because three things here legitimately have no user behind them: `/health`, + * `POST /auth/edge-token` (authenticated by a farm's own proxy token) and the + * `/ws/edge/v1` socket (authenticated by the edge JWT that mints). A global + * guard with holes punched in it reads as if those holes were oversights. + * + * `scoped` propagates `user` to the instance that uses this plugin, so each + * route file gets it without re-deriving; `name` makes it a singleton, so + * using it in five route files still verifies each token once. + */ +export const requireUser = new Elysia({ name: "continuum/require-user" }) + .resolve({ as: "scoped" }, async ({ headers }): Promise<{ user: AuthenticatedUser }> => { + let token: string; + try { + token = extractBearerToken(headers.authorization); + } catch { + throw new UnauthorizedError("Authorization header must be 'Bearer '"); + } + + let claims; + try { + claims = await jwtVerifier.verify(token); + } catch (err) { + // Never pass the underlying reason through: "signature mismatch" versus + // "expired" versus "wrong audience" tells an attacker which part of a + // forged token to fix next. It is logged instead. + if (err instanceof JwtVerificationError) { + console.warn(`[auth] token rejected: ${err.cause instanceof Error ? err.cause.message : err.message}`); + throw new UnauthorizedError("Invalid or expired access token"); + } + throw err; + } + + if (!claims.sub) { + throw new UnauthorizedError("Access token has no subject claim"); + } + + return { user: { id: claims.sub, email: claims.email } }; + }); diff --git a/src/auth/roles.ts b/src/auth/roles.ts new file mode 100644 index 0000000..55006f3 --- /dev/null +++ b/src/auth/roles.ts @@ -0,0 +1,17 @@ +import { ORG_ROLES, type OrgRole } from "../db/schema"; + +/** + * The role ladder, kept apart from `./scope.ts` because that module opens a + * database connection on import and this comparison needs nothing but the + * enum. Tests can exercise the ladder without Postgres. + */ +const RANK: Record = Object.fromEntries( + ORG_ROLES.map((role, index) => [role, index]), +) as Record; + +/** Whether `actual` is at least as privileged as `minimum`. */ +export function roleSatisfies(actual: OrgRole, minimum: OrgRole): boolean { + return RANK[actual] >= RANK[minimum]; +} + +export { ORG_ROLES, type OrgRole }; diff --git a/src/auth/scope.ts b/src/auth/scope.ts new file mode 100644 index 0000000..b497bf2 --- /dev/null +++ b/src/auth/scope.ts @@ -0,0 +1,158 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { ForbiddenError, NotFoundError } from "@continuum/ts-core"; +import { db } from "../db"; +import { farms, orgMembers, printJobs, printers } from "../db/schema"; +import { roleSatisfies, type OrgRole } from "./roles"; + +/** + * Authorization for everything except the edge-gateway socket, which + * authenticates as a farm rather than as a user. + * + * One rule, applied at the top of every handler: resolve the thing being + * acted on back to an organisation, and check the caller's membership role + * there. user -> org -> farm -> printer, with nothing carrying permissions of + * its own in between. + * + * ## 404 rather than 403 for non-members + * + * The `require*` helpers below answer "not found" when the record exists but + * the caller is not in its organisation. That is deliberate: a 403 would + * confirm that a given farm id exists and belongs to someone, which is an + * enumeration oracle over every farm and printer in the system. 403 is kept + * for the case where the caller *can* see the record and simply is not + * allowed to do this to it - a viewer trying to move a print head - where + * saying so is useful and leaks nothing they did not already know. + */ + +/** The organisations this user belongs to, with their role in each. */ +export function listMemberships(userId: string) { + return db + .select({ orgId: orgMembers.orgId, role: orgMembers.role, joinedAt: orgMembers.createdAt }) + .from(orgMembers) + .where(eq(orgMembers.userId, userId)); +} + +/** + * Subquery of the org ids this user belongs to, for use inside a `where`. + * + * A subquery rather than a fetched array of ids so list endpoints stay one + * round trip, and so a user in many organisations does not build an IN list + * whose length depends on their membership count. + */ +function orgIdsFor(userId: string) { + return db.select({ id: orgMembers.orgId }).from(orgMembers).where(eq(orgMembers.userId, userId)); +} + +/** `where` condition limiting `farms` to those the user can see. */ +export function visibleFarms(userId: string) { + return inArray(farms.orgId, orgIdsFor(userId)); +} + +/** `where` condition limiting `printers` to those the user can see. */ +export function visiblePrinters(userId: string) { + return inArray( + printers.farmId, + db.select({ id: farms.id }).from(farms).where(visibleFarms(userId)), + ); +} + +/** `where` condition limiting `print_jobs` to those the user can see. */ +export function visibleJobs(userId: string) { + return inArray( + printJobs.farmId, + db.select({ id: farms.id }).from(farms).where(visibleFarms(userId)), + ); +} + +function check(role: OrgRole | null, minimum: OrgRole, missing: string, action: string): OrgRole { + if (role === null) throw new NotFoundError(missing); + if (!roleSatisfies(role, minimum)) { + throw new ForbiddenError(`${action} requires the '${minimum}' role; you are '${role}'`); + } + return role; +} + +/** Asserts the caller holds at least `minimum` in this organisation. */ +export async function requireOrg( + userId: string, + orgId: string, + minimum: OrgRole, + action = "this operation", +): Promise { + const [row] = await db + .select({ role: orgMembers.role }) + .from(orgMembers) + .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId))) + .limit(1); + return check(row?.role ?? null, minimum, `Organisation ${orgId} not found`, action); +} + +/** + * Asserts the caller holds at least `minimum` in the farm's organisation. + * + * A left join so that "no such farm" and "a farm you are not a member of" + * run the same query and return the same 404 - see the note above. + */ +export async function requireFarm( + userId: string, + farmId: string, + minimum: OrgRole, + action = "this operation", +): Promise<{ orgId: string; role: OrgRole }> { + const [row] = await db + .select({ orgId: farms.orgId, role: orgMembers.role }) + .from(farms) + .leftJoin( + orgMembers, + and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)), + ) + .where(eq(farms.id, farmId)) + .limit(1); + + const role = check(row?.role ?? null, minimum, `Farm ${farmId} not found`, action); + return { orgId: row!.orgId, role }; +} + +/** As `requireFarm`, resolved through the printer's farm. */ +export async function requirePrinter( + userId: string, + printerId: string, + minimum: OrgRole, + action = "this operation", +): Promise<{ farmId: string; orgId: string; role: OrgRole }> { + const [row] = await db + .select({ farmId: printers.farmId, orgId: farms.orgId, role: orgMembers.role }) + .from(printers) + .innerJoin(farms, eq(farms.id, printers.farmId)) + .leftJoin( + orgMembers, + and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)), + ) + .where(eq(printers.id, printerId)) + .limit(1); + + const role = check(row?.role ?? null, minimum, `Printer ${printerId} not found`, action); + return { farmId: row!.farmId, orgId: row!.orgId, role }; +} + +/** As `requireFarm`, resolved through the job's farm. */ +export async function requireJob( + userId: string, + jobId: string, + minimum: OrgRole, + action = "this operation", +): Promise<{ farmId: string; orgId: string; role: OrgRole }> { + const [row] = await db + .select({ farmId: printJobs.farmId, orgId: farms.orgId, role: orgMembers.role }) + .from(printJobs) + .innerJoin(farms, eq(farms.id, printJobs.farmId)) + .leftJoin( + orgMembers, + and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)), + ) + .where(eq(printJobs.id, jobId)) + .limit(1); + + const role = check(row?.role ?? null, minimum, `Print job ${jobId} not found`, action); + return { farmId: row!.farmId, orgId: row!.orgId, role }; +} diff --git a/src/auth/verifier.ts b/src/auth/verifier.ts new file mode 100644 index 0000000..a95a1dc --- /dev/null +++ b/src/auth/verifier.ts @@ -0,0 +1,26 @@ +import { createSupabaseJwtVerifier } from "@continuum/ts-core"; + +/** + * The one JWT verifier in this process. + * + * Asymmetric verification against the Supabase project's published JWKS - + * this service holds no signing key and no shared secret, so a leak of + * anything here cannot be used to mint a token. That is the whole reason for + * preferring it to the HS256 path this replaces, where `SUPABASE_JWT_SECRET` + * was both the verification key and a forging key, present in the + * environment of every backend instance. + * + * `createRemoteJWKSet` does not fetch at construction: it fetches on first + * verification and then caches, re-fetching only when a token arrives with a + * key id it has not seen (which is what key rotation looks like from here). + * So building this at module load costs nothing but still fails the process + * at boot if the project URL is missing, rather than on the first request. + */ +const supabaseUrl = process.env.SUPABASE_URL; +if (!supabaseUrl) { + throw new Error( + "SUPABASE_URL is not set - it is the issuer whose JWKS verifies every access token", + ); +} + +export const jwtVerifier = createSupabaseJwtVerifier(supabaseUrl); diff --git a/src/db/projections.ts b/src/db/projections.ts index 56fc4ee..dc5fbb5 100644 --- a/src/db/projections.ts +++ b/src/db/projections.ts @@ -44,7 +44,7 @@ function projection(table: T, keys: readonl export const farmProjection = projection(farms, [ "id", "name", - "ownerId", + "orgId", "location", "settings", "createdAt", diff --git a/src/db/schema/farms.ts b/src/db/schema/farms.ts index 8f39744..5be23a2 100644 --- a/src/db/schema/farms.ts +++ b/src/db/schema/farms.ts @@ -1,15 +1,30 @@ -import { pgTable, uuid, text, timestamp, jsonb } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, jsonb, index } from "drizzle-orm/pg-core"; +import { organisations } from "./orgs"; -export const farms = pgTable("farms", { - id: uuid("id").primaryKey().defaultRandom(), - name: text("name").notNull(), - ownerId: uuid("owner_id").notNull(), - edgeProxyToken: text("edge_proxy_token").notNull(), - location: text("location"), - settings: jsonb("settings").$type>().default({}), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}); +export const farms = pgTable( + "farms", + { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + /** + * Replaces the old `owner_id`. A farm used to point straight at a single + * Supabase user, which left no way to let a second person operate it + * without handing over the account - so ownership moved up a level and + * who may do what is now a row in `org_members`. + */ + orgId: uuid("org_id") + .notNull() + .references(() => organisations.id, { onDelete: "cascade" }), + edgeProxyToken: text("edge_proxy_token").notNull(), + location: text("location"), + settings: jsonb("settings").$type>().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + orgIdx: index("farms_org_id_idx").on(table.orgId), + }), +); export type Farm = typeof farms.$inferSelect; export type NewFarm = typeof farms.$inferInsert; diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index ed06fe4..2d1b9a6 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,3 +1,4 @@ +export * from "./orgs"; export * from "./farms"; export * from "./printers"; export * from "./print_jobs"; diff --git a/src/db/schema/orgs.ts b/src/db/schema/orgs.ts new file mode 100644 index 0000000..31f495a --- /dev/null +++ b/src/db/schema/orgs.ts @@ -0,0 +1,65 @@ +import { pgTable, uuid, text, timestamp, jsonb, pgEnum, index, primaryKey } from "drizzle-orm/pg-core"; + +/** + * The role ladder, lowest first. Order matters: `src/auth/scope.ts` compares + * roles by their position in this array, so a role inserted in the middle + * changes what every `minRole` check means. + * + * - `viewer` - read farms, printers, jobs. + * - `operator` - plus submit jobs and send printer commands. Those commands + * move physical machines (home, pause, set fan/temperature), + * so this is the boundary that matters most here. + * - `owner` - plus manage membership, create and delete farms and + * printers, and rotate a farm's edge-proxy token. + */ +export const ORG_ROLES = ["viewer", "operator", "owner"] as const; +export type OrgRole = (typeof ORG_ROLES)[number]; + +export const orgRoleEnum = pgEnum("org_role", ORG_ROLES); + +/** + * An organisation is the unit of ownership and billing: it owns farms, which + * own printers. Nothing below this level carries its own access control - + * authorization is always resolved as user -> org -> farm -> printer. + */ +export const organisations = pgTable("organisations", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + settings: jsonb("settings").$type>().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +/** + * Membership is the only thing that grants access to anything in Continuum. + * + * `userId` is a Supabase `auth.users.id` - the `sub` claim of a verified + * access token. It is deliberately *not* a foreign key: that table lives in + * Supabase's own schema and may not even be in this database, so the + * reference is by value and a deleted user leaves a row that matches no + * token. Harmless, since nothing can authenticate as them any more. + */ +export const orgMembers = pgTable( + "org_members", + { + orgId: uuid("org_id") + .notNull() + .references(() => organisations.id, { onDelete: "cascade" }), + userId: uuid("user_id").notNull(), + role: orgRoleEnum("role").notNull().default("viewer"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.orgId, table.userId] }), + // Every authenticated request starts by asking "which orgs is this user + // in?", so that lookup gets its own index; the primary key is no help + // because it leads with org_id. + userIdx: index("org_members_user_id_idx").on(table.userId), + }), +); + +export type Organisation = typeof organisations.$inferSelect; +export type NewOrganisation = typeof organisations.$inferInsert; +export type OrgMember = typeof orgMembers.$inferSelect; +export type NewOrgMember = typeof orgMembers.$inferInsert; diff --git a/src/index.ts b/src/index.ts index f77bb96..73d4f22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,11 @@ import { Elysia } from "elysia"; import { cors } from "@elysiajs/cors"; import { swagger } from "@elysiajs/swagger"; import { sql } from "drizzle-orm"; +import { toErrorResponse } from "@continuum/ts-core"; import { db } from "./db"; import { valkey } from "./queue"; import { authRoutes } from "./routes/auth"; +import { orgRoutes } from "./routes/orgs"; import { farmRoutes } from "./routes/farms"; import { printerRoutes } from "./routes/printers"; import { jobRoutes } from "./routes/jobs"; @@ -12,13 +14,49 @@ import { edgeWsRoute } from "./routes/ws/edge"; const app = new Elysia() .use(cors()) - .use(swagger({ path: "/docs" })) + .use( + swagger({ + path: "/docs", + documentation: { + info: { title: "Continuum API", version: "0.1.0" }, + tags: [ + { name: "auth", description: "Sessions and edge-gateway tokens" }, + { name: "orgs", description: "Organisations and membership - the root of all access" }, + { name: "farms", description: "Sites, each belonging to one organisation" }, + { name: "printers", description: "Machines, each belonging to one farm" }, + { name: "jobs", description: "Print jobs" }, + ], + components: { + securitySchemes: { + supabase: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: + "A Supabase access token. Verified against the project JWKS; " + + "everything except /health and POST /auth/edge-token needs one.", + }, + }, + }, + }, + }), + ) + /** + * One error shape for the whole API, from @continuum/ts-core: every failure + * is `{ error: { code, message, details? } }`. The route handlers throw + * `UnauthorizedError`/`ForbiddenError`/`NotFoundError`/`ConflictError` + * rather than setting a status and returning an ad-hoc object, so an + * authorization failure cannot be forgotten halfway through a handler - the + * throw leaves it. + */ + .onError(toErrorResponse) .get("/health", async () => { const rows = await db.execute<{ ok: number }>(sql`select 1 as ok`); const valkeyPing = await valkey.ping(); return { status: "ok", db: rows[0]?.ok === 1, valkey: valkeyPing === "PONG" }; }) .use(authRoutes) + .use(orgRoutes) .use(farmRoutes) .use(printerRoutes) .use(jobRoutes) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index db9f5b1..76cb90c 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,61 +1,80 @@ import { Elysia, t } from "elysia"; import jwt from "@elysiajs/jwt"; +import { eq } from "drizzle-orm"; +import { UnauthorizedError } from "@continuum/ts-core"; import { db } from "../db"; import { farms } from "../db/schema"; -import { eq } from "drizzle-orm"; +import { listMemberships, requireUser } from "../auth"; + +const EDGE_TOKEN_TTL_SECONDS = 3600; /** - * Verifies Supabase-issued JWTs (HS256, shared secret) and issues short-lived - * edge-gateway tokens scoped to a single farm for the /ws/edge/v1 handshake. + * Two different kinds of authentication meet here, and they are not the same + * mechanism: + * + * - **Users** present a Supabase-issued access token, verified against the + * project's JWKS (RS256, public key, no secret in this process). That is + * `requireUser` and it guards everything user-facing. + * - **Edge gateways** present their farm's `edgeProxyToken` - a credential + * provisioned onto a machine, not held by a person - and trade it for a + * short-lived edge JWT that `/ws/edge/v1` accepts. That one *is* symmetric: + * this service is both its issuer and its only verifier, so there is no + * second party to keep a secret from. + * + * `POST /edge-token` is therefore deliberately not behind `requireUser`: + * a gateway has no user account to log in as. Its `edgeProxyToken` is the + * credential, and it is only ever handed out by + * `POST /farms/:id/edge-proxy-token`, which is behind `requireUser` and + * requires the `owner` role. */ export const authRoutes = new Elysia({ prefix: "/auth" }) - .use( - jwt({ - name: "supabaseJwt", - secret: process.env.SUPABASE_JWT_SECRET ?? "dev-secret-change-me", - }), - ) .use( jwt({ name: "edgeJwt", secret: process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me", }), ) - .post( - "/session", - async ({ supabaseJwt, headers, set }) => { - const authHeader = headers.authorization; - const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined; - if (!token) { - set.status = 401; - return { error: { code: "UNAUTHORIZED", message: "Missing bearer token" } }; - } - - const payload = await supabaseJwt.verify(token); - if (!payload) { - set.status = 401; - return { error: { code: "UNAUTHORIZED", message: "Invalid or expired token" } }; - } - - return { userId: payload.sub, email: payload.email, role: payload.role }; - }, - ) .post( "/edge-token", - async ({ edgeJwt, body, set }) => { - const farm = await db.query.farms.findFirst({ where: eq(farms.id, body.farmId) }); + async ({ edgeJwt, body }) => { + const farm = await db.query.farms.findFirst({ + columns: { id: true, edgeProxyToken: true }, + where: eq(farms.id, body.farmId), + }); + // One message for both "no such farm" and "wrong token": a gateway + // being reprovisioned cannot tell which half it got wrong, but neither + // can someone walking farm ids. if (!farm || farm.edgeProxyToken !== body.edgeProxyToken) { - set.status = 401; - return { error: { code: "UNAUTHORIZED", message: "Unknown farm or bad proxy token" } }; + throw new UnauthorizedError("Unknown farm or bad proxy token"); } - const token = await edgeJwt.sign({ farmId: farm.id, scope: "edge" }); - return { token, expiresInSeconds: 3600 }; + const token = await edgeJwt.sign({ + farmId: farm.id, + scope: "edge", + exp: Math.floor(Date.now() / 1000) + EDGE_TOKEN_TTL_SECONDS, + }); + return { token, expiresInSeconds: EDGE_TOKEN_TTL_SECONDS }; }, { body: t.Object({ farmId: t.String({ format: "uuid" }), - edgeProxyToken: t.String(), + edgeProxyToken: t.String({ minLength: 1 }), }), + detail: { tags: ["auth"], summary: "Trade a farm's proxy token for an edge JWT" }, }, + ) + .use(requireUser) + /** + * Who the caller is and what they can reach. A client calls this once after + * sign-in: the memberships it returns are what every other endpoint + * authorizes against, so a UI can decide what to even offer without + * probing endpoints for 403s. + */ + .get( + "/session", + async ({ user }) => ({ + user, + memberships: await listMemberships(user.id), + }), + { detail: { tags: ["auth"], summary: "The caller's identity and organisation roles" } }, ); diff --git a/src/routes/farms.ts b/src/routes/farms.ts index 2cc7668..dbd95d0 100644 --- a/src/routes/farms.ts +++ b/src/routes/farms.ts @@ -1,38 +1,79 @@ import { Elysia, t } from "elysia"; -import { eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; +import { NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; import { farms } from "../db/schema"; import { farmProjection } from "../db/projections"; +import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth"; +import { PaginationQuery, paginate } from "./pagination"; + +const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); export const farmRoutes = new Elysia({ prefix: "/farms" }) - // A projection rather than `select *`: the table holds - // `edgeProxyToken`, which trades for an edge JWT and so must never leave - // the server in a list or a fetch. See src/db/projections.ts. - .get("/", async () => db.query.farms.findMany({ columns: farmProjection.columns })) - .get("/:id", async ({ params, set }) => { - const farm = await db.query.farms.findFirst({ - columns: farmProjection.columns, - where: eq(farms.id, params.id), - }); - if (!farm) { - set.status = 404; - return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } }; - } - return farm; - }) + .use(requireUser) + /** + * Farms in the caller's organisations. The scoping is in the `where`, not + * applied to the result afterwards: a filter that runs after the query is + * one `return` away from being skipped, and it also makes the database read + * rows it is not allowed to show. + * + * A projection rather than `select *`, because the table holds + * `edgeProxyToken`, which trades for an edge JWT - see src/db/projections.ts. + */ + .get( + "/", + async ({ user, query }) => { + const scope = query.orgId + ? and(visibleFarms(user.id), eq(farms.orgId, query.orgId)) + : visibleFarms(user.id); + return db.query.farms.findMany({ + columns: farmProjection.columns, + where: scope, + orderBy: [desc(farms.createdAt)], + ...paginate(query), + }); + }, + { + query: t.Object({ + orgId: t.Optional(t.String({ format: "uuid" })), + ...PaginationQuery, + }), + detail: { tags: ["farms"], summary: "Farms in the caller's organisations" }, + }, + ) + .get( + "/:id", + async ({ user, params }) => { + const { role } = await requireFarm(user.id, params.id, "viewer"); + const farm = await db.query.farms.findFirst({ + columns: farmProjection.columns, + where: eq(farms.id, params.id), + }); + if (!farm) throw new NotFoundError(`Farm ${params.id} not found`); + return { ...farm, role }; + }, + { params: IdParams, detail: { tags: ["farms"] } }, + ) .post( "/", - async ({ body, set }) => { - // The generated token is deliberately not returned here either. A farm - // is created far more often than a gateway is provisioned, and a - // credential that comes back from a routine create ends up in logs, - // browser history and screenshots. Fetch it when a gateway actually - // needs it, through the endpoint below. + async ({ user, body, set }) => { + // `orgId` comes from the body and is then authorized, which is the + // right way round: the caller says where the farm should go, and the + // check is whether they own that organisation. What must never come + // from the body is *who the caller is* - that is `user.id`, from a + // verified token, and it is why there is no `ownerId` field here any + // more. + await requireOrg(user.id, body.orgId, "owner", "creating a farm"); + + // The generated token is deliberately not returned. A farm is created + // far more often than a gateway is provisioned, and a credential that + // comes back from a routine create ends up in logs, browser history and + // screenshots. Fetch it when a gateway actually needs it, below. const [farm] = await db .insert(farms) .values({ name: body.name, - ownerId: body.ownerId, + orgId: body.orgId, edgeProxyToken: crypto.randomUUID(), location: body.location, }) @@ -42,12 +83,50 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) }, { body: t.Object({ - name: t.String({ minLength: 1 }), - ownerId: t.String({ format: "uuid" }), - location: t.Optional(t.String()), + name: t.String({ minLength: 1, maxLength: 200 }), + orgId: t.String({ format: "uuid" }), + location: t.Optional(t.String({ maxLength: 500 })), }), + detail: { tags: ["farms"] }, }, ) + .patch( + "/:id", + async ({ user, params, body }) => { + await requireFarm(user.id, params.id, "owner", "editing a farm"); + // `orgId` is not patchable here on purpose: moving a farm between + // organisations changes who can see its print history and would need + // the `owner` role in *both*, so it wants its own endpoint rather than + // a field on a general update. + const [farm] = await db + .update(farms) + .set({ ...body, updatedAt: new Date() }) + .where(eq(farms.id, params.id)) + .returning(farmProjection.returning); + if (!farm) throw new NotFoundError(`Farm ${params.id} not found`); + return farm; + }, + { + params: IdParams, + body: t.Object({ + name: t.Optional(t.String({ minLength: 1, maxLength: 200 })), + location: t.Optional(t.String({ maxLength: 500 })), + settings: t.Optional(t.Record(t.String(), t.Unknown())), + }), + detail: { tags: ["farms"] }, + }, + ) + .delete( + "/:id", + async ({ user, params }) => { + await requireFarm(user.id, params.id, "owner", "deleting a farm"); + // Cascades to the farm's printers and print jobs, per the schema's + // foreign keys. Worth knowing before calling it. + await db.delete(farms).where(eq(farms.id, params.id)); + return { deleted: params.id }; + }, + { params: IdParams, detail: { tags: ["farms"] } }, + ) /** * Issues a *new* `edgeProxyToken` for this farm and returns it once. * @@ -62,26 +141,31 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) * token until it is reconfigured, which is the right trade for a credential * that drives physical machines - but it does mean this is not a "show me * the token" button. - * - * NOT YET AUTHENTICATED, like every route here. Until it is, this is a - * better-shaped hole, not a closed one. */ - .post("/:id/edge-proxy-token", async ({ params, set }) => { - const token = crypto.randomUUID(); - const [farm] = await db - .update(farms) - .set({ edgeProxyToken: token, updatedAt: new Date() }) - .where(eq(farms.id, params.id)) - .returning({ id: farms.id }); + .post( + "/:id/edge-proxy-token", + async ({ user, params }) => { + await requireFarm(user.id, params.id, "owner", "rotating a farm's gateway credential"); - if (!farm) { - set.status = 404; - return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } }; - } + const token = crypto.randomUUID(); + const [farm] = await db + .update(farms) + .set({ edgeProxyToken: token, updatedAt: new Date() }) + .where(eq(farms.id, params.id)) + .returning({ id: farms.id }); + if (!farm) throw new NotFoundError(`Farm ${params.id} not found`); - return { - farmId: farm.id, - edgeProxyToken: token, - note: "Any gateway still using the previous token must be reconfigured.", - }; - }); + return { + farmId: farm.id, + edgeProxyToken: token, + note: "Any gateway still using the previous token must be reconfigured.", + }; + }, + { + params: IdParams, + detail: { + tags: ["farms"], + summary: "Rotate and return this farm's edge-proxy token (owner only)", + }, + }, + ); diff --git a/src/routes/jobs.ts b/src/routes/jobs.ts index 924b15b..acfa81f 100644 --- a/src/routes/jobs.ts +++ b/src/routes/jobs.ts @@ -1,27 +1,61 @@ import { Elysia, t } from "elysia"; -import { eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; +import { ConflictError, NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; import { printJobs } from "../db/schema"; import { getUploadUrl, jobFileKey } from "../s3"; +import { requireFarm, requireJob, requireUser, visibleJobs } from "../auth"; +import { PaginationQuery, paginate } from "./pagination"; + +const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); export const jobRoutes = new Elysia({ prefix: "/jobs" }) - .get("/", async ({ query }) => { - if (query.farmId) { - return db.select().from(printJobs).where(eq(printJobs.farmId, query.farmId)); - } - return db.select().from(printJobs); - }, { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) }) - .get("/:id", async ({ params, set }) => { - const job = await db.query.printJobs.findFirst({ where: eq(printJobs.id, params.id) }); - if (!job) { - set.status = 404; - return { error: { code: "NOT_FOUND", message: `Print job ${params.id} not found` } }; - } - return job; - }) + .use(requireUser) + .get( + "/", + async ({ user, query }) => { + const scope = query.farmId + ? and(visibleJobs(user.id), eq(printJobs.farmId, query.farmId)) + : visibleJobs(user.id); + return db.query.printJobs.findMany({ + where: scope, + orderBy: [desc(printJobs.createdAt)], + ...paginate(query), + }); + }, + { + query: t.Object({ + farmId: t.Optional(t.String({ format: "uuid" })), + ...PaginationQuery, + }), + detail: { tags: ["jobs"], summary: "Print jobs in the caller's organisations" }, + }, + ) + .get( + "/:id", + async ({ user, params }) => { + await requireJob(user.id, params.id, "viewer"); + const job = await db.query.printJobs.findFirst({ where: eq(printJobs.id, params.id) }); + if (!job) throw new NotFoundError(`Print job ${params.id} not found`); + return job; + }, + { params: IdParams, detail: { tags: ["jobs"] } }, + ) + /** + * Creates the job row and hands back a presigned URL to PUT the file to. + * + * `operator`, matching printer commands: a queued job is a request to move + * a machine, even though nothing dispatches it yet. Which is the other + * thing to know about this endpoint - it ends at a `queued` row and an + * upload URL. Nothing transitions the status and nothing reaches a gateway, + * because the gateway has no start-print command yet (see the note in + * continuum-proxy's uplink protocol). Jobs accumulate until it does. + */ .post( "/", - async ({ body, set }) => { + async ({ user, body, set }) => { + await requireFarm(user.id, body.farmId, "operator", "submitting a print job"); + const jobId = crypto.randomUUID(); const r2ObjectKey = jobFileKey(body.farmId, jobId, body.fileName); @@ -32,7 +66,10 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" }) farmId: body.farmId, fileName: body.fileName, r2ObjectKey, - submittedBy: body.submittedBy, + // From the verified token, never from the body. It used to be a + // `submittedBy` field the caller filled in, which made the audit + // trail a suggestion rather than a record. + submittedBy: user.id, }) .returning(); @@ -43,9 +80,32 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" }) { body: t.Object({ farmId: t.String({ format: "uuid" }), - fileName: t.String({ minLength: 1 }), + fileName: t.String({ minLength: 1, maxLength: 500 }), contentType: t.Optional(t.String()), - submittedBy: t.String({ format: "uuid" }), }), + detail: { tags: ["jobs"], summary: "Queue a print job and get an upload URL" }, }, + ) + .delete( + "/:id", + async ({ user, params }) => { + await requireJob(user.id, params.id, "operator", "deleting a print job"); + const job = await db.query.printJobs.findFirst({ + columns: { status: true }, + where: eq(printJobs.id, params.id), + }); + if (!job) throw new NotFoundError(`Print job ${params.id} not found`); + + // Only a job that has not reached a printer. Cancelling a running print + // means telling the gateway to stop, which is a command and not a row + // deletion - and deleting the row of a print in progress would leave + // the printer running work nothing is tracking. + if (job.status !== "queued" && job.status !== "uploading") { + throw new ConflictError(`Print job ${params.id} is ${job.status} and cannot be deleted`); + } + + await db.delete(printJobs).where(eq(printJobs.id, params.id)); + return { deleted: params.id }; + }, + { params: IdParams, detail: { tags: ["jobs"], summary: "Delete a job that has not started" } }, ); diff --git a/src/routes/orgs.ts b/src/routes/orgs.ts new file mode 100644 index 0000000..b04fa04 --- /dev/null +++ b/src/routes/orgs.ts @@ -0,0 +1,202 @@ +import { Elysia, t } from "elysia"; +import { and, count, eq, ne } from "drizzle-orm"; +import { ConflictError, NotFoundError } from "@continuum/ts-core"; +import { db } from "../db"; +import { farms, orgMembers, organisations, ORG_ROLES } from "../db/schema"; +import { listMemberships, requireOrg, requireUser } from "../auth"; + +const RoleSchema = t.Union(ORG_ROLES.map((role) => t.Literal(role))); +const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); +const MemberParams = t.Object({ + id: t.String({ format: "uuid" }), + userId: t.String({ format: "uuid" }), +}); + +/** + * Counts the organisation's owners, used to refuse the one change that cannot + * be undone through the API: removing or demoting its last `owner` leaves + * nobody who can add one back, and the org's farms and printers with it. + */ +async function ownersOtherThan(orgId: string, userId: string): Promise { + const [row] = await db + .select({ n: count() }) + .from(orgMembers) + .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.role, "owner"), ne(orgMembers.userId, userId))); + return Number(row?.n ?? 0); +} + +export const orgRoutes = new Elysia({ prefix: "/orgs" }) + .use(requireUser) + /** + * Only the caller's own organisations - there is no endpoint that lists all + * of them, by design. An organisation the caller is not a member of should + * be indistinguishable from one that does not exist. + */ + .get("/", async ({ user }) => listMemberships(user.id), { + detail: { tags: ["orgs"], summary: "Organisations the caller belongs to" }, + }) + .post( + "/", + async ({ user, body, set }) => { + // One transaction: an organisation with no owner is unreachable by + // everyone including its creator, and that is exactly the state a + // failure between these two inserts would leave behind. + const org = await db.transaction(async (tx) => { + const [created] = await tx + .insert(organisations) + .values({ name: body.name }) + .returning(); + await tx + .insert(orgMembers) + .values({ orgId: created!.id, userId: user.id, role: "owner" }); + return created!; + }); + set.status = 201; + return { ...org, role: "owner" as const }; + }, + { + body: t.Object({ name: t.String({ minLength: 1, maxLength: 200 }) }), + detail: { tags: ["orgs"], summary: "Create an organisation, owned by the caller" }, + }, + ) + .get( + "/:id", + async ({ user, params }) => { + const role = await requireOrg(user.id, params.id, "viewer"); + const org = await db.query.organisations.findFirst({ + where: eq(organisations.id, params.id), + }); + // Membership resolved, so the row is there; the throw is for the + // vanishingly small window where it was deleted in between. + if (!org) throw new NotFoundError(`Organisation ${params.id} not found`); + return { ...org, role }; + }, + { params: IdParams, detail: { tags: ["orgs"] } }, + ) + .patch( + "/:id", + async ({ user, params, body }) => { + await requireOrg(user.id, params.id, "owner", "renaming an organisation"); + const [org] = await db + .update(organisations) + .set({ ...body, updatedAt: new Date() }) + .where(eq(organisations.id, params.id)) + .returning(); + if (!org) throw new NotFoundError(`Organisation ${params.id} not found`); + return org; + }, + { + params: IdParams, + body: t.Object({ + name: t.Optional(t.String({ minLength: 1, maxLength: 200 })), + settings: t.Optional(t.Record(t.String(), t.Unknown())), + }), + detail: { tags: ["orgs"] }, + }, + ) + .delete( + "/:id", + async ({ user, params }) => { + await requireOrg(user.id, params.id, "owner", "deleting an organisation"); + + // Deleting the org cascades to its farms, their printers, every print + // job and every stored frame embedding. That is far too much to lose to + // a mistyped id, so this refuses while any farm is still attached and + // makes the caller delete them one at a time first. + const [attached] = await db + .select({ n: count() }) + .from(farms) + .where(eq(farms.orgId, params.id)); + if (Number(attached?.n ?? 0) > 0) { + throw new ConflictError( + `Organisation ${params.id} still has ${attached!.n} farm(s); delete them first`, + ); + } + + await db.delete(organisations).where(eq(organisations.id, params.id)); + return { deleted: params.id }; + }, + { params: IdParams, detail: { tags: ["orgs"] } }, + ) + .get( + "/:id/members", + async ({ user, params }) => { + await requireOrg(user.id, params.id, "viewer"); + return db + .select({ + userId: orgMembers.userId, + role: orgMembers.role, + joinedAt: orgMembers.createdAt, + }) + .from(orgMembers) + .where(eq(orgMembers.orgId, params.id)); + }, + { params: IdParams, detail: { tags: ["orgs"], summary: "List members and their roles" } }, + ) + /** + * Add a member or change their role. PUT rather than POST: the caller + * states the role this user should have, and sending it twice is the same + * as sending it once. + * + * There is no invitation flow yet - this takes a Supabase user id directly, + * so adding someone means knowing their id. That is the right shape for an + * API and the wrong shape for a UI, which will need an invite-by-email + * endpoint on top of it. + */ + .put( + "/:id/members/:userId", + async ({ user, params, body }) => { + await requireOrg(user.id, params.id, "owner", "managing membership"); + + // Demoting the last owner is the same mistake as deleting them, so it + // is refused the same way. Written against "owners other than this + // one" rather than "is this me" so it also covers one owner demoting + // another, which is the same hole from the other side. + if (body.role !== "owner" && (await ownersOtherThan(params.id, params.userId)) === 0) { + throw new ConflictError( + params.userId === user.id + ? "You are the only owner of this organisation; promote another owner before demoting yourself" + : "That is the only owner of this organisation; promote another owner first", + ); + } + + const [member] = await db + .insert(orgMembers) + .values({ orgId: params.id, userId: params.userId, role: body.role }) + .onConflictDoUpdate({ + target: [orgMembers.orgId, orgMembers.userId], + set: { role: body.role, updatedAt: new Date() }, + }) + .returning({ + userId: orgMembers.userId, + role: orgMembers.role, + joinedAt: orgMembers.createdAt, + }); + return member; + }, + { + params: MemberParams, + body: t.Object({ role: RoleSchema }), + detail: { tags: ["orgs"], summary: "Add a member or change their role" }, + }, + ) + .delete( + "/:id/members/:userId", + async ({ user, params }) => { + await requireOrg(user.id, params.id, "owner", "managing membership"); + + if ((await ownersOtherThan(params.id, params.userId)) === 0) { + throw new ConflictError( + "That is the only owner of this organisation; promote another owner first", + ); + } + + const [removed] = await db + .delete(orgMembers) + .where(and(eq(orgMembers.orgId, params.id), eq(orgMembers.userId, params.userId))) + .returning({ userId: orgMembers.userId }); + if (!removed) throw new NotFoundError(`User ${params.userId} is not a member`); + return { removed: removed.userId }; + }, + { params: MemberParams, detail: { tags: ["orgs"], summary: "Remove a member" } }, + ); diff --git a/src/routes/pagination.ts b/src/routes/pagination.ts new file mode 100644 index 0000000..ce199e3 --- /dev/null +++ b/src/routes/pagination.ts @@ -0,0 +1,21 @@ +import { t } from "elysia"; + +/** + * Shared `limit`/`offset` query parameters for the list endpoints. + * + * Every list here is unbounded in principle - a farm accumulates print jobs + * forever - so a default limit is applied whether or not the caller asks for + * one. `maximum` is enforced by the validator rather than silently clamped: + * a client that asks for 10,000 rows should be told no, not handed 200 and + * left to conclude that is all there is. + */ +export const PaginationQuery = { + limit: t.Optional(t.Integer({ minimum: 1, maximum: 200, default: 50 })), + offset: t.Optional(t.Integer({ minimum: 0, default: 0 })), +}; + +export const DEFAULT_LIMIT = 50; + +export function paginate(query: { limit?: number; offset?: number }) { + return { limit: query.limit ?? DEFAULT_LIMIT, offset: query.offset ?? 0 }; +} diff --git a/src/routes/printers.ts b/src/routes/printers.ts index 4b1b834..e49953f 100644 --- a/src/routes/printers.ts +++ b/src/routes/printers.ts @@ -1,52 +1,145 @@ import { Elysia, t } from "elysia"; -import { eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; +import { NotFoundError } from "@continuum/ts-core"; +import { PrinterCommandSchema } from "@continuum/ts-types"; import { db } from "../db"; import { printers } from "../db/schema"; import { PrinterInsertSchema } from "../db/validators"; import { printerProjection } from "../db/projections"; -import { PrinterCommandSchema } from "@continuum/ts-types"; +import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth"; +import { PaginationQuery, paginate } from "./pagination"; import { isGatewayOnline, sendPrinterCommand } from "./ws/edge"; +const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); + +/** + * The columns a client may set, derived from the table's insert schema so a + * renamed column still fails to compile. + * + * Everything left out is owned by the gateway, not by the API: `status`, + * the temperatures, layer counts, progress, `vendorState`, `lastSeenAt` and + * the rest are written only by `persistTelemetry` in routes/ws/edge.ts. A + * client that could POST `status: "idle"` could make the scheduler send work + * to a printer that is mid-print. + */ +const PRINTER_WRITABLE = [ + "farmId", + "name", + "vendor", + "lanAddress", + "serialNumber", + "accessCode", + "gatewayPrinterId", + "metadata", +] as const; + +const PrinterCreateSchema = t.Pick(PrinterInsertSchema, PRINTER_WRITABLE); +// `farmId` omitted: moving a printer to another farm would move its print +// history across an authorization boundary, so it is not a field on an update. +const PrinterUpdateSchema = t.Partial( + t.Omit(PrinterCreateSchema, ["farmId"]), +); + export const printerRoutes = new Elysia({ prefix: "/printers" }) - // A projection rather than `select *`: `printers.accessCode` is the - // printer's LAN password (Bambu uses it for MQTT and as the FTPS password) - // and must not leave the server. See src/db/projections.ts. - .get("/", async ({ query }) => db.query.printers.findMany({ - columns: printerProjection.columns, - where: query.farmId ? eq(printers.farmId, query.farmId) : undefined, - }), { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) }) - .get("/:id", async ({ params, set }) => { - const printer = await db.query.printers.findFirst({ - columns: printerProjection.columns, - where: eq(printers.id, params.id), - }); - if (!printer) { - set.status = 404; - return { error: { code: "NOT_FOUND", message: `Printer ${params.id} not found` } }; - } - return printer; - }) + .use(requireUser) + // Scoped through farm -> org membership, and a projection rather than + // `select *`: `printers.accessCode` is the printer's LAN password (Bambu + // uses it for MQTT and as the FTPS password) and must not leave the server. + // See src/db/projections.ts. + .get( + "/", + async ({ user, query }) => { + const scope = query.farmId + ? and(visiblePrinters(user.id), eq(printers.farmId, query.farmId)) + : visiblePrinters(user.id); + return db.query.printers.findMany({ + columns: printerProjection.columns, + where: scope, + orderBy: [desc(printers.createdAt)], + ...paginate(query), + }); + }, + { + query: t.Object({ + farmId: t.Optional(t.String({ format: "uuid" })), + ...PaginationQuery, + }), + detail: { tags: ["printers"], summary: "Printers in the caller's organisations" }, + }, + ) + .get( + "/:id", + async ({ user, params }) => { + await requirePrinter(user.id, params.id, "viewer"); + const printer = await db.query.printers.findFirst({ + columns: printerProjection.columns, + where: eq(printers.id, params.id), + }); + if (!printer) throw new NotFoundError(`Printer ${params.id} not found`); + return printer; + }, + { params: IdParams, detail: { tags: ["printers"] } }, + ) .post( "/", - async ({ body, set }) => { - // `body` is already validated + shaped against the Drizzle table by - // PrinterInsertSchema, so it can go straight into `.values()` with no - // hand-mapping of individual fields. - const [printer] = await db.insert(printers).values(body).returning(printerProjection.returning); + async ({ user, body, set }) => { + await requireFarm(user.id, body.farmId, "owner", "adding a printer"); + const [printer] = await db + .insert(printers) + .values(body) + .returning(printerProjection.returning); set.status = 201; return printer; }, - // Derived from `printers` via drizzle-typebox - see src/db/validators.ts. - // Add or rename a column in the schema and this validator updates itself. - { body: PrinterInsertSchema }, + { body: PrinterCreateSchema, detail: { tags: ["printers"] } }, ) + .patch( + "/:id", + async ({ user, params, body }) => { + await requirePrinter(user.id, params.id, "owner", "editing a printer"); + const [printer] = await db + .update(printers) + .set({ ...body, updatedAt: new Date() }) + .where(eq(printers.id, params.id)) + .returning(printerProjection.returning); + if (!printer) throw new NotFoundError(`Printer ${params.id} not found`); + return printer; + }, + { + params: IdParams, + body: PrinterUpdateSchema, + detail: { + tags: ["printers"], + summary: "Edit a printer's configuration (name, address, credentials, gateway id)", + }, + }, + ) + .delete( + "/:id", + async ({ user, params }) => { + await requirePrinter(user.id, params.id, "owner", "deleting a printer"); + await db.delete(printers).where(eq(printers.id, params.id)); + return { deleted: params.id }; + }, + { params: IdParams, detail: { tags: ["printers"] } }, + ) + /** + * Sends a command to the printer through its farm's gateway and waits for + * the gateway's answer. + * + * `operator`, not `viewer`: every command in the set moves or heats a + * physical machine, and the blast radius of getting this boundary wrong is + * a ruined print or a hot end driven into a plate. It is the one role + * boundary here that is not about data. + */ .post( "/:id/commands", - async ({ params, body, set }) => { + async ({ user, params, body, set }) => { + await requirePrinter(user.id, params.id, "operator", "sending a printer command"); try { const result = await sendPrinterCommand(params.id, body); if (!result.ok) { - // 502, not 400: the request was fine and was delivered: it was the + // 502, not 400: the request was fine and was delivered; it was the // printer or its gateway that would not carry it out. set.status = 502; return { @@ -64,7 +157,10 @@ export const printerRoutes = new Elysia({ prefix: "/printers" }) // its farm has no gateway connected right now. set.status = 409; return { - error: { code: "PRINTER_UNREACHABLE", message: err instanceof Error ? err.message : String(err) }, + error: { + code: "PRINTER_UNREACHABLE", + message: err instanceof Error ? err.message : String(err), + }, }; } }, @@ -72,22 +168,24 @@ export const printerRoutes = new Elysia({ prefix: "/printers" }) // schema - so this route cannot drift into offering something no gateway // can execute. Note what is absent: there is no "start a print" command // yet, deliberately. - { body: PrinterCommandSchema }, + { params: IdParams, body: PrinterCommandSchema, detail: { tags: ["printers"] } }, ) - .get("/:id/gateway", async ({ params, set }) => { - const printer = await db.query.printers.findFirst({ - columns: { farmId: true, gatewayPrinterId: true }, - where: eq(printers.id, params.id), - }); - if (!printer) { - set.status = 404; - return { error: { code: "NOT_FOUND", message: `Printer ${params.id} not found` } }; - } - // Whether a command sent right now could even be delivered - worth being - // able to ask before trying, since the answer changes on its own. - return { - claimed: printer.gatewayPrinterId !== null, - gatewayPrinterId: printer.gatewayPrinterId, - gatewayOnline: isGatewayOnline(printer.farmId), - }; - }); + .get( + "/:id/gateway", + async ({ user, params }) => { + await requirePrinter(user.id, params.id, "viewer"); + const printer = await db.query.printers.findFirst({ + columns: { farmId: true, gatewayPrinterId: true }, + where: eq(printers.id, params.id), + }); + if (!printer) throw new NotFoundError(`Printer ${params.id} not found`); + // Whether a command sent right now could even be delivered - worth being + // able to ask before trying, since the answer changes on its own. + return { + claimed: printer.gatewayPrinterId !== null, + gatewayPrinterId: printer.gatewayPrinterId, + gatewayOnline: isGatewayOnline(printer.farmId), + }; + }, + { params: IdParams, detail: { tags: ["printers"] } }, + ); diff --git a/tests/authz.test.ts b/tests/authz.test.ts new file mode 100644 index 0000000..7651397 --- /dev/null +++ b/tests/authz.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; +import { Elysia } from "elysia"; +import { toErrorResponse } from "@continuum/ts-core"; +import { roleSatisfies, ORG_ROLES } from "../src/auth/roles"; +import { startDevAuthIssuer } from "../scripts/dev-auth"; + +const USER = "11111111-1111-1111-1111-111111111111"; + +describe("the role ladder", () => { + test("is ordered viewer < operator < owner", () => { + expect(ORG_ROLES).toEqual(["viewer", "operator", "owner"]); + }); + + // Written out in full rather than derived from the ladder: a table that + // computes the expected answer the same way the implementation does agrees + // with a wrong implementation. + const cases: Array<[string, string, boolean]> = [ + ["viewer", "viewer", true], + ["viewer", "operator", false], + ["viewer", "owner", false], + ["operator", "viewer", true], + ["operator", "operator", true], + ["operator", "owner", false], + ["owner", "viewer", true], + ["owner", "operator", true], + ["owner", "owner", true], + ]; + + for (const [actual, minimum, expected] of cases) { + test(`'${actual}' ${expected ? "satisfies" : "does not satisfy"} '${minimum}'`, () => { + expect(roleSatisfies(actual as never, minimum as never)).toBe(expected); + }); + } + + test("a viewer cannot send printer commands", () => { + // The boundary that moves physical machines, called out on its own + // because it is the one that is expensive to get wrong. + expect(roleSatisfies("viewer", "operator")).toBe(false); + }); +}); + +/** + * Exercises the real verification path - `createRemoteJWKSet` against a real + * JWKS endpoint over HTTP - rather than a stubbed verifier. The things that + * actually go wrong here (an unsigned token accepted, a token signed by + * another key accepted, an expired token accepted) are all invisible to a + * test that mocks the verifier out. + */ +describe("requireUser", () => { + let issuer: Awaited>; + let app: Elysia; + + beforeAll(async () => { + issuer = await startDevAuthIssuer(); + process.env.SUPABASE_URL = issuer.url; + // Imported after SUPABASE_URL is set: the verifier is built at module + // load so a misconfigured deployment fails at boot, not on first request. + const { requireUser } = await import("../src/auth/plugin"); + app = new Elysia().onError(toErrorResponse).use(requireUser).get("/whoami", ({ user }) => user); + }); + + afterAll(() => issuer.stop()); + + const get = (headers: Record = {}) => + app.handle(new Request("http://localhost/whoami", { headers })); + + test("accepts a properly signed token and exposes the subject", async () => { + const token = await issuer.mint({ sub: USER, email: "farm@example.test" }); + const response = await get({ authorization: `Bearer ${token}` }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id: USER, email: "farm@example.test" }); + }); + + test("rejects a request with no Authorization header", async () => { + const response = await get(); + expect(response.status).toBe(401); + expect((await response.json()).error.code).toBe("UNAUTHORIZED"); + }); + + test("rejects a non-Bearer Authorization header", async () => { + const response = await get({ authorization: "Basic dXNlcjpwYXNz" }); + expect(response.status).toBe(401); + }); + + test("rejects a token signed by a key that is not in the JWKS", async () => { + const token = await issuer.mintWithForeignKey({ sub: USER }); + const response = await get({ authorization: `Bearer ${token}` }); + expect(response.status).toBe(401); + }); + + test("rejects an expired token", async () => { + const token = await issuer.mint({ sub: USER, expiresIn: -60 }); + const response = await get({ authorization: `Bearer ${token}` }); + expect(response.status).toBe(401); + }); + + test("rejects a token issued for another audience", async () => { + // Supabase issues `aud: "authenticated"` for user sessions. Its anon and + // service-role keys are also valid project JWTs with other audiences, so + // without this check the public anon key would authenticate as a user. + const token = await issuer.mint({ sub: USER, audience: "anon" }); + const response = await get({ authorization: `Bearer ${token}` }); + expect(response.status).toBe(401); + }); + + test("does not say why a token was rejected", async () => { + // Distinguishing "expired" from "bad signature" tells whoever is probing + // which part of a forged token to fix next. + const expired = await get({ + authorization: `Bearer ${await issuer.mint({ sub: USER, expiresIn: -60 })}`, + }); + const foreign = await get({ + authorization: `Bearer ${await issuer.mintWithForeignKey({ sub: USER })}`, + }); + expect((await expired.json()).error.message).toBe((await foreign.json()).error.message); + }); +}); + +describe("configuration", () => { + test("the process refuses to start without SUPABASE_URL", async () => { + // Fail at boot, not on the first authenticated request: a backend that + // starts and then 500s on every login looks like an outage somewhere + // else. Run in a subprocess because the module caches its verifier. + const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/verifier.ts')"], { + cwd: import.meta.dir + "/..", + env: { ...process.env, SUPABASE_URL: "" }, + stderr: "pipe", + stdout: "pipe", + }); + expect(await proc.exited).not.toBe(0); + expect(await new Response(proc.stderr).text()).toContain("SUPABASE_URL"); + }); +});