@openparachute/hub 0.6.3 → 0.6.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +609 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +180 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +342 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +94 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +347 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -128,6 +128,15 @@ export interface RenderAccountHomeOpts {
128
128
  * branches, where no token-mint tile is shown.
129
129
  */
130
130
  mintableVerbs?: Record<string, VaultVerb[]>;
131
+ /**
132
+ * Per-vault usage stat (`"X notes · Y MB"`) for each assigned vault tile.
133
+ * Maps `vaultName` → the pre-formatted stat string. A vault absent from this
134
+ * map renders no stat — the page tolerates a vault whose usage endpoint
135
+ * failed / is unreachable / predates the feature (the `/account/` GET handler
136
+ * builds this map by fetching `/.parachute/usage` per vault and omitting any
137
+ * that don't resolve). Omitted entirely on the admin / no-vault branches.
138
+ */
139
+ usageStats?: Record<string, string>;
131
140
  /**
132
141
  * Set after a successful `POST /account/vault-token/<name>` to show the
133
142
  * freshly-minted token ONCE (the only time it's ever shown — the hub keeps
@@ -189,6 +198,7 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
189
198
  isFirstAdmin,
190
199
  csrfToken,
191
200
  mintableVerbs: opts.mintableVerbs ?? {},
201
+ usageStats: opts.usageStats ?? {},
192
202
  });
193
203
 
194
204
  const accountCard = renderAccountCard({
@@ -257,6 +267,8 @@ interface VaultCardOpts {
257
267
  isFirstAdmin: boolean;
258
268
  csrfToken: string;
259
269
  mintableVerbs: Record<string, VaultVerb[]>;
270
+ /** `vaultName` → pre-formatted usage stat ("X notes · Y MB"). */
271
+ usageStats: Record<string, string>;
260
272
  }
261
273
 
262
274
  /**
@@ -286,7 +298,8 @@ export function accountClaudeMcpAddCommand(trimmedOrigin: string, vaultName: str
286
298
  }
287
299
 
288
300
  function renderVaultCard(opts: VaultCardOpts): string {
289
- const { assignedVaults, trimmedOrigin, isFirstAdmin, csrfToken, mintableVerbs } = opts;
301
+ const { assignedVaults, trimmedOrigin, isFirstAdmin, csrfToken, mintableVerbs, usageStats } =
302
+ opts;
290
303
 
291
304
  if (assignedVaults.length > 0) {
292
305
  // One vault tile per assignment (multi-user Phase 2 PR 2). Each tile
@@ -313,15 +326,25 @@ function renderVaultCard(opts: VaultCardOpts): string {
313
326
  const addCmd = accountClaudeMcpAddCommand(trimmedOrigin, vaultName);
314
327
  const safeEndpoint = escapeHtml(endpoint);
315
328
  const safeAddCmd = escapeHtml(addCmd);
316
- const tokenMintBlock = renderTokenMintBlock(
317
- vaultName,
318
- safeVault,
319
- mintableVerbs[vaultName] ?? [],
320
- csrfToken,
321
- );
329
+ const verbsForVault = mintableVerbs[vaultName] ?? [];
330
+ const tokenMintBlock = renderTokenMintBlock(vaultName, safeVault, verbsForVault, csrfToken);
331
+ // "Configure / back up this vault ↗" — only for users whose assignment
332
+ // grants `admin` (the verb the deep-link mints). Today every assigned
333
+ // user holds admin, but gate on the verb so the button never offers
334
+ // authority the POST handler would 403.
335
+ const manageBlock = verbsForVault.includes("admin")
336
+ ? renderVaultAdminLink(vaultName, csrfToken)
337
+ : "";
338
+ // Compact usage stat ("X notes · Y MB"), when the vault's usage endpoint
339
+ // resolved. Omitted gracefully otherwise.
340
+ const usageStat = usageStats[vaultName];
341
+ const usageLine = usageStat
342
+ ? `<p class="vault-usage" data-testid="vault-usage">${escapeHtml(usageStat)}</p>`
343
+ : "";
322
344
  return `
323
345
  <div class="vault-tile" data-testid="vault-tile" data-vault-name="${safeVault}">
324
346
  <p class="vault-name"><strong>${safeVault}</strong></p>
347
+ ${usageLine}
325
348
  <div class="mcp-connect" data-testid="mcp-connect">
326
349
  <p class="mcp-connect-label" data-testid="connect-ai-heading">Connect your AI
327
350
  assistant to this vault</p>
@@ -364,6 +387,7 @@ function renderVaultCard(opts: VaultCardOpts): string {
364
387
  capture in this vault — or jump straight to bulk-importing Markdown/Obsidian
365
388
  notes into it.</span>
366
389
  </p>
390
+ ${manageBlock}
367
391
  ${tokenMintBlock}
368
392
  </div>`;
369
393
  })
@@ -473,6 +497,34 @@ function renderTokenMintBlock(
473
497
  </details>`;
474
498
  }
475
499
 
500
+ /**
501
+ * The "Configure / back up this vault ↗" affordance on a vault tile. A small
502
+ * POST form to `/account/vault-admin-token/<name>` that mints a
503
+ * `vault:<name>:admin` deep-link token and redirects into the vault's own admin
504
+ * SPA — where the assigned user can rotate vault tokens AND set up Git backup /
505
+ * mirror config. Shown only when the user's assignment grants `admin` (gated by
506
+ * the caller). A plain form button (no `<details>`) — this is a primary admin
507
+ * action for an individual user, more prominent than the headless-token mint
508
+ * below it.
509
+ *
510
+ * No-JS posture: a same-origin form POST that 303-redirects on success, same
511
+ * shape as the other `/account/*` forms. CSRF-gated via the hidden field.
512
+ */
513
+ function renderVaultAdminLink(vaultName: string, csrfToken: string): string {
514
+ // Path segment is URL-encoded; the action attribute is HTML-escaped on top.
515
+ const action = escapeHtml(`/account/vault-admin-token/${encodeURIComponent(vaultName)}`);
516
+ return `
517
+ <form method="POST" action="${action}" class="vault-admin-link"
518
+ data-testid="vault-admin-form">
519
+ ${renderCsrfHiddenInput(csrfToken)}
520
+ <button type="submit" class="btn btn-secondary" data-testid="vault-admin-button">
521
+ Configure / back up this vault ↗
522
+ </button>
523
+ <span class="vault-admin-sub">Open this vault's admin tools — rotate access tokens,
524
+ and set up Git backup (mirror to a repository).</span>
525
+ </form>`;
526
+ }
527
+
476
528
  /**
477
529
  * The show-once banner for a freshly-minted friend vault token. Rendered at
478
530
  * the top of `/account/` after a successful `POST /account/vault-token/<name>`.
@@ -717,6 +769,11 @@ const STYLES = `
717
769
  margin: 0 0 0.6rem;
718
770
  }
719
771
  .vault-name strong { color: ${PALETTE.fg}; font-weight: 600; }
772
+ .vault-usage {
773
+ font-size: 0.8rem;
774
+ color: ${PALETTE.fgMuted};
775
+ margin: 0 0 0.5rem;
776
+ }
720
777
  .vault-tiles {
721
778
  display: flex;
722
779
  flex-direction: column;
@@ -824,6 +881,21 @@ const STYLES = `
824
881
  margin: 0.4rem 0 0;
825
882
  }
826
883
 
884
+ .vault-admin-link {
885
+ margin: 0.9rem 0 0;
886
+ padding-top: 0.6rem;
887
+ border-top: 1px solid ${PALETTE.borderLight};
888
+ display: flex;
889
+ align-items: center;
890
+ flex-wrap: wrap;
891
+ gap: 0.4rem 0.75rem;
892
+ }
893
+ .vault-admin-sub {
894
+ font-size: 0.82rem;
895
+ color: ${PALETTE.fgMuted};
896
+ flex: 1 1 12rem;
897
+ }
898
+
827
899
  .token-mint {
828
900
  margin: 0.9rem 0 0;
829
901
  padding-top: 0.6rem;
@@ -981,13 +1053,14 @@ const STYLES = `
981
1053
  h1, h2 { color: #f0ece4; }
982
1054
  .subtitle, .kv dt, .mcp-field-label, .mcp-connect-hint,
983
1055
  .mcp-connect-intro, .mcp-method-sub, .mcp-method-note,
984
- .vault-notes-cta-sub { color: #a8a29a; }
1056
+ .vault-notes-cta-sub, .vault-usage { color: #a8a29a; }
985
1057
  .vault-name strong, .mcp-connect-label, .mcp-method-title { color: #f0ece4; }
986
1058
  code { background: #1f1c18; color: #e8e4dc; }
987
1059
  .copy-row code { background: transparent; }
988
1060
  .section { border-top-color: #3a362f; }
989
1061
  .mcp-method, .vault-notes-cta, .token-mint,
990
- .account-security { border-top-color: #3a362f; }
1062
+ .vault-admin-link, .account-security { border-top-color: #3a362f; }
1063
+ .vault-admin-sub { color: #a8a29a; }
991
1064
  .get-started h3 { color: #f0ece4; }
992
1065
  .starter-tile { border-color: #3a362f; background: #1f1c18; }
993
1066
  .starter-tile:hover { border-color: ${PALETTE.accent}; }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * `/account/setup/<token>` — invite redemption (design
3
+ * 2026-06-04-individual-users-and-vault-operations.md §7).
4
+ *
5
+ * Server-rendered (NOT the SPA), mirroring `/login` + the setup wizard's
6
+ * account-claim flow (`setup-wizard.ts` `handleSetupAccountPost`). A brand-new
7
+ * invitee opens the link with no session and no JS:
8
+ *
9
+ * GET → render the "pick username + password (+ vault name)" form.
10
+ * POST → redeem: look up the invite by sha256(token), validate it's still
11
+ * redeemable, validate credentials, provision the vault, create the
12
+ * user, stamp the invite used, mint a session, 302 → /account/.
13
+ *
14
+ * Redeem ORDERING (the re-usability guarantee, mirroring the wizard):
15
+ * 1. lookup + validate the invite (not-found/expired/used/revoked)
16
+ * 2. validate username/password (+ vault name)
17
+ * 3. provision the vault (idempotent-safe)
18
+ * 4. createUser (the commit point)
19
+ * 5. consumeInvite — stamp used_at + redeemed_user_id ONLY AFTER (4) commits
20
+ * 6. createSession + cookie + 302
21
+ *
22
+ * Because the invite is consumed only after the user row commits, a
23
+ * createUser exception (UNIQUE collision, disk full, anything) leaves the
24
+ * invite re-usable — the invitee can simply retry. `consumeInvite`'s
25
+ * `used_at IS NULL` guard makes the stamp itself single-use / race-free.
26
+ *
27
+ * What an invite pre-authorizes: EXACTLY one account + the one named/created
28
+ * vault at the baked-in role — NEVER host:admin, NEVER another vault. The new
29
+ * user gets `assignedVaults:[that vault]` with the invite's role; nothing
30
+ * grants admin posture (the first-admin-by-earliest-row heuristic is
31
+ * untouched — an invited user is never the earliest row).
32
+ */
33
+ import type { Database } from "bun:sqlite";
34
+ import { renderAdminError, renderInviteSetup } from "./admin-login-ui.ts";
35
+ import { type RunResult, provisionVault } from "./admin-vaults.ts";
36
+ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
37
+ import {
38
+ InviteExpiredError,
39
+ InviteNotFoundError,
40
+ InviteRevokedError,
41
+ InviteUsedError,
42
+ assertInviteRedeemable,
43
+ consumeInvite,
44
+ } from "./invites.ts";
45
+ import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
46
+ import { isHttpsRequest } from "./request-protocol.ts";
47
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "./sessions.ts";
48
+ import {
49
+ PASSWORD_MAX_LEN,
50
+ UsernameTakenError,
51
+ createUser,
52
+ getUserByUsernameCI,
53
+ validatePassword,
54
+ validateUsername,
55
+ } from "./users.ts";
56
+ import { validateVaultName } from "./vault-name.ts";
57
+
58
+ export interface AccountSetupDeps {
59
+ db: Database;
60
+ /** Hub origin — JWT `iss` for any vault bootstrap mint + the URL base. */
61
+ hubOrigin: string;
62
+ manifestPath?: string;
63
+ /** Test seam: vault provisioning shell-out. */
64
+ runCommand?: (cmd: readonly string[]) => Promise<RunResult>;
65
+ /** Test seam: clock for the rate limiter. */
66
+ now?: () => Date;
67
+ }
68
+
69
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
70
+ return new Response(body, {
71
+ status,
72
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra },
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Map an invite-rejection error to a status + user-facing copy. Not-found →
78
+ * 404 (don't confirm the token shape); expired/used/revoked → 410 Gone (the
79
+ * link existed but is no longer redeemable).
80
+ */
81
+ function rejectInvite(err: unknown): Response {
82
+ if (err instanceof InviteNotFoundError) {
83
+ return htmlResponse(
84
+ renderAdminError({
85
+ title: "Invite not found",
86
+ message:
87
+ "This invite link is not valid. Check that you copied the whole link, or ask your hub operator for a new one.",
88
+ }),
89
+ 404,
90
+ );
91
+ }
92
+ if (err instanceof InviteExpiredError) {
93
+ return htmlResponse(
94
+ renderAdminError({
95
+ title: "Invite expired",
96
+ message: "This invite link has expired. Ask your hub operator for a new one.",
97
+ }),
98
+ 410,
99
+ );
100
+ }
101
+ if (err instanceof InviteUsedError) {
102
+ return htmlResponse(
103
+ renderAdminError({
104
+ title: "Invite already used",
105
+ message:
106
+ "This invite link has already been used to create an account. If that wasn't you, contact your hub operator.",
107
+ }),
108
+ 410,
109
+ );
110
+ }
111
+ if (err instanceof InviteRevokedError) {
112
+ return htmlResponse(
113
+ renderAdminError({
114
+ title: "Invite revoked",
115
+ message: "This invite link has been revoked by your hub operator. Ask them for a new one.",
116
+ }),
117
+ 410,
118
+ );
119
+ }
120
+ // Unexpected — fail closed.
121
+ return htmlResponse(
122
+ renderAdminError({ title: "Invite error", message: "Could not process this invite link." }),
123
+ 500,
124
+ );
125
+ }
126
+
127
+ /** GET /account/setup/<token> — render the claim form (or a rejection page). */
128
+ export function handleAccountSetupGet(
129
+ req: Request,
130
+ rawToken: string,
131
+ deps: AccountSetupDeps,
132
+ ): Response {
133
+ const now = (deps.now ?? (() => new Date()))();
134
+ let invite: ReturnType<typeof assertInviteRedeemable>;
135
+ try {
136
+ invite = assertInviteRedeemable(deps.db, rawToken, now);
137
+ } catch (err) {
138
+ return rejectInvite(err);
139
+ }
140
+ const csrf = ensureCsrfToken(req);
141
+ const setCookie: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
142
+ return htmlResponse(
143
+ renderInviteSetup({
144
+ token: rawToken,
145
+ csrfToken: csrf.token,
146
+ pinnedVaultName: invite.vaultName,
147
+ provisionVault: invite.provisionVault,
148
+ }),
149
+ 200,
150
+ setCookie,
151
+ );
152
+ }
153
+
154
+ /** POST /account/setup/<token> — redeem the invite (see file docstring for ordering). */
155
+ export async function handleAccountSetupPost(
156
+ req: Request,
157
+ rawToken: string,
158
+ deps: AccountSetupDeps,
159
+ ): Promise<Response> {
160
+ const now = (deps.now ?? (() => new Date()))();
161
+
162
+ // (1) Look up + validate the invite BEFORE any work.
163
+ let invite: ReturnType<typeof assertInviteRedeemable>;
164
+ try {
165
+ invite = assertInviteRedeemable(deps.db, rawToken, now);
166
+ } catch (err) {
167
+ return rejectInvite(err);
168
+ }
169
+
170
+ // CSRF — double-submit, same shape as /account/change-password + /login.
171
+ const form = await req.formData();
172
+ const formCsrf = form.get(CSRF_FIELD_NAME);
173
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
174
+ return htmlResponse(
175
+ renderAdminError({
176
+ title: "Invalid form submission",
177
+ message: "The form's CSRF token did not match. Reload the page and try again.",
178
+ }),
179
+ 400,
180
+ );
181
+ }
182
+
183
+ // Rate limit — reuse the /login IP bucket so a redeem flood and a login
184
+ // flood share the same throttle. After CSRF (so a junk cross-site POST
185
+ // doesn't burn the bucket), before any account/vault work.
186
+ const clientIp = clientIpFromRequest(req);
187
+ const gate = checkAndRecord(clientIp, now);
188
+ if (!gate.allowed) {
189
+ return htmlResponse(
190
+ renderAdminError({
191
+ title: "Too many attempts",
192
+ message: `Please wait ${gate.retryAfterSeconds ?? 60} seconds and try again.`,
193
+ }),
194
+ 429,
195
+ );
196
+ }
197
+
198
+ const username = String(form.get("username") ?? "").trim();
199
+ const password = String(form.get("password") ?? "");
200
+ const confirm = String(form.get("password_confirm") ?? "");
201
+
202
+ const csrf = ensureCsrfToken(req);
203
+ const setCookie: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
204
+ // Re-render the form with an inline error, preserving what the invitee typed.
205
+ const rerender = (status: number, message: string, vaultNameEcho?: string): Response =>
206
+ htmlResponse(
207
+ renderInviteSetup({
208
+ token: rawToken,
209
+ csrfToken: csrf.token,
210
+ pinnedVaultName: invite.vaultName,
211
+ provisionVault: invite.provisionVault,
212
+ username,
213
+ ...(vaultNameEcho !== undefined ? { vaultName: vaultNameEcho } : {}),
214
+ errorMessage: message,
215
+ }),
216
+ status,
217
+ setCookie,
218
+ );
219
+
220
+ // (2) Validate credentials with the SAME validators as /api/users.
221
+ const u = validateUsername(username);
222
+ if (!u.valid) {
223
+ return rerender(
224
+ 400,
225
+ "Username must be 2–32 lowercase letters, digits, _ or - (and not a reserved word).",
226
+ );
227
+ }
228
+ if (password.length > PASSWORD_MAX_LEN) {
229
+ return rerender(413, `Password must be ≤ ${PASSWORD_MAX_LEN} characters.`);
230
+ }
231
+ const p = validatePassword(password);
232
+ if (!p.valid) {
233
+ return rerender(400, "Password must be at least 12 characters.");
234
+ }
235
+ if (password !== confirm) {
236
+ return rerender(400, "The two passwords don't match.");
237
+ }
238
+ // Case-insensitive uniqueness — same gate as /api/users.
239
+ if (getUserByUsernameCI(deps.db, username) !== null) {
240
+ return rerender(409, `The username "${username}" is already taken. Pick another.`);
241
+ }
242
+
243
+ // Resolve the vault name: pinned by the invite, or chosen by the invitee.
244
+ // The invitee-chosen name goes through the FULL `validateVaultName` (the
245
+ // same 2–32 + charset + reserved contract vault's init enforces), not just
246
+ // the charset regex — otherwise a 33–64 char name slips past here and fails
247
+ // at the vault CLI with a generic provision error.
248
+ let vaultName: string | null = null;
249
+ if (invite.provisionVault) {
250
+ if (invite.vaultName !== null) {
251
+ vaultName = invite.vaultName;
252
+ } else {
253
+ const chosen = String(form.get("vault_name") ?? "").trim();
254
+ const v = validateVaultName(chosen);
255
+ if (!v.ok) {
256
+ return rerender(400, v.error, chosen);
257
+ }
258
+ vaultName = v.name;
259
+ }
260
+ } else if (invite.vaultName !== null) {
261
+ // Account-only invite that assigns an existing vault.
262
+ vaultName = invite.vaultName;
263
+ }
264
+
265
+ // (3) Provision the vault (idempotent-safe). Routed through the SAME
266
+ // createVault path the wizard/SPA use, so the new vault gets the §3
267
+ // internal-live-mirror default for free. Done BEFORE createUser so a
268
+ // provisioning failure doesn't leave a vault-less account; the invite is
269
+ // still unconsumed at this point, so the invitee can retry.
270
+ if (invite.provisionVault && vaultName !== null) {
271
+ const provisioned = await provisionVault(vaultName, {
272
+ issuer: deps.hubOrigin,
273
+ ...(deps.manifestPath !== undefined ? { manifestPath: deps.manifestPath } : {}),
274
+ ...(deps.runCommand !== undefined ? { runCommand: deps.runCommand } : {}),
275
+ ...(invite.defaultMirror !== null ? { defaultMirror: invite.defaultMirror } : {}),
276
+ });
277
+ if (!provisioned.ok) {
278
+ return rerender(
279
+ provisioned.status === 400 ? 400 : 502,
280
+ provisioned.status === 400
281
+ ? provisioned.message
282
+ : "Could not provision your vault. Please try again, or ask your hub operator.",
283
+ invite.vaultName === null ? (vaultName ?? undefined) : undefined,
284
+ );
285
+ }
286
+ }
287
+
288
+ // (4) Create the user + consume the invite ATOMICALLY — the COMMIT POINT.
289
+ // The invite is consumed INSIDE createUser's transaction (the `withinTx`
290
+ // hook), so the two single-use guarantees compose:
291
+ //
292
+ // - Single-use under concurrency: two redeems of one invite both pass
293
+ // `assertInviteRedeemable` above, but only one's `consumeInvite` UPDATE
294
+ // (`used_at IS NULL AND revoked_at IS NULL`) changes a row. The loser's
295
+ // hook throws `InviteUsedError`, which rolls back ITS user insert — no
296
+ // orphan account. Exactly one account results.
297
+ // - Re-usable on failure: if createUser throws (UNIQUE collision, etc.)
298
+ // before the hook, the invite was never touched; if the hook itself
299
+ // throws (lost race), the consume + the user insert roll back together.
300
+ // Either way nothing commits, so the invite stays re-usable.
301
+ //
302
+ // passwordChanged: TRUE (the invitee chose their own password → no force-
303
+ // change). assignedVaults pins them to exactly their one vault at the
304
+ // invite's role. allowMulti because the first admin already exists.
305
+ let userId: string;
306
+ try {
307
+ const created = await createUser(deps.db, username, password, {
308
+ allowMulti: true,
309
+ passwordChanged: true,
310
+ assignedVaults: vaultName !== null ? [vaultName] : [],
311
+ role: invite.role,
312
+ withinTx: (newUserId) => {
313
+ if (!consumeInvite(deps.db, invite.tokenHash, newUserId, now)) {
314
+ // Lost the redeem race (or a concurrent revoke landed). Throw to
315
+ // roll back this user insert; surfaced as the used/410 path below.
316
+ throw new InviteUsedError();
317
+ }
318
+ },
319
+ });
320
+ userId = created.id;
321
+ } catch (err) {
322
+ if (err instanceof InviteUsedError) {
323
+ return rejectInvite(err);
324
+ }
325
+ if (err instanceof UsernameTakenError) {
326
+ return rerender(409, `The username "${username}" is already taken. Pick another.`);
327
+ }
328
+ const msg = err instanceof Error ? err.message : String(err);
329
+ console.warn(`[account-setup] createUser failed for "${username}": ${msg}`);
330
+ return rerender(500, "Could not create your account. Please try again.");
331
+ }
332
+
333
+ // (6) Sign the invitee in + land them on /account/.
334
+ const session = createSession(deps.db, { userId });
335
+ const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
336
+ secure: isHttpsRequest(req),
337
+ });
338
+ return new Response(null, {
339
+ status: 302,
340
+ headers: { location: "/account/", "cache-control": "no-store", "set-cookie": sessionCookie },
341
+ });
342
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Per-vault usage fetch for the friend-facing `/account/` home.
3
+ *
4
+ * Vault serves `GET /vault/<name>/.parachute/usage` (read-scoped) returning a
5
+ * footprint report (vault#437):
6
+ * { counts: { notes, attachments, links, tags },
7
+ * bytes: { content, db, assets, mirror?, total },
8
+ * computedAt, cached }
9
+ *
10
+ * The `/account/` GET handler renders one tile per assigned vault; this module
11
+ * fetches each vault's usage so the tile can show a compact "X notes · Y MB"
12
+ * stat. The READ scope means the assigned user's OWN authority suffices — we
13
+ * mint a short-lived `vault:<name>:read` token (the same authority the OAuth
14
+ * issuer would grant them) and call the vault over loopback.
15
+ *
16
+ * Tolerant by design: any failure (vault down, endpoint absent on an older
17
+ * vault, mint failure, malformed JSON) resolves to `null` so the tile simply
18
+ * omits the stat rather than breaking the page. Usage is a nice-to-have on a
19
+ * friend's home, never load-bearing.
20
+ *
21
+ * Injectable seams (`fetchImpl`, `signToken`) keep it unit-testable without a
22
+ * live vault or real signing key.
23
+ */
24
+ import type { Database } from "bun:sqlite";
25
+ import { signAccessToken } from "./jwt-sign.ts";
26
+
27
+ /** The subset of vault's usage report the `/account/` tile renders. */
28
+ export interface VaultUsageStat {
29
+ notes: number;
30
+ /** Physical footprint bytes (`bytes.total` from the vault report). */
31
+ totalBytes: number;
32
+ }
33
+
34
+ /** Short TTL for the read token — it's used immediately for one loopback call. */
35
+ const USAGE_READ_TOKEN_TTL_SECONDS = 60;
36
+
37
+ export interface FetchVaultUsageDeps {
38
+ db: Database;
39
+ /** Hub origin — `iss` of the minted read token. */
40
+ hubOrigin: string;
41
+ /** Loopback port the vault backend listens on (from services.json). */
42
+ vaultPort: number;
43
+ /** The user minting against their own read authority — `sub` of the token. */
44
+ userId: string;
45
+ /** Test seam — `globalThis.fetch` in production. */
46
+ fetchImpl?: typeof fetch;
47
+ /** Test seam — defaults to the real `signAccessToken`. */
48
+ signToken?: typeof signAccessToken;
49
+ /** Test seam for the clock. */
50
+ now?: () => Date;
51
+ }
52
+
53
+ /**
54
+ * Fetch one vault's usage stat for the friend's tile, or `null` on any failure.
55
+ *
56
+ * Mints a `vault:<name>:read` bearer for `userId` (capped to that one vault via
57
+ * `vaultScope`) and GETs the vault's loopback usage endpoint. Never throws —
58
+ * the page renders without the stat on any error.
59
+ */
60
+ export async function fetchVaultUsage(
61
+ vaultName: string,
62
+ deps: FetchVaultUsageDeps,
63
+ ): Promise<VaultUsageStat | null> {
64
+ const fetchImpl = deps.fetchImpl ?? fetch;
65
+ const sign = deps.signToken ?? signAccessToken;
66
+ try {
67
+ const scope = `vault:${vaultName}:read`;
68
+ const minted = await sign(deps.db, {
69
+ sub: deps.userId,
70
+ scopes: [scope],
71
+ audience: `vault.${vaultName}`,
72
+ clientId: "parachute-account",
73
+ issuer: deps.hubOrigin,
74
+ ttlSeconds: USAGE_READ_TOKEN_TTL_SECONDS,
75
+ vaultScope: [vaultName],
76
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
77
+ });
78
+ const url = `http://127.0.0.1:${deps.vaultPort}/vault/${vaultName}/.parachute/usage`;
79
+ const res = await fetchImpl(url, {
80
+ headers: { authorization: `Bearer ${minted.token}`, accept: "application/json" },
81
+ });
82
+ if (!res.ok) return null;
83
+ const body = (await res.json()) as {
84
+ counts?: { notes?: unknown };
85
+ bytes?: { total?: unknown };
86
+ };
87
+ const notes = body.counts?.notes;
88
+ const totalBytes = body.bytes?.total;
89
+ if (typeof notes !== "number" || typeof totalBytes !== "number") return null;
90
+ return { notes, totalBytes };
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Format a usage stat as the compact "X notes · Y MB" string the tile shows.
98
+ * Bytes render in the largest sensible unit (B / KB / MB / GB) with one
99
+ * decimal for MB+ and whole numbers below. Notes are pluralized.
100
+ *
101
+ * Exported for direct unit testing + reuse by the renderer.
102
+ */
103
+ export function formatUsageStat(stat: VaultUsageStat): string {
104
+ const noteLabel = stat.notes === 1 ? "note" : "notes";
105
+ return `${stat.notes} ${noteLabel} · ${formatBytes(stat.totalBytes)}`;
106
+ }
107
+
108
+ /** Human-readable byte size — B / KB / MB / GB, one decimal for MB+. */
109
+ export function formatBytes(bytes: number): string {
110
+ if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
111
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
112
+ const kb = bytes / 1024;
113
+ if (kb < 1024) return `${Math.round(kb)} KB`;
114
+ const mb = kb / 1024;
115
+ if (mb < 1024) return `${mb.toFixed(1)} MB`;
116
+ const gb = mb / 1024;
117
+ return `${gb.toFixed(1)} GB`;
118
+ }