@openparachute/hub 0.5.13 → 0.5.14-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/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-rendered HTML for `/account/` — the friend-facing user home.
|
|
3
|
+
*
|
|
4
|
+
* Multi-user Phase 1 follow-up. Companion to the first-admin gate on
|
|
5
|
+
* `/admin/host-admin-token` (see `admin-host-admin-token.ts`): without
|
|
6
|
+
* this landing surface, a non-admin friend signing in would either
|
|
7
|
+
* (a) hit a 403 wall when the SPA tries to mint a host-admin bearer,
|
|
8
|
+
* or (b) — pre-gate — silently escalate to full admin. The gate plus
|
|
9
|
+
* this page give the friend a coherent home: their assigned vault,
|
|
10
|
+
* password rotation link, sign-out.
|
|
11
|
+
*
|
|
12
|
+
* Pure renderer — no DB, no Bun.serve, no fs. The `/account/` route
|
|
13
|
+
* handler in `hub-server.ts` resolves the user + their vault + the
|
|
14
|
+
* is-first-admin flag, then calls in here. Same posture as
|
|
15
|
+
* `account-change-password-ui.ts` and `oauth-ui.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Visual chrome: cloned from `account-change-password-ui.ts` so the
|
|
18
|
+
* `/account/*` surface family stays cohesive. If/when an
|
|
19
|
+
* `auth-ui-chrome.ts` lands, both should consolidate.
|
|
20
|
+
*/
|
|
21
|
+
import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
22
|
+
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
23
|
+
import { escapeHtml } from "./oauth-ui.ts";
|
|
24
|
+
|
|
25
|
+
// --- shared chrome (mirrors account-change-password-ui.ts) ----------------
|
|
26
|
+
|
|
27
|
+
const PALETTE = {
|
|
28
|
+
bg: "#faf8f4",
|
|
29
|
+
bgSoft: "#f3f0ea",
|
|
30
|
+
fg: "#2c2a26",
|
|
31
|
+
fgMuted: "#6b6860",
|
|
32
|
+
fgDim: "#9a9690",
|
|
33
|
+
accent: "#4a7c59",
|
|
34
|
+
accentHover: "#3d6849",
|
|
35
|
+
accentSoft: "rgba(74, 124, 89, 0.08)",
|
|
36
|
+
border: "#e4e0d8",
|
|
37
|
+
borderLight: "#ece9e2",
|
|
38
|
+
cardBg: "#ffffff",
|
|
39
|
+
danger: "#a3392b",
|
|
40
|
+
dangerSoft: "rgba(163, 57, 43, 0.08)",
|
|
41
|
+
success: "#3d6849",
|
|
42
|
+
successSoft: "rgba(61, 104, 73, 0.08)",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
const FONT_SERIF = `Georgia, "Times New Roman", serif`;
|
|
46
|
+
const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
|
|
47
|
+
const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
|
|
48
|
+
|
|
49
|
+
function baseDocument(title: string, body: string): string {
|
|
50
|
+
return `<!doctype html>
|
|
51
|
+
<html lang="en">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="utf-8" />
|
|
54
|
+
<title>${escapeHtml(title)}</title>
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
56
|
+
<meta name="referrer" content="no-referrer" />
|
|
57
|
+
<style>${STYLES}</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<main>
|
|
61
|
+
${body}
|
|
62
|
+
</main>
|
|
63
|
+
</body>
|
|
64
|
+
</html>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function header(): string {
|
|
68
|
+
return `
|
|
69
|
+
<div class="brand">
|
|
70
|
+
<span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "account-home")}</span>
|
|
71
|
+
<span class="brand-name">${WORDMARK_TEXT}</span>
|
|
72
|
+
<span class="brand-tag">account</span>
|
|
73
|
+
</div>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- /account/ ------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export interface RenderAccountHomeOpts {
|
|
79
|
+
username: string;
|
|
80
|
+
/**
|
|
81
|
+
* Vault instance names this user has access to (multi-user Phase 2
|
|
82
|
+
* PR 2). Empty `[]` for admin posture (combined with `isFirstAdmin:
|
|
83
|
+
* true` this is the hub administrator's account, whose vault access
|
|
84
|
+
* is unrestricted). One or more entries for non-admin users — the
|
|
85
|
+
* page renders one Notes-CTA tile per vault.
|
|
86
|
+
*/
|
|
87
|
+
assignedVaults: string[];
|
|
88
|
+
/** Force-change-password completion flag. Currently informational only. */
|
|
89
|
+
passwordChanged: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Hub origin (no trailing slash) — the canonical URL operators connect
|
|
92
|
+
* their MCP / Surface clients to. Used both in the Notes "Open" CTA
|
|
93
|
+
* (encoded as `?url=...` on `notes.parachute.computer/add`) and as
|
|
94
|
+
* inline-code text for the "custom client" disclosure.
|
|
95
|
+
*/
|
|
96
|
+
hubOrigin: string;
|
|
97
|
+
/**
|
|
98
|
+
* Whether the signed-in user is the hub administrator (the
|
|
99
|
+
* earliest-created users row). Drives the `assignedVault: null`
|
|
100
|
+
* branch — admins see an "open admin" affordance; non-admins (a
|
|
101
|
+
* Phase 1 shape that shouldn't normally occur — admins land with
|
|
102
|
+
* `assignedVault: null`, friends always have one set) see a
|
|
103
|
+
* defensive "ask the operator" message.
|
|
104
|
+
*/
|
|
105
|
+
isFirstAdmin: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* CSRF token for the sign-out form. Same pattern as
|
|
108
|
+
* `account-change-password-ui.ts` — POSTs to `/logout` are CSRF-gated
|
|
109
|
+
* to keep a cross-origin form from logging the user out.
|
|
110
|
+
*/
|
|
111
|
+
csrfToken: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function renderAccountHome(opts: RenderAccountHomeOpts): string {
|
|
115
|
+
const { username, assignedVaults, hubOrigin, isFirstAdmin, csrfToken } = opts;
|
|
116
|
+
const safeUsername = escapeHtml(username);
|
|
117
|
+
// Origin is already canonicalized by the handler (trailing slash stripped),
|
|
118
|
+
// but defensively re-trim so a stray slash here doesn't break the CTA href.
|
|
119
|
+
const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
|
|
120
|
+
|
|
121
|
+
const vaultCard = renderVaultCard({
|
|
122
|
+
assignedVaults,
|
|
123
|
+
trimmedOrigin,
|
|
124
|
+
isFirstAdmin,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const accountCard = renderAccountCard({
|
|
128
|
+
username: safeUsername,
|
|
129
|
+
csrfToken,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const body = `
|
|
133
|
+
<div class="card">
|
|
134
|
+
<div class="card-header">
|
|
135
|
+
${header()}
|
|
136
|
+
<h1>Welcome, ${safeUsername}</h1>
|
|
137
|
+
<p class="subtitle">Your Parachute account home.</p>
|
|
138
|
+
</div>
|
|
139
|
+
${vaultCard}
|
|
140
|
+
${accountCard}
|
|
141
|
+
</div>`;
|
|
142
|
+
return baseDocument(`${username} — Parachute`, body);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface VaultCardOpts {
|
|
146
|
+
assignedVaults: string[];
|
|
147
|
+
trimmedOrigin: string;
|
|
148
|
+
isFirstAdmin: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build `<hub-origin>/vault/<name>/mcp` — the MCP endpoint a client connects
|
|
153
|
+
* to. Mirrors the SPA's `mcpEndpointFor` (McpConnectCard.tsx) and the
|
|
154
|
+
* wizard's `renderMcpTile`. The friend's `/account/` tile is server-rendered
|
|
155
|
+
* (no SPA), so we compute it here from the hub origin + vault name rather
|
|
156
|
+
* than the vault's well-known `url`.
|
|
157
|
+
*
|
|
158
|
+
* Exported for direct unit testing.
|
|
159
|
+
*/
|
|
160
|
+
export function accountMcpEndpoint(trimmedOrigin: string, vaultName: string): string {
|
|
161
|
+
return `${trimmedOrigin}/vault/${vaultName}/mcp`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The OAuth-path `claude mcp add` command — no token, triggers browser OAuth
|
|
166
|
+
* on first use. Same `parachute-<name>` server name as the SPA card and the
|
|
167
|
+
* wizard tile, so a friend and the operator end up with identically-named
|
|
168
|
+
* MCP servers. Exported for direct unit testing.
|
|
169
|
+
*/
|
|
170
|
+
export function accountClaudeMcpAddCommand(trimmedOrigin: string, vaultName: string): string {
|
|
171
|
+
return `claude mcp add --transport http parachute-${vaultName} ${accountMcpEndpoint(
|
|
172
|
+
trimmedOrigin,
|
|
173
|
+
vaultName,
|
|
174
|
+
)}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderVaultCard(opts: VaultCardOpts): string {
|
|
178
|
+
const { assignedVaults, trimmedOrigin, isFirstAdmin } = opts;
|
|
179
|
+
|
|
180
|
+
if (assignedVaults.length > 0) {
|
|
181
|
+
// One vault tile per assignment (multi-user Phase 2 PR 2). Each tile
|
|
182
|
+
// carries the Notes "Open" CTA AND a server-rendered MCP connect block
|
|
183
|
+
// (endpoint + `claude mcp add` command, each with a copy button). The
|
|
184
|
+
// connect command is the OAuth path — no token, so a non-admin friend
|
|
185
|
+
// who can't run the SPA's host-admin mint still gets a working
|
|
186
|
+
// connect affordance (the first `claude mcp add` use opens a browser,
|
|
187
|
+
// signs them in, and approves the scope). This closes the multi-user
|
|
188
|
+
// gap where the friend tile only offered the external Notes link + a
|
|
189
|
+
// bare hub-origin string.
|
|
190
|
+
const heading = assignedVaults.length === 1 ? "<h2>Your vault</h2>" : "<h2>Your vaults</h2>";
|
|
191
|
+
const tiles = assignedVaults
|
|
192
|
+
.map((vaultName) => {
|
|
193
|
+
const safeVault = escapeHtml(vaultName);
|
|
194
|
+
const vaultUrlForAdd = encodeURIComponent(`${trimmedOrigin}/vault/${vaultName}`);
|
|
195
|
+
const endpoint = accountMcpEndpoint(trimmedOrigin, vaultName);
|
|
196
|
+
const addCmd = accountClaudeMcpAddCommand(trimmedOrigin, vaultName);
|
|
197
|
+
const safeEndpoint = escapeHtml(endpoint);
|
|
198
|
+
const safeAddCmd = escapeHtml(addCmd);
|
|
199
|
+
return `
|
|
200
|
+
<div class="vault-tile" data-testid="vault-tile" data-vault-name="${safeVault}">
|
|
201
|
+
<p class="vault-name"><strong>${safeVault}</strong></p>
|
|
202
|
+
<p>
|
|
203
|
+
<a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
|
|
204
|
+
target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
|
|
205
|
+
</p>
|
|
206
|
+
<div class="mcp-connect" data-testid="mcp-connect">
|
|
207
|
+
<p class="mcp-connect-label">Connect an MCP client (Claude Code, Claude.ai)</p>
|
|
208
|
+
<div class="mcp-field">
|
|
209
|
+
<span class="mcp-field-label">Endpoint</span>
|
|
210
|
+
<div class="copy-row">
|
|
211
|
+
<code data-testid="mcp-endpoint">${safeEndpoint}</code>
|
|
212
|
+
<button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
|
|
213
|
+
data-testid="copy-mcp-endpoint">Copy</button>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
<div class="mcp-field">
|
|
217
|
+
<span class="mcp-field-label">Claude Code</span>
|
|
218
|
+
<div class="copy-row">
|
|
219
|
+
<code data-testid="mcp-add-command">${safeAddCmd}</code>
|
|
220
|
+
<button type="button" class="btn btn-copy" data-copy="${safeAddCmd}"
|
|
221
|
+
data-testid="copy-mcp-add-command">Copy</button>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
<p class="mcp-connect-hint">No token needed — the command opens a browser to
|
|
225
|
+
sign you in to this hub and approve access on first use.</p>
|
|
226
|
+
</div>
|
|
227
|
+
</div>`;
|
|
228
|
+
})
|
|
229
|
+
.join("");
|
|
230
|
+
return `
|
|
231
|
+
<section class="section" data-testid="vault-card">
|
|
232
|
+
${heading}
|
|
233
|
+
<p>Open Notes — the canonical browser UI for your vault${
|
|
234
|
+
assignedVaults.length === 1 ? "" : "s"
|
|
235
|
+
} — or connect an MCP client
|
|
236
|
+
(Claude Code, Claude.ai) with the command below. Either way you sign in to your
|
|
237
|
+
hub over HTTPS and approve access on the first connection.</p>
|
|
238
|
+
<div class="vault-tiles">${tiles}
|
|
239
|
+
</div>
|
|
240
|
+
</section>${COPY_SCRIPT}`;
|
|
241
|
+
}
|
|
242
|
+
if (isFirstAdmin) {
|
|
243
|
+
return `
|
|
244
|
+
<section class="section" data-testid="admin-card">
|
|
245
|
+
<h2>Your vault</h2>
|
|
246
|
+
<p>You're the hub administrator. Visit
|
|
247
|
+
<a href="/admin/">the admin surface</a> to manage vaults, users, and clients.</p>
|
|
248
|
+
</section>`;
|
|
249
|
+
}
|
|
250
|
+
// Defensive third branch — non-admin with no assigned vault. The
|
|
251
|
+
// /api/users path doesn't require a vault on create (admins can leave
|
|
252
|
+
// a new friend's vault list empty and fill it in later), so this
|
|
253
|
+
// state is reachable through normal flows — surface a clear "ask
|
|
254
|
+
// your admin" message.
|
|
255
|
+
return `
|
|
256
|
+
<section class="section" data-testid="no-vault-card">
|
|
257
|
+
<h2>Your vault</h2>
|
|
258
|
+
<p>Your account isn't assigned to a vault yet. Ask the hub operator
|
|
259
|
+
to assign one.</p>
|
|
260
|
+
</section>`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface AccountCardOpts {
|
|
264
|
+
username: string;
|
|
265
|
+
csrfToken: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderAccountCard(opts: AccountCardOpts): string {
|
|
269
|
+
const { username, csrfToken } = opts;
|
|
270
|
+
return `
|
|
271
|
+
<section class="section" data-testid="account-card">
|
|
272
|
+
<h2>Account</h2>
|
|
273
|
+
<dl class="kv">
|
|
274
|
+
<dt>Username</dt>
|
|
275
|
+
<dd><code>${username}</code></dd>
|
|
276
|
+
</dl>
|
|
277
|
+
<p>
|
|
278
|
+
<a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
|
|
279
|
+
</p>
|
|
280
|
+
<form method="POST" action="/logout" class="signout-form" data-testid="signout-form">
|
|
281
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
282
|
+
<button type="submit" class="btn btn-secondary">Sign out</button>
|
|
283
|
+
</form>
|
|
284
|
+
</section>`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- copy-button script ---------------------------------------------------
|
|
288
|
+
//
|
|
289
|
+
// Tiny inline progressive-enhancement script for the per-tile copy buttons.
|
|
290
|
+
// Delegated click handler reads the command/endpoint from the button's
|
|
291
|
+
// `data-copy` attribute and writes it to the clipboard, flashing "Copied ✓"
|
|
292
|
+
// for 2s. No-ops gracefully when the Clipboard API is unavailable (insecure
|
|
293
|
+
// context, older browser) — the command text stays selectable in the
|
|
294
|
+
// codebox. Mirrors the SPA `CopyButton` posture (McpConnectCard.tsx) for a
|
|
295
|
+
// surface that has no React. Only emitted on the assigned-vault branch
|
|
296
|
+
// (where copy buttons exist).
|
|
297
|
+
const COPY_SCRIPT = `
|
|
298
|
+
<script>
|
|
299
|
+
(function () {
|
|
300
|
+
document.addEventListener('click', function (e) {
|
|
301
|
+
var btn = e.target && e.target.closest ? e.target.closest('[data-copy]') : null;
|
|
302
|
+
if (!btn) return;
|
|
303
|
+
var value = btn.getAttribute('data-copy') || '';
|
|
304
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
|
305
|
+
navigator.clipboard.writeText(value).then(function () {
|
|
306
|
+
var original = btn.textContent;
|
|
307
|
+
btn.textContent = 'Copied \\u2713';
|
|
308
|
+
setTimeout(function () { btn.textContent = original; }, 2000);
|
|
309
|
+
}).catch(function () { /* insecure context — leave selectable */ });
|
|
310
|
+
});
|
|
311
|
+
})();
|
|
312
|
+
</script>`;
|
|
313
|
+
|
|
314
|
+
// --- styles ---------------------------------------------------------------
|
|
315
|
+
//
|
|
316
|
+
// Same brand palette + font stack as account-change-password-ui.ts so the
|
|
317
|
+
// `/account/*` family is visually cohesive. Extra rules (.section, .kv,
|
|
318
|
+
// .vault-name, .mcp-connect, .copy-row) describe the new card + MCP
|
|
319
|
+
// connect-block shapes this page introduces.
|
|
320
|
+
|
|
321
|
+
const STYLES = `
|
|
322
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
323
|
+
html, body { margin: 0; padding: 0; }
|
|
324
|
+
body {
|
|
325
|
+
font-family: ${FONT_SANS};
|
|
326
|
+
background: ${PALETTE.bg};
|
|
327
|
+
color: ${PALETTE.fg};
|
|
328
|
+
line-height: 1.55;
|
|
329
|
+
min-height: 100vh;
|
|
330
|
+
-webkit-font-smoothing: antialiased;
|
|
331
|
+
-moz-osx-font-smoothing: grayscale;
|
|
332
|
+
}
|
|
333
|
+
main {
|
|
334
|
+
display: flex;
|
|
335
|
+
align-items: flex-start;
|
|
336
|
+
justify-content: center;
|
|
337
|
+
min-height: 100vh;
|
|
338
|
+
padding: 2rem 1.5rem;
|
|
339
|
+
}
|
|
340
|
+
.card {
|
|
341
|
+
width: 100%;
|
|
342
|
+
max-width: 34rem;
|
|
343
|
+
background: ${PALETTE.cardBg};
|
|
344
|
+
border: 1px solid ${PALETTE.border};
|
|
345
|
+
border-radius: 12px;
|
|
346
|
+
padding: 2rem 1.75rem;
|
|
347
|
+
box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
|
|
348
|
+
}
|
|
349
|
+
.card-header { margin-bottom: 1.5rem; }
|
|
350
|
+
.brand {
|
|
351
|
+
display: flex;
|
|
352
|
+
align-items: center;
|
|
353
|
+
gap: 0.5rem;
|
|
354
|
+
color: ${PALETTE.accent};
|
|
355
|
+
font-weight: 500;
|
|
356
|
+
font-size: 0.95rem;
|
|
357
|
+
margin-bottom: 1.25rem;
|
|
358
|
+
}
|
|
359
|
+
.brand-mark { display: inline-flex; line-height: 0; }
|
|
360
|
+
.brand-mark svg { width: 20px; height: 20px; }
|
|
361
|
+
.brand-name { letter-spacing: 0.01em; }
|
|
362
|
+
.brand-tag {
|
|
363
|
+
text-transform: uppercase;
|
|
364
|
+
letter-spacing: 0.06em;
|
|
365
|
+
font-size: 0.7rem;
|
|
366
|
+
color: ${PALETTE.fgMuted};
|
|
367
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
368
|
+
padding: 0.05rem 0.4rem;
|
|
369
|
+
border-radius: 999px;
|
|
370
|
+
}
|
|
371
|
+
h1 {
|
|
372
|
+
font-family: ${FONT_SERIF};
|
|
373
|
+
font-weight: 400;
|
|
374
|
+
font-size: 1.6rem;
|
|
375
|
+
line-height: 1.2;
|
|
376
|
+
margin: 0 0 0.4rem;
|
|
377
|
+
color: ${PALETTE.fg};
|
|
378
|
+
}
|
|
379
|
+
h2 {
|
|
380
|
+
font-family: ${FONT_SERIF};
|
|
381
|
+
font-weight: 400;
|
|
382
|
+
font-size: 1.15rem;
|
|
383
|
+
line-height: 1.25;
|
|
384
|
+
margin: 0 0 0.6rem;
|
|
385
|
+
color: ${PALETTE.fg};
|
|
386
|
+
}
|
|
387
|
+
.subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
|
|
388
|
+
|
|
389
|
+
.section {
|
|
390
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
391
|
+
padding-top: 1.25rem;
|
|
392
|
+
margin-top: 1.25rem;
|
|
393
|
+
}
|
|
394
|
+
.section p { margin: 0.4rem 0; }
|
|
395
|
+
.vault-name {
|
|
396
|
+
font-family: ${FONT_MONO};
|
|
397
|
+
font-size: 1rem;
|
|
398
|
+
margin: 0 0 0.6rem;
|
|
399
|
+
}
|
|
400
|
+
.vault-name strong { color: ${PALETTE.fg}; font-weight: 600; }
|
|
401
|
+
.vault-tiles {
|
|
402
|
+
display: flex;
|
|
403
|
+
flex-direction: column;
|
|
404
|
+
gap: 0.75rem;
|
|
405
|
+
margin: 0.75rem 0 0.4rem;
|
|
406
|
+
}
|
|
407
|
+
.vault-tile {
|
|
408
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
409
|
+
border-radius: 8px;
|
|
410
|
+
padding: 0.75rem 1rem;
|
|
411
|
+
background: ${PALETTE.bgSoft};
|
|
412
|
+
}
|
|
413
|
+
.vault-tile p { margin: 0.2rem 0; }
|
|
414
|
+
.vault-tile p:last-child { margin-top: 0.5rem; }
|
|
415
|
+
|
|
416
|
+
.mcp-connect {
|
|
417
|
+
margin-top: 0.75rem;
|
|
418
|
+
padding-top: 0.6rem;
|
|
419
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
420
|
+
}
|
|
421
|
+
.mcp-connect-label {
|
|
422
|
+
font-size: 0.85rem;
|
|
423
|
+
font-weight: 500;
|
|
424
|
+
color: ${PALETTE.fg};
|
|
425
|
+
margin: 0 0 0.5rem;
|
|
426
|
+
}
|
|
427
|
+
.mcp-field { margin: 0.5rem 0; }
|
|
428
|
+
.mcp-field-label {
|
|
429
|
+
display: block;
|
|
430
|
+
font-size: 0.7rem;
|
|
431
|
+
text-transform: uppercase;
|
|
432
|
+
letter-spacing: 0.06em;
|
|
433
|
+
color: ${PALETTE.fgMuted};
|
|
434
|
+
font-family: ${FONT_MONO};
|
|
435
|
+
margin-bottom: 0.2rem;
|
|
436
|
+
}
|
|
437
|
+
.copy-row {
|
|
438
|
+
display: flex;
|
|
439
|
+
align-items: center;
|
|
440
|
+
gap: 0.5rem;
|
|
441
|
+
background: ${PALETTE.cardBg};
|
|
442
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
443
|
+
border-radius: 6px;
|
|
444
|
+
padding: 0.4rem 0.5rem;
|
|
445
|
+
}
|
|
446
|
+
.copy-row code {
|
|
447
|
+
flex: 1 1 auto;
|
|
448
|
+
overflow-x: auto;
|
|
449
|
+
white-space: nowrap;
|
|
450
|
+
background: transparent;
|
|
451
|
+
padding: 0;
|
|
452
|
+
font-size: 0.82rem;
|
|
453
|
+
}
|
|
454
|
+
.btn-copy {
|
|
455
|
+
flex: 0 0 auto;
|
|
456
|
+
font-size: 0.8rem;
|
|
457
|
+
padding: 0.3rem 0.7rem;
|
|
458
|
+
background: transparent;
|
|
459
|
+
color: ${PALETTE.fg};
|
|
460
|
+
border-color: ${PALETTE.border};
|
|
461
|
+
}
|
|
462
|
+
.btn-copy:hover { background: ${PALETTE.bgSoft}; border-color: ${PALETTE.accent}; }
|
|
463
|
+
.mcp-connect-hint {
|
|
464
|
+
font-size: 0.82rem;
|
|
465
|
+
color: ${PALETTE.fgMuted};
|
|
466
|
+
margin: 0.4rem 0 0;
|
|
467
|
+
}
|
|
468
|
+
code {
|
|
469
|
+
font-family: ${FONT_MONO};
|
|
470
|
+
background: ${PALETTE.bgSoft};
|
|
471
|
+
padding: 0.1rem 0.4rem;
|
|
472
|
+
border-radius: 4px;
|
|
473
|
+
font-size: 0.88em;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.kv { margin: 0 0 0.6rem; padding: 0; }
|
|
477
|
+
.kv dt {
|
|
478
|
+
font-size: 0.78rem;
|
|
479
|
+
text-transform: uppercase;
|
|
480
|
+
letter-spacing: 0.06em;
|
|
481
|
+
color: ${PALETTE.fgMuted};
|
|
482
|
+
font-family: ${FONT_MONO};
|
|
483
|
+
margin-top: 0.4rem;
|
|
484
|
+
}
|
|
485
|
+
.kv dd { margin: 0.15rem 0 0.4rem; }
|
|
486
|
+
|
|
487
|
+
.btn {
|
|
488
|
+
font: inherit;
|
|
489
|
+
font-weight: 500;
|
|
490
|
+
padding: 0.55rem 1rem;
|
|
491
|
+
border-radius: 6px;
|
|
492
|
+
border: 1px solid transparent;
|
|
493
|
+
cursor: pointer;
|
|
494
|
+
text-decoration: none;
|
|
495
|
+
display: inline-block;
|
|
496
|
+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
497
|
+
}
|
|
498
|
+
.btn-primary {
|
|
499
|
+
background: ${PALETTE.accent};
|
|
500
|
+
color: ${PALETTE.cardBg};
|
|
501
|
+
}
|
|
502
|
+
.btn-primary:hover { background: ${PALETTE.accentHover}; }
|
|
503
|
+
.btn-secondary {
|
|
504
|
+
background: transparent;
|
|
505
|
+
color: ${PALETTE.fg};
|
|
506
|
+
border-color: ${PALETTE.border};
|
|
507
|
+
}
|
|
508
|
+
.btn-secondary:hover {
|
|
509
|
+
background: ${PALETTE.bgSoft};
|
|
510
|
+
border-color: ${PALETTE.accent};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.signout-form { margin: 0.8rem 0 0; }
|
|
514
|
+
|
|
515
|
+
.account-action {
|
|
516
|
+
color: ${PALETTE.accent};
|
|
517
|
+
text-decoration: none;
|
|
518
|
+
font-weight: 500;
|
|
519
|
+
font-size: 0.95rem;
|
|
520
|
+
}
|
|
521
|
+
.account-action:hover { color: ${PALETTE.accentHover}; text-decoration: underline; }
|
|
522
|
+
|
|
523
|
+
a { color: ${PALETTE.accent}; }
|
|
524
|
+
a:hover { color: ${PALETTE.accentHover}; }
|
|
525
|
+
|
|
526
|
+
@media (max-width: 480px) {
|
|
527
|
+
main { padding: 1rem 0.75rem; }
|
|
528
|
+
.card { padding: 1.5rem 1.25rem; border-radius: 10px; }
|
|
529
|
+
h1 { font-size: 1.4rem; }
|
|
530
|
+
h2 { font-size: 1.05rem; }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
@media (prefers-color-scheme: dark) {
|
|
534
|
+
body { background: #1a1815; color: #e8e4dc; }
|
|
535
|
+
.card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
|
536
|
+
h1, h2 { color: #f0ece4; }
|
|
537
|
+
.subtitle, .kv dt, .mcp-field-label, .mcp-connect-hint { color: #a8a29a; }
|
|
538
|
+
.vault-name strong, .mcp-connect-label { color: #f0ece4; }
|
|
539
|
+
code { background: #1f1c18; color: #e8e4dc; }
|
|
540
|
+
.copy-row code { background: transparent; }
|
|
541
|
+
.section, .mcp-connect { border-top-color: #3a362f; }
|
|
542
|
+
.brand-tag { border-color: #3a362f; color: #a8a29a; }
|
|
543
|
+
.copy-row { background: #1f1c18; border-color: #3a362f; }
|
|
544
|
+
.btn-secondary, .btn-copy { color: #e8e4dc; border-color: #3a362f; }
|
|
545
|
+
.btn-secondary:hover, .btn-copy:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
|
|
546
|
+
}
|
|
547
|
+
`;
|
package/src/admin-handlers.ts
CHANGED
|
@@ -23,7 +23,13 @@ import {
|
|
|
23
23
|
deleteSession,
|
|
24
24
|
parseSessionCookie,
|
|
25
25
|
} from "./sessions.ts";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
PASSWORD_MAX_LEN,
|
|
28
|
+
type User,
|
|
29
|
+
getUserByUsername,
|
|
30
|
+
isFirstAdmin,
|
|
31
|
+
verifyPassword,
|
|
32
|
+
} from "./users.ts";
|
|
27
33
|
|
|
28
34
|
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
29
35
|
return new Response(body, {
|
|
@@ -41,8 +47,12 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
|
|
|
41
47
|
* `/admin/vaults` as its home (vault list, the default tab). Anywhere else
|
|
42
48
|
* would either bounce the operator out of the SPA shell or land on a
|
|
43
49
|
* legacy server-rendered page — `/admin/vaults` is the canonical entry.
|
|
50
|
+
*
|
|
51
|
+
* Exported because `api-account.ts` consumes the same constant for its
|
|
52
|
+
* change-password landing. Single source of truth so a future "default
|
|
53
|
+
* landing changed to /admin/dashboard" PR doesn't drift across two files.
|
|
44
54
|
*/
|
|
45
|
-
const POST_LOGIN_DEFAULT = "/admin/vaults";
|
|
55
|
+
export const POST_LOGIN_DEFAULT = "/admin/vaults";
|
|
46
56
|
|
|
47
57
|
/**
|
|
48
58
|
* Force-change-password landing. Multi-user Phase 1 PR 3: when a user
|
|
@@ -75,22 +85,44 @@ function safeNext(raw: string | null): string {
|
|
|
75
85
|
* boundary — once changed, no per-request scope check is needed and
|
|
76
86
|
* no token claim carries the bit forward.
|
|
77
87
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
88
|
+
* Three precedence rules, in order:
|
|
89
|
+
*
|
|
90
|
+
* 1. `password_changed === false` → `/account/change-password`
|
|
91
|
+
* (preserves `next` as a query param so the change-password POST
|
|
92
|
+
* finishes the trip).
|
|
93
|
+
* 2. Non-admin user (friend) whose `next` targets `/admin/*` → rewrite
|
|
94
|
+
* to `/account/`. Friends have no business on admin SPA URLs —
|
|
95
|
+
* `/admin/host-admin-token` would 403 (first-admin gate) and the
|
|
96
|
+
* SPA would bounce them to `/account/` anyway. Doing the rewrite
|
|
97
|
+
* at the login boundary avoids the bouncing-around UX. Other `next`
|
|
98
|
+
* paths (`/`, `/oauth/authorize/...`, `/vault/...`) are legitimate
|
|
99
|
+
* destinations for non-admin users and pass through unchanged.
|
|
100
|
+
* 3. Otherwise return `next` (the today's-default behavior).
|
|
82
101
|
*
|
|
83
|
-
*
|
|
102
|
+
* `db` is required for the non-admin check (it consults `getFirstAdminId`).
|
|
103
|
+
* Wizard-only test fixtures that pre-existed the `db` plumbing pass the
|
|
104
|
+
* harness DB through; the parameter is non-optional to make the
|
|
105
|
+
* "did you remember the gate?" review check explicit.
|
|
84
106
|
*/
|
|
85
|
-
export function loginRedirectTarget(user: User, next: string): string {
|
|
86
|
-
if (user.passwordChanged)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
export function loginRedirectTarget(db: Database, user: User, next: string): string {
|
|
108
|
+
if (!user.passwordChanged) {
|
|
109
|
+
// Preserve the operator's intended destination; the change-password
|
|
110
|
+
// POST will read this and 302 there after the change lands.
|
|
111
|
+
// Only encode `next` if it isn't already the post-change default —
|
|
112
|
+
// keeps the URL clean for the common case (no `next` param specified
|
|
113
|
+
// at login time → no `?next=` on the change-password URL).
|
|
114
|
+
if (next === POST_LOGIN_DEFAULT) return FORCE_CHANGE_PASSWORD_PATH;
|
|
115
|
+
return `${FORCE_CHANGE_PASSWORD_PATH}?next=${encodeURIComponent(next)}`;
|
|
116
|
+
}
|
|
117
|
+
// Non-admin friend aiming at admin SPA: rewrite to /account/. We check
|
|
118
|
+
// both the literal `/admin` and the `/admin/...` prefix; safeNext has
|
|
119
|
+
// already normalized to a leading-`/` same-origin path, so a plain
|
|
120
|
+
// string prefix check is sufficient (no `//admin.evil.com/` shape can
|
|
121
|
+
// reach here).
|
|
122
|
+
if (!isFirstAdmin(db, user.id) && (next === "/admin" || next.startsWith("/admin/"))) {
|
|
123
|
+
return "/account/";
|
|
124
|
+
}
|
|
125
|
+
return next;
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
// --- /login ---------------------------------------------------------------
|
|
@@ -193,7 +225,7 @@ export async function handleAdminLoginPost(
|
|
|
193
225
|
// to change the admin-typed default before continuing. Their original
|
|
194
226
|
// `next` rides along on the change-password URL so the post-change
|
|
195
227
|
// POST can land them at their intended destination.
|
|
196
|
-
const target = loginRedirectTarget(user, next);
|
|
228
|
+
const target = loginRedirectTarget(db, user, next);
|
|
197
229
|
return redirect(target, { "set-cookie": cookie });
|
|
198
230
|
}
|
|
199
231
|
|
|
@@ -25,6 +25,16 @@
|
|
|
25
25
|
* - this endpoint requires a valid `parachute_hub_session` cookie, which
|
|
26
26
|
* was set by `/login` after a password check.
|
|
27
27
|
*
|
|
28
|
+
* **First-admin gate (multi-user Phase 1 follow-up).** A valid session is
|
|
29
|
+
* necessary but NOT sufficient. The session must belong to the hub admin
|
|
30
|
+
* (the earliest-created user row, per `getFirstAdminId` in users.ts).
|
|
31
|
+
* Without this gate, any signed-in friend account created via PR 2's
|
|
32
|
+
* `/api/users` could hit this endpoint and walk away with a JWT carrying
|
|
33
|
+
* `parachute:host:admin` + `parachute:host:auth` — a full-admin privesc,
|
|
34
|
+
* since both scopes are the SPA's gate-bypass into vault provisioning,
|
|
35
|
+
* grant management, and the token registry. The SPA-side mirror is in
|
|
36
|
+
* `web/ui/src/lib/auth.ts`: 403 → redirect to `/account/`.
|
|
37
|
+
*
|
|
28
38
|
* Tokens minted here are deliberately NOT persisted in the `tokens` table
|
|
29
39
|
* (no refresh, no revocation tracking). They expire on their own; the SPA
|
|
30
40
|
* re-fetches when the JWT is about to lapse.
|
|
@@ -39,6 +49,7 @@
|
|
|
39
49
|
import type { Database } from "bun:sqlite";
|
|
40
50
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
41
51
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
52
|
+
import { isFirstAdmin } from "./users.ts";
|
|
42
53
|
|
|
43
54
|
/** Short TTL — page-snapshot threats can't carry the token forever. */
|
|
44
55
|
export const HOST_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
|
|
@@ -64,6 +75,20 @@ export async function handleHostAdminToken(
|
|
|
64
75
|
if (!session) {
|
|
65
76
|
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
66
77
|
}
|
|
78
|
+
// First-admin gate. A friend account (non-first-admin user created via
|
|
79
|
+
// `/api/users`) holds a valid session but must not be able to mint
|
|
80
|
+
// host-admin scopes. Without this check, any signed-in friend hitting
|
|
81
|
+
// `GET /admin/host-admin-token` would walk away with a JWT carrying
|
|
82
|
+
// `parachute:host:admin` + `parachute:host:auth` — full admin access.
|
|
83
|
+
// The 403 here is mirrored on the SPA side in `web/ui/src/lib/auth.ts`:
|
|
84
|
+
// 403 → redirect to `/account/` (the friend's home).
|
|
85
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
86
|
+
return jsonError(
|
|
87
|
+
403,
|
|
88
|
+
"not_admin",
|
|
89
|
+
"host-admin token mint is restricted to the hub admin — your account home is at /account/",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
67
92
|
const minted = await signAccessToken(deps.db, {
|
|
68
93
|
sub: session.userId,
|
|
69
94
|
scopes: [...HOST_ADMIN_SCOPES],
|