From fc8cf1d509e3943ab94caa71932b51f62cdb9c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Fri, 30 Oct 2026 08:13:00 +0000 Subject: [PATCH] Add organisation invitations and one-time-code gateway enrolment Invitations: token shown once, only its hash stored, 7-day expiry, the accepting account's email must match. Enrolment codes: 15 minutes, single use, hashed; POST /auth/enrol returns the farm's existing gateway token without rotating it and is rate-limited per IP. No email is sent; the inviter shares the link. Migration 0007, unit tests and scripts/e2e-onboarding.sh. --- drizzle/0007_onboarding.sql | 40 ++ drizzle/meta/0007_snapshot.json | 1058 +++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/e2e-onboarding.sh | 154 +++++ src/db/schema/index.ts | 1 + src/db/schema/onboarding.ts | 51 ++ src/db/schema/orgs.ts | 6 + src/index.ts | 3 +- src/onboarding/tokens.ts | 62 ++ src/routes/auth.ts | 61 +- src/routes/farms.ts | 32 +- src/routes/orgs.ts | 177 +++++- tests/onboarding.test.ts | 52 ++ 13 files changed, 1692 insertions(+), 12 deletions(-) create mode 100644 drizzle/0007_onboarding.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100755 scripts/e2e-onboarding.sh create mode 100644 src/db/schema/onboarding.ts create mode 100644 src/onboarding/tokens.ts create mode 100644 tests/onboarding.test.ts diff --git a/drizzle/0007_onboarding.sql b/drizzle/0007_onboarding.sql new file mode 100644 index 0000000..1eb9499 --- /dev/null +++ b/drizzle/0007_onboarding.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS "enrolment_codes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "farm_id" uuid NOT NULL, + "code_hash" text NOT NULL, + "created_by" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "used_at" timestamp with time zone, + CONSTRAINT "enrolment_codes_code_hash_unique" UNIQUE("code_hash") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "org_invitations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "org_id" uuid NOT NULL, + "email" text NOT NULL, + "role" "org_role" NOT NULL, + "token_hash" text NOT NULL, + "invited_by" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "accepted_at" timestamp with time zone, + "accepted_by" uuid, + CONSTRAINT "org_invitations_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +ALTER TABLE "org_members" ADD COLUMN "email" text;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "enrolment_codes" ADD CONSTRAINT "enrolment_codes_farm_id_farms_id_fk" FOREIGN KEY ("farm_id") REFERENCES "public"."farms"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "org_invitations" ADD CONSTRAINT "org_invitations_org_id_organisations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "enrolment_codes_farm_idx" ON "enrolment_codes" USING btree ("farm_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "org_invitations_org_idx" ON "org_invitations" USING btree ("org_id"); \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..c0d0e7e --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1058 @@ +{ + "id": "8c145e60-4c5d-4603-ba85-148c84b48e8b", + "prevId": "97bbd615-092b-4859-9b89-de9c6295a4f9", + "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'" + }, + "email": { + "name": "email", + "type": "text", + "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": { + "org_members_user_id_idx": { + "name": "org_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_members_org_id_organisations_id_fk": { + "name": "org_members_org_id_organisations_id_fk", + "tableFrom": "org_members", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_members_org_id_user_id_pk": { + "name": "org_members_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organisations": { + "name": "organisations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.farms": { + "name": "farms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "edge_proxy_token": { + "name": "edge_proxy_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "farms_org_id_idx": { + "name": "farms_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "farms_org_id_organisations_id_fk": { + "name": "farms_org_id_organisations_id_fk", + "tableFrom": "farms", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.printers": { + "name": "printers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "printer_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "printer_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "lan_address": { + "name": "lan_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serial_number": { + "name": "serial_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_printer_id": { + "name": "gateway_printer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nozzle_temp_c": { + "name": "nozzle_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "nozzle_target_temp_c": { + "name": "nozzle_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_temp_c": { + "name": "bed_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_target_temp_c": { + "name": "bed_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "progress_percent": { + "name": "progress_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_layer": { + "name": "current_layer", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_layers": { + "name": "total_layers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining_minutes": { + "name": "remaining_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_job_name": { + "name": "current_job_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_alerts": { + "name": "active_alerts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "camera_stream": { + "name": "camera_stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "camera_snapshot": { + "name": "camera_snapshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "vendor_state": { + "name": "vendor_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_error_code": { + "name": "vendor_error_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "printers_farm_gateway_printer_id_idx": { + "name": "printers_farm_gateway_printer_id_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_printer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "printers_farm_id_farms_id_fk": { + "name": "printers_farm_id_farms_id_fk", + "tableFrom": "printers", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.print_jobs": { + "name": "print_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "print_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "progress_pct": { + "name": "progress_pct", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_time_s": { + "name": "estimated_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "elapsed_time_s": { + "name": "elapsed_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "print_jobs_farm_id_farms_id_fk": { + "name": "print_jobs_farm_id_farms_id_fk", + "tableFrom": "print_jobs", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "print_jobs_printer_id_printers_id_fk": { + "name": "print_jobs_printer_id_printers_id_fk", + "tableFrom": "print_jobs", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.frame_embeddings": { + "name": "frame_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "print_job_id": { + "name": "print_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(512)", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "frame_embeddings_embedding_ivfflat_idx": { + "name": "frame_embeddings_embedding_ivfflat_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "ivfflat", + "with": { + "lists": 100 + } + } + }, + "foreignKeys": { + "frame_embeddings_print_job_id_print_jobs_id_fk": { + "name": "frame_embeddings_print_job_id_print_jobs_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "print_jobs", + "columnsFrom": [ + "print_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frame_embeddings_printer_id_printers_id_fk": { + "name": "frame_embeddings_printer_id_printers_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.printer_alerts": { + "name": "printer_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "alert_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "printer_alerts_farm_open_idx": { + "name": "printer_alerts_farm_open_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "acknowledged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "printer_alerts_farm_id_farms_id_fk": { + "name": "printer_alerts_farm_id_farms_id_fk", + "tableFrom": "printer_alerts", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "printer_alerts_printer_id_printers_id_fk": { + "name": "printer_alerts_printer_id_printers_id_fk", + "tableFrom": "printer_alerts", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.enrolment_codes": { + "name": "enrolment_codes", + "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 + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "enrolment_codes_farm_idx": { + "name": "enrolment_codes_farm_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrolment_codes_farm_id_farms_id_fk": { + "name": "enrolment_codes_farm_id_farms_id_fk", + "tableFrom": "enrolment_codes", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "enrolment_codes_code_hash_unique": { + "name": "enrolment_codes_code_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "code_hash" + ] + } + } + }, + "public.org_invitations": { + "name": "org_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "org_invitations_org_idx": { + "name": "org_invitations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invitations_org_id_organisations_id_fk": { + "name": "org_invitations_org_id_organisations_id_fk", + "tableFrom": "org_invitations", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invitations_token_hash_unique": { + "name": "org_invitations_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + } + } + }, + "enums": { + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "viewer", + "operator", + "owner" + ] + }, + "public.printer_status": { + "name": "printer_status", + "schema": "public", + "values": [ + "offline", + "idle", + "printing", + "paused", + "error", + "maintenance" + ] + }, + "public.printer_vendor": { + "name": "printer_vendor", + "schema": "public", + "values": [ + "bambu", + "prusa", + "klipper", + "octoprint" + ] + }, + "public.print_job_status": { + "name": "print_job_status", + "schema": "public", + "values": [ + "queued", + "uploading", + "printing", + "paused", + "completed", + "failed", + "cancelled" + ] + }, + "public.alert_kind": { + "name": "alert_kind", + "schema": "public", + "values": [ + "print_failed", + "print_paused", + "printer_error", + "ai_detection" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index de5d88b..57fe92f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1793346305671, "tag": "0006_octoprint_vendor", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1793347631289, + "tag": "0007_onboarding", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/e2e-onboarding.sh b/scripts/e2e-onboarding.sh new file mode 100755 index 0000000..2f137f6 --- /dev/null +++ b/scripts/e2e-onboarding.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Onboarding end to end, against the real routes and database: inviting +# someone by email, and connecting a gateway with a one-time code instead of +# copying a token. With the proxy built, the last step runs the real +# `continuum-proxy enrol` against the live backend and starts the proxy on +# what it wrote. +# +# Nothing here sends an email: the invitation link is returned to the inviter +# (see the invitations route). +# +# scripts/dev-env.sh up && scripts/dev-env.sh schema +# scripts/e2e-onboarding.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" +PORT="${PORT:-3064}"; AUTH_PORT="${AUTH_PORT:-3065}" +BASE="http://localhost:$PORT" +PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}" +DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}" +VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}" +EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}" +export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT +export R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}" R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}" R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}" +WORK="$(mktemp -d)" +BACKEND_PID=""; AUTH_PID=""; PROXY_PID=""; ORG_ID=""; FAILURES=0 +psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; } +new_uuid() { cat /proc/sys/kernel/random/uuid; } +fail() { echo "FAIL: $*" >&2; exit 1; } +ok() { echo " ok: $*"; } +bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); } +cleanup() { + local rc=$? + for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done + sleep 0.5 + for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done + [ -z "${KEEP_DATA:-}" ] && [ -n "$ORG_ID" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true + if [ $rc -ne 0 ]; then echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true; fi + rm -rf "$WORK"; exit $rc +} +trap cleanup EXIT + +req() { # method path token [json] -> status; body in $WORK/last.json + local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$1" "$BASE$2") + [ "$3" != "-" ] && args+=(-H "authorization: Bearer $3") + [ -n "${4:-}" ] && args+=(-H 'content-type: application/json' -d "$4") + curl "${args[@]}" +} +expect() { # code method path token [body] -- what + local want="$1" method="$2" path="$3" token="$4"; shift 4 + local body=""; [ "${1:-}" != "--" ] && { body="$1"; shift; }; shift + local got; got="$(req "$method" "$path" "$token" "$body")" + [ "$got" = "$want" ] && ok "$* ($got)" || bad "$*: expected $want, got $got - $(head -c 250 "$WORK/last.json")" +} +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; } + +bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 & +AUTH_PID=$! +AUTH_URL="http://localhost:$AUTH_PORT" +for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done +export SUPABASE_URL="$AUTH_URL" +mint() { curl -sf "$AUTH_URL/token?sub=$1&email=$2" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; } +DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 & +BACKEND_PID=$! +for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done +curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy" + +OWNER_ID="$(new_uuid)"; GUEST_ID="$(new_uuid)"; STRANGER_ID="$(new_uuid)" +TAG="$(new_uuid | cut -c1-8)" +OWNER="$(mint "$OWNER_ID" "owner-$TAG@example.test")" +GUEST="$(mint "$GUEST_ID" "Guest-$TAG@Example.Test")" +STRANGER="$(mint "$STRANGER_ID" "stranger-$TAG@example.test")" + +echo "==> an organisation and a farm" +expect 201 POST /orgs "$OWNER" '{"name":"e2e onboarding org"}' -- "owner creates an org" +ORG_ID="$(json 'd["id"]')" +expect 201 POST /farms "$OWNER" "{\"name\":\"e2e onboarding farm\",\"orgId\":\"$ORG_ID\"}" -- "owner creates a farm" +FARM_ID="$(json 'd["id"]')" + +echo "==> inviting by email" +expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"guest-$TAG@example.test\",\"role\":\"operator\"}" -- "owner invites an address" +TOKEN="$(json 'd["token"]')"; INV_ID="$(json 'd["id"]')" +grep -q "$TOKEN" <<<"$(psql_q "SELECT token_hash FROM org_invitations WHERE id='$INV_ID'")" && bad "the token is stored in the clear" || ok "only a hash of the token is stored" +expect 404 POST "/orgs/$ORG_ID/invitations" "$GUEST" "{\"email\":\"x-$TAG@example.test\",\"role\":\"viewer\"}" -- "a non-member cannot invite (the org does not exist for them)" +expect 200 GET "/orgs/$ORG_ID/invitations" "$OWNER" -- "owner lists pending invitations" +[ "$(json 'len(d)')" = 1 ] && ok "one pending" || bad "pending list: $(cat "$WORK/last.json")" +grep -q "$TOKEN" "$WORK/last.json" && bad "the list leaks the token" || ok "the list does not show the token" + +echo "==> accepting" +expect 403 POST /invitations/accept "$STRANGER" "{\"token\":\"$TOKEN\"}" -- "someone signed in with another address cannot use the link" +expect 404 POST /invitations/accept "$GUEST" '{"token":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}' -- "a made-up token is refused" +expect 200 POST /invitations/accept "$GUEST" "{\"token\":\"$TOKEN\"}" -- "the invited address accepts (case-insensitively)" +[ "$(json 'd["role"]')" = operator ] && ok "joined as operator" || bad "role: $(cat "$WORK/last.json")" +expect 404 POST /invitations/accept "$GUEST" "{\"token\":\"$TOKEN\"}" -- "the link works once" +expect 200 GET "/orgs/$ORG_ID/members" "$OWNER" -- "owner lists members" +grep -q "guest-$TAG@example.test" "$WORK/last.json" && ok "the member list shows the email" || bad "no email in $(cat "$WORK/last.json")" +expect 200 GET "/orgs/$ORG_ID" "$GUEST" -- "the new member can see the org" + +echo "==> expiry and revocation" +expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"stranger-$TAG@example.test\",\"role\":\"viewer\"}" -- "a second invitation" +T2="$(json 'd["token"]')"; I2="$(json 'd["id"]')" +psql_q "UPDATE org_invitations SET expires_at = now() - interval '1 minute' WHERE id='$I2'" >/dev/null +expect 404 POST /invitations/accept "$STRANGER" "{\"token\":\"$T2\"}" -- "an expired invitation is refused" +expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"stranger-$TAG@example.test\",\"role\":\"viewer\"}" -- "re-inviting issues a fresh one" +T3="$(json 'd["token"]')"; I3="$(json 'd["id"]')" +expect 200 DELETE "/orgs/$ORG_ID/invitations/$I3" "$OWNER" -- "owner revokes it" +expect 404 POST /invitations/accept "$STRANGER" "{\"token\":\"$T3\"}" -- "a revoked invitation is refused" + +echo "==> enrolling a gateway with a one-time code" +expect 403 POST "/farms/$FARM_ID/enrolment-codes" "$GUEST" -- "an operator cannot make an enrolment code" +expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "owner makes a code" +CODE="$(json 'd["code"]')" +[ "$(psql_q "SELECT count(*) FROM enrolment_codes WHERE code_hash = '$CODE'")" = 0 ] && ok "the code is not stored in the clear" || bad "code stored in the clear" +TYPED="$(echo "$CODE" | tr 'A-Z' 'a-z' | tr -d '-')" +expect 200 POST /auth/enrol - "{\"code\":\"$TYPED\"}" -- "the gateway trades the code (typed sloppily)" +GW_TOKEN="$(json 'd["gatewayToken"]')" +[ "$(json 'd["farmId"]')" = "$FARM_ID" ] && ok "it is told which farm" || bad "farm: $(cat "$WORK/last.json")" +expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_ID\",\"edgeProxyToken\":\"$GW_TOKEN\"}" -- "the credential it got works" +expect 401 POST /auth/enrol - "{\"code\":\"$CODE\"}" -- "a code works once" +expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a second code" +C2="$(json 'd["code"]')" +expect 200 POST /auth/enrol - "{\"code\":\"$C2\"}" -- "enrolling a second machine" +[ "$(json 'd["gatewayToken"]')" = "$GW_TOKEN" ] && ok "the farm's token was not rotated, so the first gateway still works" || bad "the token changed" +expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a third code, to expire" +C3="$(json 'd["code"]')" +psql_q "UPDATE enrolment_codes SET expires_at = now() - interval '1 minute' WHERE farm_id='$FARM_ID' AND used_at IS NULL" >/dev/null +expect 401 POST /auth/enrol - "{\"code\":\"$C3\"}" -- "an expired code is refused" + +if [ -x "$PROXY_DIR/target/debug/continuum-proxy" ] && "$PROXY_DIR/target/debug/continuum-proxy" enrol --help >/dev/null 2>&1; then + echo "==> the real proxy enrols itself" + expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a code for the proxy" + PCODE="$(json 'd["code"]')" + CONF="$WORK/proxy.toml" + ( cd "$WORK" && CONTINUUM_CONFIG_PATH="$CONF" "$PROXY_DIR/target/debug/continuum-proxy" enrol "$BASE" "$PCODE" > "$WORK/enrol.log" 2>&1 ) || { cat "$WORK/enrol.log"; fail "proxy enrol failed"; } + [ "$(stat -c %a "$CONF")" = 600 ] && ok "the config it wrote is owner-only" || bad "config mode $(stat -c %a "$CONF")" + grep -q "$PCODE" "$WORK/enrol.log" && bad "the code is in the proxy output" || ok "the code is not echoed" + grep -q "gateway_token" "$CONF" && grep -q "farm_id = \"$FARM_ID\"" "$CONF" && ok "farm and token are in the config" || bad "config: $(sed 's/gateway_token.*/gateway_token = /' "$CONF")" + env -C "$PROXY_DIR" CONTINUUM_CONFIG_PATH="$CONF" CONTINUUM_GO2RTC_BIN=/nonexistent CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ + CONTINUUM_PRINTERS_PATH="$WORK/none.toml" CONTINUUM_STORE_PATH="$WORK/store/continuum.db" RUST_LOG=info \ + "$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 & + PROXY_PID=$! + for _ in $(seq 1 40); do grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && break; sleep 0.5; done + grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && ok "it started on that config alone and the cloud accepted it" || bad "the enrolled proxy never connected: $(tail -5 "$WORK/proxy.log")" +else + echo " (skipping the proxy step: build continuum-proxy with the enrol command first)" +fi + +# Last, because it blocks this address from enrolling for ten minutes. +echo "==> guessing" +for i in $(seq 1 12); do req POST /auth/enrol - '{"code":"ZZZZZ-ZZZZZ"}' >/dev/null; done +expect 429 POST /auth/enrol - '{"code":"ZZZZZ-ZZZZZ"}' -- "guessing is rate-limited" + +echo +[ "$FAILURES" -eq 0 ] && echo "PASS - invitations and one-time-code enrolment." || { echo "FAIL - $FAILURES assertion(s)." >&2; exit 1; } diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 0c22411..1a35ec7 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -4,3 +4,4 @@ export * from "./printers"; export * from "./print_jobs"; export * from "./embeddings"; export * from "./alerts"; +export * from "./onboarding"; diff --git a/src/db/schema/onboarding.ts b/src/db/schema/onboarding.ts new file mode 100644 index 0000000..823b1af --- /dev/null +++ b/src/db/schema/onboarding.ts @@ -0,0 +1,51 @@ +import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core"; +import { orgRoleEnum, organisations } from "./orgs"; +import { farms } from "./farms"; + +/** + * An invitation to join an organisation, addressed to an email. + * + * Only a hash of the token is stored: the token is the whole credential, and + * the table is the kind of thing that ends up in a backup or a screenshot. + * It is shown to the inviter once, when the invitation is made. + */ +export const orgInvitations = pgTable( + "org_invitations", + { + id: uuid("id").primaryKey().defaultRandom(), + orgId: uuid("org_id") + .notNull() + .references(() => organisations.id, { onDelete: "cascade" }), + /** Lower-cased: addresses are compared case-insensitively. */ + email: text("email").notNull(), + role: orgRoleEnum("role").notNull(), + tokenHash: text("token_hash").notNull().unique(), + invitedBy: uuid("invited_by").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + acceptedAt: timestamp("accepted_at", { withTimezone: true }), + acceptedBy: uuid("accepted_by"), + }, + (table) => ({ orgIdx: index("org_invitations_org_idx").on(table.orgId) }), +); + +/** + * A short-lived, single-use code that lets a gateway fetch its farm's + * credentials without anyone copying a token around. Hashed like an + * invitation token, and for the same reason. + */ +export const enrolmentCodes = pgTable( + "enrolment_codes", + { + id: uuid("id").primaryKey().defaultRandom(), + farmId: uuid("farm_id") + .notNull() + .references(() => farms.id, { onDelete: "cascade" }), + codeHash: text("code_hash").notNull().unique(), + createdBy: uuid("created_by").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), + }, + (table) => ({ farmIdx: index("enrolment_codes_farm_idx").on(table.farmId) }), +); diff --git a/src/db/schema/orgs.ts b/src/db/schema/orgs.ts index 31f495a..5c61ca3 100644 --- a/src/db/schema/orgs.ts +++ b/src/db/schema/orgs.ts @@ -47,6 +47,12 @@ export const orgMembers = pgTable( .references(() => organisations.id, { onDelete: "cascade" }), userId: uuid("user_id").notNull(), role: orgRoleEnum("role").notNull().default("viewer"), + /** + * Shown in member lists, which would otherwise be bare ids. Taken from + * the token's `email` claim when someone creates an organisation or + * accepts an invitation; null for a member added by id. + */ + email: text("email"), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }, diff --git a/src/index.ts b/src/index.ts index 192d375..7704701 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ 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 { invitationRoutes, orgRoutes } from "./routes/orgs"; import { farmRoutes } from "./routes/farms"; import { printerRoutes } from "./routes/printers"; import { jobRoutes } from "./routes/jobs"; @@ -62,6 +62,7 @@ const app = new Elysia() }) .use(authRoutes) .use(orgRoutes) + .use(invitationRoutes) .use(farmRoutes) .use(printerRoutes) .use(jobRoutes) diff --git a/src/onboarding/tokens.ts b/src/onboarding/tokens.ts new file mode 100644 index 0000000..87e99ec --- /dev/null +++ b/src/onboarding/tokens.ts @@ -0,0 +1,62 @@ +/** + * Invitation tokens and gateway enrolment codes. Pure, so the parts worth + * getting exactly right are testable without a database. + */ +import { createHash, randomBytes, randomInt } from "node:crypto"; + +export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +export const ENROL_TTL_MS = 15 * 60 * 1000; + +/** What is stored: a hash, never the secret. SHA-256 is enough for 256-bit random input. */ +export const hashSecret = (secret: string) => createHash("sha256").update(secret).digest("hex"); + +/** 256 bits, URL-safe: goes in a link. */ +export const newInviteToken = () => randomBytes(32).toString("base64url"); + +// Crockford base32: no I, L, O or U, so a code read off a screen and typed +// into a terminal survives the usual mix-ups. +const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const CODE_LENGTH = 10; + +/** `ABCDE-FGHJK`: 50 bits, single-use and valid for fifteen minutes. */ +export function newEnrolmentCode(): string { + let code = ""; + for (let i = 0; i < CODE_LENGTH; i++) code += ALPHABET[randomInt(ALPHABET.length)]; + return `${code.slice(0, 5)}-${code.slice(5)}`; +} + +/** What was typed, reduced to what was issued: case, dashes and spaces ignored, look-alikes folded. */ +export function normaliseCode(input: string): string { + return input + .toUpperCase() + .replace(/[^0-9A-Z]/g, "") + .replace(/O/g, "0") + .replace(/[IL]/g, "1"); +} + +/** The form that is hashed for storage and lookup. */ +export const codeKey = (input: string) => hashSecret(normaliseCode(input)); + +/** + * Counts failures per key inside a sliding window, so guessing an enrolment + * code from one address stops being worth trying. In memory: it resets on + * restart, which only ever helps an attacker by a window's worth of guesses. + */ +export class FailureLimiter { + private failures = new Map(); + constructor( + private readonly max: number, + private readonly windowMs: number, + ) {} + + blocked(key: string, now = Date.now()): boolean { + const recent = (this.failures.get(key) ?? []).filter((t) => now - t < this.windowMs); + if (recent.length === 0) this.failures.delete(key); + else this.failures.set(key, recent); + return recent.length >= this.max; + } + + fail(key: string, now = Date.now()): void { + this.failures.set(key, [...(this.failures.get(key) ?? []), now]); + } +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 808ccbf..14d2d63 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,11 +1,15 @@ import { Elysia, t } from "elysia"; import jwt from "@elysiajs/jwt"; -import { eq } from "drizzle-orm"; +import { and, eq, gt, isNull } from "drizzle-orm"; import { UnauthorizedError } from "@continuum/ts-core"; import { db } from "../db"; -import { farms } from "../db/schema"; +import { enrolmentCodes, farms } from "../db/schema"; import { listMemberships, requireUser } from "../auth"; import { edgeJwtSecret } from "../auth/edge_secret"; +import { FailureLimiter, codeKey } from "../onboarding/tokens"; + +/** Ten wrong codes from one address in ten minutes and it is told to wait. */ +const enrolFailures = new FailureLimiter(10, 10 * 60 * 1000); const EDGE_TOKEN_TTL_SECONDS = 3600; @@ -64,6 +68,59 @@ export const authRoutes = new Elysia({ prefix: "/auth" }) detail: { tags: ["auth"], summary: "Trade a farm's proxy token for an edge JWT" }, }, ) + /** + * A gateway trades a one-time code (`POST /farms/:id/enrolment-codes`) for + * its farm's credentials. Not behind `requireUser` for the same reason as + * `/edge-token`: the gateway has no account, and the code is the credential. + * + * The farm's existing token is returned, not rotated, so enrolling a second + * machine cannot lock out the first. A farm created before tokens were + * minted at creation gets one here. + */ + .post( + "/enrol", + async ({ body, server, request, set }) => { + const who = server?.requestIP(request)?.address ?? "unknown"; + if (enrolFailures.blocked(who)) { + set.status = 429; + return { error: { code: "TOO_MANY_ATTEMPTS", message: "Too many wrong codes; wait a few minutes and try again" } }; + } + + // Claimed in one statement: of two gateways presenting the same code at + // once, exactly one gets a row back. + const [claimed] = await db + .update(enrolmentCodes) + .set({ usedAt: new Date() }) + .where( + and( + eq(enrolmentCodes.codeHash, codeKey(body.code)), + isNull(enrolmentCodes.usedAt), + gt(enrolmentCodes.expiresAt, new Date()), + ), + ) + .returning({ farmId: enrolmentCodes.farmId }); + if (!claimed) { + enrolFailures.fail(who); + throw new UnauthorizedError("That code is wrong, expired or already used"); + } + + const farm = await db.query.farms.findFirst({ + columns: { id: true, edgeProxyToken: true }, + where: eq(farms.id, claimed.farmId), + }); + if (!farm) throw new UnauthorizedError("That code is wrong, expired or already used"); + let token = farm.edgeProxyToken; + if (!token) { + token = crypto.randomUUID(); + await db.update(farms).set({ edgeProxyToken: token, updatedAt: new Date() }).where(eq(farms.id, farm.id)); + } + return { farmId: farm.id, gatewayToken: token }; + }, + { + body: t.Object({ code: t.String({ minLength: 4, maxLength: 64 }) }), + detail: { tags: ["auth"], summary: "Trade a one-time enrolment code for a gateway's credentials" }, + }, + ) .use(requireUser) /** * Who the caller is and what they can reach. A client calls this once after diff --git a/src/routes/farms.ts b/src/routes/farms.ts index e90eb1f..86a4a4c 100644 --- a/src/routes/farms.ts +++ b/src/routes/farms.ts @@ -2,7 +2,7 @@ import { Elysia, t } from "elysia"; import { and, desc, eq } from "drizzle-orm"; import { ConflictError, NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; -import { farms, printers } from "../db/schema"; +import { enrolmentCodes, farms, printers } from "../db/schema"; import { farmProjection, printerProjection } from "../db/projections"; import { PROVISIONABLE_VENDORS, @@ -13,6 +13,7 @@ import { type ProvisionableVendor, } from "../provision/plan"; import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth"; +import { ENROL_TTL_MS, codeKey, newEnrolmentCode } from "../onboarding/tokens"; import { PaginationQuery, paginate } from "./pagination"; import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters, requestGateway } from "./ws/edge"; @@ -136,6 +137,35 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) }, { params: IdParams, detail: { tags: ["farms"] } }, ) + /** + * A one-time code for connecting a gateway to this farm. The gateway + * trades it at `POST /auth/enrol` for the farm's credentials, so nobody has + * to copy a token between machines. + * + * Unlike rotating the token, this leaves the farm's existing credential + * alone: enrolling a gateway cannot lock out the one already running. + * Fifteen minutes, one use, and only its hash is kept. + */ + .post( + "/:id/enrolment-codes", + async ({ user, params, set }) => { + await requireFarm(user.id, params.id, "owner", "enrolling a gateway"); + const code = newEnrolmentCode(); + const expiresAt = new Date(Date.now() + ENROL_TTL_MS); + await db.insert(enrolmentCodes).values({ + farmId: params.id, + codeHash: codeKey(code), + createdBy: user.id, + expiresAt, + }); + set.status = 201; + return { code, expiresAt }; + }, + { + params: IdParams, + detail: { tags: ["farms"], summary: "A one-time code to enrol a gateway (owner only)" }, + }, + ) /** * Issues a *new* `edgeProxyToken` for this farm and returns it once. * diff --git a/src/routes/orgs.ts b/src/routes/orgs.ts index b04fa04..8ca4312 100644 --- a/src/routes/orgs.ts +++ b/src/routes/orgs.ts @@ -1,9 +1,10 @@ import { Elysia, t } from "elysia"; -import { and, count, eq, ne } from "drizzle-orm"; -import { ConflictError, NotFoundError } from "@continuum/ts-core"; +import { and, count, eq, gt, isNull, ne } from "drizzle-orm"; +import { ConflictError, ForbiddenError, NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; -import { farms, orgMembers, organisations, ORG_ROLES } from "../db/schema"; +import { farms, orgInvitations, orgMembers, organisations, ORG_ROLES } from "../db/schema"; import { listMemberships, requireOrg, requireUser } from "../auth"; +import { INVITE_TTL_MS, hashSecret, newInviteToken } from "../onboarding/tokens"; const RoleSchema = t.Union(ORG_ROLES.map((role) => t.Literal(role))); const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); @@ -48,7 +49,7 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" }) .returning(); await tx .insert(orgMembers) - .values({ orgId: created!.id, userId: user.id, role: "owner" }); + .values({ orgId: created!.id, userId: user.id, role: "owner", email: user.email?.toLowerCase() }); return created!; }); set.status = 201; @@ -125,6 +126,7 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" }) return db .select({ userId: orgMembers.userId, + email: orgMembers.email, role: orgMembers.role, joinedAt: orgMembers.createdAt, }) @@ -138,10 +140,8 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" }) * 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. + * This takes a Supabase user id directly, which is the right shape for an + * API and the wrong one for a person: they use the invitations below. */ .put( "/:id/members/:userId", @@ -180,6 +180,106 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" }) detail: { tags: ["orgs"], summary: "Add a member or change their role" }, }, ) + /** + * Invites someone by email. They join when they sign in with that address + * and open the link; nobody has to know their user id. + * + * **No email is sent from here.** The token is returned once, in the + * response, for the inviter to pass on (the console builds the link). A + * mail provider is a deployment decision this service does not make; until + * one is wired up, the inviter is the courier. The address still matters: + * only an account with that address can use the link, so a link that ends + * up somewhere else is useless to whoever finds it. + * + * A second invitation to the same address replaces the first, so there is + * only ever one live link per person per organisation. + */ + .post( + "/:id/invitations", + async ({ user, params, body, set }) => { + await requireOrg(user.id, params.id, "owner", "inviting someone"); + const email = body.email.trim().toLowerCase(); + const token = newInviteToken(); + const expiresAt = new Date(Date.now() + INVITE_TTL_MS); + + const invitation = await db.transaction(async (tx) => { + await tx + .delete(orgInvitations) + .where( + and(eq(orgInvitations.orgId, params.id), eq(orgInvitations.email, email), isNull(orgInvitations.acceptedAt)), + ); + const [created] = await tx + .insert(orgInvitations) + .values({ + orgId: params.id, + email, + role: body.role, + tokenHash: hashSecret(token), + invitedBy: user.id, + expiresAt, + }) + .returning({ + id: orgInvitations.id, + email: orgInvitations.email, + role: orgInvitations.role, + expiresAt: orgInvitations.expiresAt, + }); + return created!; + }); + set.status = 201; + return { ...invitation, token, acceptPath: `/invite?token=${token}` }; + }, + { + params: IdParams, + body: t.Object({ email: t.String({ format: "email", maxLength: 320 }), role: RoleSchema }), + detail: { tags: ["orgs"], summary: "Invite someone by email (the link is returned once)" }, + }, + ) + .get( + "/:id/invitations", + async ({ user, params }) => { + await requireOrg(user.id, params.id, "owner", "viewing invitations"); + return db + .select({ + id: orgInvitations.id, + email: orgInvitations.email, + role: orgInvitations.role, + createdAt: orgInvitations.createdAt, + expiresAt: orgInvitations.expiresAt, + }) + .from(orgInvitations) + .where( + and( + eq(orgInvitations.orgId, params.id), + isNull(orgInvitations.acceptedAt), + gt(orgInvitations.expiresAt, new Date()), + ), + ); + }, + { params: IdParams, detail: { tags: ["orgs"], summary: "Invitations not yet accepted" } }, + ) + .delete( + "/:id/invitations/:invitationId", + async ({ user, params }) => { + await requireOrg(user.id, params.id, "owner", "revoking an invitation"); + const [removed] = await db + .delete(orgInvitations) + .where( + and( + eq(orgInvitations.id, params.invitationId), + eq(orgInvitations.orgId, params.id), + isNull(orgInvitations.acceptedAt), + ), + ) + .returning({ id: orgInvitations.id }); + if (!removed) throw new NotFoundError("No such pending invitation"); + return { revoked: removed.id }; + }, + { + params: t.Object({ id: t.String({ format: "uuid" }), invitationId: t.String({ format: "uuid" }) }), + detail: { tags: ["orgs"], summary: "Revoke an invitation" }, + }, + ) .delete( "/:id/members/:userId", async ({ user, params }) => { @@ -200,3 +300,64 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" }) }, { params: MemberParams, detail: { tags: ["orgs"], summary: "Remove a member" } }, ); + +/** + * Accepting an invitation. Separate from `/orgs` because the caller is not + * yet a member of anything it could be scoped to. + */ +export const invitationRoutes = new Elysia({ prefix: "/invitations" }) + .use(requireUser) + .post( + "/accept", + async ({ user, body }) => { + const invitation = await db.query.orgInvitations.findFirst({ + where: and( + eq(orgInvitations.tokenHash, hashSecret(body.token)), + isNull(orgInvitations.acceptedAt), + gt(orgInvitations.expiresAt, new Date()), + ), + }); + // One answer for unknown, used, revoked and expired: which of those it + // was is not for someone holding a guessed or stale link to learn. + if (!invitation) throw new NotFoundError("This invitation is invalid, expired or already used"); + + if (!user.email) { + throw new ForbiddenError("Your sign-in carries no email address, so it cannot be matched to an invitation"); + } + if (user.email.toLowerCase() !== invitation.email) { + throw new ForbiddenError(`This invitation was sent to a different address than the one you are signed in with`); + } + + const rank = (role: string) => ORG_ROLES.indexOf(role as (typeof ORG_ROLES)[number]); + await db.transaction(async (tx) => { + // Claimed first and conditionally: two tabs opening the link at once + // cannot both succeed. + const [claimed] = await tx + .update(orgInvitations) + .set({ acceptedAt: new Date(), acceptedBy: user.id }) + .where(and(eq(orgInvitations.id, invitation.id), isNull(orgInvitations.acceptedAt))) + .returning({ id: orgInvitations.id }); + if (!claimed) throw new ConflictError("This invitation was just used"); + + const existing = await tx.query.orgMembers.findFirst({ + where: and(eq(orgMembers.orgId, invitation.orgId), eq(orgMembers.userId, user.id)), + }); + // An invitation never lowers a role someone already has. + const role = existing && rank(existing.role) > rank(invitation.role) ? existing.role : invitation.role; + await tx + .insert(orgMembers) + .values({ orgId: invitation.orgId, userId: user.id, role, email: invitation.email }) + .onConflictDoUpdate({ + target: [orgMembers.orgId, orgMembers.userId], + set: { role, email: invitation.email, updatedAt: new Date() }, + }); + }); + + const org = await db.query.organisations.findFirst({ where: eq(organisations.id, invitation.orgId) }); + return { orgId: invitation.orgId, orgName: org?.name ?? "", role: invitation.role }; + }, + { + body: t.Object({ token: t.String({ minLength: 20, maxLength: 200 }) }), + detail: { tags: ["orgs"], summary: "Accept an invitation with the token from its link" }, + }, + ); diff --git a/tests/onboarding.test.ts b/tests/onboarding.test.ts new file mode 100644 index 0000000..7fd3fe7 --- /dev/null +++ b/tests/onboarding.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + FailureLimiter, + codeKey, + hashSecret, + newEnrolmentCode, + newInviteToken, + normaliseCode, +} from "../src/onboarding/tokens"; + +describe("enrolment codes", () => { + test("have the shape that was promised and do not repeat", () => { + const codes = new Set(Array.from({ length: 200 }, newEnrolmentCode)); + expect(codes.size).toBe(200); + for (const code of codes) expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/); + }); + + test("a code typed carelessly still finds the one that was issued", () => { + const issued = "AB0C1-DEF23"; + for (const typed of ["ab0c1-def23", "AB0C1DEF23", " ab0c1 def23 ", "ABOC1-DEF23", "AB0Cl-DEF23", "ABOCI-DEF23"]) { + expect(codeKey(typed)).toBe(codeKey(issued)); + } + expect(codeKey("AB0C1-DEF24")).not.toBe(codeKey(issued)); + }); + + test("normalising is idempotent", () => { + expect(normaliseCode(normaliseCode("ab-oc1"))).toBe(normaliseCode("ab-oc1")); + }); +}); + +describe("invitation tokens", () => { + test("are long, URL-safe and unique, and only their hash is kept", () => { + const a = newInviteToken(); + expect(a).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(newInviteToken()).not.toBe(a); + expect(hashSecret(a)).toMatch(/^[0-9a-f]{64}$/); + expect(hashSecret(a)).not.toContain(a); + }); +}); + +describe("FailureLimiter", () => { + test("blocks after too many failures and forgets them after the window", () => { + const limiter = new FailureLimiter(3, 1000); + for (let i = 0; i < 3; i++) { + expect(limiter.blocked("ip", i)).toBe(false); + limiter.fail("ip", i); + } + expect(limiter.blocked("ip", 10)).toBe(true); + expect(limiter.blocked("other", 10)).toBe(false); + expect(limiter.blocked("ip", 2000)).toBe(false); + }); +});