@openparachute/hub 0.7.3-rc.3 → 0.7.3-rc.5
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.
- package/package.json +1 -1
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/oauth-handlers.test.ts +122 -1
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-users.ts +3 -0
- package/src/hub-db.ts +85 -1
- package/src/invites.ts +155 -31
- package/src/oauth-handlers.ts +35 -1
- package/src/rate-limit.ts +111 -20
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
AUTH_IP_CEILING_MAX_ATTEMPTS,
|
|
3
4
|
CHANGE_PASSWORD_MAX_ATTEMPTS,
|
|
4
5
|
CHANGE_PASSWORD_WINDOW_MS,
|
|
5
6
|
MAX_ATTEMPTS,
|
|
@@ -7,9 +8,12 @@ import {
|
|
|
7
8
|
UNKNOWN_IP_SENTINEL,
|
|
8
9
|
WINDOW_MS,
|
|
9
10
|
__resetForTests,
|
|
11
|
+
authIpCeilingRateLimiter,
|
|
10
12
|
changePasswordRateLimiter,
|
|
11
13
|
checkAndRecord,
|
|
12
14
|
clientIpFromRequest,
|
|
15
|
+
compositeKey,
|
|
16
|
+
loginRateLimiter,
|
|
13
17
|
} from "../rate-limit.ts";
|
|
14
18
|
|
|
15
19
|
afterEach(() => {
|
|
@@ -302,3 +306,85 @@ describe("changePasswordRateLimiter — tighter floor for /account/change-passwo
|
|
|
302
306
|
expect(changePasswordRateLimiter.checkAndRecord("user-a", now).allowed).toBe(true);
|
|
303
307
|
});
|
|
304
308
|
});
|
|
309
|
+
|
|
310
|
+
// The shared-egress-IP fix: the per-account FLOOR is now keyed by
|
|
311
|
+
// `compositeKey(ip, identity)` so a room of users behind ONE NAT'd /
|
|
312
|
+
// Cloudflare egress IP doesn't pool into one 5-slot bucket. A coarse per-IP
|
|
313
|
+
// CEILING (60/15min) backstops username-rotation across the floors.
|
|
314
|
+
describe("compositeKey + shared-egress-IP login floor", () => {
|
|
315
|
+
test("normalizes identity: trims + lowercases so casing/whitespace share a bucket", () => {
|
|
316
|
+
expect(compositeKey("1.2.3.4", "Alice")).toBe("1.2.3.4|alice");
|
|
317
|
+
expect(compositeKey("1.2.3.4", " ALICE ")).toBe("1.2.3.4|alice");
|
|
318
|
+
// Case-flip evasion is closed: 'Alice' and 'alice' resolve to one key.
|
|
319
|
+
expect(compositeKey("1.2.3.4", "Alice")).toBe(compositeKey("1.2.3.4", "alice"));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// (a) REGRESSION: two distinct usernames from the SAME ip each get a full
|
|
323
|
+
// independent 5/15min floor (the shared-wifi bug). Before the fix both would
|
|
324
|
+
// have pooled into one per-IP bucket and the second user's 1st attempt would
|
|
325
|
+
// have been the 6th overall → 429.
|
|
326
|
+
test("(a) two usernames from the same IP each get an independent 5/15min floor", () => {
|
|
327
|
+
const ip = "203.0.113.7";
|
|
328
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
329
|
+
// Alice exhausts her floor.
|
|
330
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
331
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(true);
|
|
332
|
+
}
|
|
333
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(false);
|
|
334
|
+
// Bob (same IP, different username) still has a fresh full floor.
|
|
335
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
336
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "bob"), now).allowed).toBe(true);
|
|
337
|
+
}
|
|
338
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "bob"), now).allowed).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// (b) a single (ip,username) still denies on the 6th attempt.
|
|
342
|
+
test("(b) a single (ip,username) still denies on the 6th attempt", () => {
|
|
343
|
+
const ip = "203.0.113.7";
|
|
344
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
345
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
346
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now).allowed).toBe(true);
|
|
347
|
+
}
|
|
348
|
+
const denied = loginRateLimiter.checkAndRecord(compositeKey(ip, "alice"), now);
|
|
349
|
+
expect(denied.allowed).toBe(false);
|
|
350
|
+
expect(denied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// (c) the per-IP ceiling denies on the 61st attempt from one IP even across
|
|
354
|
+
// rotated usernames (each username's own floor never trips because each only
|
|
355
|
+
// sees one attempt, but the coarse ceiling caps total per-IP volume).
|
|
356
|
+
test("(c) per-IP ceiling denies the 61st attempt across rotated usernames", () => {
|
|
357
|
+
const ip = "203.0.113.7";
|
|
358
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
359
|
+
for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
|
|
360
|
+
// Rotate a fresh username every attempt so no per-account floor ever fills.
|
|
361
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, `u${i}`), now).allowed).toBe(true);
|
|
362
|
+
expect(authIpCeilingRateLimiter.checkAndRecord(ip, now).allowed).toBe(true);
|
|
363
|
+
}
|
|
364
|
+
// 61st attempt: a brand-new username (floor is fresh) but the ceiling is full.
|
|
365
|
+
expect(loginRateLimiter.checkAndRecord(compositeKey(ip, "u60"), now).allowed).toBe(true);
|
|
366
|
+
const ceilingDenied = authIpCeilingRateLimiter.checkAndRecord(ip, now);
|
|
367
|
+
expect(ceilingDenied.allowed).toBe(false);
|
|
368
|
+
expect(ceilingDenied.retryAfterSeconds).toBe(WINDOW_MS / 1000);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("the ceiling is per-IP: a different IP is unaffected by another's full ceiling", () => {
|
|
372
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
373
|
+
for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
|
|
374
|
+
authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now);
|
|
375
|
+
}
|
|
376
|
+
expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(false);
|
|
377
|
+
expect(authIpCeilingRateLimiter.checkAndRecord("198.51.100.99", now).allowed).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("ceiling matches the signup precedent (60) and `__resetForTests` clears it", () => {
|
|
381
|
+
expect(AUTH_IP_CEILING_MAX_ATTEMPTS).toBe(60);
|
|
382
|
+
const now = new Date("2026-06-25T12:00:00Z");
|
|
383
|
+
for (let i = 0; i < AUTH_IP_CEILING_MAX_ATTEMPTS; i++) {
|
|
384
|
+
authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now);
|
|
385
|
+
}
|
|
386
|
+
expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(false);
|
|
387
|
+
__resetForTests();
|
|
388
|
+
expect(authIpCeilingRateLimiter.checkAndRecord("203.0.113.7", now).allowed).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -332,6 +332,100 @@ describe("login two-step (TOTP) — hub#473", () => {
|
|
|
332
332
|
expect(denied.status).toBe(429);
|
|
333
333
|
expect(denied.headers.get("retry-after")).not.toBeNull();
|
|
334
334
|
});
|
|
335
|
+
|
|
336
|
+
// Fallback path: when the pending login can't be resolved (bad/expired
|
|
337
|
+
// token), the floor keys by IP ALONE — NOT the (rotating) pendingToken.
|
|
338
|
+
// Proven here by sending a DIFFERENT bogus pending cookie on every attempt
|
|
339
|
+
// from one IP: if the floor keyed by the token each would get a fresh bucket
|
|
340
|
+
// and never 429; keying by IP, the 6th still trips.
|
|
341
|
+
test("2FA floor with unresolvable pending tokens keys by IP, not the token", async () => {
|
|
342
|
+
const buildReq = (bogusToken: string) => {
|
|
343
|
+
const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "000000", next: "/admin/vaults" });
|
|
344
|
+
return new Request("http://hub.test/login/2fa", {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: {
|
|
347
|
+
...tf.headers,
|
|
348
|
+
cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${bogusToken}`,
|
|
349
|
+
"cf-connecting-ip": "203.0.113.77",
|
|
350
|
+
},
|
|
351
|
+
body: tf.body,
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
for (let i = 0; i < 5; i++) {
|
|
355
|
+
// A unique bogus token per attempt → never resolves to a pending login.
|
|
356
|
+
const r = await handleAdminLoginTotpPost(harness.db, buildReq(`bogus-${i}`));
|
|
357
|
+
expect(r.status).toBe(401); // no pending login → 401, counts toward the IP floor
|
|
358
|
+
}
|
|
359
|
+
const denied = await handleAdminLoginTotpPost(harness.db, buildReq("bogus-final"));
|
|
360
|
+
expect(denied.status).toBe(429);
|
|
361
|
+
expect(denied.headers.get("retry-after")).not.toBeNull();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// (d) The 2FA floor is keyed by (ip,userId), NOT the rotating pendingToken.
|
|
365
|
+
// Re-POSTing /login mints a FRESH pendingToken for the SAME user — that must
|
|
366
|
+
// NOT reset the per-account TOTP floor (otherwise an attacker grinds TOTP
|
|
367
|
+
// forever by re-doing the password step between every 5 code attempts).
|
|
368
|
+
test("(d) re-POSTing /login (new pendingToken) does NOT reset the per-account TOTP floor", async () => {
|
|
369
|
+
const u = await createUser(harness.db, "owner", "owner-password-123", {
|
|
370
|
+
passwordChanged: true,
|
|
371
|
+
});
|
|
372
|
+
await persistEnrollment(harness.db, u.id, generateTotpSecret("owner").secret);
|
|
373
|
+
const ATTACK_IP = "203.0.113.66";
|
|
374
|
+
|
|
375
|
+
const passwordPost = async (): Promise<string> => {
|
|
376
|
+
const pw = formBody({
|
|
377
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
378
|
+
username: "owner",
|
|
379
|
+
password: "owner-password-123",
|
|
380
|
+
next: "/admin/vaults",
|
|
381
|
+
});
|
|
382
|
+
const res = await handleAdminLoginPost(
|
|
383
|
+
harness.db,
|
|
384
|
+
new Request("http://hub.test/login", {
|
|
385
|
+
method: "POST",
|
|
386
|
+
headers: { ...pw.headers, cookie: CSRF_COOKIE, "cf-connecting-ip": ATTACK_IP },
|
|
387
|
+
body: pw.body,
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
const token = cookieFrom(res, PENDING_LOGIN_COOKIE_NAME);
|
|
391
|
+
expect(token).toBeTruthy();
|
|
392
|
+
return token!;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const wrongTotp = (pendingToken: string): Request => {
|
|
396
|
+
const tf = formBody({ [CSRF_FIELD_NAME]: TEST_CSRF, code: "000000", next: "/admin/vaults" });
|
|
397
|
+
return new Request("http://hub.test/login/2fa", {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: {
|
|
400
|
+
...tf.headers,
|
|
401
|
+
cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
|
|
402
|
+
"cf-connecting-ip": ATTACK_IP,
|
|
403
|
+
},
|
|
404
|
+
body: tf.body,
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// First password step → pendingToken1.
|
|
409
|
+
const token1 = await passwordPost();
|
|
410
|
+
// 4 wrong TOTP attempts against pendingToken1 (all 401, all count toward
|
|
411
|
+
// the (ip,userId) bucket).
|
|
412
|
+
for (let i = 0; i < 4; i++) {
|
|
413
|
+
expect((await handleAdminLoginTotpPost(harness.db, wrongTotp(token1))).status).toBe(401);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Re-do the password step → a BRAND-NEW pendingToken2 (same userId).
|
|
417
|
+
const token2 = await passwordPost();
|
|
418
|
+
expect(token2).not.toBe(token1);
|
|
419
|
+
|
|
420
|
+
// 5th wrong TOTP — under pendingToken2 but the SAME (ip,userId) bucket →
|
|
421
|
+
// still the 5th attempt → allowed (401), not reset to fresh.
|
|
422
|
+
expect((await handleAdminLoginTotpPost(harness.db, wrongTotp(token2))).status).toBe(401);
|
|
423
|
+
// 6th wrong TOTP → floor full → 429. If the floor were keyed by the
|
|
424
|
+
// pendingToken, token2 would have a fresh bucket and this would be a 401.
|
|
425
|
+
const denied = await handleAdminLoginTotpPost(harness.db, wrongTotp(token2));
|
|
426
|
+
expect(denied.status).toBe(429);
|
|
427
|
+
expect(denied.headers.get("retry-after")).not.toBeNull();
|
|
428
|
+
});
|
|
335
429
|
});
|
|
336
430
|
|
|
337
431
|
describe("/account/2fa handlers — hub#473", () => {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
setPassword,
|
|
24
24
|
setUserVaults,
|
|
25
25
|
userCount,
|
|
26
|
+
validateEmail,
|
|
26
27
|
validatePassword,
|
|
27
28
|
validateUsername,
|
|
28
29
|
vaultVerbsForRole,
|
|
@@ -431,6 +432,38 @@ describe("validatePassword", () => {
|
|
|
431
432
|
});
|
|
432
433
|
});
|
|
433
434
|
|
|
435
|
+
describe("validateEmail (v15, B2)", () => {
|
|
436
|
+
test("accepts ordinary addresses, canonicalizing to lowercase/trimmed", () => {
|
|
437
|
+
const r = validateEmail(" Alice@Example.com ");
|
|
438
|
+
expect(r.valid).toBe(true);
|
|
439
|
+
if (r.valid) expect(r.email).toBe("alice@example.com");
|
|
440
|
+
expect(validateEmail("a.b+tag@sub.domain.co.uk").valid).toBe(true);
|
|
441
|
+
expect(validateEmail("user_name@x.io").valid).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("rejects malformed shapes", () => {
|
|
445
|
+
for (const bad of [
|
|
446
|
+
"",
|
|
447
|
+
"not-an-email",
|
|
448
|
+
"@example.com",
|
|
449
|
+
"alice@",
|
|
450
|
+
"alice@example",
|
|
451
|
+
"alice example@x.io",
|
|
452
|
+
"two@@x.io",
|
|
453
|
+
"alice@x.c",
|
|
454
|
+
]) {
|
|
455
|
+
expect(validateEmail(bad).valid).toBe(false);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("rejects over-length addresses (>254)", () => {
|
|
460
|
+
const long = `${"a".repeat(250)}@x.io`;
|
|
461
|
+
const r = validateEmail(long);
|
|
462
|
+
expect(r.valid).toBe(false);
|
|
463
|
+
if (!r.valid) expect(r.reason).toBe("length");
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
434
467
|
describe("resetUserPassword", () => {
|
|
435
468
|
test("returns false when user does not exist", async () => {
|
|
436
469
|
const { db, cleanup } = makeDb();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-vault storage cap store (`src/vault-caps.ts`, migration v15, B4). The
|
|
3
|
+
* cap is PERSISTED here at provision time; a separate Phase-2 PR reads + enforces
|
|
4
|
+
* it. These tests cover the read/write/upsert/remove primitives + the "no row
|
|
5
|
+
* = uncapped" contract the Phase-2 reader relies on.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_VAULT_CAP_BYTES,
|
|
14
|
+
getVaultCap,
|
|
15
|
+
getVaultCapBytes,
|
|
16
|
+
removeVaultCap,
|
|
17
|
+
setVaultCap,
|
|
18
|
+
} from "../vault-caps.ts";
|
|
19
|
+
|
|
20
|
+
function makeDb() {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-vault-caps-"));
|
|
22
|
+
const db = openHubDb(hubDbPath(dir));
|
|
23
|
+
return {
|
|
24
|
+
db,
|
|
25
|
+
cleanup: () => {
|
|
26
|
+
db.close();
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("vault-caps", () => {
|
|
33
|
+
test("default cap constant is ~1 GiB", () => {
|
|
34
|
+
expect(DEFAULT_VAULT_CAP_BYTES).toBe(1024 * 1024 * 1024);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("no row = uncapped (null) — the Phase-2 reader's contract", () => {
|
|
38
|
+
const { db, cleanup } = makeDb();
|
|
39
|
+
try {
|
|
40
|
+
expect(getVaultCap(db, "nope")).toBeNull();
|
|
41
|
+
expect(getVaultCapBytes(db, "nope")).toBeNull();
|
|
42
|
+
} finally {
|
|
43
|
+
cleanup();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("setVaultCap persists, getVaultCapBytes reads it back", () => {
|
|
48
|
+
const { db, cleanup } = makeDb();
|
|
49
|
+
try {
|
|
50
|
+
const now = new Date("2026-06-25T00:00:00Z");
|
|
51
|
+
const cap = setVaultCap(db, "alice", DEFAULT_VAULT_CAP_BYTES, now);
|
|
52
|
+
expect(cap.vaultName).toBe("alice");
|
|
53
|
+
expect(cap.capBytes).toBe(DEFAULT_VAULT_CAP_BYTES);
|
|
54
|
+
expect(cap.createdAt).toBe(now.toISOString());
|
|
55
|
+
expect(getVaultCapBytes(db, "alice")).toBe(DEFAULT_VAULT_CAP_BYTES);
|
|
56
|
+
} finally {
|
|
57
|
+
cleanup();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("upsert: re-set overwrites cap_bytes, bumps updated_at, preserves created_at", () => {
|
|
62
|
+
const { db, cleanup } = makeDb();
|
|
63
|
+
try {
|
|
64
|
+
const t0 = new Date("2026-06-25T00:00:00Z");
|
|
65
|
+
const t1 = new Date("2026-06-26T00:00:00Z");
|
|
66
|
+
setVaultCap(db, "alice", 1000, t0);
|
|
67
|
+
const updated = setVaultCap(db, "alice", 2000, t1);
|
|
68
|
+
expect(updated.capBytes).toBe(2000);
|
|
69
|
+
expect(updated.createdAt).toBe(t0.toISOString()); // preserved
|
|
70
|
+
expect(updated.updatedAt).toBe(t1.toISOString()); // bumped
|
|
71
|
+
expect(getVaultCapBytes(db, "alice")).toBe(2000);
|
|
72
|
+
} finally {
|
|
73
|
+
cleanup();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("removeVaultCap drops the row (cascade parity) — returns count", () => {
|
|
78
|
+
const { db, cleanup } = makeDb();
|
|
79
|
+
try {
|
|
80
|
+
setVaultCap(db, "alice", 1000);
|
|
81
|
+
expect(removeVaultCap(db, "alice")).toBe(1);
|
|
82
|
+
expect(getVaultCapBytes(db, "alice")).toBeNull();
|
|
83
|
+
// Idempotent — removing a missing row is 0.
|
|
84
|
+
expect(removeVaultCap(db, "alice")).toBe(0);
|
|
85
|
+
} finally {
|
|
86
|
+
cleanup();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/account-setup.ts
CHANGED
|
@@ -6,10 +6,21 @@
|
|
|
6
6
|
* account-claim flow (`setup-wizard.ts` `handleSetupAccountPost`). A brand-new
|
|
7
7
|
* invitee opens the link with no session and no JS:
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* MULTI-USE (v15, DEMO-PREP-2026-06-25 Workstream B): this same surface is the
|
|
10
|
+
* PUBLIC SIGNUP PAGE (B4). A multi-use invite link (`max_uses > 1`) lets many
|
|
11
|
+
* strangers redeem the one link until its seats run out — each redeem creates
|
|
12
|
+
* a fresh account + vault. Such a link also collects the redeemer's EMAIL (B2,
|
|
13
|
+
* so the operator can reach signups) and STAMPS a per-vault storage cap (B4)
|
|
14
|
+
* onto each provisioned vault. A legacy single-use admin/friends invite
|
|
15
|
+
* (`max_uses = 1`, the default) behaves exactly as before — no email field, no
|
|
16
|
+
* cap, single redeem.
|
|
17
|
+
*
|
|
18
|
+
* GET → render the "pick username + password (+ email, + vault name)" form.
|
|
10
19
|
* POST → redeem: look up the invite by sha256(token), validate it's still
|
|
11
|
-
* redeemable
|
|
12
|
-
*
|
|
20
|
+
* redeemable (seats left, not expired/revoked), validate credentials
|
|
21
|
+
* (+ email for public links), provision the vault (stamping its cap),
|
|
22
|
+
* create the user, record the redemption (bump used_count, capture
|
|
23
|
+
* email), mint a session, 302 → /account/.
|
|
13
24
|
*
|
|
14
25
|
* Redeem ORDERING (the re-usability guarantee, mirroring the wizard):
|
|
15
26
|
* 1. lookup + validate the invite (not-found/expired/used/revoked)
|
|
@@ -27,14 +38,17 @@
|
|
|
27
38
|
* The vault must still exist in services.json (the vault-delete
|
|
28
39
|
* cascade revokes pending invites, so this re-check is defense in
|
|
29
40
|
* depth against manual manifest edits).
|
|
30
|
-
* 4. createUser (the commit point)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
41
|
+
* 4. createUser (the commit point) — and INSIDE its transaction (the
|
|
42
|
+
* `withinTx` hook): recordInviteRedemption (bump used_count + stamp
|
|
43
|
+
* used_at/email/redeemed_user_id) AND, for a capped provisioning link,
|
|
44
|
+
* setVaultCap. All three commit atomically with the user row.
|
|
45
|
+
* 5. createSession + cookie + 302
|
|
33
46
|
*
|
|
34
|
-
* Because the
|
|
35
|
-
* createUser exception (UNIQUE collision, disk full, anything)
|
|
36
|
-
*
|
|
37
|
-
*
|
|
47
|
+
* Because the redemption is recorded only inside the user-row transaction, a
|
|
48
|
+
* createUser exception (UNIQUE collision, disk full, anything) rolls back the
|
|
49
|
+
* used_count bump + cap write together — the seat stays available and the
|
|
50
|
+
* invitee can simply retry. `recordInviteRedemption`'s `used_count < max_uses`
|
|
51
|
+
* guard makes the increment itself exhaustion-safe / race-free.
|
|
38
52
|
*
|
|
39
53
|
* What an invite pre-authorizes: EXACTLY one account + the one named/created/
|
|
40
54
|
* shared vault at the baked-in role — NEVER host:admin, NEVER another vault.
|
|
@@ -53,14 +67,15 @@ import { type RunResult, provisionVault } from "./admin-vaults.ts";
|
|
|
53
67
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
54
68
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
55
69
|
import {
|
|
70
|
+
InviteExhaustedError,
|
|
56
71
|
InviteExpiredError,
|
|
57
72
|
InviteNotFoundError,
|
|
58
73
|
InviteRevokedError,
|
|
59
74
|
InviteUsedError,
|
|
60
75
|
assertInviteRedeemable,
|
|
61
|
-
|
|
76
|
+
recordInviteRedemption,
|
|
62
77
|
} from "./invites.ts";
|
|
63
|
-
import {
|
|
78
|
+
import { clientIpFromRequest, signupRateLimiter } from "./rate-limit.ts";
|
|
64
79
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
65
80
|
import { SESSION_TTL_MS, buildSessionCookie, createSession } from "./sessions.ts";
|
|
66
81
|
import {
|
|
@@ -68,9 +83,11 @@ import {
|
|
|
68
83
|
UsernameTakenError,
|
|
69
84
|
createUser,
|
|
70
85
|
getUserByUsernameCI,
|
|
86
|
+
validateEmail,
|
|
71
87
|
validatePassword,
|
|
72
88
|
validateUsername,
|
|
73
89
|
} from "./users.ts";
|
|
90
|
+
import { setVaultCap } from "./vault-caps.ts";
|
|
74
91
|
import { validateVaultName } from "./vault-name.ts";
|
|
75
92
|
import { listVaultNamesFromPath } from "./vault-names.ts";
|
|
76
93
|
|
|
@@ -127,6 +144,16 @@ function rejectInvite(err: unknown): Response {
|
|
|
127
144
|
410,
|
|
128
145
|
);
|
|
129
146
|
}
|
|
147
|
+
if (err instanceof InviteExhaustedError) {
|
|
148
|
+
return htmlResponse(
|
|
149
|
+
renderAdminError({
|
|
150
|
+
title: "Signups closed",
|
|
151
|
+
message:
|
|
152
|
+
"This signup link has reached its maximum number of accounts. Ask your hub operator for a new link.",
|
|
153
|
+
}),
|
|
154
|
+
410,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
130
157
|
if (err instanceof InviteRevokedError) {
|
|
131
158
|
return htmlResponse(
|
|
132
159
|
renderAdminError({
|
|
@@ -166,12 +193,25 @@ export function handleAccountSetupGet(
|
|
|
166
193
|
pinnedUsername: invite.username,
|
|
167
194
|
role: invite.role,
|
|
168
195
|
provisionVault: invite.provisionVault,
|
|
196
|
+
collectEmail: collectsEmail(invite),
|
|
169
197
|
}),
|
|
170
198
|
200,
|
|
171
199
|
setCookie,
|
|
172
200
|
);
|
|
173
201
|
}
|
|
174
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Whether this redemption captures the redeemer's email (B2). Multi-use
|
|
205
|
+
* public-signup links collect it (the operator must be able to reach the
|
|
206
|
+
* stranger who signed up); legacy single-use admin invites don't bother
|
|
207
|
+
* (the admin already knows who they handed the link to). The signal: a
|
|
208
|
+
* multi-use link (`max_uses > 1`). Public-signup links always mint with
|
|
209
|
+
* `max_uses > 1`, so this cleanly distinguishes them from the friends flow.
|
|
210
|
+
*/
|
|
211
|
+
function collectsEmail(invite: { maxUses: number }): boolean {
|
|
212
|
+
return invite.maxUses > 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
175
215
|
/** POST /account/setup/<token> — redeem the invite (see file docstring for ordering). */
|
|
176
216
|
export async function handleAccountSetupPost(
|
|
177
217
|
req: Request,
|
|
@@ -201,11 +241,15 @@ export async function handleAccountSetupPost(
|
|
|
201
241
|
);
|
|
202
242
|
}
|
|
203
243
|
|
|
204
|
-
// Rate limit —
|
|
205
|
-
//
|
|
206
|
-
//
|
|
244
|
+
// Rate limit — a DEDICATED signup bucket (per-IP, 60/15min), NOT the /login
|
|
245
|
+
// bucket. A public multi-use signup link is redeemed by a room of people
|
|
246
|
+
// sharing one NAT'd egress IP, so the /login-sized 5/15min cap would 429 the
|
|
247
|
+
// ~6th legitimate signer mid-demo. Generous-but-bounded: the invite's own
|
|
248
|
+
// max_uses + expiry are the primary bound; this is the abuse floor. After
|
|
249
|
+
// CSRF (so a junk cross-site POST doesn't burn the bucket), before any
|
|
250
|
+
// account/vault work.
|
|
207
251
|
const clientIp = clientIpFromRequest(req);
|
|
208
|
-
const gate = checkAndRecord(clientIp, now);
|
|
252
|
+
const gate = signupRateLimiter.checkAndRecord(clientIp, now);
|
|
209
253
|
if (!gate.allowed) {
|
|
210
254
|
return htmlResponse(
|
|
211
255
|
renderAdminError({
|
|
@@ -223,6 +267,10 @@ export async function handleAccountSetupPost(
|
|
|
223
267
|
invite.username !== null ? invite.username : String(form.get("username") ?? "").trim();
|
|
224
268
|
const password = String(form.get("password") ?? "");
|
|
225
269
|
const confirm = String(form.get("password_confirm") ?? "");
|
|
270
|
+
// Email (B2) — captured only for public-signup links (multi-use). The raw
|
|
271
|
+
// value is echoed back on a re-render so the redeemer doesn't retype it.
|
|
272
|
+
const collectEmail = collectsEmail(invite);
|
|
273
|
+
const emailRaw = String(form.get("email") ?? "").trim();
|
|
226
274
|
|
|
227
275
|
const csrf = ensureCsrfToken(req);
|
|
228
276
|
const setCookie: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
@@ -236,7 +284,9 @@ export async function handleAccountSetupPost(
|
|
|
236
284
|
pinnedUsername: invite.username,
|
|
237
285
|
role: invite.role,
|
|
238
286
|
provisionVault: invite.provisionVault,
|
|
287
|
+
collectEmail,
|
|
239
288
|
username,
|
|
289
|
+
...(collectEmail ? { email: emailRaw } : {}),
|
|
240
290
|
...(vaultNameEcho !== undefined ? { vaultName: vaultNameEcho } : {}),
|
|
241
291
|
errorMessage: message,
|
|
242
292
|
}),
|
|
@@ -266,6 +316,21 @@ export async function handleAccountSetupPost(
|
|
|
266
316
|
if (password !== confirm) {
|
|
267
317
|
return rerender(400, "The two passwords don't match.");
|
|
268
318
|
}
|
|
319
|
+
// (2b) Email (B2) — required + format-validated ONLY for public-signup
|
|
320
|
+
// (multi-use) links, where the operator must be able to reach the stranger
|
|
321
|
+
// who signed up. Legacy single-use admin invites don't collect it. The
|
|
322
|
+
// canonical form is lowercased/trimmed by validateEmail.
|
|
323
|
+
let email: string | null = null;
|
|
324
|
+
if (collectEmail) {
|
|
325
|
+
if (emailRaw.length === 0) {
|
|
326
|
+
return rerender(400, "Enter your email so the hub operator can reach you.");
|
|
327
|
+
}
|
|
328
|
+
const e = validateEmail(emailRaw);
|
|
329
|
+
if (!e.valid) {
|
|
330
|
+
return rerender(400, "Enter a valid email address (e.g. you@example.com).");
|
|
331
|
+
}
|
|
332
|
+
email = e.email;
|
|
333
|
+
}
|
|
269
334
|
// Case-insensitive uniqueness — same gate as /api/users. For a pre-named
|
|
270
335
|
// invite the name can't be changed by the invitee, so a collision (someone
|
|
271
336
|
// took the name between mint and redeem) needs the operator to revoke +
|
|
@@ -393,22 +458,33 @@ export async function handleAccountSetupPost(
|
|
|
393
458
|
: undefined,
|
|
394
459
|
);
|
|
395
460
|
}
|
|
461
|
+
|
|
462
|
+
// NOTE: the per-vault storage cap (B4) is PERSISTED below, inside
|
|
463
|
+
// createUser's transaction (the `withinTx` hook), so the cap write +
|
|
464
|
+
// redemption record + user insert all commit (or roll back) atomically —
|
|
465
|
+
// no stranded cap row for a vault whose account creation failed. The
|
|
466
|
+
// vault itself is provisioned here (the CLI shell-out can't join the
|
|
467
|
+
// sqlite tx), but the cap METADATA is hub-DB state, so it belongs in the
|
|
468
|
+
// atomic hook.
|
|
396
469
|
}
|
|
397
470
|
|
|
398
|
-
// (4) Create the user +
|
|
399
|
-
// The
|
|
400
|
-
// hook), so the
|
|
471
|
+
// (4) Create the user + record the redemption ATOMICALLY — the COMMIT POINT.
|
|
472
|
+
// The redemption is recorded INSIDE createUser's transaction (the `withinTx`
|
|
473
|
+
// hook), so the seat-accounting guarantees compose:
|
|
401
474
|
//
|
|
402
|
-
// -
|
|
403
|
-
// `assertInviteRedeemable` above, but only one's `
|
|
404
|
-
// (`
|
|
405
|
-
// hook throws
|
|
406
|
-
//
|
|
475
|
+
// - Exhaustion under concurrency: two redeems of the LAST seat both pass
|
|
476
|
+
// `assertInviteRedeemable` above, but only one's `recordInviteRedemption`
|
|
477
|
+
// UPDATE (`used_count < max_uses AND revoked_at IS NULL`) changes a row.
|
|
478
|
+
// The loser's hook throws (InviteExhaustedError for a multi-use link,
|
|
479
|
+
// InviteUsedError for single-use), which rolls back ITS user insert — no
|
|
480
|
+
// orphan account. Exactly one account per remaining seat results.
|
|
407
481
|
// - Re-usable on failure: if createUser throws (UNIQUE collision, etc.)
|
|
408
|
-
// before the hook,
|
|
409
|
-
// throws (lost race), the
|
|
410
|
-
// Either way nothing commits, so the
|
|
482
|
+
// before the hook, used_count was never bumped; if the hook itself
|
|
483
|
+
// throws (lost race), the increment + the user insert roll back together.
|
|
484
|
+
// Either way nothing commits, so the seat stays available.
|
|
411
485
|
//
|
|
486
|
+
// The email (B2) is recorded with the redemption (latest redeemer) AND on
|
|
487
|
+
// the account row (`users.email`, the canonical per-account field) below.
|
|
412
488
|
// passwordChanged: TRUE (the invitee chose their own password → no force-
|
|
413
489
|
// change). assignedVaults pins them to exactly their one vault at the
|
|
414
490
|
// invite's role. allowMulti because the first admin already exists.
|
|
@@ -419,17 +495,27 @@ export async function handleAccountSetupPost(
|
|
|
419
495
|
passwordChanged: true,
|
|
420
496
|
assignedVaults: vaultName !== null ? [vaultName] : [],
|
|
421
497
|
role: invite.role,
|
|
498
|
+
...(email !== null ? { email } : {}),
|
|
422
499
|
withinTx: (newUserId) => {
|
|
423
|
-
if (!
|
|
424
|
-
// Lost the redeem race (or a concurrent revoke landed). Throw
|
|
425
|
-
// roll back this user insert; surfaced
|
|
426
|
-
|
|
500
|
+
if (!recordInviteRedemption(deps.db, invite.tokenHash, newUserId, email, now)) {
|
|
501
|
+
// Lost the redeem race (or a concurrent revoke landed). Throw the
|
|
502
|
+
// shape-appropriate error to roll back this user insert; surfaced
|
|
503
|
+
// as the 410 path below.
|
|
504
|
+
throw invite.maxUses > 1 ? new InviteExhaustedError() : new InviteUsedError();
|
|
505
|
+
}
|
|
506
|
+
// (3b) Persist the per-vault storage cap (B4) atomically with the
|
|
507
|
+
// account + redemption. Only for a freshly-provisioned vault carrying
|
|
508
|
+
// an invite cap; legacy/uncapped links leave the vault with no
|
|
509
|
+
// vault_caps row ("uncapped" for the Phase-2 reader). The vault was
|
|
510
|
+
// confirmed freshly created above; `setVaultCap` is a plain upsert.
|
|
511
|
+
if (invite.provisionVault && vaultName !== null && invite.vaultCapBytes !== null) {
|
|
512
|
+
setVaultCap(deps.db, vaultName, invite.vaultCapBytes, now);
|
|
427
513
|
}
|
|
428
514
|
},
|
|
429
515
|
});
|
|
430
516
|
userId = created.id;
|
|
431
517
|
} catch (err) {
|
|
432
|
-
if (err instanceof InviteUsedError) {
|
|
518
|
+
if (err instanceof InviteUsedError || err instanceof InviteExhaustedError) {
|
|
433
519
|
return rejectInvite(err);
|
|
434
520
|
}
|
|
435
521
|
if (err instanceof UsernameTakenError) {
|