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:
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Executable
+154
@@ -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; }
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
);
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user