@openparachute/hub 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Tests for hub#649 — per-client-IP + total WebSocket connection caps at the
3
+ * upgrade gate (`maybeUpgradeWebSocket` via the fetch fn), with release
4
+ * riding the ws-bridge close path.
5
+ *
6
+ * Three tiers:
7
+ * - UNIT (fetch fn + spy upgrade): per-IP cap refusal (the HEADLINE test —
8
+ * it FAILED pre-fix with all 40 upgrades accepted), global cap refusal,
9
+ * generic 429 body, forwarded-header trust (spoofed XFF on an untrusted
10
+ * layer can't escape its bucket), release on a failed `server.upgrade`,
11
+ * env-configurable caps.
12
+ * - UNIT (pure): `wsCapBucketKey` derivation + `WsConnectionTracker`
13
+ * accounting (double-release latch, key eviction at zero).
14
+ * - INTEGRATION (real Bun.serve hub + real WS echo upstream): a churn of
15
+ * open/close cycles returns the counters to zero — including the
16
+ * unreachable-upstream teardown path, where the bridge (not the client)
17
+ * initiates the close.
18
+ *
19
+ * Every test injects its OWN tracker via `HubFetchDeps.wsConnectionTracker`
20
+ * so nothing here consumes (or depends on) the process-wide default
21
+ * tracker's counters.
22
+ */
23
+ import { describe, expect, test } from "bun:test";
24
+ import { mkdtempSync, rmSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+ import { WS_CAP_SHARED_BUCKET, hubFetch, wsCapBucketKey } from "../hub-server.ts";
28
+ import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
29
+ import { type WsBridgeData, createWsBridgeHandlers } from "../ws-bridge.ts";
30
+ import {
31
+ DEFAULT_WS_MAX_PER_IP,
32
+ DEFAULT_WS_MAX_TOTAL,
33
+ WsConnectionTracker,
34
+ wsCapsFromEnv,
35
+ } from "../ws-connection-caps.ts";
36
+
37
+ interface Harness {
38
+ dir: string;
39
+ manifestPath: string;
40
+ cleanup: () => void;
41
+ }
42
+
43
+ function makeHarness(): Harness {
44
+ const dir = mkdtempSync(join(tmpdir(), "pcli-ws-caps-"));
45
+ return {
46
+ dir,
47
+ manifestPath: join(dir, "services.json"),
48
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
49
+ };
50
+ }
51
+
52
+ function wsEntry(port: number, extra: Partial<ServiceEntry> = {}): ServiceEntry {
53
+ return {
54
+ name: "wsmod",
55
+ port,
56
+ paths: ["/wsmod"],
57
+ health: "/wsmod/health",
58
+ version: "0.1.0",
59
+ websocket: true,
60
+ ...extra,
61
+ };
62
+ }
63
+
64
+ function upgradeReq(path: string, headers: Record<string, string> = {}): Request {
65
+ return new Request(`http://127.0.0.1${path}`, {
66
+ headers: { upgrade: "websocket", connection: "Upgrade", ...headers },
67
+ });
68
+ }
69
+
70
+ interface UpgradeSpy {
71
+ requestIP: () => { address: string };
72
+ upgrade: (req: Request, options: { data: WsBridgeData }) => boolean;
73
+ calls: { data: WsBridgeData }[];
74
+ }
75
+
76
+ function upgradeSpy(address: string, accept = true): UpgradeSpy {
77
+ const calls: { data: WsBridgeData }[] = [];
78
+ return {
79
+ requestIP: () => ({ address }),
80
+ upgrade: (_req, options) => {
81
+ calls.push(options);
82
+ return accept;
83
+ },
84
+ calls,
85
+ };
86
+ }
87
+
88
+ async function waitFor(cond: () => boolean, timeoutMs = 5000): Promise<void> {
89
+ const deadline = Date.now() + timeoutMs;
90
+ while (!cond()) {
91
+ if (Date.now() > deadline) throw new Error("waitFor timed out");
92
+ await Bun.sleep(10);
93
+ }
94
+ }
95
+
96
+ // ===========================================================================
97
+ // Unit — cap refusal at the upgrade gate
98
+ // ===========================================================================
99
+
100
+ describe("WS connection caps — upgrade-gate refusal (hub#649)", () => {
101
+ test("HEADLINE: a same-IP upgrade flood is refused past the default per-IP cap (429), not accepted unboundedly", async () => {
102
+ // Pre-fix evidence: run against the unmodified tree (with the default
103
+ // tracker), this expressed-behavior test failed with accepted=40 — the
104
+ // hub had NO connection bound at all.
105
+ const h = makeHarness();
106
+ try {
107
+ writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
108
+ const spy = upgradeSpy("127.0.0.1");
109
+ const tracker = new WsConnectionTracker(); // built-in defaults: 32 / 512
110
+ const fetcher = hubFetch(h.dir, {
111
+ manifestPath: h.manifestPath,
112
+ wsConnectionTracker: tracker,
113
+ });
114
+
115
+ let accepted = 0;
116
+ const refusals: Response[] = [];
117
+ for (let i = 0; i < 40; i++) {
118
+ // Loopback peer = trusted forwarder (tailscale serve / funnel
119
+ // shape) — all 40 attempts attribute to ONE public client IP.
120
+ const res = await fetcher(
121
+ upgradeReq("/wsmod/ws", { "x-forwarded-for": "198.51.100.7" }),
122
+ spy,
123
+ );
124
+ if (res === undefined) accepted++;
125
+ else refusals.push(res);
126
+ }
127
+
128
+ expect(accepted).toBe(DEFAULT_WS_MAX_PER_IP); // 32
129
+ expect(refusals.length).toBe(8);
130
+ expect(spy.calls.length).toBe(DEFAULT_WS_MAX_PER_IP); // upgrade never attempted over-cap
131
+ for (const res of refusals) expect(res.status).toBe(429);
132
+
133
+ // The refusal is generic: no counts, no cap identity, no numbers.
134
+ const body = (await refusals[0]!.json()) as { error: string; error_description: string };
135
+ expect(body.error).toBe("too_many_connections");
136
+ expect(JSON.stringify(body)).not.toMatch(/\d/);
137
+ } finally {
138
+ h.cleanup();
139
+ }
140
+ });
141
+
142
+ test("global cap: distinct client IPs are refused once the total is saturated", async () => {
143
+ const h = makeHarness();
144
+ try {
145
+ writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
146
+ const spy = upgradeSpy("127.0.0.1");
147
+ const tracker = new WsConnectionTracker(10, 3); // per-IP generous, total=3
148
+ const fetcher = hubFetch(h.dir, {
149
+ manifestPath: h.manifestPath,
150
+ wsConnectionTracker: tracker,
151
+ });
152
+
153
+ for (let i = 0; i < 3; i++) {
154
+ const res = await fetcher(
155
+ upgradeReq("/wsmod/ws", { "x-forwarded-for": `198.51.100.${i}` }),
156
+ spy,
157
+ );
158
+ expect(res).toBeUndefined(); // upgraded
159
+ }
160
+ const res = await fetcher(
161
+ upgradeReq("/wsmod/ws", { "x-forwarded-for": "198.51.100.99" }),
162
+ spy,
163
+ );
164
+ expect(res?.status).toBe(429);
165
+ expect(spy.calls.length).toBe(3);
166
+ expect(tracker.totalCount).toBe(3);
167
+ } finally {
168
+ h.cleanup();
169
+ }
170
+ });
171
+
172
+ test("forwarded-header trust: spoofed XFF from a direct (non-loopback) peer does NOT escape the peer's bucket", async () => {
173
+ const h = makeHarness();
174
+ try {
175
+ writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
176
+ // Direct public peer — its XFF is attacker-controlled and must be ignored.
177
+ const spy = upgradeSpy("203.0.113.50");
178
+ const tracker = new WsConnectionTracker(2, 100);
179
+ const fetcher = hubFetch(h.dir, {
180
+ manifestPath: h.manifestPath,
181
+ wsConnectionTracker: tracker,
182
+ });
183
+
184
+ const r1 = await fetcher(upgradeReq("/wsmod/ws", { "x-forwarded-for": "1.1.1.1" }), spy);
185
+ const r2 = await fetcher(upgradeReq("/wsmod/ws", { "x-forwarded-for": "2.2.2.2" }), spy);
186
+ const r3 = await fetcher(upgradeReq("/wsmod/ws", { "x-forwarded-for": "3.3.3.3" }), spy);
187
+ expect(r1).toBeUndefined();
188
+ expect(r2).toBeUndefined();
189
+ expect(r3?.status).toBe(429); // rotating XFF minted no fresh buckets
190
+ expect(tracker.countFor("203.0.113.50")).toBe(2);
191
+ } finally {
192
+ h.cleanup();
193
+ }
194
+ });
195
+
196
+ test("forwarded-header trust: XFF from a loopback (trusted forwarder) peer DOES key distinct buckets", async () => {
197
+ const h = makeHarness();
198
+ try {
199
+ writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
200
+ const spy = upgradeSpy("127.0.0.1");
201
+ const tracker = new WsConnectionTracker(2, 100);
202
+ const fetcher = hubFetch(h.dir, {
203
+ manifestPath: h.manifestPath,
204
+ wsConnectionTracker: tracker,
205
+ });
206
+
207
+ // Two different real clients behind the on-box forwarder: each gets
208
+ // its own per-IP allotment.
209
+ for (const ip of ["198.51.100.1", "198.51.100.1", "198.51.100.2", "198.51.100.2"]) {
210
+ const res = await fetcher(upgradeReq("/wsmod/ws", { "x-forwarded-for": ip }), spy);
211
+ expect(res).toBeUndefined();
212
+ }
213
+ expect(tracker.countFor("198.51.100.1")).toBe(2);
214
+ expect(tracker.countFor("198.51.100.2")).toBe(2);
215
+ } finally {
216
+ h.cleanup();
217
+ }
218
+ });
219
+
220
+ test("a failed server.upgrade releases the slot (no leak without a socket)", async () => {
221
+ const h = makeHarness();
222
+ try {
223
+ writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
224
+ const spy = upgradeSpy("127.0.0.1", false); // handshake malformed → upgrade() = false
225
+ const tracker = new WsConnectionTracker(1, 1);
226
+ const fetcher = hubFetch(h.dir, {
227
+ manifestPath: h.manifestPath,
228
+ wsConnectionTracker: tracker,
229
+ });
230
+
231
+ const r1 = await fetcher(upgradeReq("/wsmod/ws"), spy);
232
+ expect(r1?.status).toBe(400);
233
+ expect(tracker.totalCount).toBe(0); // released inline
234
+ // The (1,1)-capped tracker still admits the next attempt.
235
+ const r2 = await fetcher(upgradeReq("/wsmod/ws"), spy);
236
+ expect(r2?.status).toBe(400);
237
+ expect(tracker.totalCount).toBe(0);
238
+ } finally {
239
+ h.cleanup();
240
+ }
241
+ });
242
+
243
+ test("caps are configurable: an env-built tracker enforces the overridden values", async () => {
244
+ const h = makeHarness();
245
+ try {
246
+ writeManifest({ services: [wsEntry(12345)] }, h.manifestPath);
247
+ const caps = wsCapsFromEnv({
248
+ PARACHUTE_WS_MAX_PER_IP: "2",
249
+ PARACHUTE_WS_MAX_TOTAL: "50",
250
+ } as NodeJS.ProcessEnv);
251
+ const tracker = new WsConnectionTracker(caps.maxPerIp, caps.maxTotal);
252
+ const spy = upgradeSpy("127.0.0.1");
253
+ const fetcher = hubFetch(h.dir, {
254
+ manifestPath: h.manifestPath,
255
+ wsConnectionTracker: tracker,
256
+ });
257
+
258
+ const headers = { "x-forwarded-for": "198.51.100.7" };
259
+ expect(await fetcher(upgradeReq("/wsmod/ws", headers), spy)).toBeUndefined();
260
+ expect(await fetcher(upgradeReq("/wsmod/ws", headers), spy)).toBeUndefined();
261
+ const res = await fetcher(upgradeReq("/wsmod/ws", headers), spy);
262
+ expect(res?.status).toBe(429);
263
+ } finally {
264
+ h.cleanup();
265
+ }
266
+ });
267
+ });
268
+
269
+ // ===========================================================================
270
+ // Unit — env parsing, bucket-key derivation, tracker accounting
271
+ // ===========================================================================
272
+
273
+ describe("wsCapsFromEnv", () => {
274
+ test("absent env → defaults", () => {
275
+ expect(wsCapsFromEnv({} as NodeJS.ProcessEnv)).toEqual({
276
+ maxPerIp: DEFAULT_WS_MAX_PER_IP,
277
+ maxTotal: DEFAULT_WS_MAX_TOTAL,
278
+ });
279
+ });
280
+
281
+ test("valid positive integers are honored", () => {
282
+ expect(
283
+ wsCapsFromEnv({
284
+ PARACHUTE_WS_MAX_PER_IP: "8",
285
+ PARACHUTE_WS_MAX_TOTAL: "1024",
286
+ } as NodeJS.ProcessEnv),
287
+ ).toEqual({ maxPerIp: 8, maxTotal: 1024 });
288
+ });
289
+
290
+ test("malformed / non-positive values fall back to the defaults, never to unlimited", () => {
291
+ for (const bad of ["", "abc", "0", "-3", "12.5", "1e3", "32x"]) {
292
+ const caps = wsCapsFromEnv({
293
+ PARACHUTE_WS_MAX_PER_IP: bad,
294
+ PARACHUTE_WS_MAX_TOTAL: bad,
295
+ } as NodeJS.ProcessEnv);
296
+ expect(caps).toEqual({ maxPerIp: DEFAULT_WS_MAX_PER_IP, maxTotal: DEFAULT_WS_MAX_TOTAL });
297
+ }
298
+ });
299
+ });
300
+
301
+ describe("wsCapBucketKey — trust-model keying", () => {
302
+ const req = (headers: Record<string, string> = {}) =>
303
+ new Request("http://127.0.0.1/wsmod/ws", { headers });
304
+
305
+ test("loopback peer: CF-Connecting-IP wins, then XFF first hop, then the peer itself", () => {
306
+ expect(
307
+ wsCapBucketKey(
308
+ req({ "cf-connecting-ip": "198.51.100.9", "x-forwarded-for": "10.0.0.1" }),
309
+ "127.0.0.1",
310
+ ),
311
+ ).toBe("198.51.100.9");
312
+ expect(wsCapBucketKey(req({ "x-forwarded-for": "198.51.100.7, 10.0.0.1" }), "127.0.0.1")).toBe(
313
+ "198.51.100.7",
314
+ );
315
+ expect(wsCapBucketKey(req(), "::1")).toBe("::1");
316
+ });
317
+
318
+ test("non-loopback peer: keyed by peer address, injected headers ignored", () => {
319
+ expect(
320
+ wsCapBucketKey(
321
+ req({ "cf-connecting-ip": "1.2.3.4", "x-forwarded-for": "5.6.7.8" }),
322
+ "203.0.113.50",
323
+ ),
324
+ ).toBe("203.0.113.50");
325
+ });
326
+
327
+ test("underivable (no peer) → the shared fail-closed bucket, headers still ignored", () => {
328
+ expect(wsCapBucketKey(req({ "x-forwarded-for": "5.6.7.8" }), null)).toBe(WS_CAP_SHARED_BUCKET);
329
+ expect(wsCapBucketKey(req(), " ")).toBe(WS_CAP_SHARED_BUCKET);
330
+ });
331
+ });
332
+
333
+ describe("WsConnectionTracker accounting", () => {
334
+ test("release is latched: double-release decrements once, never below zero", () => {
335
+ const tracker = new WsConnectionTracker(2, 2);
336
+ const a = tracker.tryAcquire("k");
337
+ const b = tracker.tryAcquire("k");
338
+ if (!a.ok || !b.ok) throw new Error("expected both acquires to succeed");
339
+ a.release();
340
+ a.release(); // latched no-op
341
+ expect(tracker.totalCount).toBe(1);
342
+ expect(tracker.countFor("k")).toBe(1);
343
+ b.release();
344
+ b.release();
345
+ expect(tracker.totalCount).toBe(0);
346
+ expect(tracker.countFor("k")).toBe(0);
347
+ expect(tracker.keyCount).toBe(0); // bucket evicted at zero — no map growth
348
+ });
349
+
350
+ test("refusals don't mutate counts", () => {
351
+ const tracker = new WsConnectionTracker(1, 10);
352
+ const a = tracker.tryAcquire("k");
353
+ if (!a.ok) throw new Error("expected acquire to succeed");
354
+ const refused = tracker.tryAcquire("k");
355
+ expect(refused.ok).toBe(false);
356
+ if (!refused.ok) expect(refused.reason).toBe("per_ip_cap");
357
+ expect(tracker.totalCount).toBe(1);
358
+ a.release();
359
+ expect(tracker.totalCount).toBe(0);
360
+ });
361
+ });
362
+
363
+ // ===========================================================================
364
+ // Integration — real sockets: churn returns the counters to zero
365
+ // ===========================================================================
366
+
367
+ function startWsEchoUpstream(): { port: number; stop: () => void } {
368
+ const server = Bun.serve({
369
+ port: 0,
370
+ hostname: "127.0.0.1",
371
+ fetch(req, srv) {
372
+ if (srv.upgrade(req)) return undefined as unknown as Response;
373
+ return new Response("expected websocket", { status: 400 });
374
+ },
375
+ websocket: {
376
+ message(ws, msg) {
377
+ ws.send(typeof msg === "string" ? `echo:${msg}` : msg);
378
+ },
379
+ },
380
+ });
381
+ return { port: server.port as number, stop: () => server.stop(true) };
382
+ }
383
+
384
+ function startHub(h: Harness, tracker: WsConnectionTracker): { port: number; stop: () => void } {
385
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, wsConnectionTracker: tracker });
386
+ const server = Bun.serve<WsBridgeData>({
387
+ port: 0,
388
+ hostname: "127.0.0.1",
389
+ fetch: (req, srv) => fetcher(req, srv),
390
+ websocket: createWsBridgeHandlers(),
391
+ });
392
+ return { port: server.port as number, stop: () => server.stop(true) };
393
+ }
394
+
395
+ describe("WS connection caps — integration (real sockets, churn to zero)", () => {
396
+ test("open/close churn returns per-IP + total counters to zero every cycle", async () => {
397
+ const h = makeHarness();
398
+ const upstream = startWsEchoUpstream();
399
+ let hub: { port: number; stop: () => void } | undefined;
400
+ try {
401
+ writeManifest({ services: [wsEntry(upstream.port)] }, h.manifestPath);
402
+ const tracker = new WsConnectionTracker(); // defaults — churn never nears them
403
+ hub = startHub(h, tracker);
404
+
405
+ for (let cycle = 0; cycle < 3; cycle++) {
406
+ const clients: WebSocket[] = [];
407
+ for (let i = 0; i < 3; i++) {
408
+ const ws = new WebSocket(`ws://127.0.0.1:${hub.port}/wsmod/ws`);
409
+ const opened = new Promise<void>((resolve, reject) => {
410
+ ws.addEventListener("open", () => resolve());
411
+ ws.addEventListener("error", () => reject(new Error("client failed to connect")));
412
+ });
413
+ clients.push(ws);
414
+ await opened;
415
+ }
416
+ // Slots held while the sockets live (all test clients are direct
417
+ // loopback peers → one shared bucket).
418
+ expect(tracker.totalCount).toBe(3);
419
+ expect(tracker.countFor("127.0.0.1")).toBe(3);
420
+
421
+ for (const ws of clients) ws.close(1000, "done");
422
+ await waitFor(() => tracker.totalCount === 0);
423
+ expect(tracker.keyCount).toBe(0); // bucket map fully drained — no leak
424
+ }
425
+ } finally {
426
+ hub?.stop();
427
+ upstream.stop();
428
+ h.cleanup();
429
+ }
430
+ });
431
+
432
+ test("bridge-initiated teardown (unreachable upstream) also releases the slot", async () => {
433
+ const h = makeHarness();
434
+ // Bind a port + release it so the upstream connect gets ECONNREFUSED.
435
+ const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") });
436
+ const deadPort = probe.port as number;
437
+ probe.stop(true);
438
+ let hub: { port: number; stop: () => void } | undefined;
439
+ try {
440
+ writeManifest({ services: [wsEntry(deadPort)] }, h.manifestPath);
441
+ const tracker = new WsConnectionTracker();
442
+ hub = startHub(h, tracker);
443
+
444
+ const ws = new WebSocket(`ws://127.0.0.1:${hub.port}/wsmod/ws`);
445
+ const closed = new Promise<number>((resolve) => {
446
+ ws.addEventListener("close", (ev) => resolve(ev.code));
447
+ });
448
+ expect(await closed).toBe(1011);
449
+ await waitFor(() => tracker.totalCount === 0);
450
+ expect(tracker.keyCount).toBe(0);
451
+ } finally {
452
+ hub?.stop();
453
+ h.cleanup();
454
+ }
455
+ });
456
+ });
@@ -13,9 +13,20 @@
13
13
  *
14
14
  * Redeem ORDERING (the re-usability guarantee, mirroring the wizard):
15
15
  * 1. lookup + validate the invite (not-found/expired/used/revoked)
16
- * 2. validate username/password (+ vault name)
17
- * 3. provision the vault (must FRESHLY CREATE reject a pre-existing name;
18
- * attaching the new user to someone else's vault is a cross-tenant breach)
16
+ * 2. validate username/password (+ vault name). A pre-named invite
17
+ * (invite.username set) ENFORCES the usernamethe form field is
18
+ * ignored and the invite's name is used; if it's been taken since
19
+ * mint, the invitee is told to ask the operator for a new link
20
+ * (the invite stays unconsumed so the operator can revoke + re-mint).
21
+ * 3. resolve the vault:
22
+ * - provision_vault=1 → provision (must FRESHLY CREATE — reject a
23
+ * pre-existing name; silently attaching the new user to someone
24
+ * else's vault would be a cross-tenant breach)
25
+ * - provision_vault=0 + vault_name → SHARED-VAULT invite: assign the
26
+ * admin's EXISTING vault at the invite's role (no provisioning).
27
+ * The vault must still exist in services.json (the vault-delete
28
+ * cascade revokes pending invites, so this re-check is defense in
29
+ * depth against manual manifest edits).
19
30
  * 4. createUser (the commit point)
20
31
  * 5. consumeInvite — stamp used_at + redeemed_user_id ONLY AFTER (4) commits
21
32
  * 6. createSession + cookie + 302
@@ -25,15 +36,21 @@
25
36
  * invite re-usable — the invitee can simply retry. `consumeInvite`'s
26
37
  * `used_at IS NULL` guard makes the stamp itself single-use / race-free.
27
38
  *
28
- * What an invite pre-authorizes: EXACTLY one account + the one named/created
29
- * vault at the baked-in role — NEVER host:admin, NEVER another vault. The new
30
- * user gets `assignedVaults:[that vault]` with the invite's role; nothing
31
- * grants admin posture (the first-admin-by-earliest-row heuristic is
32
- * untouched — an invited user is never the earliest row).
39
+ * What an invite pre-authorizes: EXACTLY one account + the one named/created/
40
+ * shared vault at the baked-in role — NEVER host:admin, NEVER another vault.
41
+ * The new user gets `assignedVaults:[that vault]` with the invite's role;
42
+ * nothing grants admin posture (the first-admin-by-earliest-row heuristic is
43
+ * untouched — an invited user is never the earliest row). The shared-vault
44
+ * shape is admin-authorized by construction: only a host:admin bearer can
45
+ * mint an invite, and that same authority can already assign any user to any
46
+ * vault via POST /api/users. The role ('read' or 'write') is enforced at
47
+ * every mint chokepoint via `vaultVerbsForRole` and at the vault by
48
+ * scope-guard.
33
49
  */
34
50
  import type { Database } from "bun:sqlite";
35
51
  import { renderAdminError, renderInviteSetup } from "./admin-login-ui.ts";
36
52
  import { type RunResult, provisionVault } from "./admin-vaults.ts";
53
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
37
54
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
38
55
  import {
39
56
  InviteExpiredError,
@@ -55,6 +72,7 @@ import {
55
72
  validateUsername,
56
73
  } from "./users.ts";
57
74
  import { validateVaultName } from "./vault-name.ts";
75
+ import { listVaultNamesFromPath } from "./vault-names.ts";
58
76
 
59
77
  export interface AccountSetupDeps {
60
78
  db: Database;
@@ -145,6 +163,8 @@ export function handleAccountSetupGet(
145
163
  token: rawToken,
146
164
  csrfToken: csrf.token,
147
165
  pinnedVaultName: invite.vaultName,
166
+ pinnedUsername: invite.username,
167
+ role: invite.role,
148
168
  provisionVault: invite.provisionVault,
149
169
  }),
150
170
  200,
@@ -196,7 +216,11 @@ export async function handleAccountSetupPost(
196
216
  );
197
217
  }
198
218
 
199
- const username = String(form.get("username") ?? "").trim();
219
+ // Pre-named invite → the invite's username is ENFORCED (the form renders it
220
+ // read-only and any submitted value is ignored — server is the source of
221
+ // truth, same posture as the pinned vault name).
222
+ const username =
223
+ invite.username !== null ? invite.username : String(form.get("username") ?? "").trim();
200
224
  const password = String(form.get("password") ?? "");
201
225
  const confirm = String(form.get("password_confirm") ?? "");
202
226
 
@@ -209,6 +233,8 @@ export async function handleAccountSetupPost(
209
233
  token: rawToken,
210
234
  csrfToken: csrf.token,
211
235
  pinnedVaultName: invite.vaultName,
236
+ pinnedUsername: invite.username,
237
+ role: invite.role,
212
238
  provisionVault: invite.provisionVault,
213
239
  username,
214
240
  ...(vaultNameEcho !== undefined ? { vaultName: vaultNameEcho } : {}),
@@ -218,12 +244,16 @@ export async function handleAccountSetupPost(
218
244
  setCookie,
219
245
  );
220
246
 
221
- // (2) Validate credentials with the SAME validators as /api/users.
247
+ // (2) Validate credentials with the SAME validators as /api/users. Runs for
248
+ // the pre-named case too — defense in depth against a hand-edited invites
249
+ // row carrying a name the vocabulary forbids.
222
250
  const u = validateUsername(username);
223
251
  if (!u.valid) {
224
252
  return rerender(
225
253
  400,
226
- "Username must be 2–32 lowercase letters, digits, _ or - (and not a reserved word).",
254
+ invite.username !== null
255
+ ? "This invite's pre-set username is not valid. Ask your hub operator for a new invite."
256
+ : "Username must be 2–32 lowercase letters, digits, _ or - (and not a reserved word).",
227
257
  );
228
258
  }
229
259
  if (password.length > PASSWORD_MAX_LEN) {
@@ -236,9 +266,25 @@ export async function handleAccountSetupPost(
236
266
  if (password !== confirm) {
237
267
  return rerender(400, "The two passwords don't match.");
238
268
  }
239
- // Case-insensitive uniqueness — same gate as /api/users.
269
+ // Case-insensitive uniqueness — same gate as /api/users. For a pre-named
270
+ // invite the name can't be changed by the invitee, so a collision (someone
271
+ // took the name between mint and redeem) needs the operator to revoke +
272
+ // re-mint; the invite stays unconsumed either way.
273
+ //
274
+ // Username-existence oracle, accepted trade-off: a 409 here confirms to the
275
+ // bearer that a given name is taken. The probe is gated behind a single-use,
276
+ // unexpired invite token (256-bit, sha256-at-rest) — not an open endpoint —
277
+ // and for the pre-named case the probed name was chosen by the ADMIN at
278
+ // mint (where it was already validated against existing users), not
279
+ // attacker-supplied. The same disclosure already exists for the (more
280
+ // privileged) callers of /api/users and the setup wizard.
240
281
  if (getUserByUsernameCI(deps.db, username) !== null) {
241
- return rerender(409, `The username "${username}" is already taken. Pick another.`);
282
+ return rerender(
283
+ 409,
284
+ invite.username !== null
285
+ ? `The username "${username}" chosen for this invite is already taken. Ask your hub operator for a new invite.`
286
+ : `The username "${username}" is already taken. Pick another.`,
287
+ );
242
288
  }
243
289
 
244
290
  // Resolve the vault name: pinned by the invite, or chosen by the invitee.
@@ -248,6 +294,18 @@ export async function handleAccountSetupPost(
248
294
  // at the vault CLI with a generic provision error.
249
295
  let vaultName: string | null = null;
250
296
  if (invite.provisionVault) {
297
+ // Defense in depth against a hand-edited invites row: the API refuses to
298
+ // MINT provision_vault=1 with role != 'write' (a fresh vault's SOLE user
299
+ // must hold write — a read-only owner would leave the new vault
300
+ // permanently un-writable). Honor the same invariant at redeem so a row
301
+ // that bypassed the API can't create that dead-end. The invite stays
302
+ // unconsumed; the operator re-mints a valid one.
303
+ if (invite.role !== "write") {
304
+ return rerender(
305
+ 400,
306
+ "This invite is not valid (a new vault's owner must have write access). Ask your hub operator for a new invite.",
307
+ );
308
+ }
251
309
  if (invite.vaultName !== null) {
252
310
  vaultName = invite.vaultName;
253
311
  } else {
@@ -268,16 +326,29 @@ export async function handleAccountSetupPost(
268
326
  vaultName = v.name;
269
327
  }
270
328
  } else if (invite.vaultName !== null) {
271
- // UNSUPPORTED shared-vault case: an account-only invite (provision_vault
272
- // =false) that pins an EXISTING vault would assign the new user owner-
273
- // admin on a pre-existing vault — a cross-tenant breach, and the owner-
274
- // vs-shared role split isn't built. The admin API rejects creating such
275
- // an invite (defense in depth); reject here too in case one slipped
276
- // through. The legit account-only invite (vaultName === null) is unaffected.
277
- return rerender(
278
- 400,
279
- "This invite is not supported (shared-vault invites aren't available yet). Ask your hub operator for a new one.",
280
- );
329
+ // SHARED-VAULT invite: assign the redeemer to the admin's EXISTING vault
330
+ // at the invite's baked-in role ('read' or 'write') no provisioning.
331
+ // Admin-authorized by construction: only a host:admin bearer can mint an
332
+ // invite, and that same authority can already assign any user to any
333
+ // vault via POST /api/users; the invite packages that assignment as a
334
+ // deliverable link. The role is enforced downstream at every mint
335
+ // chokepoint (`vaultVerbsForRole`: read → ["read"]) and at the vault by
336
+ // scope-guard — a read-role redeemer can never obtain a write-capable
337
+ // token for this (or any other) vault.
338
+ //
339
+ // The vault must still exist: the vault-delete cascade
340
+ // (`revokeInvitesForVault`) revokes pending invites pinned to a deleted
341
+ // vault, so this re-check is defense in depth (manual services.json
342
+ // edits, restored DB). The invite stays unconsumed on this rejection.
343
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
344
+ const known = new Set(listVaultNamesFromPath(manifestPath));
345
+ if (!known.has(invite.vaultName)) {
346
+ return rerender(
347
+ 400,
348
+ "The vault this invite shares no longer exists on this hub. Ask your hub operator for a new invite.",
349
+ );
350
+ }
351
+ vaultName = invite.vaultName;
281
352
  }
282
353
 
283
354
  // (3) Provision the vault — must FRESHLY CREATE it (see the security