@openparachute/hub 0.6.3 → 0.6.4-rc.2
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__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/account-home-ui.ts
CHANGED
|
@@ -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 } =
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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,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
|
+
}
|