@openparachute/hub 0.7.3-rc.3 → 0.7.3-rc.4
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__/public-signup.test.ts +619 -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-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/rate-limit.ts +38 -1
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
package/package.json
CHANGED
|
@@ -733,6 +733,7 @@ import { findGrant, recordGrant } from "../grants.ts";
|
|
|
733
733
|
import { findInviteByHash, issueInvite } from "../invites.ts";
|
|
734
734
|
import { findTokenRowByJti, listActiveRevocations, recordTokenMint } from "../jwt-sign.ts";
|
|
735
735
|
import { createUser, setUserVaults } from "../users.ts";
|
|
736
|
+
import { getVaultCapBytes, setVaultCap } from "../vault-caps.ts";
|
|
736
737
|
|
|
737
738
|
const VAULT_ORIGIN = "http://127.0.0.1:19400";
|
|
738
739
|
const AGENT_ORIGIN = "http://127.0.0.1:19410";
|
|
@@ -1026,11 +1027,19 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
|
|
|
1026
1027
|
const pendingWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
|
|
1027
1028
|
const pendingDefault = issueInvite(db, { createdBy: alice.id, vaultName: "default" });
|
|
1028
1029
|
const redeemedWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1030
|
+
// Simulate a fully-redeemed single-use invite exactly as the redeem path
|
|
1031
|
+
// leaves it (v15): used_at stamped AND used_count bumped to max_uses, so
|
|
1032
|
+
// the cascade's `used_count < max_uses` exhaustion guard treats it as
|
|
1033
|
+
// terminal (untouched), same as the pre-v15 `used_at IS NULL` guard did.
|
|
1034
|
+
db.prepare(
|
|
1035
|
+
"UPDATE invites SET used_at = ?, used_count = 1, redeemed_user_id = ? WHERE token = ?",
|
|
1036
|
+
).run(new Date().toISOString(), alice.id, redeemedWork.invite.tokenHash);
|
|
1037
|
+
|
|
1038
|
+
// 4b. vault_caps (v15): one row per vault. The work row is dropped by
|
|
1039
|
+
// the cascade; the default row stays (a re-created same-name vault
|
|
1040
|
+
// must not inherit a stale cap).
|
|
1041
|
+
setVaultCap(db, "work", 1024 * 1024 * 1024);
|
|
1042
|
+
setVaultCap(db, "default", 2 * 1024 * 1024 * 1024);
|
|
1034
1043
|
|
|
1035
1044
|
// 5. Connections: one sourced on work (torn down), one on default (kept).
|
|
1036
1045
|
putConnection(store, {
|
|
@@ -1092,6 +1101,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
|
|
|
1092
1101
|
grants_dropped: number;
|
|
1093
1102
|
user_vaults_removed: number;
|
|
1094
1103
|
invites_invalidated: number;
|
|
1104
|
+
vault_cap_removed: boolean;
|
|
1095
1105
|
connections_torn_down: number;
|
|
1096
1106
|
orphaned_channels: string[];
|
|
1097
1107
|
vault_removed: boolean;
|
|
@@ -1134,6 +1144,11 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
|
|
|
1134
1144
|
expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.usedAt).not.toBeNull();
|
|
1135
1145
|
expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.revokedAt).toBeNull();
|
|
1136
1146
|
|
|
1147
|
+
// 4b. vault_caps: the work cap row dropped; the default cap row stays.
|
|
1148
|
+
expect(out.cascade.vault_cap_removed).toBe(true);
|
|
1149
|
+
expect(getVaultCapBytes(db, "work")).toBeNull();
|
|
1150
|
+
expect(getVaultCapBytes(db, "default")).toBe(2 * 1024 * 1024 * 1024);
|
|
1151
|
+
|
|
1137
1152
|
// 5. Connections: work connection torn down (trigger deregistered +
|
|
1138
1153
|
// channel entry deleted + record removed); default connection kept.
|
|
1139
1154
|
expect(out.cascade.connections_torn_down).toBe(1);
|
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-use public-signup links + email-as-username + per-vault caps
|
|
3
|
+
* (DEMO-PREP-2026-06-25 Workstream B: B1 multi-use invite, B2 email capture,
|
|
4
|
+
* B4 public signup page; the cap value persisted for B3's Phase-2 enforcement).
|
|
5
|
+
*
|
|
6
|
+
* Coverage required by the brief:
|
|
7
|
+
* - multi-use exhaustion (used_count == max_uses → refused)
|
|
8
|
+
* - expiry refusal (multi-use link past expires_at → 410)
|
|
9
|
+
* - email capture (stored on users + on the invite, validated)
|
|
10
|
+
* - signup → vault provision with the cap PERSISTED to vault_caps
|
|
11
|
+
* - backwards-compat with legacy single-use invites
|
|
12
|
+
*
|
|
13
|
+
* Plus: the migration is backwards-compatible (legacy rows redeem unchanged),
|
|
14
|
+
* the invite primitive's seat accounting, and the API mint gates.
|
|
15
|
+
*/
|
|
16
|
+
import type { Database } from "bun:sqlite";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { handleAccountSetupGet, handleAccountSetupPost } from "../account-setup.ts";
|
|
22
|
+
import type { RunResult } from "../admin-vaults.ts";
|
|
23
|
+
import { handleCreateInvite } from "../api-invites.ts";
|
|
24
|
+
import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
25
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
26
|
+
import {
|
|
27
|
+
InviteExhaustedError,
|
|
28
|
+
InviteUsedError,
|
|
29
|
+
assertInviteRedeemable,
|
|
30
|
+
findInviteByRawToken,
|
|
31
|
+
inviteStatus,
|
|
32
|
+
issueInvite,
|
|
33
|
+
recordInviteRedemption,
|
|
34
|
+
} from "../invites.ts";
|
|
35
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
36
|
+
import { __resetForTests } from "../rate-limit.ts";
|
|
37
|
+
import { createUser, getUserByUsernameCI } from "../users.ts";
|
|
38
|
+
import { getVaultCapBytes } from "../vault-caps.ts";
|
|
39
|
+
|
|
40
|
+
const ISSUER = "https://hub.test";
|
|
41
|
+
|
|
42
|
+
interface Harness {
|
|
43
|
+
db: Database;
|
|
44
|
+
manifestPath: string;
|
|
45
|
+
cleanup: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeHarness(): Harness {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-public-signup-"));
|
|
50
|
+
const db = openHubDb(hubDbPath(dir));
|
|
51
|
+
const manifestPath = join(dir, "services.json");
|
|
52
|
+
writeFileSync(
|
|
53
|
+
manifestPath,
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
services: [
|
|
56
|
+
{
|
|
57
|
+
name: "parachute-vault",
|
|
58
|
+
port: 4101,
|
|
59
|
+
paths: ["/vault/seed"],
|
|
60
|
+
health: "/health",
|
|
61
|
+
version: "0.0.0-test",
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
db,
|
|
68
|
+
manifestPath,
|
|
69
|
+
cleanup: () => {
|
|
70
|
+
db.close();
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let harness: Harness;
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
harness = makeHarness();
|
|
79
|
+
__resetForTests();
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
harness.cleanup();
|
|
83
|
+
__resetForTests();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** Stub vault create: append the named vault path to services.json. */
|
|
87
|
+
function makeStubRunCommand() {
|
|
88
|
+
const calls: string[][] = [];
|
|
89
|
+
const run = async (cmd: readonly string[]): Promise<RunResult> => {
|
|
90
|
+
calls.push([...cmd]);
|
|
91
|
+
const name = cmd[2] ?? "";
|
|
92
|
+
const manifest = JSON.parse(readFileSync(harness.manifestPath, "utf8")) as {
|
|
93
|
+
services: { name: string; paths: string[] }[];
|
|
94
|
+
};
|
|
95
|
+
const vaultSvc = manifest.services.find((s) => s.name === "parachute-vault");
|
|
96
|
+
if (vaultSvc && !vaultSvc.paths.includes(`/vault/${name}`)) {
|
|
97
|
+
vaultSvc.paths.push(`/vault/${name}`);
|
|
98
|
+
writeFileSync(harness.manifestPath, JSON.stringify(manifest));
|
|
99
|
+
}
|
|
100
|
+
const createJson = {
|
|
101
|
+
name,
|
|
102
|
+
token: "",
|
|
103
|
+
paths: {
|
|
104
|
+
vault_dir: `/d/${name}`,
|
|
105
|
+
vault_db: `/d/${name}/v.db`,
|
|
106
|
+
vault_config: `/d/${name}/v.yaml`,
|
|
107
|
+
},
|
|
108
|
+
set_as_default: false,
|
|
109
|
+
};
|
|
110
|
+
return { exitCode: 0, stdout: JSON.stringify(createJson), stderr: "" };
|
|
111
|
+
};
|
|
112
|
+
return { run, calls };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function csrfPair(): { token: string; cookieFragment: string } {
|
|
116
|
+
const token = generateCsrfToken();
|
|
117
|
+
const cookie = buildCsrfCookie(token, { secure: false }).split(";")[0] ?? "";
|
|
118
|
+
return { token, cookieFragment: cookie };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function postReq(token: string, fields: Record<string, string>, csrfCookie: string): Request {
|
|
122
|
+
const form = new URLSearchParams(fields);
|
|
123
|
+
return new Request(`${ISSUER}/account/setup/${token}`, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "content-type": "application/x-www-form-urlencoded", cookie: csrfCookie },
|
|
126
|
+
body: form.toString(),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function deps(runCommand?: (cmd: readonly string[]) => Promise<RunResult>) {
|
|
131
|
+
return {
|
|
132
|
+
db: harness.db,
|
|
133
|
+
hubOrigin: ISSUER,
|
|
134
|
+
manifestPath: harness.manifestPath,
|
|
135
|
+
...(runCommand !== undefined ? { runCommand } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Drive one full public-signup redemption (multi-use shape) for a new account. */
|
|
140
|
+
async function signup(
|
|
141
|
+
rawToken: string,
|
|
142
|
+
username: string,
|
|
143
|
+
email: string,
|
|
144
|
+
run: (cmd: readonly string[]) => Promise<RunResult>,
|
|
145
|
+
): Promise<Response> {
|
|
146
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
147
|
+
__resetForTests(); // each signup is a distinct IP-bucket attempt window
|
|
148
|
+
return handleAccountSetupPost(
|
|
149
|
+
postReq(
|
|
150
|
+
rawToken,
|
|
151
|
+
{
|
|
152
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
153
|
+
username,
|
|
154
|
+
email,
|
|
155
|
+
password: `${username}-strong-password-1`,
|
|
156
|
+
password_confirm: `${username}-strong-password-1`,
|
|
157
|
+
vault_name: username,
|
|
158
|
+
},
|
|
159
|
+
cookieFragment,
|
|
160
|
+
),
|
|
161
|
+
rawToken,
|
|
162
|
+
deps(run),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ===========================================================================
|
|
167
|
+
// B1 — multi-use seat accounting (primitive level)
|
|
168
|
+
// ===========================================================================
|
|
169
|
+
|
|
170
|
+
describe("invite primitive — multi-use seats (v15)", () => {
|
|
171
|
+
test("legacy default is single-use: max_uses=1, used_count=0", async () => {
|
|
172
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
173
|
+
const { invite } = issueInvite(harness.db, { createdBy: admin.id });
|
|
174
|
+
expect(invite.maxUses).toBe(1);
|
|
175
|
+
expect(invite.usedCount).toBe(0);
|
|
176
|
+
expect(invite.email).toBeNull();
|
|
177
|
+
expect(invite.vaultCapBytes).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// recordInviteRedemption's redeemed_user_id FKs users(id), so tests pass
|
|
181
|
+
// real committed user ids (as the production redeem path does).
|
|
182
|
+
async function makeUsers(...names: string[]): Promise<string[]> {
|
|
183
|
+
const ids: string[] = [];
|
|
184
|
+
for (const n of names) {
|
|
185
|
+
const u = await createUser(harness.db, n, `${n}-password-1`, { allowMulti: true });
|
|
186
|
+
ids.push(u.id);
|
|
187
|
+
}
|
|
188
|
+
return ids;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
test("recordInviteRedemption bumps used_count, stamps used_at on first only", async () => {
|
|
192
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
193
|
+
const [u1, u2] = await makeUsers("ra", "rb");
|
|
194
|
+
const { rawToken, invite } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 3 });
|
|
195
|
+
const t0 = new Date("2026-06-25T00:00:00Z");
|
|
196
|
+
const t1 = new Date("2026-06-25T01:00:00Z");
|
|
197
|
+
expect(recordInviteRedemption(harness.db, invite.tokenHash, u1!, "a@x.io", t0)).toBe(true);
|
|
198
|
+
let row = findInviteByRawToken(harness.db, rawToken);
|
|
199
|
+
expect(row?.usedCount).toBe(1);
|
|
200
|
+
expect(row?.usedAt).toBe(t0.toISOString());
|
|
201
|
+
expect(row?.email).toBe("a@x.io");
|
|
202
|
+
// Second redeem: used_count bumps, used_at preserved (COALESCE), email updates.
|
|
203
|
+
expect(recordInviteRedemption(harness.db, invite.tokenHash, u2!, "b@x.io", t1)).toBe(true);
|
|
204
|
+
row = findInviteByRawToken(harness.db, rawToken);
|
|
205
|
+
expect(row?.usedCount).toBe(2);
|
|
206
|
+
expect(row?.usedAt).toBe(t0.toISOString()); // unchanged
|
|
207
|
+
expect(row?.email).toBe("b@x.io"); // latest redeemer
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("exhaustion: the (max_uses+1)th record is refused (zero rows)", async () => {
|
|
211
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
212
|
+
const [u1, u2, u3] = await makeUsers("ea", "eb", "ec");
|
|
213
|
+
const { invite } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 2 });
|
|
214
|
+
expect(recordInviteRedemption(harness.db, invite.tokenHash, u1!)).toBe(true);
|
|
215
|
+
expect(recordInviteRedemption(harness.db, invite.tokenHash, u2!)).toBe(true);
|
|
216
|
+
// Third — over cap.
|
|
217
|
+
expect(recordInviteRedemption(harness.db, invite.tokenHash, u3!)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("status: partially-used multi-use link stays 'pending'; exhausted → 'redeemed'", async () => {
|
|
221
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
222
|
+
const [u1, u2] = await makeUsers("sa", "sb");
|
|
223
|
+
const { rawToken, invite } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 2 });
|
|
224
|
+
recordInviteRedemption(harness.db, invite.tokenHash, u1!);
|
|
225
|
+
expect(inviteStatus(findInviteByRawToken(harness.db, rawToken)!)).toBe("pending");
|
|
226
|
+
recordInviteRedemption(harness.db, invite.tokenHash, u2!);
|
|
227
|
+
expect(inviteStatus(findInviteByRawToken(harness.db, rawToken)!)).toBe("redeemed");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("assertInviteRedeemable: exhausted single-use throws InviteUsedError, multi-use throws InviteExhaustedError", async () => {
|
|
231
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
232
|
+
const [u1, u2, u3] = await makeUsers("aa", "ab", "ac");
|
|
233
|
+
const single = issueInvite(harness.db, { createdBy: admin.id, maxUses: 1 });
|
|
234
|
+
recordInviteRedemption(harness.db, single.invite.tokenHash, u1!);
|
|
235
|
+
expect(() => assertInviteRedeemable(harness.db, single.rawToken)).toThrow(InviteUsedError);
|
|
236
|
+
|
|
237
|
+
const multi = issueInvite(harness.db, { createdBy: admin.id, maxUses: 2 });
|
|
238
|
+
recordInviteRedemption(harness.db, multi.invite.tokenHash, u2!);
|
|
239
|
+
recordInviteRedemption(harness.db, multi.invite.tokenHash, u3!);
|
|
240
|
+
expect(() => assertInviteRedeemable(harness.db, multi.rawToken)).toThrow(InviteExhaustedError);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ===========================================================================
|
|
245
|
+
// B4 — public signup page end-to-end (multi-use redemption)
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
|
|
248
|
+
describe("public signup page — multi-use redemption + caps", () => {
|
|
249
|
+
test("two strangers redeem ONE link → two accounts, two vaults, cap persisted, used_count=2", async () => {
|
|
250
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
251
|
+
const cap = 1024 * 1024 * 1024; // 1 GiB
|
|
252
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
253
|
+
createdBy: admin.id,
|
|
254
|
+
maxUses: 5,
|
|
255
|
+
vaultCapBytes: cap,
|
|
256
|
+
});
|
|
257
|
+
const stub = makeStubRunCommand();
|
|
258
|
+
|
|
259
|
+
const r1 = await signup(rawToken, "alice", "alice@example.com", stub.run);
|
|
260
|
+
expect(r1.status).toBe(302);
|
|
261
|
+
const r2 = await signup(rawToken, "bob", "bob@example.com", stub.run);
|
|
262
|
+
expect(r2.status).toBe(302);
|
|
263
|
+
|
|
264
|
+
// Two distinct accounts with their emails stored.
|
|
265
|
+
const alice = getUserByUsernameCI(harness.db, "alice");
|
|
266
|
+
const bob = getUserByUsernameCI(harness.db, "bob");
|
|
267
|
+
expect(alice?.email).toBe("alice@example.com");
|
|
268
|
+
expect(bob?.email).toBe("bob@example.com");
|
|
269
|
+
expect(alice?.assignedVaults).toEqual(["alice"]);
|
|
270
|
+
expect(bob?.assignedVaults).toEqual(["bob"]);
|
|
271
|
+
|
|
272
|
+
// Each provisioned vault carries the cap (B4 persistence for the Phase-2 reader).
|
|
273
|
+
expect(getVaultCapBytes(harness.db, "alice")).toBe(cap);
|
|
274
|
+
expect(getVaultCapBytes(harness.db, "bob")).toBe(cap);
|
|
275
|
+
|
|
276
|
+
// The link recorded two redemptions, still has seats, stays pending.
|
|
277
|
+
const after = findInviteByRawToken(harness.db, rawToken);
|
|
278
|
+
expect(after?.usedCount).toBe(2);
|
|
279
|
+
expect(after?.maxUses).toBe(5);
|
|
280
|
+
expect(inviteStatus(after!)).toBe("pending");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("multi-use link with NO cap on the invite → provisioned vault has NO vault_caps row (uncapped contract)", async () => {
|
|
284
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
285
|
+
// issueInvite directly (bypasses the API's auto-default) with no cap.
|
|
286
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 5 });
|
|
287
|
+
const stub = makeStubRunCommand();
|
|
288
|
+
expect((await signup(rawToken, "nina", "nina@example.com", stub.run)).status).toBe(302);
|
|
289
|
+
// No cap row → the Phase-2 reader treats the vault as uncapped.
|
|
290
|
+
expect(getVaultCapBytes(harness.db, "nina")).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("CONCURRENT redeem of a max_uses=1 link: exactly one 302, one 410 (atomic exhaustion guard)", async () => {
|
|
294
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
295
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 1 });
|
|
296
|
+
const stub = makeStubRunCommand();
|
|
297
|
+
|
|
298
|
+
// Build two complete POST requests up front, then fire them together with
|
|
299
|
+
// NO reset between them — a genuine race for the single seat. (The `signup`
|
|
300
|
+
// helper resets the rate-limiter per call, which would serialize the
|
|
301
|
+
// window; here both share one generous signup bucket, so neither 429s.)
|
|
302
|
+
const mkPost = (username: string): Request => {
|
|
303
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
304
|
+
return postReq(
|
|
305
|
+
rawToken,
|
|
306
|
+
{
|
|
307
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
308
|
+
username,
|
|
309
|
+
email: `${username}@example.com`,
|
|
310
|
+
password: `${username}-strong-password-1`,
|
|
311
|
+
password_confirm: `${username}-strong-password-1`,
|
|
312
|
+
vault_name: username,
|
|
313
|
+
},
|
|
314
|
+
cookieFragment,
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
const [r1, r2] = await Promise.all([
|
|
318
|
+
handleAccountSetupPost(mkPost("racea"), rawToken, deps(stub.run)),
|
|
319
|
+
handleAccountSetupPost(mkPost("raceb"), rawToken, deps(stub.run)),
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
// Exactly one winner (302) and one loser (410) — never two 302s.
|
|
323
|
+
expect([r1.status, r2.status].sort()).toEqual([302, 410]);
|
|
324
|
+
|
|
325
|
+
// The link recorded exactly ONE redemption; exactly one account exists.
|
|
326
|
+
const after = findInviteByRawToken(harness.db, rawToken);
|
|
327
|
+
expect(after?.usedCount).toBe(1);
|
|
328
|
+
const accounts = [
|
|
329
|
+
getUserByUsernameCI(harness.db, "racea"),
|
|
330
|
+
getUserByUsernameCI(harness.db, "raceb"),
|
|
331
|
+
].filter((u) => u !== null);
|
|
332
|
+
expect(accounts.length).toBe(1);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("exhaustion refusal: the (N+1)th signup on a maxed link → 410", async () => {
|
|
336
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
337
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 1 + 1 });
|
|
338
|
+
const stub = makeStubRunCommand();
|
|
339
|
+
expect((await signup(rawToken, "alice", "alice@example.com", stub.run)).status).toBe(302);
|
|
340
|
+
expect((await signup(rawToken, "bob", "bob@example.com", stub.run)).status).toBe(302);
|
|
341
|
+
// Third — link is exhausted.
|
|
342
|
+
const r3 = await signup(rawToken, "carol", "carol@example.com", stub.run);
|
|
343
|
+
expect(r3.status).toBe(410);
|
|
344
|
+
expect(await r3.text()).toContain("Signups closed");
|
|
345
|
+
expect(getUserByUsernameCI(harness.db, "carol")).toBeNull();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("expiry refusal: a multi-use link past expires_at → 410, no account created", async () => {
|
|
349
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
350
|
+
const t0 = new Date("2026-06-25T00:00:00Z");
|
|
351
|
+
const { rawToken } = issueInvite(harness.db, {
|
|
352
|
+
createdBy: admin.id,
|
|
353
|
+
maxUses: 10,
|
|
354
|
+
expiresInSeconds: 60,
|
|
355
|
+
now: () => t0,
|
|
356
|
+
});
|
|
357
|
+
const stub = makeStubRunCommand();
|
|
358
|
+
const later = new Date(t0.getTime() + 120_000);
|
|
359
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
360
|
+
const res = await handleAccountSetupPost(
|
|
361
|
+
postReq(
|
|
362
|
+
rawToken,
|
|
363
|
+
{
|
|
364
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
365
|
+
username: "dave",
|
|
366
|
+
email: "dave@example.com",
|
|
367
|
+
password: "dave-strong-password-1",
|
|
368
|
+
password_confirm: "dave-strong-password-1",
|
|
369
|
+
},
|
|
370
|
+
cookieFragment,
|
|
371
|
+
),
|
|
372
|
+
rawToken,
|
|
373
|
+
{ ...deps(stub.run), now: () => later },
|
|
374
|
+
);
|
|
375
|
+
expect(res.status).toBe(410);
|
|
376
|
+
expect(getUserByUsernameCI(harness.db, "dave")).toBeNull();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("email capture: GET renders the email field for a multi-use link", async () => {
|
|
380
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
381
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 5 });
|
|
382
|
+
const html = await handleAccountSetupGet(
|
|
383
|
+
new Request(`${ISSUER}/account/setup/${rawToken}`),
|
|
384
|
+
rawToken,
|
|
385
|
+
deps(),
|
|
386
|
+
).text();
|
|
387
|
+
expect(html).toContain('name="email"');
|
|
388
|
+
expect(html).toContain('type="email"');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("email validation: malformed email → 400 re-render, no account", async () => {
|
|
392
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
393
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 5 });
|
|
394
|
+
const stub = makeStubRunCommand();
|
|
395
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
396
|
+
const res = await handleAccountSetupPost(
|
|
397
|
+
postReq(
|
|
398
|
+
rawToken,
|
|
399
|
+
{
|
|
400
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
401
|
+
username: "eve",
|
|
402
|
+
email: "not-an-email",
|
|
403
|
+
password: "eve-strong-password-1",
|
|
404
|
+
password_confirm: "eve-strong-password-1",
|
|
405
|
+
},
|
|
406
|
+
cookieFragment,
|
|
407
|
+
),
|
|
408
|
+
rawToken,
|
|
409
|
+
deps(stub.run),
|
|
410
|
+
);
|
|
411
|
+
expect(res.status).toBe(400);
|
|
412
|
+
expect(await res.text()).toContain("valid email");
|
|
413
|
+
expect(getUserByUsernameCI(harness.db, "eve")).toBeNull();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("missing email on a multi-use link → 400, no account", async () => {
|
|
417
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
418
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, maxUses: 5 });
|
|
419
|
+
const stub = makeStubRunCommand();
|
|
420
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
421
|
+
const res = await handleAccountSetupPost(
|
|
422
|
+
postReq(
|
|
423
|
+
rawToken,
|
|
424
|
+
{
|
|
425
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
426
|
+
username: "frank",
|
|
427
|
+
password: "frank-strong-password-1",
|
|
428
|
+
password_confirm: "frank-strong-password-1",
|
|
429
|
+
},
|
|
430
|
+
cookieFragment,
|
|
431
|
+
),
|
|
432
|
+
rawToken,
|
|
433
|
+
deps(stub.run),
|
|
434
|
+
);
|
|
435
|
+
expect(res.status).toBe(400);
|
|
436
|
+
expect(getUserByUsernameCI(harness.db, "frank")).toBeNull();
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ===========================================================================
|
|
441
|
+
// Backwards-compat — legacy single-use invites redeem unchanged
|
|
442
|
+
// ===========================================================================
|
|
443
|
+
|
|
444
|
+
describe("backwards-compat — legacy single-use invites", () => {
|
|
445
|
+
test("a legacy invite row (no v15 columns set) redeems with NO email field and single-use", async () => {
|
|
446
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
447
|
+
// Simulate a legacy invite: a default issueInvite is max_uses=1 / no email
|
|
448
|
+
// / no cap — exactly what a pre-v15 row looks like after the migration's
|
|
449
|
+
// column defaults backfill it.
|
|
450
|
+
const { rawToken } = issueInvite(harness.db, { createdBy: admin.id, vaultName: "maya" });
|
|
451
|
+
|
|
452
|
+
// GET form has NO email field (single-use → friends flow, no email).
|
|
453
|
+
const html = await handleAccountSetupGet(
|
|
454
|
+
new Request(`${ISSUER}/account/setup/${rawToken}`),
|
|
455
|
+
rawToken,
|
|
456
|
+
deps(),
|
|
457
|
+
).text();
|
|
458
|
+
expect(html).not.toContain('name="email"');
|
|
459
|
+
|
|
460
|
+
const stub = makeStubRunCommand();
|
|
461
|
+
const { token: csrfToken, cookieFragment } = csrfPair();
|
|
462
|
+
const res = await handleAccountSetupPost(
|
|
463
|
+
postReq(
|
|
464
|
+
rawToken,
|
|
465
|
+
{
|
|
466
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
467
|
+
username: "maya",
|
|
468
|
+
// No email submitted — single-use doesn't require it.
|
|
469
|
+
password: "maya-strong-password-1",
|
|
470
|
+
password_confirm: "maya-strong-password-1",
|
|
471
|
+
},
|
|
472
|
+
cookieFragment,
|
|
473
|
+
),
|
|
474
|
+
rawToken,
|
|
475
|
+
deps(stub.run),
|
|
476
|
+
);
|
|
477
|
+
expect(res.status).toBe(302);
|
|
478
|
+
|
|
479
|
+
const user = getUserByUsernameCI(harness.db, "maya");
|
|
480
|
+
expect(user).not.toBeNull();
|
|
481
|
+
expect(user?.email).toBeNull(); // no email captured on the friends flow
|
|
482
|
+
expect(user?.assignedVaults).toEqual(["maya"]);
|
|
483
|
+
|
|
484
|
+
// Vault provisioned WITHOUT a cap (legacy invites carry no cap).
|
|
485
|
+
expect(getVaultCapBytes(harness.db, "maya")).toBeNull();
|
|
486
|
+
|
|
487
|
+
// Single-use: a replay is refused (used → 410).
|
|
488
|
+
__resetForTests();
|
|
489
|
+
const replay = await handleAccountSetupPost(
|
|
490
|
+
postReq(
|
|
491
|
+
rawToken,
|
|
492
|
+
{
|
|
493
|
+
[CSRF_FIELD_NAME]: csrfToken,
|
|
494
|
+
username: "maya2",
|
|
495
|
+
password: "maya2-strong-password-1",
|
|
496
|
+
password_confirm: "maya2-strong-password-1",
|
|
497
|
+
},
|
|
498
|
+
cookieFragment,
|
|
499
|
+
),
|
|
500
|
+
rawToken,
|
|
501
|
+
deps(stub.run),
|
|
502
|
+
);
|
|
503
|
+
expect(replay.status).toBe(410);
|
|
504
|
+
expect(await replay.text()).toContain("already used");
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// ===========================================================================
|
|
509
|
+
// API mint gates (POST /api/invites) — admin creates a multi-use capped link
|
|
510
|
+
// ===========================================================================
|
|
511
|
+
|
|
512
|
+
describe("POST /api/invites — multi-use + cap mint gates", () => {
|
|
513
|
+
async function adminBearer(adminId: string): Promise<string> {
|
|
514
|
+
const minted = await signAccessToken(harness.db, {
|
|
515
|
+
sub: adminId,
|
|
516
|
+
scopes: ["parachute:host:admin"],
|
|
517
|
+
audience: "hub",
|
|
518
|
+
clientId: "parachute-hub-spa",
|
|
519
|
+
issuer: ISSUER,
|
|
520
|
+
ttlSeconds: 300,
|
|
521
|
+
vaultScope: [],
|
|
522
|
+
});
|
|
523
|
+
return minted.token;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function createReq(bearer: string, body: Record<string, unknown>): Request {
|
|
527
|
+
return new Request(`${ISSUER}/api/invites`, {
|
|
528
|
+
method: "POST",
|
|
529
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
530
|
+
body: JSON.stringify(body),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
test("mints a multi-use capped link; wire shape carries the new fields", async () => {
|
|
535
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
536
|
+
const bearer = await adminBearer(admin.id);
|
|
537
|
+
const cap = 1024 * 1024 * 1024;
|
|
538
|
+
const res = await handleCreateInvite(
|
|
539
|
+
createReq(bearer, { max_uses: 25, vault_cap_bytes: cap }),
|
|
540
|
+
{ db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath },
|
|
541
|
+
);
|
|
542
|
+
expect(res.status).toBe(201);
|
|
543
|
+
const body = (await res.json()) as {
|
|
544
|
+
invite: { max_uses: number; used_count: number; vault_cap_bytes: number; email: null };
|
|
545
|
+
url: string;
|
|
546
|
+
};
|
|
547
|
+
expect(body.invite.max_uses).toBe(25);
|
|
548
|
+
expect(body.invite.used_count).toBe(0);
|
|
549
|
+
expect(body.invite.vault_cap_bytes).toBe(cap);
|
|
550
|
+
expect(body.url).toContain("/account/setup/");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("auto-applies the ~1 GiB default cap to a multi-use provisioning link with no explicit cap", async () => {
|
|
554
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
555
|
+
const bearer = await adminBearer(admin.id);
|
|
556
|
+
const res = await handleCreateInvite(createReq(bearer, { max_uses: 10 }), {
|
|
557
|
+
db: harness.db,
|
|
558
|
+
issuer: ISSUER,
|
|
559
|
+
manifestPath: harness.manifestPath,
|
|
560
|
+
});
|
|
561
|
+
expect(res.status).toBe(201);
|
|
562
|
+
const body = (await res.json()) as { invite: { vault_cap_bytes: number } };
|
|
563
|
+
expect(body.invite.vault_cap_bytes).toBe(1024 * 1024 * 1024);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("single-use link with no explicit cap stays uncapped (no auto-default)", async () => {
|
|
567
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
568
|
+
const bearer = await adminBearer(admin.id);
|
|
569
|
+
const res = await handleCreateInvite(createReq(bearer, {}), {
|
|
570
|
+
db: harness.db,
|
|
571
|
+
issuer: ISSUER,
|
|
572
|
+
manifestPath: harness.manifestPath,
|
|
573
|
+
});
|
|
574
|
+
expect(res.status).toBe(201);
|
|
575
|
+
const body = (await res.json()) as { invite: { vault_cap_bytes: number | null } };
|
|
576
|
+
expect(body.invite.vault_cap_bytes).toBeNull();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("rejects max_uses > 1 with a pre-named username", async () => {
|
|
580
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
581
|
+
const bearer = await adminBearer(admin.id);
|
|
582
|
+
const res = await handleCreateInvite(
|
|
583
|
+
createReq(bearer, { max_uses: 10, username: "jonathan" }),
|
|
584
|
+
{ db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath },
|
|
585
|
+
);
|
|
586
|
+
expect(res.status).toBe(400);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("rejects max_uses > 1 with a pinned NEW vault name", async () => {
|
|
590
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
591
|
+
const bearer = await adminBearer(admin.id);
|
|
592
|
+
const res = await handleCreateInvite(
|
|
593
|
+
createReq(bearer, { max_uses: 10, vault_name: "shared", provision_vault: true }),
|
|
594
|
+
{ db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath },
|
|
595
|
+
);
|
|
596
|
+
expect(res.status).toBe(400);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("rejects vault_cap_bytes on a non-provisioning invite", async () => {
|
|
600
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
601
|
+
const bearer = await adminBearer(admin.id);
|
|
602
|
+
const res = await handleCreateInvite(
|
|
603
|
+
createReq(bearer, { provision_vault: false, vault_cap_bytes: 1000 }),
|
|
604
|
+
{ db: harness.db, issuer: ISSUER, manifestPath: harness.manifestPath },
|
|
605
|
+
);
|
|
606
|
+
expect(res.status).toBe(400);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("rejects max_uses out of range", async () => {
|
|
610
|
+
const admin = await createUser(harness.db, "operator", "operator-password-1");
|
|
611
|
+
const bearer = await adminBearer(admin.id);
|
|
612
|
+
const res = await handleCreateInvite(createReq(bearer, { max_uses: 0 }), {
|
|
613
|
+
db: harness.db,
|
|
614
|
+
issuer: ISSUER,
|
|
615
|
+
manifestPath: harness.manifestPath,
|
|
616
|
+
});
|
|
617
|
+
expect(res.status).toBe(400);
|
|
618
|
+
});
|
|
619
|
+
});
|