@openparachute/hub 0.6.3 → 0.6.4-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +609 -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 +180 -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 +342 -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 +94 -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 +347 -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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /account/vault-admin-token/<name>` — friend-facing vault ADMIN deep-link.
|
|
3
|
+
*
|
|
4
|
+
* The non-admin sibling of `GET /admin/vault-admin-token/<name>`
|
|
5
|
+
* (`admin-vault-admin-token.ts`). Both mint a `vault:<name>:admin` JWT and
|
|
6
|
+
* hand it to the vault's own admin SPA via a `#token=<jwt>` URL fragment — the
|
|
7
|
+
* SPA reads `location.hash` on bootstrap, then strips it. The admin sibling is
|
|
8
|
+
* gated `isFirstAdmin` and returns JSON for the hub SPA's fetch; THIS surface
|
|
9
|
+
* is gated on ASSIGNMENT (an individual user holds `admin` on a vault they're
|
|
10
|
+
* assigned to) and is a server-rendered POST → 303 redirect, matching the
|
|
11
|
+
* no-JS posture of the rest of `/account/*`.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists: an assigned user is ALREADY authorized for `vault:<name>:admin`
|
|
14
|
+
* (`vaultVerbsForUserVault` returns `["read","write","admin"]` for their vault,
|
|
15
|
+
* 2026-05-30; vault gates mirror/backup config + token rotation on that scope).
|
|
16
|
+
* The authority existed; the only missing piece was a friend-reachable HTTP
|
|
17
|
+
* unlock + a button. With this, an individual user can open their vault's admin
|
|
18
|
+
* SPA — token mint/revoke, **and the Git-backup / mirror config** — without
|
|
19
|
+
* host-admin. The deep-link lands on the vault admin home (`managementUrl`,
|
|
20
|
+
* `/admin/` by default), whose "Git backup →" link reaches `VaultMirror`.
|
|
21
|
+
*
|
|
22
|
+
* Authorization — the same three-gate spine as `/account/vault-token/<name>`
|
|
23
|
+
* (`account-vault-token.ts`), here pinned to the single `admin` verb:
|
|
24
|
+
*
|
|
25
|
+
* 1. **Session.** A valid hub session cookie (the user's). No session → 401.
|
|
26
|
+
* 2. **Assignment.** `<name>` MUST be one of the user's `user_vaults`
|
|
27
|
+
* assignments AND that assignment's role must grant `admin`. Read via
|
|
28
|
+
* `vaultVerbsForUserVault` (`null` for an unassigned vault → 403; a role
|
|
29
|
+
* that doesn't grant `admin` → 403). The first admin (empty
|
|
30
|
+
* `assignedVaults`, no `user_vaults` rows) gets `null` here too — by
|
|
31
|
+
* design, the same as `/account/vault-token`: admins use the SPA's
|
|
32
|
+
* `/admin/vault-admin-token` path, not this friend surface.
|
|
33
|
+
*
|
|
34
|
+
* CSRF: double-submit cookie, same `__csrf` field + `verifyCsrfToken` as
|
|
35
|
+
* `/account/vault-token`, `/account/change-password`, and `/logout`. A
|
|
36
|
+
* cross-site POST without the matching cookie/form token → 400.
|
|
37
|
+
*
|
|
38
|
+
* Force-change-password gate (item F / hub#469): if `!user.passwordChanged`,
|
|
39
|
+
* 303 → `/account/change-password` BEFORE minting — identical to the gate
|
|
40
|
+
* `/account/vault-token` applies post-#550. An admin-created/reset user lands
|
|
41
|
+
* with a temp password and `password_changed: false`; without this gate they
|
|
42
|
+
* could mint a `vault:<name>:admin` deep-link token (10-min TTL, but still) and
|
|
43
|
+
* keep using the temp password instead of rotating it. Placed AFTER the
|
|
44
|
+
* authority gates (so an unassigned/garbage request still gets its 403/400) and
|
|
45
|
+
* BEFORE the mint. This does NOT reintroduce the bypass #469 closed.
|
|
46
|
+
*
|
|
47
|
+
* Mint: `signAccessToken` (the same machinery the OAuth issuer + admin paths
|
|
48
|
+
* use) with `scopes: ["vault:<name>:admin"]`, `audience: "vault.<name>"`,
|
|
49
|
+
* `iss`: the hub origin, `sub`: the user id, `vaultScope: [<name>]`, and the
|
|
50
|
+
* SAME short 10-min TTL as the admin sibling (`VAULT_ADMIN_TOKEN_TTL_SECONDS`):
|
|
51
|
+
* this is a deep-link bootstrap token, not a long-lived headless credential
|
|
52
|
+
* (that's `/account/vault-token`). A `tokens` registry row records it
|
|
53
|
+
* (`created_via='cli_mint'`, `userId` = the user) so it's revocable.
|
|
54
|
+
*
|
|
55
|
+
* Response: 303 → `<vault-url><managementUrl>#token=<jwt>`. 303 (See Other) so
|
|
56
|
+
* the browser re-issues the navigation as GET. The token rides the URL
|
|
57
|
+
* fragment, which is never sent to the server — same contract the hub SPA's
|
|
58
|
+
* `VaultsList` "Manage" button uses (vault PR #219).
|
|
59
|
+
*/
|
|
60
|
+
import type { Database } from "bun:sqlite";
|
|
61
|
+
import { renderAdminError } from "./admin-login-ui.ts";
|
|
62
|
+
import { VAULT_ADMIN_TOKEN_TTL_SECONDS } from "./admin-vault-admin-token.ts";
|
|
63
|
+
import { CSRF_FIELD_NAME, verifyCsrfToken } from "./csrf.ts";
|
|
64
|
+
import { recordTokenMint, signAccessToken } from "./jwt-sign.ts";
|
|
65
|
+
import { findActiveSession } from "./sessions.ts";
|
|
66
|
+
import { getUserById, vaultVerbsForUserVault } from "./users.ts";
|
|
67
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
68
|
+
|
|
69
|
+
/** Lowercase-only vault-name charset — single source of truth in vault-name.ts. */
|
|
70
|
+
const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
|
|
71
|
+
/** client_id stamped on the minted JWT + registry row. Matches the admin sibling. */
|
|
72
|
+
const ACCOUNT_VAULT_ADMIN_CLIENT_ID = "parachute-hub-spa";
|
|
73
|
+
|
|
74
|
+
export interface AccountVaultAdminTokenDeps {
|
|
75
|
+
db: Database;
|
|
76
|
+
/** Hub origin for this request — `iss` of the minted token + base of the redirect URL. */
|
|
77
|
+
hubOrigin: string;
|
|
78
|
+
/**
|
|
79
|
+
* The vault's declared `managementUrl` (from its `.parachute/module.json`),
|
|
80
|
+
* resolved by the route handler at request time. Either an absolute URL or a
|
|
81
|
+
* path relative to the vault's mounted URL. Defaults to `/admin/` (vault's
|
|
82
|
+
* canonical value) when the handler can't resolve one — that's where the
|
|
83
|
+
* admin sibling's deep-link lands too.
|
|
84
|
+
*/
|
|
85
|
+
managementUrl?: string;
|
|
86
|
+
/** Test seam for the clock (mint). */
|
|
87
|
+
now?: () => Date;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
91
|
+
return new Response(body, {
|
|
92
|
+
status,
|
|
93
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a vault's `managementUrl` against the vault's hub-mounted URL.
|
|
99
|
+
* Absolute URL → returned verbatim; path → joined onto the vault URL after
|
|
100
|
+
* trimming a trailing slash. Mirrors `resolveManagementUrl` in the SPA's
|
|
101
|
+
* `web/ui/src/lib/api.ts` so hub-server and SPA deep-links agree.
|
|
102
|
+
*/
|
|
103
|
+
function resolveManagementUrl(vaultUrl: string, managementUrl: string): string {
|
|
104
|
+
if (/^https?:\/\//i.test(managementUrl)) return managementUrl;
|
|
105
|
+
const base = vaultUrl.replace(/\/+$/, "");
|
|
106
|
+
const tail = managementUrl.startsWith("/") ? managementUrl : `/${managementUrl}`;
|
|
107
|
+
return `${base}${tail}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function handleAccountVaultAdminTokenPost(
|
|
111
|
+
req: Request,
|
|
112
|
+
vaultName: string,
|
|
113
|
+
deps: AccountVaultAdminTokenDeps,
|
|
114
|
+
): Promise<Response> {
|
|
115
|
+
if (req.method !== "POST") {
|
|
116
|
+
return htmlResponse("method not allowed", 405);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Gate 1 — session. No identity, no mint.
|
|
120
|
+
const session = findActiveSession(deps.db, req);
|
|
121
|
+
if (!session) {
|
|
122
|
+
return htmlResponse(
|
|
123
|
+
renderAdminError({
|
|
124
|
+
title: "Not signed in",
|
|
125
|
+
message: "Please sign in before opening your vault's admin tools.",
|
|
126
|
+
}),
|
|
127
|
+
401,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const user = getUserById(deps.db, session.userId);
|
|
131
|
+
if (!user) {
|
|
132
|
+
return htmlResponse(
|
|
133
|
+
renderAdminError({
|
|
134
|
+
title: "Account not found",
|
|
135
|
+
message: "The signed-in account no longer exists. Please sign in again.",
|
|
136
|
+
}),
|
|
137
|
+
401,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// CSRF — verify before any state change, same shape + 400 as the other
|
|
142
|
+
// `/account/*` POSTs.
|
|
143
|
+
const form = await req.formData();
|
|
144
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
145
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
146
|
+
return htmlResponse(
|
|
147
|
+
renderAdminError({
|
|
148
|
+
title: "Invalid form submission",
|
|
149
|
+
message: "The form's CSRF token did not match. Reload the page and try again.",
|
|
150
|
+
}),
|
|
151
|
+
400,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Vault-name shape guard — reject anything that can't be a services.json key
|
|
156
|
+
// before any DB / authority work. Same posture as the other vault-token mints.
|
|
157
|
+
if (!VAULT_NAME_RE.test(vaultName)) {
|
|
158
|
+
return htmlResponse(
|
|
159
|
+
renderAdminError({
|
|
160
|
+
title: "Invalid vault name",
|
|
161
|
+
message: `"${vaultName}" is not a valid vault name.`,
|
|
162
|
+
}),
|
|
163
|
+
400,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Gate 2 — assignment + admin-verb cap. `vaultVerbsForUserVault` returns:
|
|
168
|
+
// - null → no assignment for this vault → 403.
|
|
169
|
+
// - [...] → the verbs the assignment role permits; must include `admin`.
|
|
170
|
+
// Assigned users hold read/write/admin on their vault (2026-05-30); the first
|
|
171
|
+
// admin has no `user_vaults` rows so gets `null` here and a 403 — by design,
|
|
172
|
+
// admins use the SPA path. This is the cap to the user's actual authority.
|
|
173
|
+
const allowed = vaultVerbsForUserVault(deps.db, user.id, vaultName);
|
|
174
|
+
if (allowed === null || !allowed.includes("admin")) {
|
|
175
|
+
return htmlResponse(
|
|
176
|
+
renderAdminError({
|
|
177
|
+
title: "Not your vault to manage",
|
|
178
|
+
message: `You don't have admin access to a vault named "${vaultName}". Ask the hub operator if you think this is wrong.`,
|
|
179
|
+
}),
|
|
180
|
+
403,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Force-change-password gate (item F / hub#469). An admin-created/reset user
|
|
185
|
+
// with `password_changed: false` is sent to the change-password rail BEFORE
|
|
186
|
+
// minting — same gate `/account/vault-token` applies, so a temp-password
|
|
187
|
+
// handoff can't be parlayed into a vault-admin deep-link. Placed AFTER the
|
|
188
|
+
// authority gates (an unassigned/garbage request still gets its 403/400) and
|
|
189
|
+
// BEFORE the mint. 303 so the browser re-issues as GET. Does NOT reintroduce
|
|
190
|
+
// the bypass #469 closed.
|
|
191
|
+
if (!user.passwordChanged) {
|
|
192
|
+
return new Response(null, {
|
|
193
|
+
status: 303,
|
|
194
|
+
headers: { location: "/account/change-password", "cache-control": "no-store" },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Mint the vault-admin deep-link token. Short TTL (10 min, matching the admin
|
|
199
|
+
// sibling) — it's a bootstrap token the vault SPA trades for its session, not
|
|
200
|
+
// a long-lived headless credential (that's `/account/vault-token`).
|
|
201
|
+
const scope = `vault:${vaultName}:admin`;
|
|
202
|
+
const audience = `vault.${vaultName}`;
|
|
203
|
+
const minted = await signAccessToken(deps.db, {
|
|
204
|
+
sub: user.id,
|
|
205
|
+
scopes: [scope],
|
|
206
|
+
audience,
|
|
207
|
+
clientId: ACCOUNT_VAULT_ADMIN_CLIENT_ID,
|
|
208
|
+
issuer: deps.hubOrigin,
|
|
209
|
+
ttlSeconds: VAULT_ADMIN_TOKEN_TTL_SECONDS,
|
|
210
|
+
vaultScope: [vaultName],
|
|
211
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
recordTokenMint(deps.db, {
|
|
215
|
+
jti: minted.jti,
|
|
216
|
+
createdVia: "cli_mint",
|
|
217
|
+
subject: user.id,
|
|
218
|
+
// Anchor the registry row to the user's id so the operator's token registry
|
|
219
|
+
// + the revocation list attribute it correctly.
|
|
220
|
+
userId: user.id,
|
|
221
|
+
clientId: ACCOUNT_VAULT_ADMIN_CLIENT_ID,
|
|
222
|
+
scopes: [scope],
|
|
223
|
+
expiresAt: minted.expiresAt,
|
|
224
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Build the redirect target: <vault-url><managementUrl>#token=<jwt>. The
|
|
228
|
+
// vault URL is the hub-mounted path (`<hubOrigin>/vault/<name>`); the
|
|
229
|
+
// managementUrl (default `/admin/`) is the vault admin SPA entry point. The
|
|
230
|
+
// JWT rides the URL fragment — never sent to the server — exactly as the hub
|
|
231
|
+
// SPA's "Manage" button does (vault PR #219).
|
|
232
|
+
const trimmedOrigin = deps.hubOrigin.replace(/\/+$/, "");
|
|
233
|
+
const vaultUrl = `${trimmedOrigin}/vault/${vaultName}`;
|
|
234
|
+
const target = resolveManagementUrl(vaultUrl, deps.managementUrl ?? "/admin/");
|
|
235
|
+
const sep = target.includes("#") ? "&" : "#";
|
|
236
|
+
const location = `${target}${sep}token=${minted.token}`;
|
|
237
|
+
|
|
238
|
+
return new Response(null, {
|
|
239
|
+
status: 303,
|
|
240
|
+
headers: { location, "cache-control": "no-store" },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -75,9 +75,15 @@ import { vaultTokenMintRateLimiter } from "./rate-limit.ts";
|
|
|
75
75
|
import { findActiveSession } from "./sessions.ts";
|
|
76
76
|
import { isTotpEnrolled } from "./two-factor-store.ts";
|
|
77
77
|
import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } from "./users.ts";
|
|
78
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
78
79
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Lowercase-only vault-name charset (item I) — single source of truth in
|
|
82
|
+
* vault-name.ts, matching what vault's init enforces. Was `[a-zA-Z0-9_-]`; the
|
|
83
|
+
* uppercase superset let a mint name drift from vault's lowercase URL-derived
|
|
84
|
+
* name, so the minted token's `vault.<Name>` audience wouldn't validate.
|
|
85
|
+
*/
|
|
86
|
+
const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
|
|
81
87
|
/** Verbs this surface will ever mint. `admin` is deliberately absent. */
|
|
82
88
|
const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write", "admin"];
|
|
83
89
|
/** client_id stamped on the minted JWT + registry row. */
|
|
@@ -233,6 +239,25 @@ export async function handleAccountVaultTokenPost(
|
|
|
233
239
|
});
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
// Force-change-password gate (item F / hub#469, NARROW). A user the admin
|
|
243
|
+
// created/reset lands with `password_changed: false` and an admin-known temp
|
|
244
|
+
// password. Without this gate an authorized friend could mint a LONG-LIVED
|
|
245
|
+
// vault token here and then keep using (or never rotate) the temp password —
|
|
246
|
+
// the token outlives any later rotation, defeating the "temp password is a
|
|
247
|
+
// one-time handoff" model. So an authorized-but-unrotated friend is sent to
|
|
248
|
+
// the change-password rail BEFORE minting anything. Placed AFTER the
|
|
249
|
+
// authority gates (so an unassigned/garbage request still gets its 403/400,
|
|
250
|
+
// preserving those semantics) and BEFORE the rate-limit + mint. 303 (See
|
|
251
|
+
// Other) so the browser re-issues as GET. Narrow #469 fix — gates
|
|
252
|
+
// token-minting specifically; the broad per-request /account/* wall is
|
|
253
|
+
// deferred to Aaron's design call.
|
|
254
|
+
if (!user.passwordChanged) {
|
|
255
|
+
return new Response(null, {
|
|
256
|
+
status: 303,
|
|
257
|
+
headers: { location: "/account/change-password", "cache-control": "no-store" },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
236
261
|
// Rate limit — after CSRF + authority shape, before the mint. Per-user.
|
|
237
262
|
const rlNow = (deps.now ?? (() => new Date()))();
|
|
238
263
|
const gate = vaultTokenMintRateLimiter.checkAndRecord(user.id, rlNow);
|
package/src/admin-login-ui.ts
CHANGED
|
@@ -152,6 +152,100 @@ export function renderTotpChallenge(props: TotpChallengeProps): string {
|
|
|
152
152
|
return baseDocument("Two-factor authentication — Parachute", body);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// --- invite redemption ( /account/setup/<token> ) --------------------------
|
|
156
|
+
|
|
157
|
+
export interface InviteSetupProps {
|
|
158
|
+
/** The raw token from the URL — POSTs back to the same path. */
|
|
159
|
+
token: string;
|
|
160
|
+
csrfToken: string;
|
|
161
|
+
/**
|
|
162
|
+
* When the invite pins a vault name the redeemer can't choose one — we show
|
|
163
|
+
* it read-only. When null the redeemer names their own vault (a text field).
|
|
164
|
+
*/
|
|
165
|
+
pinnedVaultName: string | null;
|
|
166
|
+
/** Whether redemption provisions a vault at all (shows the vault row iff true). */
|
|
167
|
+
provisionVault: boolean;
|
|
168
|
+
username?: string;
|
|
169
|
+
vaultName?: string;
|
|
170
|
+
errorMessage?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Server-rendered "claim your invite" form for `/account/setup/<token>`.
|
|
175
|
+
* Stands alone without the SPA (same posture as `/login` + the setup wizard):
|
|
176
|
+
* a brand-new invitee hits this with no session and no JS. Posts back to the
|
|
177
|
+
* same `/account/setup/<token>` path. Reuses the shared login chrome.
|
|
178
|
+
*/
|
|
179
|
+
export function renderInviteSetup(props: InviteSetupProps): string {
|
|
180
|
+
const { token, csrfToken, pinnedVaultName, provisionVault, username, vaultName, errorMessage } =
|
|
181
|
+
props;
|
|
182
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
183
|
+
const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
|
|
184
|
+
|
|
185
|
+
// Vault row: shown only when the invite provisions a vault. Pinned → a
|
|
186
|
+
// read-only display of the name the admin chose (redeemer can't squat names).
|
|
187
|
+
// Unpinned → a text field the redeemer fills.
|
|
188
|
+
let vaultRow = "";
|
|
189
|
+
if (provisionVault) {
|
|
190
|
+
if (pinnedVaultName !== null) {
|
|
191
|
+
vaultRow = `
|
|
192
|
+
<label class="field">
|
|
193
|
+
<span class="field-label">Your vault</span>
|
|
194
|
+
<input type="text" value="${escapeAttr(pinnedVaultName)}" readonly disabled />
|
|
195
|
+
<span class="field-hint">Your hub operator named this vault for you.</span>
|
|
196
|
+
</label>`;
|
|
197
|
+
} else {
|
|
198
|
+
const vaultAttr = vaultName ? ` value="${escapeAttr(vaultName)}"` : "";
|
|
199
|
+
vaultRow = `
|
|
200
|
+
<label class="field">
|
|
201
|
+
<span class="field-label">Name your vault</span>
|
|
202
|
+
<input type="text" name="vault_name" autocomplete="off"
|
|
203
|
+
required minlength="2" maxlength="32"
|
|
204
|
+
pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
|
|
205
|
+
spellcheck="false" autocapitalize="off"${vaultAttr} />
|
|
206
|
+
<span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code> (2–32 chars)</span>
|
|
207
|
+
</label>`;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const body = `
|
|
212
|
+
<div class="card">
|
|
213
|
+
<div class="card-header">
|
|
214
|
+
${header()}
|
|
215
|
+
<h1>Claim your invite</h1>
|
|
216
|
+
<p class="subtitle">Pick a username and password to create your Parachute account${
|
|
217
|
+
provisionVault ? " and your own vault" : ""
|
|
218
|
+
}.</p>
|
|
219
|
+
</div>
|
|
220
|
+
${error}
|
|
221
|
+
<form method="POST" action="/account/setup/${escapeAttr(token)}" class="auth-form">
|
|
222
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
223
|
+
<label class="field">
|
|
224
|
+
<span class="field-label">Username</span>
|
|
225
|
+
<input type="text" name="username" autocomplete="username" autofocus
|
|
226
|
+
required minlength="2" maxlength="32"
|
|
227
|
+
pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
|
|
228
|
+
spellcheck="false" autocapitalize="off"${usernameAttr} />
|
|
229
|
+
<span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
|
|
230
|
+
</label>
|
|
231
|
+
<label class="field">
|
|
232
|
+
<span class="field-label">Password</span>
|
|
233
|
+
<input type="password" name="password" autocomplete="new-password"
|
|
234
|
+
required minlength="12" />
|
|
235
|
+
<span class="field-hint">at least 12 characters</span>
|
|
236
|
+
</label>
|
|
237
|
+
<label class="field">
|
|
238
|
+
<span class="field-label">Confirm password</span>
|
|
239
|
+
<input type="password" name="password_confirm" autocomplete="new-password"
|
|
240
|
+
required minlength="12" />
|
|
241
|
+
</label>
|
|
242
|
+
${vaultRow}
|
|
243
|
+
<button type="submit" class="btn btn-primary">Create my account</button>
|
|
244
|
+
</form>
|
|
245
|
+
</div>`;
|
|
246
|
+
return baseDocument("Claim your invite — Parachute", body);
|
|
247
|
+
}
|
|
248
|
+
|
|
155
249
|
// --- error page ------------------------------------------------------------
|
|
156
250
|
|
|
157
251
|
export function renderAdminError(props: { title: string; message: string }): string {
|
|
@@ -28,13 +28,19 @@ import type { Database } from "bun:sqlite";
|
|
|
28
28
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
29
29
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
30
30
|
import { isFirstAdmin } from "./users.ts";
|
|
31
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
31
32
|
|
|
32
33
|
/** Short TTL — matches host-admin-token. SPA re-fetches on near-expiry. */
|
|
33
34
|
export const VAULT_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
|
|
34
35
|
const VAULT_ADMIN_CLIENT_ID = "parachute-hub-spa";
|
|
35
36
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Lowercase-only vault-name charset (item I) — single source of truth in
|
|
39
|
+
* vault-name.ts, matching vault's init. Keeps URL-injection out AND closes the
|
|
40
|
+
* case-drift class (an uppercased name minting `vault.<Name>` audience that
|
|
41
|
+
* vault's lowercase URL-derived name would never validate).
|
|
42
|
+
*/
|
|
43
|
+
const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
|
|
38
44
|
|
|
39
45
|
export interface MintVaultAdminTokenDeps {
|
|
40
46
|
db: Database;
|
package/src/admin-vaults.ts
CHANGED
|
@@ -59,6 +59,8 @@ import type { Database } from "bun:sqlite";
|
|
|
59
59
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
60
60
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
61
61
|
import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
62
|
+
import { enrichedPath } from "./spawn-path.ts";
|
|
63
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
62
64
|
import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
63
65
|
|
|
64
66
|
/** Scope required to call POST /vaults. */
|
|
@@ -72,7 +74,12 @@ export const HOST_ADMIN_SCOPE = "parachute:host:admin";
|
|
|
72
74
|
* the hub rejects them at the API edge before a vault under those names
|
|
73
75
|
* can register and capture the proxy path.
|
|
74
76
|
*/
|
|
75
|
-
|
|
77
|
+
// Lowercase-only (item I) — single source of truth in vault-name.ts. Vault's
|
|
78
|
+
// init enforces `[a-z0-9_-]`; a hub-side `[a-zA-Z0-9_-]` superset let an
|
|
79
|
+
// uppercased name through that vault would then lowercase or reject, drifting
|
|
80
|
+
// the hub's idea of the vault from vault's. The hub-only reservations (`new`,
|
|
81
|
+
// `assets`) shadow SPA routes and stay on top of vault's `list`.
|
|
82
|
+
const VAULT_NAME_PATTERN = VAULT_NAME_CHARSET_RE;
|
|
76
83
|
const RESERVED_VAULT_NAMES = new Set(["list", "new", "assets"]);
|
|
77
84
|
|
|
78
85
|
export interface CreateVaultRequest {
|
|
@@ -164,7 +171,7 @@ async function parseBody(req: Request): Promise<ParseResult | ParseError> {
|
|
|
164
171
|
return {
|
|
165
172
|
ok: false,
|
|
166
173
|
status: 400,
|
|
167
|
-
message: "vault name must contain only letters, numbers, hyphens, and underscores",
|
|
174
|
+
message: "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
|
|
168
175
|
};
|
|
169
176
|
}
|
|
170
177
|
if (RESERVED_VAULT_NAMES.has(name)) {
|
|
@@ -230,9 +237,13 @@ function buildEntry(
|
|
|
230
237
|
async function defaultRunCommand(cmd: readonly string[]): Promise<RunResult> {
|
|
231
238
|
// Inherit env so the child sees PATH, HOME, BUN_INSTALL, etc. Bun.spawn
|
|
232
239
|
// defaults to empty env — see api-modules-ops.ts:defaultRun for the rationale.
|
|
240
|
+
// PATH enrichment (hub launchd-PATH regression): a launchd-managed hub bakes
|
|
241
|
+
// a minimal PATH, so this shell-out (vault ops) inherits that thin PATH too;
|
|
242
|
+
// enrich it with operator-tool dirs (`$HOME/.local/bin`, brew bin). See
|
|
243
|
+
// `spawn-path.ts`.
|
|
233
244
|
const proc = Bun.spawn([...cmd], {
|
|
234
245
|
stdio: ["ignore", "pipe", "pipe"],
|
|
235
|
-
env: process.env,
|
|
246
|
+
env: { ...process.env, PATH: enrichedPath() },
|
|
236
247
|
});
|
|
237
248
|
// Drain both pipes in parallel — leaving stderr unread can deadlock long
|
|
238
249
|
// installs once the OS pipe buffer fills (#97). Captured stderr is folded
|
|
@@ -268,10 +279,17 @@ async function orchestrate(
|
|
|
268
279
|
manifestPath: string,
|
|
269
280
|
name: string,
|
|
270
281
|
runCommand: (cmd: readonly string[]) => Promise<RunResult>,
|
|
282
|
+
opts: { noMirror?: boolean } = {},
|
|
271
283
|
): Promise<OrchestrateOk | OrchestrateError> {
|
|
272
284
|
const vaultRegistered = findService("parachute-vault", manifestPath) !== undefined;
|
|
285
|
+
// `--no-mirror` opts this create out of the default internal live mirror
|
|
286
|
+
// (§3 default_mirror knob). Only meaningful on the `create` branch — the
|
|
287
|
+
// bootstrap `install` path provisions the default vault and follows the
|
|
288
|
+
// server-wide knob.
|
|
273
289
|
const cmd = vaultRegistered
|
|
274
|
-
?
|
|
290
|
+
? opts.noMirror
|
|
291
|
+
? ["parachute-vault", "create", name, "--json", "--no-mirror"]
|
|
292
|
+
: ["parachute-vault", "create", name, "--json"]
|
|
275
293
|
: ["parachute", "install", "vault"];
|
|
276
294
|
let result: RunResult;
|
|
277
295
|
try {
|
|
@@ -320,6 +338,102 @@ async function orchestrate(
|
|
|
320
338
|
return { ok: true, createJson };
|
|
321
339
|
}
|
|
322
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Result of {@link provisionVault} — the programmatic vault-provisioning
|
|
343
|
+
* core shared by the authed HTTP handler and the invite-redeem path.
|
|
344
|
+
*
|
|
345
|
+
* - `created: true` — a fresh vault was provisioned this call. `entry`
|
|
346
|
+
* describes it; `createJson` (when present) carries the single-emit
|
|
347
|
+
* bootstrap creds.
|
|
348
|
+
* - `created: false` — the vault already existed (idempotent). `entry`
|
|
349
|
+
* describes the existing vault; no `createJson`.
|
|
350
|
+
*/
|
|
351
|
+
export type ProvisionVaultResult =
|
|
352
|
+
| {
|
|
353
|
+
ok: true;
|
|
354
|
+
created: boolean;
|
|
355
|
+
entry: WellKnownVaultEntry;
|
|
356
|
+
createJson: VaultCreateJson | null;
|
|
357
|
+
}
|
|
358
|
+
| { ok: false; status: number; message: string };
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Provision (or no-op if it exists) a vault by name — the auth-free core
|
|
362
|
+
* lifted out of {@link handleCreateVault} so the invite-redeem flow can
|
|
363
|
+
* provision a vault for a freshly-created account WITHOUT a host:admin
|
|
364
|
+
* bearer (the redeemer holds no admin authority; the invite IS the
|
|
365
|
+
* authorization). The HTTP handler keeps the host:admin gate in front of
|
|
366
|
+
* this; the invite path calls it directly after validating the invite.
|
|
367
|
+
*
|
|
368
|
+
* Idempotent-safe: if the vault already exists this returns
|
|
369
|
+
* `created:false` with the existing entry rather than re-running the CLI —
|
|
370
|
+
* a redeem retry (or a name collision) lands the user on the existing
|
|
371
|
+
* vault instead of erroring.
|
|
372
|
+
*
|
|
373
|
+
* Name validation matches `parachute-vault create`'s rules. Callers that
|
|
374
|
+
* accept an untrusted name (the invite redeemer naming their own vault)
|
|
375
|
+
* MUST pass it through here so the same regex + reserved-name gate the
|
|
376
|
+
* `/vaults` API edge enforces applies.
|
|
377
|
+
*/
|
|
378
|
+
export async function provisionVault(
|
|
379
|
+
name: string,
|
|
380
|
+
deps: {
|
|
381
|
+
issuer: string;
|
|
382
|
+
manifestPath?: string;
|
|
383
|
+
runCommand?: CreateVaultDeps["runCommand"];
|
|
384
|
+
/** `'off'` appends `--no-mirror`; anything else follows the server knob. */
|
|
385
|
+
defaultMirror?: string;
|
|
386
|
+
},
|
|
387
|
+
): Promise<ProvisionVaultResult> {
|
|
388
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
389
|
+
const runCommand = deps.runCommand ?? defaultRunCommand;
|
|
390
|
+
|
|
391
|
+
if (!VAULT_NAME_PATTERN.test(name)) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
status: 400,
|
|
395
|
+
message: "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if (RESERVED_VAULT_NAMES.has(name)) {
|
|
399
|
+
return { ok: false, status: 400, message: `"${name}" is a reserved vault name` };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Idempotency: if the vault already exists, return the existing entry.
|
|
403
|
+
const existing = findExistingVault(manifestPath, name);
|
|
404
|
+
if (existing) {
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
created: false,
|
|
408
|
+
entry: buildEntry(name, existing.path, existing.version, deps.issuer),
|
|
409
|
+
createJson: null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const result = await orchestrate(manifestPath, name, runCommand, {
|
|
414
|
+
noMirror: deps.defaultMirror === "off",
|
|
415
|
+
});
|
|
416
|
+
if (!result.ok) {
|
|
417
|
+
return { ok: false, status: result.status, message: result.message };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Re-read services.json: the CLI just wrote it.
|
|
421
|
+
const created = findExistingVault(manifestPath, name);
|
|
422
|
+
if (!created) {
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
status: 500,
|
|
426
|
+
message: `vault "${name}" was provisioned but is not in services.json — manual recovery required`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
created: true,
|
|
432
|
+
entry: buildEntry(name, created.path, created.version, deps.issuer),
|
|
433
|
+
createJson: result.createJson,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
323
437
|
export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Promise<Response> {
|
|
324
438
|
if (req.method !== "POST") {
|
|
325
439
|
return new Response("method not allowed", { status: 405 });
|
|
@@ -341,35 +455,29 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
|
|
|
341
455
|
}
|
|
342
456
|
const { name } = parsed.body;
|
|
343
457
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const result = await orchestrate(manifestPath, name, runCommand);
|
|
358
|
-
if (!result.ok) {
|
|
359
|
-
return jsonError(result.status, "server_error", result.message);
|
|
458
|
+
const provisioned = await provisionVault(name, {
|
|
459
|
+
issuer: deps.issuer,
|
|
460
|
+
manifestPath,
|
|
461
|
+
runCommand,
|
|
462
|
+
});
|
|
463
|
+
if (!provisioned.ok) {
|
|
464
|
+
// parseBody already enforced the name regex/reserved gate, so a
|
|
465
|
+
// provisionVault 400 here would be a redundant re-check; map any
|
|
466
|
+
// non-ok to its status. invalid_request for 400, server_error otherwise.
|
|
467
|
+
const error = provisioned.status === 400 ? "invalid_request" : "server_error";
|
|
468
|
+
return jsonError(provisioned.status, error, provisioned.message);
|
|
360
469
|
}
|
|
361
470
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
`vault "${name}" was provisioned but is not in services.json — manual recovery required`,
|
|
369
|
-
);
|
|
471
|
+
// Idempotent re-POST: existing vault → 200, no single-emit creds.
|
|
472
|
+
if (!provisioned.created) {
|
|
473
|
+
return new Response(JSON.stringify(provisioned.entry), {
|
|
474
|
+
status: 200,
|
|
475
|
+
headers: { "content-type": "application/json" },
|
|
476
|
+
});
|
|
370
477
|
}
|
|
371
478
|
|
|
372
|
-
const entry =
|
|
479
|
+
const entry = provisioned.entry;
|
|
480
|
+
const result = { createJson: provisioned.createJson };
|
|
373
481
|
// Access token (a `vault:<name>:admin` JWT, possibly empty post-DROP) +
|
|
374
482
|
// filesystem paths are single-emit at create time. We surface them here so
|
|
375
483
|
// the caller can immediately bootstrap a connection to the new vault.
|