@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,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
|
+
}
|
|
@@ -69,15 +69,22 @@ import {
|
|
|
69
69
|
} from "./account-home-ui.ts";
|
|
70
70
|
import { renderAdminError } from "./admin-login-ui.ts";
|
|
71
71
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
72
|
+
import { userHasExternalAiGrant } from "./grants.ts";
|
|
72
73
|
import { inferAudience } from "./jwt-audience.ts";
|
|
73
74
|
import { recordTokenMint, signAccessToken } from "./jwt-sign.ts";
|
|
74
75
|
import { vaultTokenMintRateLimiter } from "./rate-limit.ts";
|
|
75
76
|
import { findActiveSession } from "./sessions.ts";
|
|
76
77
|
import { isTotpEnrolled } from "./two-factor-store.ts";
|
|
77
78
|
import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } from "./users.ts";
|
|
79
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
78
80
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Lowercase-only vault-name charset (item I) — single source of truth in
|
|
83
|
+
* vault-name.ts, matching what vault's init enforces. Was `[a-zA-Z0-9_-]`; the
|
|
84
|
+
* uppercase superset let a mint name drift from vault's lowercase URL-derived
|
|
85
|
+
* name, so the minted token's `vault.<Name>` audience wouldn't validate.
|
|
86
|
+
*/
|
|
87
|
+
const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
|
|
81
88
|
/** Verbs this surface will ever mint. `admin` is deliberately absent. */
|
|
82
89
|
const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write", "admin"];
|
|
83
90
|
/** client_id stamped on the minted JWT + registry row. */
|
|
@@ -183,6 +190,14 @@ export async function handleAccountVaultTokenPost(
|
|
|
183
190
|
csrfToken: csrf.token,
|
|
184
191
|
twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
|
|
185
192
|
mintableVerbs: buildMintableVerbs(deps.db, user.id, user.assignedVaults),
|
|
193
|
+
// hub#583: "connected" means an EXTERNAL AI/MCP client (Claude, Cursor,
|
|
194
|
+
// …) was wired to a vault — NOT a first-party browser sign-in. Notes /
|
|
195
|
+
// the admin SPA are OAuth clients too and write vault-scoped grants, so
|
|
196
|
+
// the old `userHasVaultGrant` lit "✓ You're connected" the moment the
|
|
197
|
+
// user opened Notes. `userHasExternalAiGrant` filters those out.
|
|
198
|
+
connectedVault: user.assignedVaults.some((v) =>
|
|
199
|
+
userHasExternalAiGrant(deps.db, user.id, v),
|
|
200
|
+
),
|
|
186
201
|
...extras,
|
|
187
202
|
}),
|
|
188
203
|
status,
|
|
@@ -233,6 +248,25 @@ export async function handleAccountVaultTokenPost(
|
|
|
233
248
|
});
|
|
234
249
|
}
|
|
235
250
|
|
|
251
|
+
// Force-change-password gate (item F / hub#469, NARROW). A user the admin
|
|
252
|
+
// created/reset lands with `password_changed: false` and an admin-known temp
|
|
253
|
+
// password. Without this gate an authorized friend could mint a LONG-LIVED
|
|
254
|
+
// vault token here and then keep using (or never rotate) the temp password —
|
|
255
|
+
// the token outlives any later rotation, defeating the "temp password is a
|
|
256
|
+
// one-time handoff" model. So an authorized-but-unrotated friend is sent to
|
|
257
|
+
// the change-password rail BEFORE minting anything. Placed AFTER the
|
|
258
|
+
// authority gates (so an unassigned/garbage request still gets its 403/400,
|
|
259
|
+
// preserving those semantics) and BEFORE the rate-limit + mint. 303 (See
|
|
260
|
+
// Other) so the browser re-issues as GET. Narrow #469 fix — gates
|
|
261
|
+
// token-minting specifically; the broad per-request /account/* wall is
|
|
262
|
+
// deferred to Aaron's design call.
|
|
263
|
+
if (!user.passwordChanged) {
|
|
264
|
+
return new Response(null, {
|
|
265
|
+
status: 303,
|
|
266
|
+
headers: { location: "/account/change-password", "cache-control": "no-store" },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
236
270
|
// Rate limit — after CSRF + authority shape, before the mint. Per-user.
|
|
237
271
|
const rlNow = (deps.now ?? (() => new Date()))();
|
|
238
272
|
const gate = vaultTokenMintRateLimiter.checkAndRecord(user.id, rlNow);
|
package/src/admin-login-ui.ts
CHANGED
|
@@ -152,6 +152,127 @@ 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
|
+
// OPTIONAL (not `required`): a blank submission defaults the vault name
|
|
200
|
+
// to the chosen username, resolved server-side (this form is no-JS, so
|
|
201
|
+
// the server is the source of truth). The inline script below is a
|
|
202
|
+
// progressive-enhancement pre-fill only.
|
|
203
|
+
vaultRow = `
|
|
204
|
+
<label class="field">
|
|
205
|
+
<span class="field-label">Name your vault</span>
|
|
206
|
+
<input type="text" name="vault_name" id="vault_name" autocomplete="off"
|
|
207
|
+
minlength="2" maxlength="32"
|
|
208
|
+
pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
|
|
209
|
+
spellcheck="false" autocapitalize="off"
|
|
210
|
+
placeholder="defaults to your username"${vaultAttr} />
|
|
211
|
+
<span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code> (2–32 chars). Leave blank to use your username.</span>
|
|
212
|
+
</label>`;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Progressive enhancement ONLY: mirror the username into the (empty) vault
|
|
217
|
+
// field as the user types, so they see the default before submitting. The
|
|
218
|
+
// server applies the same default with no JS (the field is optional), so
|
|
219
|
+
// this is purely cosmetic — it stops mirroring the moment the user edits the
|
|
220
|
+
// vault field directly. Shown only for the unpinned-provision case.
|
|
221
|
+
const vaultPrefillScript =
|
|
222
|
+
provisionVault && pinnedVaultName === null
|
|
223
|
+
? `
|
|
224
|
+
<script>
|
|
225
|
+
(function () {
|
|
226
|
+
var u = document.getElementById("username");
|
|
227
|
+
var v = document.getElementById("vault_name");
|
|
228
|
+
if (!u || !v) return;
|
|
229
|
+
var dirty = v.value !== "";
|
|
230
|
+
v.addEventListener("input", function () { dirty = true; });
|
|
231
|
+
u.addEventListener("input", function () {
|
|
232
|
+
if (!dirty) v.value = u.value;
|
|
233
|
+
});
|
|
234
|
+
})();
|
|
235
|
+
</script>`
|
|
236
|
+
: "";
|
|
237
|
+
|
|
238
|
+
const body = `
|
|
239
|
+
<div class="card">
|
|
240
|
+
<div class="card-header">
|
|
241
|
+
${header()}
|
|
242
|
+
<h1>Claim your invite</h1>
|
|
243
|
+
<p class="subtitle">Pick a username and password to create your Parachute account${
|
|
244
|
+
provisionVault ? " and your own vault" : ""
|
|
245
|
+
}.</p>
|
|
246
|
+
</div>
|
|
247
|
+
${error}
|
|
248
|
+
<form method="POST" action="/account/setup/${escapeAttr(token)}" class="auth-form">
|
|
249
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
250
|
+
<label class="field">
|
|
251
|
+
<span class="field-label">Username</span>
|
|
252
|
+
<input type="text" name="username" id="username" autocomplete="username" autofocus
|
|
253
|
+
required minlength="2" maxlength="32"
|
|
254
|
+
pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
|
|
255
|
+
spellcheck="false" autocapitalize="off"${usernameAttr} />
|
|
256
|
+
<span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
|
|
257
|
+
</label>
|
|
258
|
+
<label class="field">
|
|
259
|
+
<span class="field-label">Password</span>
|
|
260
|
+
<input type="password" name="password" autocomplete="new-password"
|
|
261
|
+
required minlength="12" />
|
|
262
|
+
<span class="field-hint">at least 12 characters</span>
|
|
263
|
+
</label>
|
|
264
|
+
<label class="field">
|
|
265
|
+
<span class="field-label">Confirm password</span>
|
|
266
|
+
<input type="password" name="password_confirm" autocomplete="new-password"
|
|
267
|
+
required minlength="12" />
|
|
268
|
+
</label>
|
|
269
|
+
${vaultRow}
|
|
270
|
+
<button type="submit" class="btn btn-primary">Create my account</button>
|
|
271
|
+
</form>
|
|
272
|
+
</div>${vaultPrefillScript}`;
|
|
273
|
+
return baseDocument("Claim your invite — Parachute", body);
|
|
274
|
+
}
|
|
275
|
+
|
|
155
276
|
// --- error page ------------------------------------------------------------
|
|
156
277
|
|
|
157
278
|
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;
|