@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.
- package/package.json +1 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- 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 +236 -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 +195 -3
- 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__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -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__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- 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 +36 -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 +118 -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 +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- 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/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -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 +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- 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
|
@@ -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
|
+
}
|