@openparachute/hub 0.6.3 → 0.6.4-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.
Files changed (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Per-vault backup (mirror) status fetch for the friend-facing `/account/` home.
3
+ *
4
+ * Vault serves `GET /vault/<name>/.parachute/mirror` (ADMIN-scoped) returning
5
+ * the persisted mirror config + the runtime status the manager is tracking:
6
+ * { config: { enabled, location, external_path, sync_mode, auto_push, ... },
7
+ * status: { enabled, last_commit_sha, last_error, ... } }
8
+ *
9
+ * The `/account/` GET handler renders one tile per assigned vault; this module
10
+ * fetches each vault's mirror status so the tile can show a warm, plain-language
11
+ * backup line ("✓ Backed up — full version history", or "+ GitHub" when a push
12
+ * remote is configured). Backup is the local git version-history mirror vault
13
+ * stands up by default; the GitHub variant is the auto-push-to-a-remote setup.
14
+ *
15
+ * The endpoint gates on `vault:<name>:admin`, so this is only fetched for users
16
+ * who hold the admin verb on the vault (same gate as the "Advanced vault
17
+ * settings ↗" deep-link). We mint a short-lived `vault:<name>:admin` token —
18
+ * the same authority the OAuth issuer / admin path would grant them — and call
19
+ * the vault over loopback.
20
+ *
21
+ * Tolerant by design: any failure (vault down, endpoint absent on an older
22
+ * vault, mint failure, malformed JSON, insufficient scope) resolves to `null`
23
+ * so the tile simply omits the backup line rather than breaking the page —
24
+ * exactly the posture of `account-usage.ts`'s `fetchVaultUsage`.
25
+ *
26
+ * Injectable seams (`fetchImpl`, `signToken`) keep it unit-testable without a
27
+ * live vault or real signing key.
28
+ */
29
+ import type { Database } from "bun:sqlite";
30
+ import { signAccessToken } from "./jwt-sign.ts";
31
+
32
+ /** The subset of vault's mirror report the `/account/` tile renders. */
33
+ export interface VaultMirrorStat {
34
+ /** Backup is on — a version-history mirror is configured + bootstrapped. */
35
+ enabled: boolean;
36
+ /**
37
+ * Backup leaves the box — an auto-push remote (GitHub or any git remote) is
38
+ * configured (`config.auto_push`). Drives the "+ GitHub" line variant AND
39
+ * gates the "Back up to GitHub ↗" action (suppressed once already pushing).
40
+ * Threaded through as a proper boolean so the renderer never has to re-derive
41
+ * "are we pushing?" from display-string content.
42
+ */
43
+ backedUpToRemote: boolean;
44
+ }
45
+
46
+ /** Short TTL for the admin token — used immediately for one loopback call. */
47
+ const MIRROR_READ_TOKEN_TTL_SECONDS = 60;
48
+
49
+ export interface FetchVaultMirrorStatusDeps {
50
+ db: Database;
51
+ /** Hub origin — `iss` of the minted token. */
52
+ hubOrigin: string;
53
+ /** Loopback port the vault backend listens on (from services.json). */
54
+ vaultPort: number;
55
+ /** The user minting against their own admin authority — `sub` of the token. */
56
+ userId: string;
57
+ /** Test seam — `globalThis.fetch` in production. */
58
+ fetchImpl?: typeof fetch;
59
+ /** Test seam — defaults to the real `signAccessToken`. */
60
+ signToken?: typeof signAccessToken;
61
+ /** Test seam for the clock. */
62
+ now?: () => Date;
63
+ }
64
+
65
+ /**
66
+ * Fetch one vault's backup (mirror) status for the friend's tile, or `null` on
67
+ * any failure.
68
+ *
69
+ * Mints a `vault:<name>:admin` bearer for `userId` (capped to that one vault via
70
+ * `vaultScope`) and GETs the vault's loopback mirror endpoint. Never throws —
71
+ * the page renders without the backup line on any error.
72
+ *
73
+ * "Backed up" is true when the persisted config says `enabled` (a version-
74
+ * history mirror) — we read the persisted config, not just the runtime
75
+ * `status.enabled`, so a freshly-configured-but-not-yet-bootstrapped vault still
76
+ * reads as backed up. `backedUpToRemote` is true when an auto-push remote is
77
+ * configured (the GitHub variant of backup).
78
+ */
79
+ export async function fetchVaultMirrorStatus(
80
+ vaultName: string,
81
+ deps: FetchVaultMirrorStatusDeps,
82
+ ): Promise<VaultMirrorStat | null> {
83
+ const fetchImpl = deps.fetchImpl ?? fetch;
84
+ const sign = deps.signToken ?? signAccessToken;
85
+ try {
86
+ const scope = `vault:${vaultName}:admin`;
87
+ const minted = await sign(deps.db, {
88
+ sub: deps.userId,
89
+ scopes: [scope],
90
+ audience: `vault.${vaultName}`,
91
+ clientId: "parachute-account",
92
+ issuer: deps.hubOrigin,
93
+ ttlSeconds: MIRROR_READ_TOKEN_TTL_SECONDS,
94
+ vaultScope: [vaultName],
95
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
96
+ });
97
+ const url = `http://127.0.0.1:${deps.vaultPort}/vault/${vaultName}/.parachute/mirror`;
98
+ const res = await fetchImpl(url, {
99
+ headers: { authorization: `Bearer ${minted.token}`, accept: "application/json" },
100
+ });
101
+ if (!res.ok) return null;
102
+ const body = (await res.json()) as {
103
+ config?: { enabled?: unknown; auto_push?: unknown };
104
+ };
105
+ const enabled = body.config?.enabled;
106
+ if (typeof enabled !== "boolean") return null;
107
+ const backedUpToRemote = body.config?.auto_push === true;
108
+ return { enabled, backedUpToRemote };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Format a mirror stat as the warm, plain-language backup line the tile shows,
116
+ * or `null` when backup is off (the tile then omits the line entirely — we
117
+ * don't nag with a "not backed up" warning on the everyday home).
118
+ *
119
+ * Exported for direct unit testing + reuse by the renderer.
120
+ */
121
+ export function formatMirrorLine(stat: VaultMirrorStat): string | null {
122
+ if (!stat.enabled) return null;
123
+ return stat.backedUpToRemote
124
+ ? "Backed up — version history + GitHub"
125
+ : "Backed up — full version history";
126
+ }
@@ -0,0 +1,381 @@
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 (must FRESHLY CREATE — reject a pre-existing name;
18
+ * attaching the new user to someone else's vault is a cross-tenant breach)
19
+ * 4. createUser (the commit point)
20
+ * 5. consumeInvite — stamp used_at + redeemed_user_id ONLY AFTER (4) commits
21
+ * 6. createSession + cookie + 302
22
+ *
23
+ * Because the invite is consumed only after the user row commits, a
24
+ * createUser exception (UNIQUE collision, disk full, anything) leaves the
25
+ * invite re-usable — the invitee can simply retry. `consumeInvite`'s
26
+ * `used_at IS NULL` guard makes the stamp itself single-use / race-free.
27
+ *
28
+ * What an invite pre-authorizes: EXACTLY one account + the one named/created
29
+ * vault at the baked-in role — NEVER host:admin, NEVER another vault. The new
30
+ * user gets `assignedVaults:[that vault]` with the invite's role; nothing
31
+ * grants admin posture (the first-admin-by-earliest-row heuristic is
32
+ * untouched — an invited user is never the earliest row).
33
+ */
34
+ import type { Database } from "bun:sqlite";
35
+ import { renderAdminError, renderInviteSetup } from "./admin-login-ui.ts";
36
+ import { type RunResult, provisionVault } from "./admin-vaults.ts";
37
+ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
38
+ import {
39
+ InviteExpiredError,
40
+ InviteNotFoundError,
41
+ InviteRevokedError,
42
+ InviteUsedError,
43
+ assertInviteRedeemable,
44
+ consumeInvite,
45
+ } from "./invites.ts";
46
+ import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
47
+ import { isHttpsRequest } from "./request-protocol.ts";
48
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "./sessions.ts";
49
+ import {
50
+ PASSWORD_MAX_LEN,
51
+ UsernameTakenError,
52
+ createUser,
53
+ getUserByUsernameCI,
54
+ validatePassword,
55
+ validateUsername,
56
+ } from "./users.ts";
57
+ import { validateVaultName } from "./vault-name.ts";
58
+
59
+ export interface AccountSetupDeps {
60
+ db: Database;
61
+ /** Hub origin — JWT `iss` for any vault bootstrap mint + the URL base. */
62
+ hubOrigin: string;
63
+ manifestPath?: string;
64
+ /** Test seam: vault provisioning shell-out. */
65
+ runCommand?: (cmd: readonly string[]) => Promise<RunResult>;
66
+ /** Test seam: clock for the rate limiter. */
67
+ now?: () => Date;
68
+ }
69
+
70
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
71
+ return new Response(body, {
72
+ status,
73
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra },
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Map an invite-rejection error to a status + user-facing copy. Not-found →
79
+ * 404 (don't confirm the token shape); expired/used/revoked → 410 Gone (the
80
+ * link existed but is no longer redeemable).
81
+ */
82
+ function rejectInvite(err: unknown): Response {
83
+ if (err instanceof InviteNotFoundError) {
84
+ return htmlResponse(
85
+ renderAdminError({
86
+ title: "Invite not found",
87
+ message:
88
+ "This invite link is not valid. Check that you copied the whole link, or ask your hub operator for a new one.",
89
+ }),
90
+ 404,
91
+ );
92
+ }
93
+ if (err instanceof InviteExpiredError) {
94
+ return htmlResponse(
95
+ renderAdminError({
96
+ title: "Invite expired",
97
+ message: "This invite link has expired. Ask your hub operator for a new one.",
98
+ }),
99
+ 410,
100
+ );
101
+ }
102
+ if (err instanceof InviteUsedError) {
103
+ return htmlResponse(
104
+ renderAdminError({
105
+ title: "Invite already used",
106
+ message:
107
+ "This invite link has already been used to create an account. If that wasn't you, contact your hub operator.",
108
+ }),
109
+ 410,
110
+ );
111
+ }
112
+ if (err instanceof InviteRevokedError) {
113
+ return htmlResponse(
114
+ renderAdminError({
115
+ title: "Invite revoked",
116
+ message: "This invite link has been revoked by your hub operator. Ask them for a new one.",
117
+ }),
118
+ 410,
119
+ );
120
+ }
121
+ // Unexpected — fail closed.
122
+ return htmlResponse(
123
+ renderAdminError({ title: "Invite error", message: "Could not process this invite link." }),
124
+ 500,
125
+ );
126
+ }
127
+
128
+ /** GET /account/setup/<token> — render the claim form (or a rejection page). */
129
+ export function handleAccountSetupGet(
130
+ req: Request,
131
+ rawToken: string,
132
+ deps: AccountSetupDeps,
133
+ ): Response {
134
+ const now = (deps.now ?? (() => new Date()))();
135
+ let invite: ReturnType<typeof assertInviteRedeemable>;
136
+ try {
137
+ invite = assertInviteRedeemable(deps.db, rawToken, now);
138
+ } catch (err) {
139
+ return rejectInvite(err);
140
+ }
141
+ const csrf = ensureCsrfToken(req);
142
+ const setCookie: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
143
+ return htmlResponse(
144
+ renderInviteSetup({
145
+ token: rawToken,
146
+ csrfToken: csrf.token,
147
+ pinnedVaultName: invite.vaultName,
148
+ provisionVault: invite.provisionVault,
149
+ }),
150
+ 200,
151
+ setCookie,
152
+ );
153
+ }
154
+
155
+ /** POST /account/setup/<token> — redeem the invite (see file docstring for ordering). */
156
+ export async function handleAccountSetupPost(
157
+ req: Request,
158
+ rawToken: string,
159
+ deps: AccountSetupDeps,
160
+ ): Promise<Response> {
161
+ const now = (deps.now ?? (() => new Date()))();
162
+
163
+ // (1) Look up + validate the invite BEFORE any work.
164
+ let invite: ReturnType<typeof assertInviteRedeemable>;
165
+ try {
166
+ invite = assertInviteRedeemable(deps.db, rawToken, now);
167
+ } catch (err) {
168
+ return rejectInvite(err);
169
+ }
170
+
171
+ // CSRF — double-submit, same shape as /account/change-password + /login.
172
+ const form = await req.formData();
173
+ const formCsrf = form.get(CSRF_FIELD_NAME);
174
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
175
+ return htmlResponse(
176
+ renderAdminError({
177
+ title: "Invalid form submission",
178
+ message: "The form's CSRF token did not match. Reload the page and try again.",
179
+ }),
180
+ 400,
181
+ );
182
+ }
183
+
184
+ // Rate limit — reuse the /login IP bucket so a redeem flood and a login
185
+ // flood share the same throttle. After CSRF (so a junk cross-site POST
186
+ // doesn't burn the bucket), before any account/vault work.
187
+ const clientIp = clientIpFromRequest(req);
188
+ const gate = checkAndRecord(clientIp, now);
189
+ if (!gate.allowed) {
190
+ return htmlResponse(
191
+ renderAdminError({
192
+ title: "Too many attempts",
193
+ message: `Please wait ${gate.retryAfterSeconds ?? 60} seconds and try again.`,
194
+ }),
195
+ 429,
196
+ );
197
+ }
198
+
199
+ const username = String(form.get("username") ?? "").trim();
200
+ const password = String(form.get("password") ?? "");
201
+ const confirm = String(form.get("password_confirm") ?? "");
202
+
203
+ const csrf = ensureCsrfToken(req);
204
+ const setCookie: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
205
+ // Re-render the form with an inline error, preserving what the invitee typed.
206
+ const rerender = (status: number, message: string, vaultNameEcho?: string): Response =>
207
+ htmlResponse(
208
+ renderInviteSetup({
209
+ token: rawToken,
210
+ csrfToken: csrf.token,
211
+ pinnedVaultName: invite.vaultName,
212
+ provisionVault: invite.provisionVault,
213
+ username,
214
+ ...(vaultNameEcho !== undefined ? { vaultName: vaultNameEcho } : {}),
215
+ errorMessage: message,
216
+ }),
217
+ status,
218
+ setCookie,
219
+ );
220
+
221
+ // (2) Validate credentials with the SAME validators as /api/users.
222
+ const u = validateUsername(username);
223
+ if (!u.valid) {
224
+ return rerender(
225
+ 400,
226
+ "Username must be 2–32 lowercase letters, digits, _ or - (and not a reserved word).",
227
+ );
228
+ }
229
+ if (password.length > PASSWORD_MAX_LEN) {
230
+ return rerender(413, `Password must be ≤ ${PASSWORD_MAX_LEN} characters.`);
231
+ }
232
+ const p = validatePassword(password);
233
+ if (!p.valid) {
234
+ return rerender(400, "Password must be at least 12 characters.");
235
+ }
236
+ if (password !== confirm) {
237
+ return rerender(400, "The two passwords don't match.");
238
+ }
239
+ // Case-insensitive uniqueness — same gate as /api/users.
240
+ if (getUserByUsernameCI(deps.db, username) !== null) {
241
+ return rerender(409, `The username "${username}" is already taken. Pick another.`);
242
+ }
243
+
244
+ // Resolve the vault name: pinned by the invite, or chosen by the invitee.
245
+ // The invitee-chosen name goes through the FULL `validateVaultName` (the
246
+ // same 2–32 + charset + reserved contract vault's init enforces), not just
247
+ // the charset regex — otherwise a 33–64 char name slips past here and fails
248
+ // at the vault CLI with a generic provision error.
249
+ let vaultName: string | null = null;
250
+ if (invite.provisionVault) {
251
+ if (invite.vaultName !== null) {
252
+ vaultName = invite.vaultName;
253
+ } else {
254
+ // Unpinned name: the invitee names their own vault. The field is
255
+ // OPTIONAL (no-JS server-side default) — a blank submission defaults
256
+ // the vault name to the chosen username. Either way the resolved name
257
+ // runs through the full validator; a username that isn't a valid vault
258
+ // name (e.g. too long, disallowed chars) re-renders asking for an
259
+ // explicit vault name with the validator's error.
260
+ const submitted = String(form.get("vault_name") ?? "").trim();
261
+ const chosen = submitted === "" ? username : submitted;
262
+ const v = validateVaultName(chosen);
263
+ if (!v.ok) {
264
+ // Echo the resolved name only if the invitee typed one; a blank
265
+ // (username-derived) failure shouldn't pre-fill the vault box.
266
+ return rerender(400, v.error, submitted === "" ? undefined : submitted);
267
+ }
268
+ vaultName = v.name;
269
+ }
270
+ } else if (invite.vaultName !== null) {
271
+ // UNSUPPORTED shared-vault case: an account-only invite (provision_vault
272
+ // =false) that pins an EXISTING vault would assign the new user owner-
273
+ // admin on a pre-existing vault — a cross-tenant breach, and the owner-
274
+ // vs-shared role split isn't built. The admin API rejects creating such
275
+ // an invite (defense in depth); reject here too in case one slipped
276
+ // through. The legit account-only invite (vaultName === null) is unaffected.
277
+ return rerender(
278
+ 400,
279
+ "This invite is not supported (shared-vault invites aren't available yet). Ask your hub operator for a new one.",
280
+ );
281
+ }
282
+
283
+ // (3) Provision the vault — must FRESHLY CREATE it (see the security
284
+ // invariant on the `!provisioned.created` check below). Routed through the
285
+ // SAME createVault path the wizard/SPA use, so the new vault gets the §3
286
+ // internal-live-mirror default for free. Done BEFORE createUser so a
287
+ // provisioning failure doesn't leave a vault-less account; the invite is
288
+ // still unconsumed at this point, so the invitee can retry.
289
+ if (invite.provisionVault && vaultName !== null) {
290
+ const provisioned = await provisionVault(vaultName, {
291
+ issuer: deps.hubOrigin,
292
+ ...(deps.manifestPath !== undefined ? { manifestPath: deps.manifestPath } : {}),
293
+ ...(deps.runCommand !== undefined ? { runCommand: deps.runCommand } : {}),
294
+ ...(invite.defaultMirror !== null ? { defaultMirror: invite.defaultMirror } : {}),
295
+ });
296
+ if (!provisioned.ok) {
297
+ return rerender(
298
+ provisioned.status === 400 ? 400 : 502,
299
+ provisioned.status === 400
300
+ ? provisioned.message
301
+ : "Could not provision your vault. Please try again, or ask your hub operator.",
302
+ invite.vaultName === null ? (vaultName ?? undefined) : undefined,
303
+ );
304
+ }
305
+ // SECURITY INVARIANT: an invite redeem may grant access ONLY to a vault
306
+ // that was FRESHLY CREATED during this redeem. `provisionVault` returns
307
+ // `created:true` (HTTP 201) for a new vault, `created:false` (HTTP 200)
308
+ // when the name ALREADY EXISTS — in which case it hands back SOMEONE
309
+ // ELSE'S vault. Attaching the new user there as owner would be a cross-
310
+ // tenant breach. Reject any non-created (already-existing) result; the
311
+ // invitee must choose a different, unused name. This also closes the
312
+ // concurrent-redeem race on a new name: first redeem 201, second 200 →
313
+ // second rejected.
314
+ if (!provisioned.created) {
315
+ return rerender(
316
+ 409,
317
+ `A vault named "${vaultName}" already exists. Choose a different name.`,
318
+ // Echo the typed name only if the invitee chose it explicitly; a
319
+ // username-derived collision shouldn't pre-fill the vault box.
320
+ invite.vaultName === null && String(form.get("vault_name") ?? "").trim() !== ""
321
+ ? vaultName
322
+ : undefined,
323
+ );
324
+ }
325
+ }
326
+
327
+ // (4) Create the user + consume the invite ATOMICALLY — the COMMIT POINT.
328
+ // The invite is consumed INSIDE createUser's transaction (the `withinTx`
329
+ // hook), so the two single-use guarantees compose:
330
+ //
331
+ // - Single-use under concurrency: two redeems of one invite both pass
332
+ // `assertInviteRedeemable` above, but only one's `consumeInvite` UPDATE
333
+ // (`used_at IS NULL AND revoked_at IS NULL`) changes a row. The loser's
334
+ // hook throws `InviteUsedError`, which rolls back ITS user insert — no
335
+ // orphan account. Exactly one account results.
336
+ // - Re-usable on failure: if createUser throws (UNIQUE collision, etc.)
337
+ // before the hook, the invite was never touched; if the hook itself
338
+ // throws (lost race), the consume + the user insert roll back together.
339
+ // Either way nothing commits, so the invite stays re-usable.
340
+ //
341
+ // passwordChanged: TRUE (the invitee chose their own password → no force-
342
+ // change). assignedVaults pins them to exactly their one vault at the
343
+ // invite's role. allowMulti because the first admin already exists.
344
+ let userId: string;
345
+ try {
346
+ const created = await createUser(deps.db, username, password, {
347
+ allowMulti: true,
348
+ passwordChanged: true,
349
+ assignedVaults: vaultName !== null ? [vaultName] : [],
350
+ role: invite.role,
351
+ withinTx: (newUserId) => {
352
+ if (!consumeInvite(deps.db, invite.tokenHash, newUserId, now)) {
353
+ // Lost the redeem race (or a concurrent revoke landed). Throw to
354
+ // roll back this user insert; surfaced as the used/410 path below.
355
+ throw new InviteUsedError();
356
+ }
357
+ },
358
+ });
359
+ userId = created.id;
360
+ } catch (err) {
361
+ if (err instanceof InviteUsedError) {
362
+ return rejectInvite(err);
363
+ }
364
+ if (err instanceof UsernameTakenError) {
365
+ return rerender(409, `The username "${username}" is already taken. Pick another.`);
366
+ }
367
+ const msg = err instanceof Error ? err.message : String(err);
368
+ console.warn(`[account-setup] createUser failed for "${username}": ${msg}`);
369
+ return rerender(500, "Could not create your account. Please try again.");
370
+ }
371
+
372
+ // (6) Sign the invitee in + land them on /account/.
373
+ const session = createSession(deps.db, { userId });
374
+ const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
375
+ secure: isHttpsRequest(req),
376
+ });
377
+ return new Response(null, {
378
+ status: 302,
379
+ headers: { location: "/account/", "cache-control": "no-store", "set-cookie": sessionCookie },
380
+ });
381
+ }
@@ -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
+ }