Authenticate and authorize every route: org layer + Supabase JWKS
Until now every HTTP route was unauthenticated. A probe against the
running server returned a farm's `edgeProxyToken` and a printer's
`accessCode` to an anonymous caller, and that pair is enough to connect
as a farm's gateway and drive its printers. The previous commit stopped
the credentials appearing in responses; this one makes the routes ask
who is calling.
Authentication. Access tokens are verified against the Supabase
project's published JWKS via `createSupabaseJwtVerifier` from
`@continuum/ts-core`: RS256, public key only, no secret in this
process. It replaces the HS256 `@elysiajs/jwt` path, where
`SUPABASE_JWT_SECRET` was simultaneously the verification key and a
key that could mint tokens, present in the environment of every
instance. `src/auth/verifier.ts` builds it at module load so a
misconfigured deployment fails at boot rather than on first login.
The edge-gateway token stays symmetric and that is not an
inconsistency: this service is both its issuer and its only verifier,
so there is no second party to keep a secret from. It now carries an
`exp`, which it did not, despite the response saying
`expiresInSeconds: 3600`.
Tenancy. An org layer above farms - `organisations`,
`org_members(org_id, user_id, role)`, `farms.org_id` - so a farm can
have more than one operator without sharing an account. `farms.owner_id`
is gone. Authorization is one rule applied everywhere: resolve the
thing being acted on back to an organisation and check the caller's
role there, user -> org -> farm -> printer, with nothing in between
carrying permissions of its own. The ladder is
`viewer` < `operator` < `owner`; printer commands and job submission
need `operator`, because they move physical machines, and that is the
one boundary here that is not about data.
A non-member gets 404, not 403: a 403 would confirm that a given farm
or printer id exists and belongs to someone, which is an enumeration
oracle over the whole system. 403 is kept for a member who may see the
record but not do this to it.
List endpoints scope in the `where` clause, not after the query - a
filter applied to the result is one `return` away from being skipped.
They are also bounded now (`limit`/`offset`, default 50, max 200
refused rather than clamped).
`ownerId` and `submittedBy` no longer come from request bodies. Nor do
the printer telemetry columns: `POST /printers` is narrowed to the
columns a client owns, because a client that could POST
`status: "idle"` could make the scheduler send work to a printer that
is mid-print.
Also fills the CRUD gaps that made the API unusable - PATCH and DELETE
for farms, printers, jobs and orgs, and membership management - plus
one error shape for the whole API via `toErrorResponse`, so an
authorization failure is a throw that leaves the handler rather than a
status somebody has to remember to set.
Guards on the two changes that cannot be undone through the API:
removing or demoting an organisation's last owner, and deleting an
organisation while farms are still attached (it would cascade to every
printer, print job and stored frame).
Migration 0001 is hand-edited after generation. drizzle-kit emitted
`ADD COLUMN org_id uuid NOT NULL`, which cannot apply to a populated
table, and dropped `owner_id` without moving what it encodes anywhere.
It now adds the column nullable, creates one organisation per distinct
legacy owner with that user as its `owner` member, points each farm at
it, and only then tightens the column and drops `owner_id`. Verified
both ways: from empty via `drizzle-kit migrate`, and against a seeded
database where two farms shared an owner and a third did not.
Testing. `scripts/dev-auth.ts` is a local RS256 issuer publishing a
JWKS at the path the backend fetches, so the real verification path is
testable without a Supabase project. The alternative was a test-only
bypass in the backend - an env var making it trust an unsigned token -
which would be a permanent hole one misconfiguration from production,
and would mean the code under test is not the code that ships.
- `tests/authz.test.ts` (19 tests): the role ladder written out in full
rather than derived the same way the implementation derives it, and
the real JWKS path - a token signed by a key outside the JWKS, an
expired token and a token with `aud: "anon"` (Supabase's own anon key
is a valid project JWT) are all rejected, with the same message, so
probing cannot tell which part of a forged token to fix next.
- `scripts/e2e-authz.sh` (68 assertions, real Postgres, real routes):
four users across two organisations; anonymous 401s, cross-org 404s,
viewer-cannot-command, operator-cannot-rotate, a spoofed
`submittedBy` in the body ignored, no credential in any read, and the
rotated token still minting an edge JWT.
- `scripts/e2e-edge.sh` updated for the org layer and now authenticates
as a seeded `operator` - the weakest role that should work, so the
run would fail if the command boundary were not where it claims to
be. Re-verified against both live printers: telemetry from `h2c` and
`p1p` persisted, and an authenticated `set_fan_speed` went
HTTP -> WebSocket -> MQTT -> P1P and back `ok:true`, fan returned
to 0.
Two bugs found in the test scripts while writing this, both the same
shape as the earlier `pkill -f` incident: `kill "${PID:-0}"` with an
unset PID signals the whole process group, including the harness, and
`uuidgen` is not installed in this sandbox.
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
-- Hand-edited after generation. drizzle-kit emitted
|
||||
-- ALTER TABLE "farms" ADD COLUMN "org_id" uuid NOT NULL;
|
||||
-- which cannot apply to a farms table that already has rows, and it emitted
|
||||
-- the DROP of "owner_id" without moving the ownership it encodes anywhere -
|
||||
-- so an existing database would either refuse the migration or silently lose
|
||||
-- who owned each farm.
|
||||
--
|
||||
-- The order below is the fix: add the column nullable, derive one
|
||||
-- organisation per distinct owner and make that owner its `owner` member,
|
||||
-- point each farm at it, and only then tighten the column and drop
|
||||
-- "owner_id". Edited statements are marked; the rest is generated output.
|
||||
--
|
||||
-- Safe to keep across regeneration: drizzle only appends new migration files
|
||||
-- and diffs against meta/0001_snapshot.json, not against this SQL.
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."org_role" AS ENUM('viewer', 'operator', 'owner');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "organisations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "org_members" (
|
||||
"org_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"role" "org_role" DEFAULT 'viewer' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "org_members_org_id_user_id_pk" PRIMARY KEY("org_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "org_members" ADD CONSTRAINT "org_members_org_id_organisations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "org_members_user_id_idx" ON "org_members" USING btree ("user_id");--> statement-breakpoint
|
||||
-- EDITED: generated as `uuid NOT NULL`. Nullable for now; tightened below,
|
||||
-- once every row has a value.
|
||||
ALTER TABLE "farms" ADD COLUMN "org_id" uuid;--> statement-breakpoint
|
||||
-- EDITED (added): backfill. One organisation per distinct farms.owner_id,
|
||||
-- with that user as its sole `owner` member. Farms that shared an owner end
|
||||
-- up in one organisation together, which is what the old column meant.
|
||||
--
|
||||
-- A DO block rather than a temporary table so the whole thing is one
|
||||
-- statement with no state outlasting it, and so RETURNING ... INTO can carry
|
||||
-- each generated organisation id straight to the rows that need it.
|
||||
DO $$
|
||||
DECLARE
|
||||
legacy_owner record;
|
||||
new_org_id uuid;
|
||||
BEGIN
|
||||
FOR legacy_owner IN SELECT DISTINCT "owner_id" FROM "farms" LOOP
|
||||
INSERT INTO "organisations" ("name")
|
||||
VALUES ('Organisation ' || left(legacy_owner."owner_id"::text, 8))
|
||||
RETURNING "id" INTO new_org_id;
|
||||
|
||||
INSERT INTO "org_members" ("org_id", "user_id", "role")
|
||||
VALUES (new_org_id, legacy_owner."owner_id", 'owner');
|
||||
|
||||
UPDATE "farms" SET "org_id" = new_org_id WHERE "owner_id" = legacy_owner."owner_id";
|
||||
END LOOP;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- EDITED (added): now that the backfill has run, enforce what the schema says.
|
||||
ALTER TABLE "farms" ALTER COLUMN "org_id" SET NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "farms" ADD CONSTRAINT "farms_org_id_organisations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "farms_org_id_idx" ON "farms" USING btree ("org_id");--> statement-breakpoint
|
||||
ALTER TABLE "farms" DROP COLUMN IF EXISTS "owner_id";
|
||||
@@ -0,0 +1,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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* A stand-in for Supabase Auth: an RS256 issuer that publishes a JWKS at the
|
||||
* same path a Supabase project does, and mints access tokens on request.
|
||||
*
|
||||
* This exists so the *real* verification path is testable. The backend
|
||||
* verifies tokens with `createRemoteJWKSet` against
|
||||
* `$SUPABASE_URL/auth/v1/.well-known/jwks.json`, and the alternative to
|
||||
* standing that endpoint up locally is a test-only bypass in the backend -
|
||||
* an env var that makes it trust an unsigned token. That bypass would be a
|
||||
* permanent hole one misconfiguration away from production, and it would mean
|
||||
* the code paths under test are not the code paths that ship.
|
||||
*
|
||||
* Point the backend at it with `SUPABASE_URL=http://localhost:<port>`.
|
||||
*
|
||||
* Usage as a server:
|
||||
* bun scripts/dev-auth.ts [--port 4545]
|
||||
* GET /auth/v1/.well-known/jwks.json the public keys
|
||||
* GET /token?sub=<uuid>&email=<email> a signed access token
|
||||
*
|
||||
* Usage from a test:
|
||||
* const issuer = await startDevAuthIssuer();
|
||||
* const token = await issuer.mint({ sub: userId });
|
||||
* issuer.stop();
|
||||
*/
|
||||
import { SignJWT, exportJWK, generateKeyPair, calculateJwkThumbprint } from "jose";
|
||||
|
||||
export interface MintOptions {
|
||||
sub: string;
|
||||
email?: string;
|
||||
/** Seconds until expiry. Negative values mint an already-expired token. */
|
||||
expiresIn?: number;
|
||||
/** Overrides the `aud` claim; the backend requires "authenticated". */
|
||||
audience?: string;
|
||||
}
|
||||
|
||||
export async function startDevAuthIssuer(port = 0) {
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true });
|
||||
const publicJwk = await exportJWK(publicKey);
|
||||
const kid = await calculateJwkThumbprint(publicJwk);
|
||||
const jwks = { keys: [{ ...publicJwk, kid, alg: "RS256", use: "sig" }] };
|
||||
|
||||
async function mint(options: MintOptions): Promise<string> {
|
||||
const expiresIn = options.expiresIn ?? 3600;
|
||||
return new SignJWT({ sub: options.sub, email: options.email, role: "authenticated" })
|
||||
.setProtectedHeader({ alg: "RS256", kid })
|
||||
.setIssuedAt()
|
||||
.setAudience(options.audience ?? "authenticated")
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) + expiresIn)
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/auth/v1/.well-known/jwks.json") {
|
||||
return Response.json(jwks);
|
||||
}
|
||||
if (url.pathname === "/token") {
|
||||
const sub = url.searchParams.get("sub");
|
||||
if (!sub) return new Response("?sub=<uuid> is required", { status: 400 });
|
||||
const expiresIn = url.searchParams.get("expiresIn");
|
||||
return Response.json({
|
||||
access_token: await mint({
|
||||
sub,
|
||||
email: url.searchParams.get("email") ?? undefined,
|
||||
expiresIn: expiresIn ? Number(expiresIn) : undefined,
|
||||
audience: url.searchParams.get("audience") ?? undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
url: `http://localhost:${server.port}`,
|
||||
port: server.port,
|
||||
kid,
|
||||
mint,
|
||||
/** A token signed by a *different* key, for testing rejection. */
|
||||
async mintWithForeignKey(options: MintOptions): Promise<string> {
|
||||
const other = await generateKeyPair("RS256", { extractable: true });
|
||||
const otherJwk = await exportJWK(other.publicKey);
|
||||
return new SignJWT({ sub: options.sub, role: "authenticated" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: await calculateJwkThumbprint(otherJwk) })
|
||||
.setIssuedAt()
|
||||
.setAudience(options.audience ?? "authenticated")
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) + (options.expiresIn ?? 3600))
|
||||
.sign(other.privateKey);
|
||||
},
|
||||
stop: () => server.stop(true),
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const portFlag = process.argv.indexOf("--port");
|
||||
const port = portFlag === -1 ? 4545 : Number(process.argv[portFlag + 1]);
|
||||
const issuer = await startDevAuthIssuer(port);
|
||||
console.log(`dev-auth issuer on ${issuer.url} (kid ${issuer.kid})`);
|
||||
console.log(` SUPABASE_URL=${issuer.url}`);
|
||||
}
|
||||
Executable
+323
@@ -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 <method> <path> <token|-> [json body]` -> prints the status code,
|
||||
# leaves the response body in $WORK/last.json.
|
||||
req() {
|
||||
local method="$1" path="$2" token="$3" body="${4:-}"
|
||||
local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$method" "$BASE$path")
|
||||
[ "$token" != "-" ] && args+=(-H "authorization: Bearer $token")
|
||||
[ -n "$body" ] && args+=(-H 'content-type: application/json' -d "$body")
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
# `expect <code> <method> <path> <token> [body] -- <description>`
|
||||
expect() {
|
||||
local want="$1"; shift
|
||||
local method="$1" path="$2" token="$3"; shift 3
|
||||
local body=""
|
||||
if [ "${1:-}" != "--" ]; then body="$1"; shift; fi
|
||||
shift # the --
|
||||
local what="$*"
|
||||
local got; got="$(req "$method" "$path" "$token" "$body")"
|
||||
if [ "$got" = "$want" ]; then
|
||||
ok "$what ($got)"
|
||||
else
|
||||
bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")"
|
||||
fi
|
||||
}
|
||||
|
||||
body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; }
|
||||
body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; }
|
||||
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; }
|
||||
|
||||
# --- 0. preconditions ------------------------------------------------------
|
||||
|
||||
echo "==> checking the database is up and migrated"
|
||||
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
|
||||
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|
||||
|| fail "farms.org_id is missing - run scripts/dev-env.sh schema"
|
||||
psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \
|
||||
|| fail "org_members is missing - run scripts/dev-env.sh schema"
|
||||
ok "schema has the org layer"
|
||||
|
||||
# --- 1. the issuer and the backend ----------------------------------------
|
||||
|
||||
echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT"
|
||||
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||
AUTH_PID=$!
|
||||
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||
for _ in $(seq 1 40); do
|
||||
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break
|
||||
sleep 0.25
|
||||
done
|
||||
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \
|
||||
|| fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")"
|
||||
export SUPABASE_URL="$AUTH_URL"
|
||||
ok "issuer up, SUPABASE_URL=$SUPABASE_URL"
|
||||
|
||||
mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; }
|
||||
|
||||
OWNER_A_ID="$(new_uuid)"; OWNER_A="$(mint "$OWNER_A_ID")"
|
||||
OPERATOR_A_ID="$(new_uuid)"; OPERATOR_A="$(mint "$OPERATOR_A_ID")"
|
||||
VIEWER_A_ID="$(new_uuid)"; VIEWER_A="$(mint "$VIEWER_A_ID")"
|
||||
OUTSIDER_ID="$(new_uuid)"; OUTSIDER="$(mint "$OUTSIDER_ID")"
|
||||
ok "minted four access tokens"
|
||||
|
||||
echo "==> starting continuum-backend on :$PORT"
|
||||
bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf "$BASE/health" >/dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
|
||||
ok "backend healthy"
|
||||
|
||||
# --- 2. anonymous access --------------------------------------------------
|
||||
|
||||
echo "==> nothing works without a token"
|
||||
expect 200 GET /health - -- "GET /health is public"
|
||||
expect 401 GET /orgs - -- "GET /orgs rejects anonymous"
|
||||
expect 401 GET /farms - -- "GET /farms rejects anonymous"
|
||||
expect 401 GET /printers - -- "GET /printers rejects anonymous"
|
||||
expect 401 GET /jobs - -- "GET /jobs rejects anonymous"
|
||||
expect 401 GET /auth/session - -- "GET /auth/session rejects anonymous"
|
||||
expect 401 POST /farms - '{"name":"x","orgId":"00000000-0000-0000-0000-000000000000"}' -- "POST /farms rejects anonymous"
|
||||
expect 401 GET /farms -H.invalid.token -- "a malformed token is rejected"
|
||||
|
||||
# --- 3. the seeded world --------------------------------------------------
|
||||
|
||||
echo "==> building org A (via the API, as owner_a)"
|
||||
expect 201 POST /orgs "$OWNER_A" '{"name":"e2e org A"}' -- "owner_a creates org A"
|
||||
ORG_A="$(json 'd["id"]')"
|
||||
body_has '"role":"owner"'
|
||||
ok "org A = $ORG_A, creator is its owner"
|
||||
|
||||
expect 200 GET /auth/session "$OWNER_A" -- "owner_a reads their session"
|
||||
body_has "$ORG_A"
|
||||
|
||||
expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_A_ID" "$OWNER_A" '{"role":"operator"}' -- "owner_a adds operator_a"
|
||||
expect 200 PUT "/orgs/$ORG_A/members/$VIEWER_A_ID" "$OWNER_A" '{"role":"viewer"}' -- "owner_a adds viewer_a"
|
||||
|
||||
expect 201 POST /farms "$OWNER_A" "{\"name\":\"e2e farm A\",\"orgId\":\"$ORG_A\"}" -- "owner_a creates farm A"
|
||||
FARM_A="$(json 'd["id"]')"
|
||||
body_lacks edgeProxyToken
|
||||
ok "farm A = $FARM_A, and the create response carries no proxy token"
|
||||
|
||||
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
|
||||
+70
-16
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { JwtVerificationError, UnauthorizedError, extractBearerToken } from "@continuum/ts-core";
|
||||
import { jwtVerifier } from "./verifier";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a verified Supabase access token and exposes the caller as `user`.
|
||||
*
|
||||
* Applied per route group with `.use(requireUser)` rather than globally,
|
||||
* because three things here legitimately have no user behind them: `/health`,
|
||||
* `POST /auth/edge-token` (authenticated by a farm's own proxy token) and the
|
||||
* `/ws/edge/v1` socket (authenticated by the edge JWT that mints). A global
|
||||
* guard with holes punched in it reads as if those holes were oversights.
|
||||
*
|
||||
* `scoped` propagates `user` to the instance that uses this plugin, so each
|
||||
* route file gets it without re-deriving; `name` makes it a singleton, so
|
||||
* using it in five route files still verifies each token once.
|
||||
*/
|
||||
export const requireUser = new Elysia({ name: "continuum/require-user" })
|
||||
.resolve({ as: "scoped" }, async ({ headers }): Promise<{ user: AuthenticatedUser }> => {
|
||||
let token: string;
|
||||
try {
|
||||
token = extractBearerToken(headers.authorization);
|
||||
} catch {
|
||||
throw new UnauthorizedError("Authorization header must be 'Bearer <access token>'");
|
||||
}
|
||||
|
||||
let claims;
|
||||
try {
|
||||
claims = await jwtVerifier.verify(token);
|
||||
} catch (err) {
|
||||
// Never pass the underlying reason through: "signature mismatch" versus
|
||||
// "expired" versus "wrong audience" tells an attacker which part of a
|
||||
// forged token to fix next. It is logged instead.
|
||||
if (err instanceof JwtVerificationError) {
|
||||
console.warn(`[auth] token rejected: ${err.cause instanceof Error ? err.cause.message : err.message}`);
|
||||
throw new UnauthorizedError("Invalid or expired access token");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!claims.sub) {
|
||||
throw new UnauthorizedError("Access token has no subject claim");
|
||||
}
|
||||
|
||||
return { user: { id: claims.sub, email: claims.email } };
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ORG_ROLES, type OrgRole } from "../db/schema";
|
||||
|
||||
/**
|
||||
* The role ladder, kept apart from `./scope.ts` because that module opens a
|
||||
* database connection on import and this comparison needs nothing but the
|
||||
* enum. Tests can exercise the ladder without Postgres.
|
||||
*/
|
||||
const RANK: Record<OrgRole, number> = Object.fromEntries(
|
||||
ORG_ROLES.map((role, index) => [role, index]),
|
||||
) as Record<OrgRole, number>;
|
||||
|
||||
/** Whether `actual` is at least as privileged as `minimum`. */
|
||||
export function roleSatisfies(actual: OrgRole, minimum: OrgRole): boolean {
|
||||
return RANK[actual] >= RANK[minimum];
|
||||
}
|
||||
|
||||
export { ORG_ROLES, type OrgRole };
|
||||
@@ -0,0 +1,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<OrgRole> {
|
||||
const [row] = await db
|
||||
.select({ role: orgMembers.role })
|
||||
.from(orgMembers)
|
||||
.where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
|
||||
.limit(1);
|
||||
return check(row?.role ?? null, minimum, `Organisation ${orgId} not found`, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the caller holds at least `minimum` in the farm's organisation.
|
||||
*
|
||||
* A left join so that "no such farm" and "a farm you are not a member of"
|
||||
* run the same query and return the same 404 - see the note above.
|
||||
*/
|
||||
export async function requireFarm(
|
||||
userId: string,
|
||||
farmId: string,
|
||||
minimum: OrgRole,
|
||||
action = "this operation",
|
||||
): Promise<{ orgId: string; role: OrgRole }> {
|
||||
const [row] = await db
|
||||
.select({ orgId: farms.orgId, role: orgMembers.role })
|
||||
.from(farms)
|
||||
.leftJoin(
|
||||
orgMembers,
|
||||
and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)),
|
||||
)
|
||||
.where(eq(farms.id, farmId))
|
||||
.limit(1);
|
||||
|
||||
const role = check(row?.role ?? null, minimum, `Farm ${farmId} not found`, action);
|
||||
return { orgId: row!.orgId, role };
|
||||
}
|
||||
|
||||
/** As `requireFarm`, resolved through the printer's farm. */
|
||||
export async function requirePrinter(
|
||||
userId: string,
|
||||
printerId: string,
|
||||
minimum: OrgRole,
|
||||
action = "this operation",
|
||||
): Promise<{ farmId: string; orgId: string; role: OrgRole }> {
|
||||
const [row] = await db
|
||||
.select({ farmId: printers.farmId, orgId: farms.orgId, role: orgMembers.role })
|
||||
.from(printers)
|
||||
.innerJoin(farms, eq(farms.id, printers.farmId))
|
||||
.leftJoin(
|
||||
orgMembers,
|
||||
and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)),
|
||||
)
|
||||
.where(eq(printers.id, printerId))
|
||||
.limit(1);
|
||||
|
||||
const role = check(row?.role ?? null, minimum, `Printer ${printerId} not found`, action);
|
||||
return { farmId: row!.farmId, orgId: row!.orgId, role };
|
||||
}
|
||||
|
||||
/** As `requireFarm`, resolved through the job's farm. */
|
||||
export async function requireJob(
|
||||
userId: string,
|
||||
jobId: string,
|
||||
minimum: OrgRole,
|
||||
action = "this operation",
|
||||
): Promise<{ farmId: string; orgId: string; role: OrgRole }> {
|
||||
const [row] = await db
|
||||
.select({ farmId: printJobs.farmId, orgId: farms.orgId, role: orgMembers.role })
|
||||
.from(printJobs)
|
||||
.innerJoin(farms, eq(farms.id, printJobs.farmId))
|
||||
.leftJoin(
|
||||
orgMembers,
|
||||
and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)),
|
||||
)
|
||||
.where(eq(printJobs.id, jobId))
|
||||
.limit(1);
|
||||
|
||||
const role = check(row?.role ?? null, minimum, `Print job ${jobId} not found`, action);
|
||||
return { farmId: row!.farmId, orgId: row!.orgId, role };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -44,7 +44,7 @@ function projection<T extends object, K extends keyof T>(table: T, keys: readonl
|
||||
export const farmProjection = projection(farms, [
|
||||
"id",
|
||||
"name",
|
||||
"ownerId",
|
||||
"orgId",
|
||||
"location",
|
||||
"settings",
|
||||
"createdAt",
|
||||
|
||||
+19
-4
@@ -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", {
|
||||
export const farms = pgTable(
|
||||
"farms",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
ownerId: uuid("owner_id").notNull(),
|
||||
/**
|
||||
* Replaces the old `owner_id`. A farm used to point straight at a single
|
||||
* Supabase user, which left no way to let a second person operate it
|
||||
* without handing over the account - so ownership moved up a level and
|
||||
* who may do what is now a row in `org_members`.
|
||||
*/
|
||||
orgId: uuid("org_id")
|
||||
.notNull()
|
||||
.references(() => organisations.id, { onDelete: "cascade" }),
|
||||
edgeProxyToken: text("edge_proxy_token").notNull(),
|
||||
location: text("location"),
|
||||
settings: jsonb("settings").$type<Record<string, unknown>>().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
},
|
||||
(table) => ({
|
||||
orgIdx: index("farms_org_id_idx").on(table.orgId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Farm = typeof farms.$inferSelect;
|
||||
export type NewFarm = typeof farms.$inferInsert;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./orgs";
|
||||
export * from "./farms";
|
||||
export * from "./printers";
|
||||
export * from "./print_jobs";
|
||||
|
||||
@@ -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<Record<string, unknown>>().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Membership is the only thing that grants access to anything in Continuum.
|
||||
*
|
||||
* `userId` is a Supabase `auth.users.id` - the `sub` claim of a verified
|
||||
* access token. It is deliberately *not* a foreign key: that table lives in
|
||||
* Supabase's own schema and may not even be in this database, so the
|
||||
* reference is by value and a deleted user leaves a row that matches no
|
||||
* token. Harmless, since nothing can authenticate as them any more.
|
||||
*/
|
||||
export const orgMembers = pgTable(
|
||||
"org_members",
|
||||
{
|
||||
orgId: uuid("org_id")
|
||||
.notNull()
|
||||
.references(() => organisations.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id").notNull(),
|
||||
role: orgRoleEnum("role").notNull().default("viewer"),
|
||||
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;
|
||||
+39
-1
@@ -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)
|
||||
|
||||
+54
-35
@@ -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" } },
|
||||
);
|
||||
|
||||
+116
-32
@@ -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 }) => {
|
||||
.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) {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } };
|
||||
}
|
||||
return farm;
|
||||
})
|
||||
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 }) => {
|
||||
.post(
|
||||
"/:id/edge-proxy-token",
|
||||
async ({ user, params }) => {
|
||||
await requireFarm(user.id, params.id, "owner", "rotating a farm's gateway credential");
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
const [farm] = await db
|
||||
.update(farms)
|
||||
.set({ edgeProxyToken: token, updatedAt: new Date() })
|
||||
.where(eq(farms.id, params.id))
|
||||
.returning({ id: farms.id });
|
||||
|
||||
if (!farm) {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } };
|
||||
}
|
||||
if (!farm) throw new NotFoundError(`Farm ${params.id} not found`);
|
||||
|
||||
return {
|
||||
farmId: farm.id,
|
||||
edgeProxyToken: token,
|
||||
note: "Any gateway still using the previous token must be reconfigured.",
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
params: IdParams,
|
||||
detail: {
|
||||
tags: ["farms"],
|
||||
summary: "Rotate and return this farm's edge-proxy token (owner only)",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+77
-17
@@ -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 }) => {
|
||||
.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) {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: `Print job ${params.id} not found` } };
|
||||
}
|
||||
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" } },
|
||||
);
|
||||
|
||||
@@ -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<number> {
|
||||
const [row] = await db
|
||||
.select({ n: count() })
|
||||
.from(orgMembers)
|
||||
.where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.role, "owner"), ne(orgMembers.userId, userId)));
|
||||
return Number(row?.n ?? 0);
|
||||
}
|
||||
|
||||
export const orgRoutes = new Elysia({ prefix: "/orgs" })
|
||||
.use(requireUser)
|
||||
/**
|
||||
* Only the caller's own organisations - there is no endpoint that lists all
|
||||
* of them, by design. An organisation the caller is not a member of should
|
||||
* be indistinguishable from one that does not exist.
|
||||
*/
|
||||
.get("/", async ({ user }) => listMemberships(user.id), {
|
||||
detail: { tags: ["orgs"], summary: "Organisations the caller belongs to" },
|
||||
})
|
||||
.post(
|
||||
"/",
|
||||
async ({ user, body, set }) => {
|
||||
// One transaction: an organisation with no owner is unreachable by
|
||||
// everyone including its creator, and that is exactly the state a
|
||||
// failure between these two inserts would leave behind.
|
||||
const org = await db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(organisations)
|
||||
.values({ name: body.name })
|
||||
.returning();
|
||||
await tx
|
||||
.insert(orgMembers)
|
||||
.values({ orgId: created!.id, userId: user.id, role: "owner" });
|
||||
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" } },
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
+130
-32
@@ -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({
|
||||
.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: query.farmId ? eq(printers.farmId, query.farmId) : undefined,
|
||||
}), { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) })
|
||||
.get("/:id", async ({ params, set }) => {
|
||||
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) {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: `Printer ${params.id} not found` } };
|
||||
}
|
||||
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,17 +168,17 @@ 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 }) => {
|
||||
.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) {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: `Printer ${params.id} not found` } };
|
||||
}
|
||||
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 {
|
||||
@@ -90,4 +186,6 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
|
||||
gatewayPrinterId: printer.gatewayPrinterId,
|
||||
gatewayOnline: isGatewayOnline(printer.farmId),
|
||||
};
|
||||
});
|
||||
},
|
||||
{ params: IdParams, detail: { tags: ["printers"] } },
|
||||
);
|
||||
|
||||
@@ -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<ReturnType<typeof startDevAuthIssuer>>;
|
||||
let app: Elysia;
|
||||
|
||||
beforeAll(async () => {
|
||||
issuer = await startDevAuthIssuer();
|
||||
process.env.SUPABASE_URL = issuer.url;
|
||||
// Imported after SUPABASE_URL is set: the verifier is built at module
|
||||
// load so a misconfigured deployment fails at boot, not on first request.
|
||||
const { requireUser } = await import("../src/auth/plugin");
|
||||
app = new Elysia().onError(toErrorResponse).use(requireUser).get("/whoami", ({ user }) => user);
|
||||
});
|
||||
|
||||
afterAll(() => issuer.stop());
|
||||
|
||||
const get = (headers: Record<string, string> = {}) =>
|
||||
app.handle(new Request("http://localhost/whoami", { headers }));
|
||||
|
||||
test("accepts a properly signed token and exposes the subject", async () => {
|
||||
const token = await issuer.mint({ sub: USER, email: "farm@example.test" });
|
||||
const response = await get({ authorization: `Bearer ${token}` });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ id: USER, email: "farm@example.test" });
|
||||
});
|
||||
|
||||
test("rejects a request with no Authorization header", async () => {
|
||||
const response = await get();
|
||||
expect(response.status).toBe(401);
|
||||
expect((await response.json()).error.code).toBe("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("rejects a non-Bearer Authorization header", async () => {
|
||||
const response = await get({ authorization: "Basic dXNlcjpwYXNz" });
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("rejects a token signed by a key that is not in the JWKS", async () => {
|
||||
const token = await issuer.mintWithForeignKey({ sub: USER });
|
||||
const response = await get({ authorization: `Bearer ${token}` });
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("rejects an expired token", async () => {
|
||||
const token = await issuer.mint({ sub: USER, expiresIn: -60 });
|
||||
const response = await get({ authorization: `Bearer ${token}` });
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("rejects a token issued for another audience", async () => {
|
||||
// Supabase issues `aud: "authenticated"` for user sessions. Its anon and
|
||||
// service-role keys are also valid project JWTs with other audiences, so
|
||||
// without this check the public anon key would authenticate as a user.
|
||||
const token = await issuer.mint({ sub: USER, audience: "anon" });
|
||||
const response = await get({ authorization: `Bearer ${token}` });
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("does not say why a token was rejected", async () => {
|
||||
// Distinguishing "expired" from "bad signature" tells whoever is probing
|
||||
// which part of a forged token to fix next.
|
||||
const expired = await get({
|
||||
authorization: `Bearer ${await issuer.mint({ sub: USER, expiresIn: -60 })}`,
|
||||
});
|
||||
const foreign = await get({
|
||||
authorization: `Bearer ${await issuer.mintWithForeignKey({ sub: USER })}`,
|
||||
});
|
||||
expect((await expired.json()).error.message).toBe((await foreign.json()).error.message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration", () => {
|
||||
test("the process refuses to start without SUPABASE_URL", async () => {
|
||||
// Fail at boot, not on the first authenticated request: a backend that
|
||||
// starts and then 500s on every login looks like an outage somewhere
|
||||
// else. Run in a subprocess because the module caches its verifier.
|
||||
const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/verifier.ts')"], {
|
||||
cwd: import.meta.dir + "/..",
|
||||
env: { ...process.env, SUPABASE_URL: "" },
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
expect(await proc.exited).not.toBe(0);
|
||||
expect(await new Response(proc.stderr).text()).toContain("SUPABASE_URL");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user