Add organisation invitations and one-time-code gateway enrolment

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