@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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 +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- 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 +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- 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 +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- 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 +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- 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 +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -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-tRmPbbC7.js +0 -61
package/src/account-home-ui.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
22
22
|
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
23
23
|
import { escapeHtml } from "./oauth-ui.ts";
|
|
24
|
+
import type { VaultVerb } from "./users.ts";
|
|
24
25
|
|
|
25
26
|
// --- shared chrome (mirrors account-change-password-ui.ts) ----------------
|
|
26
27
|
|
|
@@ -109,8 +110,56 @@ export interface RenderAccountHomeOpts {
|
|
|
109
110
|
* to keep a cross-origin form from logging the user out.
|
|
110
111
|
*/
|
|
111
112
|
csrfToken: string;
|
|
113
|
+
/**
|
|
114
|
+
* Whether the signed-in user has TOTP 2FA enrolled (hub#473). Drives the
|
|
115
|
+
* Account card's 2FA status line + enroll/manage link. The link points at
|
|
116
|
+
* `/account/2fa` either way — "Set up" when off, "Manage" when on.
|
|
117
|
+
*/
|
|
118
|
+
twoFactorEnabled: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Per-vault mintable verbs for the "mint an access token" affordance on
|
|
121
|
+
* each vault tile (friend headless-client path). Maps `vaultName` → the
|
|
122
|
+
* verbs the user's assignment role permits (today always
|
|
123
|
+
* `["read", "write"]` since every `user_vaults.role` is `'write'`). A
|
|
124
|
+
* vault absent from this map (or mapped to an empty list) renders no mint
|
|
125
|
+
* affordance — the UI never offers a verb the server would reject. The
|
|
126
|
+
* `/account/` GET handler builds this from `vaultVerbsForUserVault` for
|
|
127
|
+
* each assigned vault. Omitted (or empty) for the admin / no-vault
|
|
128
|
+
* branches, where no token-mint tile is shown.
|
|
129
|
+
*/
|
|
130
|
+
mintableVerbs?: Record<string, VaultVerb[]>;
|
|
131
|
+
/**
|
|
132
|
+
* Set after a successful `POST /account/vault-token/<name>` to show the
|
|
133
|
+
* freshly-minted token ONCE (the only time it's ever shown — the hub keeps
|
|
134
|
+
* no plaintext copy). Drives the show-once banner at the top of the page.
|
|
135
|
+
* Absent on the normal GET render.
|
|
136
|
+
*/
|
|
137
|
+
mintedToken?: MintedTokenView;
|
|
138
|
+
/**
|
|
139
|
+
* Set after a `POST /account/vault-token/<name>` that failed authorization
|
|
140
|
+
* or validation, to surface an inline error banner on the re-rendered page
|
|
141
|
+
* (e.g. unassigned vault, capped verb, rate-limited). Absent on success and
|
|
142
|
+
* on the normal GET render.
|
|
143
|
+
*/
|
|
144
|
+
mintError?: string;
|
|
112
145
|
}
|
|
113
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The one-time view of a freshly-minted friend vault token. The hub stores
|
|
149
|
+
* only a hash-keyed registry row (no plaintext), so this is the single moment
|
|
150
|
+
* the token string is shown — the UI copy makes that explicit.
|
|
151
|
+
*/
|
|
152
|
+
export interface MintedTokenView {
|
|
153
|
+
vaultName: string;
|
|
154
|
+
verb: VaultVerb;
|
|
155
|
+
token: string;
|
|
156
|
+
/** Whole-day TTL for the "expires in N days" copy. */
|
|
157
|
+
expiresInDays: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Friend-mint token default lifetime: 90 days (matches the CLI/api-mint default). */
|
|
161
|
+
export const ACCOUNT_VAULT_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
162
|
+
|
|
114
163
|
export function renderAccountHome(opts: RenderAccountHomeOpts): string {
|
|
115
164
|
const { username, assignedVaults, hubOrigin, isFirstAdmin, csrfToken } = opts;
|
|
116
165
|
const safeUsername = escapeHtml(username);
|
|
@@ -118,15 +167,25 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
|
|
|
118
167
|
// but defensively re-trim so a stray slash here doesn't break the CTA href.
|
|
119
168
|
const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
|
|
120
169
|
|
|
170
|
+
const mintedBanner = opts.mintedToken ? renderMintedTokenBanner(opts.mintedToken) : "";
|
|
171
|
+
const mintErrorBanner = opts.mintError
|
|
172
|
+
? `<div class="mint-error-banner" data-testid="mint-error-banner" role="alert">${escapeHtml(
|
|
173
|
+
opts.mintError,
|
|
174
|
+
)}</div>`
|
|
175
|
+
: "";
|
|
176
|
+
|
|
121
177
|
const vaultCard = renderVaultCard({
|
|
122
178
|
assignedVaults,
|
|
123
179
|
trimmedOrigin,
|
|
124
180
|
isFirstAdmin,
|
|
181
|
+
csrfToken,
|
|
182
|
+
mintableVerbs: opts.mintableVerbs ?? {},
|
|
125
183
|
});
|
|
126
184
|
|
|
127
185
|
const accountCard = renderAccountCard({
|
|
128
186
|
username: safeUsername,
|
|
129
187
|
csrfToken,
|
|
188
|
+
twoFactorEnabled: opts.twoFactorEnabled,
|
|
130
189
|
});
|
|
131
190
|
|
|
132
191
|
const body = `
|
|
@@ -136,9 +195,11 @@ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
|
|
|
136
195
|
<h1>Welcome, ${safeUsername}</h1>
|
|
137
196
|
<p class="subtitle">Your Parachute account home.</p>
|
|
138
197
|
</div>
|
|
198
|
+
${mintedBanner}
|
|
199
|
+
${mintErrorBanner}
|
|
139
200
|
${vaultCard}
|
|
140
201
|
${accountCard}
|
|
141
|
-
</div
|
|
202
|
+
</div>${COPY_SCRIPT}`;
|
|
142
203
|
return baseDocument(`${username} — Parachute`, body);
|
|
143
204
|
}
|
|
144
205
|
|
|
@@ -146,49 +207,126 @@ interface VaultCardOpts {
|
|
|
146
207
|
assignedVaults: string[];
|
|
147
208
|
trimmedOrigin: string;
|
|
148
209
|
isFirstAdmin: boolean;
|
|
210
|
+
csrfToken: string;
|
|
211
|
+
mintableVerbs: Record<string, VaultVerb[]>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Build `<hub-origin>/vault/<name>/mcp` — the MCP endpoint a client connects
|
|
216
|
+
* to. Mirrors the SPA's `mcpEndpointFor` (McpConnectCard.tsx) and the
|
|
217
|
+
* wizard's `renderMcpTile`. The friend's `/account/` tile is server-rendered
|
|
218
|
+
* (no SPA), so we compute it here from the hub origin + vault name rather
|
|
219
|
+
* than the vault's well-known `url`.
|
|
220
|
+
*
|
|
221
|
+
* Exported for direct unit testing.
|
|
222
|
+
*/
|
|
223
|
+
export function accountMcpEndpoint(trimmedOrigin: string, vaultName: string): string {
|
|
224
|
+
return `${trimmedOrigin}/vault/${vaultName}/mcp`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The OAuth-path `claude mcp add` command — no token, triggers browser OAuth
|
|
229
|
+
* on first use. Same `parachute-<name>` server name as the SPA card and the
|
|
230
|
+
* wizard tile, so a friend and the operator end up with identically-named
|
|
231
|
+
* MCP servers. Exported for direct unit testing.
|
|
232
|
+
*/
|
|
233
|
+
export function accountClaudeMcpAddCommand(trimmedOrigin: string, vaultName: string): string {
|
|
234
|
+
return `claude mcp add --transport http parachute-${vaultName} ${accountMcpEndpoint(
|
|
235
|
+
trimmedOrigin,
|
|
236
|
+
vaultName,
|
|
237
|
+
)}`;
|
|
149
238
|
}
|
|
150
239
|
|
|
151
240
|
function renderVaultCard(opts: VaultCardOpts): string {
|
|
152
|
-
const { assignedVaults, trimmedOrigin, isFirstAdmin } = opts;
|
|
241
|
+
const { assignedVaults, trimmedOrigin, isFirstAdmin, csrfToken, mintableVerbs } = opts;
|
|
153
242
|
|
|
154
243
|
if (assignedVaults.length > 0) {
|
|
155
|
-
// One vault tile per assignment (multi-user Phase 2 PR 2). Each
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
244
|
+
// One vault tile per assignment (multi-user Phase 2 PR 2). Each tile
|
|
245
|
+
// leads with a friendly "connect your AI assistant to this vault" block
|
|
246
|
+
// that covers BOTH connect paths a non-technical friend is likely to
|
|
247
|
+
// use — Claude Code (the `claude mcp add` CLI command) and Claude.ai on
|
|
248
|
+
// the web (Settings → Connectors → Add custom connector, pointed at the
|
|
249
|
+
// endpoint). Both are the OAuth path — no token to paste, the first
|
|
250
|
+
// connection opens a browser to sign in + approve. The Notes "Open" CTA
|
|
251
|
+
// sits alongside as the browser-UI option. Phrasing mirrors
|
|
252
|
+
// parachute.computer/install.njk's #connect-mcp-clients section so the
|
|
253
|
+
// operator docs and the friend's account page stay consistent.
|
|
254
|
+
//
|
|
255
|
+
// This closes the multi-user gap where the friend tile read as MCP
|
|
256
|
+
// jargon ("Connect an MCP client") rather than "here's how to connect
|
|
257
|
+
// this to your AI" — and where the web (Claude.ai) path was entirely
|
|
258
|
+
// missing, only the Claude Code CLI command was offered.
|
|
161
259
|
const heading = assignedVaults.length === 1 ? "<h2>Your vault</h2>" : "<h2>Your vaults</h2>";
|
|
162
260
|
const tiles = assignedVaults
|
|
163
261
|
.map((vaultName) => {
|
|
164
262
|
const safeVault = escapeHtml(vaultName);
|
|
165
263
|
const vaultUrlForAdd = encodeURIComponent(`${trimmedOrigin}/vault/${vaultName}`);
|
|
264
|
+
const endpoint = accountMcpEndpoint(trimmedOrigin, vaultName);
|
|
265
|
+
const addCmd = accountClaudeMcpAddCommand(trimmedOrigin, vaultName);
|
|
266
|
+
const safeEndpoint = escapeHtml(endpoint);
|
|
267
|
+
const safeAddCmd = escapeHtml(addCmd);
|
|
268
|
+
const tokenMintBlock = renderTokenMintBlock(
|
|
269
|
+
vaultName,
|
|
270
|
+
safeVault,
|
|
271
|
+
mintableVerbs[vaultName] ?? [],
|
|
272
|
+
csrfToken,
|
|
273
|
+
);
|
|
166
274
|
return `
|
|
167
275
|
<div class="vault-tile" data-testid="vault-tile" data-vault-name="${safeVault}">
|
|
168
276
|
<p class="vault-name"><strong>${safeVault}</strong></p>
|
|
169
|
-
<
|
|
277
|
+
<div class="mcp-connect" data-testid="mcp-connect">
|
|
278
|
+
<p class="mcp-connect-label" data-testid="connect-ai-heading">Connect your AI
|
|
279
|
+
assistant to this vault</p>
|
|
280
|
+
<p class="mcp-connect-intro">Two common ways. Both sign you in to this hub over
|
|
281
|
+
HTTPS and ask you to approve access the first time — no token to copy.</p>
|
|
282
|
+
|
|
283
|
+
<div class="mcp-method" data-testid="connect-method-claude-code">
|
|
284
|
+
<p class="mcp-method-title">Claude Code (terminal)</p>
|
|
285
|
+
<p class="mcp-method-sub">Run this in your terminal:</p>
|
|
286
|
+
<div class="copy-row">
|
|
287
|
+
<code data-testid="mcp-add-command">${safeAddCmd}</code>
|
|
288
|
+
<button type="button" class="btn btn-copy" data-copy="${safeAddCmd}"
|
|
289
|
+
data-testid="copy-mcp-add-command">Copy</button>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div class="mcp-method" data-testid="connect-method-claude-ai">
|
|
294
|
+
<p class="mcp-method-title">Claude.ai (web)</p>
|
|
295
|
+
<p class="mcp-method-sub">In Claude.ai, open <strong>Settings → Connectors</strong>,
|
|
296
|
+
choose <strong>Add custom connector</strong>, and paste this endpoint:</p>
|
|
297
|
+
<div class="copy-row">
|
|
298
|
+
<code data-testid="mcp-endpoint">${safeEndpoint}</code>
|
|
299
|
+
<button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
|
|
300
|
+
data-testid="copy-mcp-endpoint">Copy</button>
|
|
301
|
+
</div>
|
|
302
|
+
<p class="mcp-method-note">Claude.ai then redirects you here to sign in and
|
|
303
|
+
approve. (Your hub must be reachable from the web for this.)</p>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<p class="mcp-connect-hint" data-testid="connect-any-client-hint">Any other MCP
|
|
307
|
+
client (Codex, Goose, Cursor, your own agent): point it at the same endpoint
|
|
308
|
+
above over HTTP.</p>
|
|
309
|
+
</div>
|
|
310
|
+
<p class="vault-notes-cta">
|
|
170
311
|
<a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
|
|
171
312
|
target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
|
|
313
|
+
<span class="vault-notes-cta-sub">Prefer a browser UI? Open Notes to browse +
|
|
314
|
+
capture in this vault.</span>
|
|
172
315
|
</p>
|
|
316
|
+
${tokenMintBlock}
|
|
173
317
|
</div>`;
|
|
174
318
|
})
|
|
175
319
|
.join("");
|
|
176
320
|
return `
|
|
177
321
|
<section class="section" data-testid="vault-card">
|
|
178
322
|
${heading}
|
|
179
|
-
<p>
|
|
323
|
+
<p>Connect Claude (or any AI assistant) to your vault${
|
|
180
324
|
assignedVaults.length === 1 ? "" : "s"
|
|
181
|
-
}
|
|
182
|
-
|
|
325
|
+
} — pick Claude Code or
|
|
326
|
+
Claude.ai below — or open Notes for a browser UI. The first connection signs you in
|
|
327
|
+
to your hub over HTTPS and asks you to approve access.</p>
|
|
183
328
|
<div class="vault-tiles">${tiles}
|
|
184
329
|
</div>
|
|
185
|
-
<details class="custom-client">
|
|
186
|
-
<summary>Use a custom client</summary>
|
|
187
|
-
<p>To connect a custom Surface or MCP client running on your own machine,
|
|
188
|
-
point it at the hub origin below and run through OAuth — the hub will
|
|
189
|
-
ask you to consent.</p>
|
|
190
|
-
<p class="hub-origin-line">Hub origin: <code>${hubOriginDisplay}</code></p>
|
|
191
|
-
</details>
|
|
192
330
|
</section>`;
|
|
193
331
|
}
|
|
194
332
|
if (isFirstAdmin) {
|
|
@@ -207,28 +345,140 @@ function renderVaultCard(opts: VaultCardOpts): string {
|
|
|
207
345
|
return `
|
|
208
346
|
<section class="section" data-testid="no-vault-card">
|
|
209
347
|
<h2>Your vault</h2>
|
|
210
|
-
<p>
|
|
211
|
-
|
|
348
|
+
<p>You don't have a vault yet, so there's nothing to connect to. A vault
|
|
349
|
+
is your personal knowledge store on this hub — once the operator
|
|
350
|
+
assigns you one, this page will show you how to connect Claude (or
|
|
351
|
+
any AI assistant) to it.</p>
|
|
352
|
+
<p><strong>Ask the hub operator to assign you a vault.</strong></p>
|
|
212
353
|
</section>`;
|
|
213
354
|
}
|
|
214
355
|
|
|
356
|
+
/**
|
|
357
|
+
* The "mint an access token (for scripts / headless clients)" affordance on a
|
|
358
|
+
* vault tile. Sits BELOW the OAuth connect block + Notes CTA — the no-token
|
|
359
|
+
* OAuth path stays the recommended default; this is the secondary, opt-in
|
|
360
|
+
* path for clients that can't do an interactive browser sign-in (cron jobs,
|
|
361
|
+
* headless agents, a `curl` script).
|
|
362
|
+
*
|
|
363
|
+
* Renders one radio per verb the user's assignment role permits (`verbs` —
|
|
364
|
+
* today always `["read", "write"]`). The UI NEVER offers a verb the server
|
|
365
|
+
* would reject: a read-only assignment shows only "Read". An empty `verbs`
|
|
366
|
+
* list (unknown / unmappable role) renders nothing — fail-closed, matching
|
|
367
|
+
* the server's `vaultVerbsForUserVault` returning `[]`.
|
|
368
|
+
*
|
|
369
|
+
* The form POSTs `application/x-www-form-urlencoded` to
|
|
370
|
+
* `/account/vault-token/<name>` with the CSRF hidden field + a `verb` radio —
|
|
371
|
+
* same no-JS-required posture as the change-password and sign-out forms. The
|
|
372
|
+
* `<details>` keeps it collapsed by default so the tile leads with the
|
|
373
|
+
* recommended OAuth path.
|
|
374
|
+
*/
|
|
375
|
+
function renderTokenMintBlock(
|
|
376
|
+
vaultName: string,
|
|
377
|
+
safeVault: string,
|
|
378
|
+
verbs: VaultVerb[],
|
|
379
|
+
csrfToken: string,
|
|
380
|
+
): string {
|
|
381
|
+
if (verbs.length === 0) return "";
|
|
382
|
+
// Path segment is URL-encoded; the action attribute is HTML-escaped on top.
|
|
383
|
+
const action = escapeHtml(`/account/vault-token/${encodeURIComponent(vaultName)}`);
|
|
384
|
+
const radios = verbs
|
|
385
|
+
.map((verb, i) => {
|
|
386
|
+
const checked = i === 0 ? " checked" : "";
|
|
387
|
+
const label = verb === "read" ? "Read-only" : "Read + write";
|
|
388
|
+
return `
|
|
389
|
+
<label class="mint-verb-option">
|
|
390
|
+
<input type="radio" name="verb" value="${verb}"${checked}
|
|
391
|
+
data-testid="mint-verb-${verb}" />
|
|
392
|
+
<span><strong>${verb}</strong> — ${label} access to <code>${safeVault}</code></span>
|
|
393
|
+
</label>`;
|
|
394
|
+
})
|
|
395
|
+
.join("");
|
|
396
|
+
return `
|
|
397
|
+
<details class="token-mint" data-testid="token-mint">
|
|
398
|
+
<summary data-testid="token-mint-summary">Mint an access token
|
|
399
|
+
<span class="token-mint-sub">for scripts / headless clients</span></summary>
|
|
400
|
+
<div class="token-mint-body">
|
|
401
|
+
<p class="token-mint-intro">Most clients should use the no-token
|
|
402
|
+
connect options above — they sign you in over HTTPS and never
|
|
403
|
+
ask you to paste a secret. Mint a token only for a script or
|
|
404
|
+
headless client that can't open a browser to sign in. It's a
|
|
405
|
+
bearer for <code>vault:${safeVault}:<verb></code>, scoped to
|
|
406
|
+
<strong>this vault only</strong>, and you'll see it once.</p>
|
|
407
|
+
<form method="POST" action="${action}" class="mint-form"
|
|
408
|
+
data-testid="mint-form">
|
|
409
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
410
|
+
<fieldset class="mint-verbs">
|
|
411
|
+
<legend>Access level</legend>${radios}
|
|
412
|
+
</fieldset>
|
|
413
|
+
<button type="submit" class="btn btn-secondary"
|
|
414
|
+
data-testid="mint-token-button">Mint token</button>
|
|
415
|
+
</form>
|
|
416
|
+
</div>
|
|
417
|
+
</details>`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The show-once banner for a freshly-minted friend vault token. Rendered at
|
|
422
|
+
* the top of `/account/` after a successful `POST /account/vault-token/<name>`.
|
|
423
|
+
* The token string appears here and NOWHERE else — the hub stores only a
|
|
424
|
+
* hash-keyed registry row. The copy is explicit about that ("save it now —
|
|
425
|
+
* it won't be shown again"). No revoke link: there's no friend-facing revoke
|
|
426
|
+
* surface today, so we don't claim one.
|
|
427
|
+
*/
|
|
428
|
+
function renderMintedTokenBanner(view: MintedTokenView): string {
|
|
429
|
+
const safeVault = escapeHtml(view.vaultName);
|
|
430
|
+
const scope = `vault:${view.vaultName}:${view.verb}`;
|
|
431
|
+
const safeScope = escapeHtml(scope);
|
|
432
|
+
// The token value goes in a data attribute for the copy button + as text.
|
|
433
|
+
// It's a hub-signed JWT (no HTML-significant chars in base64url + dots), but
|
|
434
|
+
// escape defensively all the same.
|
|
435
|
+
const safeToken = escapeHtml(view.token);
|
|
436
|
+
return `
|
|
437
|
+
<div class="minted-banner" data-testid="minted-token-banner" role="status">
|
|
438
|
+
<p class="minted-title">Your access token for <code>${safeVault}</code></p>
|
|
439
|
+
<p class="minted-warn"><strong>Save it now — it won't be shown again.</strong>
|
|
440
|
+
This is a bearer credential for <code>${safeScope}</code>. It expires in
|
|
441
|
+
${view.expiresInDays} days. Treat it like a password; anyone who has it
|
|
442
|
+
can act on this vault at that access level.</p>
|
|
443
|
+
<div class="copy-row">
|
|
444
|
+
<code data-testid="minted-token-value">${safeToken}</code>
|
|
445
|
+
<button type="button" class="btn btn-copy" data-copy="${safeToken}"
|
|
446
|
+
data-testid="copy-minted-token">Copy</button>
|
|
447
|
+
</div>
|
|
448
|
+
<p class="minted-hint">Use it as <code>Authorization: Bearer <token></code>
|
|
449
|
+
when calling this vault's MCP endpoint. To revoke it, ask the hub operator.</p>
|
|
450
|
+
</div>`;
|
|
451
|
+
}
|
|
452
|
+
|
|
215
453
|
interface AccountCardOpts {
|
|
216
454
|
username: string;
|
|
217
455
|
csrfToken: string;
|
|
456
|
+
twoFactorEnabled: boolean;
|
|
218
457
|
}
|
|
219
458
|
|
|
220
459
|
function renderAccountCard(opts: AccountCardOpts): string {
|
|
221
|
-
const { username, csrfToken } = opts;
|
|
460
|
+
const { username, csrfToken, twoFactorEnabled } = opts;
|
|
461
|
+
const twoFactorStatus = twoFactorEnabled
|
|
462
|
+
? `<dd><span class="badge badge-on" data-testid="2fa-status">On</span></dd>`
|
|
463
|
+
: `<dd><span class="badge badge-off" data-testid="2fa-status">Off</span></dd>`;
|
|
464
|
+
const twoFactorLink = twoFactorEnabled
|
|
465
|
+
? `<a class="account-action" href="/account/2fa" data-testid="manage-2fa-link">Manage two-factor →</a>`
|
|
466
|
+
: `<a class="account-action" href="/account/2fa" data-testid="setup-2fa-link">Set up two-factor →</a>`;
|
|
222
467
|
return `
|
|
223
468
|
<section class="section" data-testid="account-card">
|
|
224
469
|
<h2>Account</h2>
|
|
225
470
|
<dl class="kv">
|
|
226
471
|
<dt>Username</dt>
|
|
227
472
|
<dd><code>${username}</code></dd>
|
|
473
|
+
<dt>Two-factor authentication</dt>
|
|
474
|
+
${twoFactorStatus}
|
|
228
475
|
</dl>
|
|
229
476
|
<p>
|
|
230
477
|
<a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
|
|
231
478
|
</p>
|
|
479
|
+
<p>
|
|
480
|
+
${twoFactorLink}
|
|
481
|
+
</p>
|
|
232
482
|
<form method="POST" action="/logout" class="signout-form" data-testid="signout-form">
|
|
233
483
|
${renderCsrfHiddenInput(csrfToken)}
|
|
234
484
|
<button type="submit" class="btn btn-secondary">Sign out</button>
|
|
@@ -236,12 +486,41 @@ function renderAccountCard(opts: AccountCardOpts): string {
|
|
|
236
486
|
</section>`;
|
|
237
487
|
}
|
|
238
488
|
|
|
489
|
+
// --- copy-button script ---------------------------------------------------
|
|
490
|
+
//
|
|
491
|
+
// Tiny inline progressive-enhancement script for the page's copy buttons
|
|
492
|
+
// (the per-tile MCP command/endpoint buttons AND the show-once minted-token
|
|
493
|
+
// banner's Copy button). Delegated click handler reads the value from the
|
|
494
|
+
// button's `data-copy` attribute and writes it to the clipboard, flashing
|
|
495
|
+
// "Copied ✓" for 2s. No-ops gracefully when the Clipboard API is unavailable
|
|
496
|
+
// (insecure context, older browser) — the value stays selectable in the
|
|
497
|
+
// codebox. Mirrors the SPA `CopyButton` posture (McpConnectCard.tsx) for a
|
|
498
|
+
// surface that has no React. Emitted once at the page body level so it covers
|
|
499
|
+
// both the vault tiles and the minted-token banner (which lives above the
|
|
500
|
+
// vault card).
|
|
501
|
+
const COPY_SCRIPT = `
|
|
502
|
+
<script>
|
|
503
|
+
(function () {
|
|
504
|
+
document.addEventListener('click', function (e) {
|
|
505
|
+
var btn = e.target && e.target.closest ? e.target.closest('[data-copy]') : null;
|
|
506
|
+
if (!btn) return;
|
|
507
|
+
var value = btn.getAttribute('data-copy') || '';
|
|
508
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
|
509
|
+
navigator.clipboard.writeText(value).then(function () {
|
|
510
|
+
var original = btn.textContent;
|
|
511
|
+
btn.textContent = 'Copied \\u2713';
|
|
512
|
+
setTimeout(function () { btn.textContent = original; }, 2000);
|
|
513
|
+
}).catch(function () { /* insecure context — leave selectable */ });
|
|
514
|
+
});
|
|
515
|
+
})();
|
|
516
|
+
</script>`;
|
|
517
|
+
|
|
239
518
|
// --- styles ---------------------------------------------------------------
|
|
240
519
|
//
|
|
241
520
|
// Same brand palette + font stack as account-change-password-ui.ts so the
|
|
242
521
|
// `/account/*` family is visually cohesive. Extra rules (.section, .kv,
|
|
243
|
-
// .vault-name, .
|
|
244
|
-
//
|
|
522
|
+
// .vault-name, .mcp-connect, .copy-row) describe the new card + MCP
|
|
523
|
+
// connect-block shapes this page introduces.
|
|
245
524
|
|
|
246
525
|
const STYLES = `
|
|
247
526
|
*, *::before, *::after { box-sizing: border-box; }
|
|
@@ -338,22 +617,173 @@ const STYLES = `
|
|
|
338
617
|
.vault-tile p { margin: 0.2rem 0; }
|
|
339
618
|
.vault-tile p:last-child { margin-top: 0.5rem; }
|
|
340
619
|
|
|
341
|
-
.
|
|
342
|
-
|
|
343
|
-
|
|
620
|
+
.mcp-connect {
|
|
621
|
+
margin-bottom: 0.75rem;
|
|
622
|
+
}
|
|
623
|
+
.mcp-connect-label {
|
|
624
|
+
font-family: ${FONT_SERIF};
|
|
625
|
+
font-size: 1.05rem;
|
|
626
|
+
font-weight: 400;
|
|
627
|
+
color: ${PALETTE.fg};
|
|
628
|
+
margin: 0 0 0.3rem;
|
|
629
|
+
}
|
|
630
|
+
.mcp-connect-intro {
|
|
344
631
|
font-size: 0.85rem;
|
|
345
632
|
color: ${PALETTE.fgMuted};
|
|
346
|
-
|
|
347
|
-
padding: 0.25rem 0;
|
|
348
|
-
user-select: none;
|
|
633
|
+
margin: 0 0 0.75rem;
|
|
349
634
|
}
|
|
350
|
-
.
|
|
351
|
-
|
|
635
|
+
.mcp-method {
|
|
636
|
+
margin: 0.75rem 0;
|
|
637
|
+
padding-top: 0.6rem;
|
|
638
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
639
|
+
}
|
|
640
|
+
.mcp-method-title {
|
|
352
641
|
font-size: 0.9rem;
|
|
642
|
+
font-weight: 600;
|
|
643
|
+
color: ${PALETTE.fg};
|
|
644
|
+
margin: 0 0 0.15rem;
|
|
645
|
+
}
|
|
646
|
+
.mcp-method-sub {
|
|
647
|
+
font-size: 0.82rem;
|
|
353
648
|
color: ${PALETTE.fgMuted};
|
|
354
|
-
margin: 0.4rem
|
|
649
|
+
margin: 0 0 0.4rem;
|
|
650
|
+
}
|
|
651
|
+
.mcp-method-note {
|
|
652
|
+
font-size: 0.78rem;
|
|
653
|
+
color: ${PALETTE.fgMuted};
|
|
654
|
+
margin: 0.35rem 0 0;
|
|
355
655
|
}
|
|
356
|
-
.
|
|
656
|
+
.mcp-field { margin: 0.5rem 0; }
|
|
657
|
+
.mcp-field-label {
|
|
658
|
+
display: block;
|
|
659
|
+
font-size: 0.7rem;
|
|
660
|
+
text-transform: uppercase;
|
|
661
|
+
letter-spacing: 0.06em;
|
|
662
|
+
color: ${PALETTE.fgMuted};
|
|
663
|
+
font-family: ${FONT_MONO};
|
|
664
|
+
margin-bottom: 0.2rem;
|
|
665
|
+
}
|
|
666
|
+
.vault-notes-cta {
|
|
667
|
+
margin: 0.9rem 0 0;
|
|
668
|
+
padding-top: 0.6rem;
|
|
669
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
670
|
+
display: flex;
|
|
671
|
+
align-items: center;
|
|
672
|
+
flex-wrap: wrap;
|
|
673
|
+
gap: 0.5rem 0.75rem;
|
|
674
|
+
}
|
|
675
|
+
.vault-notes-cta-sub {
|
|
676
|
+
font-size: 0.82rem;
|
|
677
|
+
color: ${PALETTE.fgMuted};
|
|
678
|
+
flex: 1 1 12rem;
|
|
679
|
+
}
|
|
680
|
+
.copy-row {
|
|
681
|
+
display: flex;
|
|
682
|
+
align-items: center;
|
|
683
|
+
gap: 0.5rem;
|
|
684
|
+
background: ${PALETTE.cardBg};
|
|
685
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
686
|
+
border-radius: 6px;
|
|
687
|
+
padding: 0.4rem 0.5rem;
|
|
688
|
+
}
|
|
689
|
+
.copy-row code {
|
|
690
|
+
flex: 1 1 auto;
|
|
691
|
+
overflow-x: auto;
|
|
692
|
+
white-space: nowrap;
|
|
693
|
+
background: transparent;
|
|
694
|
+
padding: 0;
|
|
695
|
+
font-size: 0.82rem;
|
|
696
|
+
}
|
|
697
|
+
.btn-copy {
|
|
698
|
+
flex: 0 0 auto;
|
|
699
|
+
font-size: 0.8rem;
|
|
700
|
+
padding: 0.3rem 0.7rem;
|
|
701
|
+
background: transparent;
|
|
702
|
+
color: ${PALETTE.fg};
|
|
703
|
+
border-color: ${PALETTE.border};
|
|
704
|
+
}
|
|
705
|
+
.btn-copy:hover { background: ${PALETTE.bgSoft}; border-color: ${PALETTE.accent}; }
|
|
706
|
+
.mcp-connect-hint {
|
|
707
|
+
font-size: 0.82rem;
|
|
708
|
+
color: ${PALETTE.fgMuted};
|
|
709
|
+
margin: 0.4rem 0 0;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.token-mint {
|
|
713
|
+
margin: 0.9rem 0 0;
|
|
714
|
+
padding-top: 0.6rem;
|
|
715
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
716
|
+
}
|
|
717
|
+
.token-mint > summary {
|
|
718
|
+
cursor: pointer;
|
|
719
|
+
font-size: 0.88rem;
|
|
720
|
+
font-weight: 600;
|
|
721
|
+
color: ${PALETTE.fg};
|
|
722
|
+
list-style: revert;
|
|
723
|
+
}
|
|
724
|
+
.token-mint-sub {
|
|
725
|
+
font-weight: 400;
|
|
726
|
+
font-size: 0.8rem;
|
|
727
|
+
color: ${PALETTE.fgMuted};
|
|
728
|
+
}
|
|
729
|
+
.token-mint-body { margin-top: 0.6rem; }
|
|
730
|
+
.token-mint-intro {
|
|
731
|
+
font-size: 0.8rem;
|
|
732
|
+
color: ${PALETTE.fgMuted};
|
|
733
|
+
margin: 0 0 0.6rem;
|
|
734
|
+
}
|
|
735
|
+
.mint-verbs {
|
|
736
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
737
|
+
border-radius: 6px;
|
|
738
|
+
padding: 0.5rem 0.7rem;
|
|
739
|
+
margin: 0 0 0.6rem;
|
|
740
|
+
}
|
|
741
|
+
.mint-verbs legend {
|
|
742
|
+
font-size: 0.7rem;
|
|
743
|
+
text-transform: uppercase;
|
|
744
|
+
letter-spacing: 0.06em;
|
|
745
|
+
color: ${PALETTE.fgMuted};
|
|
746
|
+
font-family: ${FONT_MONO};
|
|
747
|
+
padding: 0 0.3rem;
|
|
748
|
+
}
|
|
749
|
+
.mint-verb-option {
|
|
750
|
+
display: flex;
|
|
751
|
+
align-items: baseline;
|
|
752
|
+
gap: 0.5rem;
|
|
753
|
+
font-size: 0.85rem;
|
|
754
|
+
margin: 0.3rem 0;
|
|
755
|
+
}
|
|
756
|
+
.mint-verb-option input { margin: 0; }
|
|
757
|
+
.mint-form .btn { margin-top: 0.2rem; }
|
|
758
|
+
|
|
759
|
+
.minted-banner {
|
|
760
|
+
border: 1px solid ${PALETTE.accent};
|
|
761
|
+
background: ${PALETTE.accentSoft};
|
|
762
|
+
border-radius: 8px;
|
|
763
|
+
padding: 0.9rem 1rem;
|
|
764
|
+
margin: 1.25rem 0 0;
|
|
765
|
+
}
|
|
766
|
+
.minted-title {
|
|
767
|
+
font-family: ${FONT_SERIF};
|
|
768
|
+
font-size: 1.05rem;
|
|
769
|
+
margin: 0 0 0.3rem;
|
|
770
|
+
color: ${PALETTE.fg};
|
|
771
|
+
}
|
|
772
|
+
.minted-warn { font-size: 0.85rem; margin: 0 0 0.6rem; color: ${PALETTE.fg}; }
|
|
773
|
+
.minted-banner .copy-row { margin: 0.4rem 0; }
|
|
774
|
+
.minted-banner .copy-row code { font-size: 0.72rem; }
|
|
775
|
+
.minted-hint { font-size: 0.8rem; color: ${PALETTE.fgMuted}; margin: 0.4rem 0 0; }
|
|
776
|
+
|
|
777
|
+
.mint-error-banner {
|
|
778
|
+
border: 1px solid ${PALETTE.danger};
|
|
779
|
+
background: ${PALETTE.dangerSoft};
|
|
780
|
+
color: ${PALETTE.danger};
|
|
781
|
+
border-radius: 8px;
|
|
782
|
+
padding: 0.7rem 1rem;
|
|
783
|
+
margin: 1.25rem 0 0;
|
|
784
|
+
font-size: 0.88rem;
|
|
785
|
+
}
|
|
786
|
+
|
|
357
787
|
code {
|
|
358
788
|
font-family: ${FONT_MONO};
|
|
359
789
|
background: ${PALETTE.bgSoft};
|
|
@@ -373,6 +803,17 @@ const STYLES = `
|
|
|
373
803
|
}
|
|
374
804
|
.kv dd { margin: 0.15rem 0 0.4rem; }
|
|
375
805
|
|
|
806
|
+
.badge {
|
|
807
|
+
display: inline-block;
|
|
808
|
+
font-size: 0.78rem;
|
|
809
|
+
font-weight: 500;
|
|
810
|
+
padding: 0.1rem 0.5rem;
|
|
811
|
+
border-radius: 999px;
|
|
812
|
+
border: 1px solid transparent;
|
|
813
|
+
}
|
|
814
|
+
.badge-on { background: ${PALETTE.successSoft}; color: ${PALETTE.success}; border-color: ${PALETTE.success}; }
|
|
815
|
+
.badge-off { background: ${PALETTE.bgSoft}; color: ${PALETTE.fgMuted}; border-color: ${PALETTE.border}; }
|
|
816
|
+
|
|
376
817
|
.btn {
|
|
377
818
|
font: inherit;
|
|
378
819
|
font-weight: 500;
|
|
@@ -423,12 +864,21 @@ const STYLES = `
|
|
|
423
864
|
body { background: #1a1815; color: #e8e4dc; }
|
|
424
865
|
.card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
|
425
866
|
h1, h2 { color: #f0ece4; }
|
|
426
|
-
.subtitle, .kv dt, .
|
|
427
|
-
.
|
|
867
|
+
.subtitle, .kv dt, .mcp-field-label, .mcp-connect-hint,
|
|
868
|
+
.mcp-connect-intro, .mcp-method-sub, .mcp-method-note,
|
|
869
|
+
.vault-notes-cta-sub { color: #a8a29a; }
|
|
870
|
+
.vault-name strong, .mcp-connect-label, .mcp-method-title { color: #f0ece4; }
|
|
428
871
|
code { background: #1f1c18; color: #e8e4dc; }
|
|
872
|
+
.copy-row code { background: transparent; }
|
|
429
873
|
.section { border-top-color: #3a362f; }
|
|
874
|
+
.mcp-method, .vault-notes-cta, .token-mint { border-top-color: #3a362f; }
|
|
430
875
|
.brand-tag { border-color: #3a362f; color: #a8a29a; }
|
|
431
|
-
.
|
|
432
|
-
.btn-secondary
|
|
876
|
+
.copy-row { background: #1f1c18; border-color: #3a362f; }
|
|
877
|
+
.btn-secondary, .btn-copy { color: #e8e4dc; border-color: #3a362f; }
|
|
878
|
+
.btn-secondary:hover, .btn-copy:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
|
|
879
|
+
.token-mint > summary { color: #f0ece4; }
|
|
880
|
+
.token-mint-sub, .token-mint-intro, .mint-verbs legend, .minted-hint { color: #a8a29a; }
|
|
881
|
+
.mint-verbs { border-color: #3a362f; }
|
|
882
|
+
.minted-title, .minted-warn { color: #f0ece4; }
|
|
433
883
|
}
|
|
434
884
|
`;
|