@openparachute/hub 0.7.2 → 0.7.3-rc.10
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-agent-grants.test.ts +113 -0
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +136 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/oauth-client.test.ts +75 -0
- package/src/__tests__/oauth-handlers.test.ts +413 -5
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- 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/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-agent-grants.ts +51 -3
- 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-modules-ops.ts +12 -0
- package/src/api-modules.ts +47 -13
- package/src/api-users.ts +3 -0
- package/src/api-vault-caps.ts +206 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +173 -0
- package/src/commands/init.ts +73 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/help.ts +8 -0
- package/src/hub-db.ts +108 -1
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +27 -0
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +72 -3
- package/src/module-manifest.ts +23 -9
- package/src/oauth-client.ts +102 -12
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +145 -20
- package/src/rate-limit.ts +111 -20
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
- package/src/vault-hub-origin-env.ts +109 -16
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-B5AUE359.js +0 -61
|
@@ -293,6 +293,29 @@ describe("parseWizardArgs", () => {
|
|
|
293
293
|
expect(r.opts.vaultMode).toBe("import");
|
|
294
294
|
expect(r.opts.vaultImportRemoteUrl).toBe("https://github.com/me/v.git");
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
test("parses --transcribe-mode + --transcribe-key + --config-dir", () => {
|
|
298
|
+
const r = parseWizardArgs([
|
|
299
|
+
"--hub-url",
|
|
300
|
+
"http://x",
|
|
301
|
+
"--transcribe-mode",
|
|
302
|
+
"groq",
|
|
303
|
+
"--transcribe-key",
|
|
304
|
+
"gsk_test",
|
|
305
|
+
"--config-dir",
|
|
306
|
+
"/tmp/ph",
|
|
307
|
+
]);
|
|
308
|
+
expect("error" in r).toBe(false);
|
|
309
|
+
if ("error" in r) throw new Error(r.error);
|
|
310
|
+
expect(r.opts.transcribeMode).toBe("groq");
|
|
311
|
+
expect(r.opts.transcribeApiKey).toBe("gsk_test");
|
|
312
|
+
expect(r.opts.configDir).toBe("/tmp/ph");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("rejects invalid --transcribe-mode", () => {
|
|
316
|
+
const r = parseWizardArgs(["--hub-url", "http://x", "--transcribe-mode", "garbage"]);
|
|
317
|
+
expect("error" in r).toBe(true);
|
|
318
|
+
});
|
|
296
319
|
});
|
|
297
320
|
|
|
298
321
|
describe("runCliWizard", () => {
|
|
@@ -509,4 +532,127 @@ describe("runCliWizard", () => {
|
|
|
509
532
|
});
|
|
510
533
|
expect(code).toBe(1);
|
|
511
534
|
});
|
|
535
|
+
|
|
536
|
+
test("password validator floor is 12 (matches the server) — an 11-char password is rejected", async () => {
|
|
537
|
+
const { fetchImpl } = makeFakeHub();
|
|
538
|
+
const logs: string[] = [];
|
|
539
|
+
const code = await runCliWizard({
|
|
540
|
+
hubUrl: "http://127.0.0.1:1939",
|
|
541
|
+
log: (l) => logs.push(l),
|
|
542
|
+
fetchImpl,
|
|
543
|
+
sleep: async () => {},
|
|
544
|
+
accountUsername: "admin",
|
|
545
|
+
accountPassword: "elevenchar.", // 11 chars
|
|
546
|
+
vaultMode: "skip",
|
|
547
|
+
exposeMode: "localhost",
|
|
548
|
+
});
|
|
549
|
+
expect(code).toBe(1);
|
|
550
|
+
expect(logs.join("\n")).toContain("at least 12 characters");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("exactly 12 chars passes the validator (no early bounce)", async () => {
|
|
554
|
+
const { state, fetchImpl } = makeFakeHub();
|
|
555
|
+
const code = await runCliWizard({
|
|
556
|
+
hubUrl: "http://127.0.0.1:1939",
|
|
557
|
+
log: () => {},
|
|
558
|
+
fetchImpl,
|
|
559
|
+
sleep: async () => {},
|
|
560
|
+
accountUsername: "admin",
|
|
561
|
+
accountPassword: "twelvecharss", // 12 chars
|
|
562
|
+
vaultMode: "skip",
|
|
563
|
+
exposeMode: "localhost",
|
|
564
|
+
});
|
|
565
|
+
expect(code).toBe(0);
|
|
566
|
+
expect(state.posted.some((p) => p.path === "/admin/setup/account")).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("transcription step runs between vault and expose when configDir is set", async () => {
|
|
570
|
+
const { state, fetchImpl } = makeFakeHub();
|
|
571
|
+
const transcribeCmds: string[][] = [];
|
|
572
|
+
const code = await runCliWizard({
|
|
573
|
+
hubUrl: "http://127.0.0.1:1939",
|
|
574
|
+
log: () => {},
|
|
575
|
+
fetchImpl,
|
|
576
|
+
sleep: async () => {},
|
|
577
|
+
accountUsername: "admin",
|
|
578
|
+
accountPassword: "longpassword",
|
|
579
|
+
vaultMode: "skip",
|
|
580
|
+
exposeMode: "localhost",
|
|
581
|
+
configDir: "/tmp/never-written-none-mode",
|
|
582
|
+
transcribeMode: "none", // none writes nothing + spawns nothing
|
|
583
|
+
transcribeRunCommand: async (cmd) => {
|
|
584
|
+
transcribeCmds.push([...cmd]);
|
|
585
|
+
return 0;
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
expect(code).toBe(0);
|
|
589
|
+
// The vault + expose POSTs still happened in order.
|
|
590
|
+
expect(state.posted.map((p) => p.path)).toEqual([
|
|
591
|
+
"/admin/setup/account",
|
|
592
|
+
"/admin/setup/vault",
|
|
593
|
+
"/admin/setup/expose",
|
|
594
|
+
]);
|
|
595
|
+
// mode=none → no transcription subprocess.
|
|
596
|
+
expect(transcribeCmds).toEqual([]);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("transcription step (cloud) drives the install one-shot via the injected runner", async () => {
|
|
600
|
+
const { fetchImpl } = makeFakeHub();
|
|
601
|
+
const transcribeCmds: string[][] = [];
|
|
602
|
+
const code = await runCliWizard({
|
|
603
|
+
hubUrl: "http://127.0.0.1:1939",
|
|
604
|
+
log: () => {},
|
|
605
|
+
fetchImpl,
|
|
606
|
+
sleep: async () => {},
|
|
607
|
+
accountUsername: "admin",
|
|
608
|
+
accountPassword: "longpassword",
|
|
609
|
+
vaultMode: "skip",
|
|
610
|
+
exposeMode: "localhost",
|
|
611
|
+
configDir: "/tmp/never-written-cloud-mode",
|
|
612
|
+
transcribeMode: "groq",
|
|
613
|
+
transcribeApiKey: "gsk_wired",
|
|
614
|
+
platform: "linux",
|
|
615
|
+
transcribeRunCommand: async (cmd) => {
|
|
616
|
+
transcribeCmds.push([...cmd]);
|
|
617
|
+
return 0;
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
expect(code).toBe(0);
|
|
621
|
+
expect(transcribeCmds[0]).toEqual([
|
|
622
|
+
"parachute",
|
|
623
|
+
"install",
|
|
624
|
+
"scribe",
|
|
625
|
+
"--scribe-provider",
|
|
626
|
+
"groq",
|
|
627
|
+
"--scribe-key",
|
|
628
|
+
"gsk_wired",
|
|
629
|
+
]);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("transcription step is skipped when configDir is absent", async () => {
|
|
633
|
+
const { state, fetchImpl } = makeFakeHub();
|
|
634
|
+
let ran = false;
|
|
635
|
+
const code = await runCliWizard({
|
|
636
|
+
hubUrl: "http://127.0.0.1:1939",
|
|
637
|
+
log: () => {},
|
|
638
|
+
fetchImpl,
|
|
639
|
+
sleep: async () => {},
|
|
640
|
+
accountUsername: "admin",
|
|
641
|
+
accountPassword: "longpassword",
|
|
642
|
+
vaultMode: "skip",
|
|
643
|
+
exposeMode: "localhost",
|
|
644
|
+
// no configDir
|
|
645
|
+
transcribeRunCommand: async () => {
|
|
646
|
+
ran = true;
|
|
647
|
+
return 0;
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
expect(code).toBe(0);
|
|
651
|
+
expect(ran).toBe(false);
|
|
652
|
+
expect(state.posted.map((p) => p.path)).toEqual([
|
|
653
|
+
"/admin/setup/account",
|
|
654
|
+
"/admin/setup/vault",
|
|
655
|
+
"/admin/setup/expose",
|
|
656
|
+
]);
|
|
657
|
+
});
|
|
512
658
|
});
|
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) {
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
} from "./jwt-sign.ts";
|
|
81
81
|
import { type OAuthClient, deriveVaultScopeFromMcpUrl, realOAuthClient } from "./oauth-client.ts";
|
|
82
82
|
import { type PendingFlow, deleteFlow, getFlowByState, putFlow } from "./oauth-flows-store.ts";
|
|
83
|
+
import { isSafeHubReturnTo } from "./oauth-handlers.ts";
|
|
83
84
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
84
85
|
import { isFirstAdmin } from "./users.ts";
|
|
85
86
|
import { validateVaultName } from "./vault-name.ts";
|
|
@@ -630,10 +631,15 @@ async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Pr
|
|
|
630
631
|
const grant = getGrant(deps.storePath, id);
|
|
631
632
|
if (!grant) return jsonError(404, "not_found", `no grant ${id}`);
|
|
632
633
|
|
|
633
|
-
|
|
634
|
+
// `returnTo` is consumed ONLY by the mcp/OAuth path (approveMcpGrant), which
|
|
635
|
+
// is the one approve flow that hands the browser off to a remote consent
|
|
636
|
+
// screen and needs somewhere to land on return. vault/service approvals
|
|
637
|
+
// complete synchronously and return JSON — there's no redirect, so they
|
|
638
|
+
// ignore `returnTo` by design.
|
|
639
|
+
let body: { token?: unknown; returnTo?: unknown } = {};
|
|
634
640
|
try {
|
|
635
641
|
const raw = await req.text();
|
|
636
|
-
if (raw.trim().length > 0) body = JSON.parse(raw) as { token?: unknown };
|
|
642
|
+
if (raw.trim().length > 0) body = JSON.parse(raw) as { token?: unknown; returnTo?: unknown };
|
|
637
643
|
} catch {
|
|
638
644
|
return jsonError(400, "invalid_request", "body must be JSON when present");
|
|
639
645
|
}
|
|
@@ -748,7 +754,7 @@ async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Pr
|
|
|
748
754
|
*/
|
|
749
755
|
async function approveMcpGrant(
|
|
750
756
|
grant: GrantRecord,
|
|
751
|
-
body: { token?: unknown },
|
|
757
|
+
body: { token?: unknown; returnTo?: unknown },
|
|
752
758
|
deps: AgentGrantsDeps,
|
|
753
759
|
approvedAt: string,
|
|
754
760
|
): Promise<Response> {
|
|
@@ -850,6 +856,16 @@ async function approveMcpGrant(
|
|
|
850
856
|
? discovery.scopesSupported.join(" ")
|
|
851
857
|
: undefined;
|
|
852
858
|
|
|
859
|
+
// OPTIONAL `returnTo` — the same-origin (hub-relative) page the operator
|
|
860
|
+
// started from (the agent ops surface / admin grants page). Stash it on the
|
|
861
|
+
// flow ONLY when it passes the shared open-redirect guard; absent/invalid →
|
|
862
|
+
// omit it (the callback then renders the back-compat close-tab page). This is
|
|
863
|
+
// the open-redirect-load-bearing check — reuse, don't reinvent.
|
|
864
|
+
const returnTo =
|
|
865
|
+
typeof body.returnTo === "string" && isSafeHubReturnTo(body.returnTo)
|
|
866
|
+
? body.returnTo
|
|
867
|
+
: undefined;
|
|
868
|
+
|
|
853
869
|
const flow: PendingFlow = {
|
|
854
870
|
state,
|
|
855
871
|
grantId: grant.id,
|
|
@@ -861,6 +877,7 @@ async function approveMcpGrant(
|
|
|
861
877
|
mcpUrl,
|
|
862
878
|
...(scope ? { scope } : {}),
|
|
863
879
|
redirectUri,
|
|
880
|
+
...(returnTo ? { returnTo } : {}),
|
|
864
881
|
createdAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
865
882
|
};
|
|
866
883
|
putFlow(deps.flowsStorePath, flow, (deps.now?.() ?? new Date()).getTime());
|
|
@@ -1040,6 +1057,17 @@ export async function handleOAuthGrantCallback(
|
|
|
1040
1057
|
// NEVER log a token.
|
|
1041
1058
|
console.log(`agent grant approved: id=${grant.id} agent=${grant.agent} kind=mcp mode=oauth`);
|
|
1042
1059
|
|
|
1060
|
+
// If the operator started from a hub page (agent ops surface / admin grants),
|
|
1061
|
+
// send them back there instead of a dead-end "close this tab" screen. Re-run
|
|
1062
|
+
// the open-redirect guard DEFENSIVELY at redirect time (the value was already
|
|
1063
|
+
// gated at stash time; this is belt-and-suspenders against a tampered store).
|
|
1064
|
+
// Append `?mcp_connected=1` (preserving any existing query) so the SPA can
|
|
1065
|
+
// react (toast / refetch the grant). Absent/invalid returnTo → the
|
|
1066
|
+
// back-compat close-tab page.
|
|
1067
|
+
if (flow.returnTo && isSafeHubReturnTo(flow.returnTo)) {
|
|
1068
|
+
return redirectToReturn(flow.returnTo);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1043
1071
|
return htmlPage(
|
|
1044
1072
|
200,
|
|
1045
1073
|
"Connected",
|
|
@@ -1047,6 +1075,26 @@ export async function handleOAuthGrantCallback(
|
|
|
1047
1075
|
);
|
|
1048
1076
|
}
|
|
1049
1077
|
|
|
1078
|
+
/**
|
|
1079
|
+
* 302 back to a same-origin (hub-relative) `returnTo`, appending
|
|
1080
|
+
* `mcp_connected=1` so the SPA can show a success toast / refetch. The caller
|
|
1081
|
+
* MUST have already passed `returnTo` through `isSafeHubReturnTo` — this builds
|
|
1082
|
+
* the URL against a fixed dummy origin purely to merge the query param
|
|
1083
|
+
* correctly, then emits only the path+query (never the origin), so a
|
|
1084
|
+
* scheme-relative value can't leak through into the Location header.
|
|
1085
|
+
*/
|
|
1086
|
+
function redirectToReturn(returnTo: string): Response {
|
|
1087
|
+
// Parse against a fixed base so query-string merging is correct even when
|
|
1088
|
+
// returnTo already carries `?...`. Only `pathname + search + hash` is emitted.
|
|
1089
|
+
const u = new URL(returnTo, "http://hub.invalid");
|
|
1090
|
+
u.searchParams.set("mcp_connected", "1");
|
|
1091
|
+
const location = `${u.pathname}${u.search}${u.hash}`;
|
|
1092
|
+
return new Response(null, {
|
|
1093
|
+
status: 302,
|
|
1094
|
+
headers: { location, "cache-control": "no-store" },
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1050
1098
|
/** Minimal server-rendered HTML — NEVER carries a token. */
|
|
1051
1099
|
function htmlPage(status: number, heading: string, message: string): Response {
|
|
1052
1100
|
const html = `<!doctype html>
|
package/src/admin-handlers.ts
CHANGED
|
@@ -21,7 +21,13 @@ import {
|
|
|
21
21
|
getPendingLogin,
|
|
22
22
|
parsePendingLoginCookie,
|
|
23
23
|
} from "./pending-login.ts";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
authIpCeilingRateLimiter,
|
|
26
|
+
clientIpFromRequest,
|
|
27
|
+
compositeKey,
|
|
28
|
+
loginRateLimiter,
|
|
29
|
+
totpRateLimiter,
|
|
30
|
+
} from "./rate-limit.ts";
|
|
25
31
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
26
32
|
import {
|
|
27
33
|
SESSION_TTL_MS,
|
|
@@ -167,24 +173,42 @@ export async function handleAdminLoginPost(
|
|
|
167
173
|
);
|
|
168
174
|
}
|
|
169
175
|
// Rate-limit gate fires *after* CSRF (so a junk cross-site POST doesn't
|
|
170
|
-
// burn a bucket slot for the victim
|
|
176
|
+
// burn a bucket slot for the victim) but *before* credential check.
|
|
171
177
|
// Every legitimate login attempt — wrong password, missing user, eventually
|
|
172
|
-
// failed-2FA
|
|
173
|
-
//
|
|
178
|
+
// failed-2FA — counts toward the same bucket so an attacker can't partition
|
|
179
|
+
// the cooldown across stages.
|
|
180
|
+
//
|
|
181
|
+
// Two tiers (the shared-egress-IP fix): a coarse per-IP CEILING (60/15min)
|
|
182
|
+
// backstops username-rotation, then a per-(ip,username) FLOOR (5/15min) so
|
|
183
|
+
// each account behind a shared NAT / Cloudflare egress IP gets its own
|
|
184
|
+
// bucket instead of the whole room pooling into one 5-slot per-IP bucket.
|
|
185
|
+
// `username` is hoisted above the gate because the floor keys on it. The same
|
|
186
|
+
// `loginRateLimiter` + `compositeKey(ip, username)` scheme backs the
|
|
187
|
+
// `/oauth/authorize` password door, so both doors share ONE per-account
|
|
188
|
+
// bucket.
|
|
189
|
+
const username = String(form.get("username") ?? "");
|
|
174
190
|
const clientIp = clientIpFromRequest(req);
|
|
175
191
|
const now = deps.now ? deps.now() : new Date();
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
// Both checks always run (no short-circuit): a denied `checkAndRecord` does
|
|
193
|
+
// NOT append a timestamp, so running the floor after a denied ceiling never
|
|
194
|
+
// double-counts. We need both recorded so each independently tracks its own
|
|
195
|
+
// bucket (the floor still gates per-account even when the ceiling is fine).
|
|
196
|
+
const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
|
|
197
|
+
const floor = loginRateLimiter.checkAndRecord(compositeKey(clientIp, username), now);
|
|
198
|
+
if (!ceiling.allowed || !floor.allowed) {
|
|
199
|
+
const retryAfterSeconds = Math.max(
|
|
200
|
+
ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
|
|
201
|
+
floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
|
|
202
|
+
);
|
|
178
203
|
return htmlResponse(
|
|
179
204
|
renderAdminError({
|
|
180
205
|
title: "Too many login attempts",
|
|
181
|
-
message: `Too many login attempts
|
|
206
|
+
message: `Too many login attempts. Try again in ${retryAfterSeconds} seconds.`,
|
|
182
207
|
}),
|
|
183
208
|
429,
|
|
184
|
-
{ "retry-after": String(
|
|
209
|
+
{ "retry-after": String(retryAfterSeconds) },
|
|
185
210
|
);
|
|
186
211
|
}
|
|
187
|
-
const username = String(form.get("username") ?? "");
|
|
188
212
|
const password = String(form.get("password") ?? "");
|
|
189
213
|
const next = safeNext(String(form.get("next") ?? ""));
|
|
190
214
|
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
@@ -314,23 +338,36 @@ export async function handleAdminLoginTotpPost(
|
|
|
314
338
|
const next = safeNext(String(form.get("next") ?? ""));
|
|
315
339
|
const code = String(form.get("code") ?? "");
|
|
316
340
|
|
|
317
|
-
// Rate-limit BEFORE
|
|
341
|
+
// Rate-limit BEFORE verifying the factor. Resolve the pending login FIRST so
|
|
342
|
+
// the per-account floor can key on the STABLE userId (NOT the pendingToken,
|
|
343
|
+
// which a fresh /login success rotates and would reset the bucket). Resolving
|
|
344
|
+
// here also lets a bad/expired pending token fall back to keying the floor by
|
|
345
|
+
// IP alone — never by the rotating pendingToken.
|
|
318
346
|
const clientIp = clientIpFromRequest(req);
|
|
319
347
|
const now = deps.now ? deps.now() : new Date();
|
|
320
|
-
const
|
|
321
|
-
|
|
348
|
+
const pendingToken = parsePendingLoginCookie(req.headers.get("cookie"));
|
|
349
|
+
const pending = getPendingLogin(pendingToken, () => now);
|
|
350
|
+
// Two tiers (the shared-egress-IP fix): coarse per-IP CEILING (60/15min) +
|
|
351
|
+
// per-(ip,userId) FLOOR (5/15min). When userId can't be resolved (bad/expired
|
|
352
|
+
// pending token), key the floor by IP alone — do NOT key by the pendingToken.
|
|
353
|
+
const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
|
|
354
|
+
const floorKey = pending ? compositeKey(clientIp, pending.userId) : clientIp;
|
|
355
|
+
const floor = totpRateLimiter.checkAndRecord(floorKey, now);
|
|
356
|
+
if (!ceiling.allowed || !floor.allowed) {
|
|
357
|
+
const retryAfterSeconds = Math.max(
|
|
358
|
+
ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
|
|
359
|
+
floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
|
|
360
|
+
);
|
|
322
361
|
return htmlResponse(
|
|
323
362
|
renderAdminError({
|
|
324
363
|
title: "Too many attempts",
|
|
325
|
-
message: `Too many verification attempts
|
|
364
|
+
message: `Too many verification attempts. Try again in ${retryAfterSeconds} seconds.`,
|
|
326
365
|
}),
|
|
327
366
|
429,
|
|
328
|
-
{ "retry-after": String(
|
|
367
|
+
{ "retry-after": String(retryAfterSeconds) },
|
|
329
368
|
);
|
|
330
369
|
}
|
|
331
370
|
|
|
332
|
-
const pendingToken = parsePendingLoginCookie(req.headers.get("cookie"));
|
|
333
|
-
const pending = getPendingLogin(pendingToken, () => now);
|
|
334
371
|
if (!pending) {
|
|
335
372
|
// No live pending login — expired, missing, or tampered. Send the user
|
|
336
373
|
// back to the start; clear any stale pending cookie.
|