@openparachute/hub 0.5.10-rc.6 → 0.5.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 -1
- package/src/__tests__/admin-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +139 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +30 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/src/oauth-ui.ts
CHANGED
|
@@ -82,6 +82,28 @@ export interface ConsentViewProps {
|
|
|
82
82
|
* vault-config-and-scopes design — force the picker, don't default).
|
|
83
83
|
*/
|
|
84
84
|
vaultPicker?: VaultPicker;
|
|
85
|
+
/**
|
|
86
|
+
* Named-vault display: substitute unnamed `vault:<verb>` rows with
|
|
87
|
+
* `vault:<displayVault>:<verb>` in the rendered scope list so the user
|
|
88
|
+
* sees the scope shape that will actually be minted into the token. The
|
|
89
|
+
* raw `scopes` value still drives the form-roundtrip; this only changes
|
|
90
|
+
* what the operator reads.
|
|
91
|
+
*
|
|
92
|
+
* - Non-admin user (assigned_vault set): pass the assigned vault name so
|
|
93
|
+
* the row reads `vault:my-vault:read`.
|
|
94
|
+
* - Admin user with a single vault available + the picker pre-checks the
|
|
95
|
+
* first option: pass that name so the displayed scope matches what the
|
|
96
|
+
* default-Approve will mint.
|
|
97
|
+
* - Admin user with multiple vaults / no obvious default: pass `null` (or
|
|
98
|
+
* omit) and the row renders as `vault:<TBD>:read` with a tooltip
|
|
99
|
+
* pointing at the picker. Same explanation either way.
|
|
100
|
+
*
|
|
101
|
+
* Closes the "raw scope display" leg of the approval-UX bug — silent
|
|
102
|
+
* narrowing at mint surprised operators who thought they were granting
|
|
103
|
+
* vault-wide access. Now they see the named form on the consent screen
|
|
104
|
+
* itself.
|
|
105
|
+
*/
|
|
106
|
+
displayVault?: string | null;
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
export interface VaultPicker {
|
|
@@ -89,6 +111,20 @@ export interface VaultPicker {
|
|
|
89
111
|
unnamedVerbs: string[];
|
|
90
112
|
/** Vault names registered on this host. Empty → caller can't approve. */
|
|
91
113
|
availableVaults: string[];
|
|
114
|
+
/**
|
|
115
|
+
* Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md, decision-pin
|
|
116
|
+
* "consent picker for non-admin users"): set when the signed-in user has a
|
|
117
|
+
* non-null `assigned_vault`. The picker renders the vault name as a
|
|
118
|
+
* read-only label with an admin-managed note instead of the free dropdown
|
|
119
|
+
* — the user can't choose a different vault. The form still POSTs the
|
|
120
|
+
* locked value via `vault_pick` so the server-side defense in
|
|
121
|
+
* `handleConsentSubmit` (which refuses mints whose picked vault disagrees
|
|
122
|
+
* with the user's `assigned_vault`) sees an unambiguous value.
|
|
123
|
+
*
|
|
124
|
+
* Admin users (assigned_vault === null) leave this undefined and see the
|
|
125
|
+
* full dropdown — existing behavior.
|
|
126
|
+
*/
|
|
127
|
+
lockedVault?: string;
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
export interface ErrorViewProps {
|
|
@@ -99,10 +135,22 @@ export interface ErrorViewProps {
|
|
|
99
135
|
|
|
100
136
|
/**
|
|
101
137
|
* Props for the "App not yet approved" view rendered when an unapproved
|
|
102
|
-
* client lands on `/oauth/authorize`.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
138
|
+
* client lands on `/oauth/authorize`. Two-branch UI:
|
|
139
|
+
*
|
|
140
|
+
* - Authenticated admin (operator session present + same-origin) — render
|
|
141
|
+
* the inline approve form (closes #208). One click flips the client to
|
|
142
|
+
* `approved` and re-enters the OAuth flow at consent.
|
|
143
|
+
* - Unauthenticated viewer — render TWO CTAs (no terminal mention):
|
|
144
|
+
* 1. Primary: "Sign in as admin to approve" → links to
|
|
145
|
+
* `/login?next=/admin/approve-client/<client_id>` so the admin
|
|
146
|
+
* lands directly on the approval page after sign-in.
|
|
147
|
+
* 2. Secondary: a fully-qualified shareable deep link to
|
|
148
|
+
* `<hub_origin>/admin/approve-client/<client_id>` with a copy
|
|
149
|
+
* button — the operator can send it to whoever runs the hub.
|
|
150
|
+
*
|
|
151
|
+
* The CLI fallback (`parachute auth approve-client <id>`) was retired —
|
|
152
|
+
* the web path is the path now. Operators who want the CLI still have it
|
|
153
|
+
* in the shell; we no longer point new users there from the browser.
|
|
106
154
|
*/
|
|
107
155
|
export interface ApprovePendingViewProps {
|
|
108
156
|
/** Display name to show — falls back to client_id when no name was supplied at DCR. */
|
|
@@ -119,6 +167,13 @@ export interface ApprovePendingViewProps {
|
|
|
119
167
|
* #244). Single-vault hubs leave this absent and the section omits.
|
|
120
168
|
*/
|
|
121
169
|
requestedVault?: string;
|
|
170
|
+
/**
|
|
171
|
+
* Fully-qualified hub origin used to build the shareable approval
|
|
172
|
+
* deep-link in the unauthenticated branch. Required because the link
|
|
173
|
+
* the operator copies needs to work when opened in a different browser
|
|
174
|
+
* session — only the absolute URL gets that.
|
|
175
|
+
*/
|
|
176
|
+
hubOrigin: string;
|
|
122
177
|
/**
|
|
123
178
|
* When set, render the inline approve form. The form posts to
|
|
124
179
|
* `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
|
|
@@ -165,14 +220,25 @@ export function renderLogin(props: LoginViewProps): string {
|
|
|
165
220
|
}
|
|
166
221
|
|
|
167
222
|
export function renderConsent(props: ConsentViewProps): string {
|
|
168
|
-
const { params, clientName, clientId, scopes, vaultPicker, csrfToken } = props;
|
|
223
|
+
const { params, clientName, clientId, scopes, vaultPicker, csrfToken, displayVault } = props;
|
|
224
|
+
// Substitute unnamed `vault:<verb>` rows with the resolved named form so
|
|
225
|
+
// the operator sees the scope shape that will appear in the token. Raw
|
|
226
|
+
// `scopes` keeps the wire form for the hidden form fields; only what's
|
|
227
|
+
// rendered changes. See `ConsentViewProps.displayVault`.
|
|
228
|
+
const displayedScopes = scopes.map((s) => substituteVaultDisplay(s, displayVault));
|
|
169
229
|
const scopeRows =
|
|
170
|
-
|
|
230
|
+
displayedScopes.length === 0
|
|
171
231
|
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
172
|
-
:
|
|
232
|
+
: displayedScopes.map(renderScopeRow).join("\n");
|
|
173
233
|
const pickerSection = vaultPicker ? renderVaultPicker(vaultPicker) : "";
|
|
234
|
+
// Approve is disabled when the picker can't yield a valid vault. The
|
|
235
|
+
// empty-vault branch (no vaults registered) is the original case. A
|
|
236
|
+
// locked-vault picker (multi-user Phase 1) always has a valid value via
|
|
237
|
+
// the hidden input, so Approve stays enabled.
|
|
174
238
|
const approveDisabled =
|
|
175
|
-
vaultPicker && vaultPicker.availableVaults.length === 0
|
|
239
|
+
vaultPicker && vaultPicker.lockedVault === undefined && vaultPicker.availableVaults.length === 0
|
|
240
|
+
? " disabled"
|
|
241
|
+
: "";
|
|
176
242
|
const body = `
|
|
177
243
|
<div class="card">
|
|
178
244
|
<div class="card-header">
|
|
@@ -209,6 +275,32 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
209
275
|
|
|
210
276
|
function renderVaultPicker(picker: VaultPicker): string {
|
|
211
277
|
const verbList = picker.unnamedVerbs.map((v) => `<code>vault:${escapeHtml(v)}</code>`).join(", ");
|
|
278
|
+
|
|
279
|
+
// Multi-user Phase 1: non-admin users see the picker locked to their
|
|
280
|
+
// `assigned_vault`. The form still posts via `vault_pick` so the
|
|
281
|
+
// server-side defense in `handleConsentSubmit` sees the value — but the
|
|
282
|
+
// user can't change it through the UI. A small `<input type=hidden>` and
|
|
283
|
+
// a read-only label is the smallest diff that ships the lock without
|
|
284
|
+
// disabling the broader form flow (Approve / Deny still work).
|
|
285
|
+
if (picker.lockedVault !== undefined) {
|
|
286
|
+
const locked = escapeHtml(picker.lockedVault);
|
|
287
|
+
return `
|
|
288
|
+
<section class="vault-picker vault-picker-locked">
|
|
289
|
+
<h2 class="scopes-title">Vault</h2>
|
|
290
|
+
<p class="picker-help">
|
|
291
|
+
${verbList} apply to your assigned vault.
|
|
292
|
+
</p>
|
|
293
|
+
<div class="vault-locked-row">
|
|
294
|
+
<code class="vault-locked-name">${locked}</code>
|
|
295
|
+
<span class="vault-locked-badge">Assigned</span>
|
|
296
|
+
</div>
|
|
297
|
+
<p class="vault-locked-note">
|
|
298
|
+
Assigned vault — admin-managed; you can't change this here.
|
|
299
|
+
</p>
|
|
300
|
+
<input type="hidden" name="vault_pick" value="${locked}" />
|
|
301
|
+
</section>`;
|
|
302
|
+
}
|
|
303
|
+
|
|
212
304
|
if (picker.availableVaults.length === 0) {
|
|
213
305
|
return `
|
|
214
306
|
<section class="vault-picker vault-picker-empty">
|
|
@@ -240,21 +332,32 @@ function renderVaultPicker(picker: VaultPicker): string {
|
|
|
240
332
|
}
|
|
241
333
|
|
|
242
334
|
/**
|
|
243
|
-
* "App not yet approved" page (#74).
|
|
244
|
-
* operator session (#208), render the inline approve form so one click lands
|
|
245
|
-
* the client as `approved` and re-enters the OAuth flow at consent. Without
|
|
246
|
-
* a session, fall back to the original CLI-only message — anyone hitting
|
|
247
|
-
* /oauth/authorize unauthenticated to the hub itself can't be trusted to
|
|
248
|
-
* approve a DCR client from the browser, so they need to drop to a terminal
|
|
249
|
-
* and run `parachute auth approve-client <id>`.
|
|
335
|
+
* "App not yet approved" page (#74). Two branches:
|
|
250
336
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
337
|
+
* - **Authenticated operator with same-origin posture** (#208): render the
|
|
338
|
+
* inline approve form so one click flips the client to `approved` and
|
|
339
|
+
* re-enters the OAuth flow at consent.
|
|
340
|
+
* - **Unauthenticated viewer** (Issue 1 in the approval-UX PR): render a
|
|
341
|
+
* primary "Sign in as admin to approve" CTA wired to
|
|
342
|
+
* `/login?next=/admin/approve-client/<id>` so the admin lands directly
|
|
343
|
+
* on the approval page after sign-in, plus a secondary shareable
|
|
344
|
+
* deep-link section (fully-qualified `<hub_origin>/admin/approve-client/<id>`
|
|
345
|
+
* in a code block + Copy-to-clipboard button). The pre-rc.19 CLI hint
|
|
346
|
+
* ("ask the operator to run `parachute auth approve-client <id>`") was
|
|
347
|
+
* retired — the web path is the path now. CLI is still available for
|
|
348
|
+
* terminal-first operators who already know it; we just stop pointing
|
|
349
|
+
* new users there from a browser they're already in.
|
|
254
350
|
*/
|
|
255
351
|
export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
256
|
-
const {
|
|
257
|
-
|
|
352
|
+
const {
|
|
353
|
+
clientName,
|
|
354
|
+
clientId,
|
|
355
|
+
redirectUris,
|
|
356
|
+
requestedScopes,
|
|
357
|
+
requestedVault,
|
|
358
|
+
hubOrigin,
|
|
359
|
+
approveForm,
|
|
360
|
+
} = props;
|
|
258
361
|
const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
|
|
259
362
|
const scopeRows =
|
|
260
363
|
requestedScopes.length === 0
|
|
@@ -279,15 +382,8 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
|
279
382
|
<input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
|
|
280
383
|
<input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
|
|
281
384
|
<button type="submit" class="btn btn-primary">Approve and continue</button>
|
|
282
|
-
</form
|
|
283
|
-
|
|
284
|
-
Or run <code>parachute auth approve-client ${escapeHtml(clientId)}</code> from a terminal.
|
|
285
|
-
</p>`
|
|
286
|
-
: `
|
|
287
|
-
<p class="approve-cli-hint">
|
|
288
|
-
Ask the operator to run <code>parachute auth approve-client ${escapeHtml(clientId)}</code>
|
|
289
|
-
from a terminal, then try again.
|
|
290
|
-
</p>`;
|
|
385
|
+
</form>`
|
|
386
|
+
: renderUnauthenticatedApproveCtas(hubOrigin, clientId);
|
|
291
387
|
const body = `
|
|
292
388
|
<div class="card">
|
|
293
389
|
<div class="card-header">
|
|
@@ -325,6 +421,111 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
|
325
421
|
return baseDocument("App not yet approved", body);
|
|
326
422
|
}
|
|
327
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Unauthenticated branch of `renderApprovePending`. Two CTAs:
|
|
426
|
+
*
|
|
427
|
+
* 1. Primary: "Sign in as admin to approve" → links to
|
|
428
|
+
* `/login?next=/admin/approve-client/<client_id>` so the admin lands
|
|
429
|
+
* on the approval page after sign-in.
|
|
430
|
+
* 2. Secondary: a fully-qualified shareable deep-link to
|
|
431
|
+
* `<hub_origin>/admin/approve-client/<client_id>` with a Copy button
|
|
432
|
+
* so the operator can send it to whoever runs the hub.
|
|
433
|
+
*
|
|
434
|
+
* Inline JS is scoped to the Copy button only — `navigator.clipboard.writeText`
|
|
435
|
+
* with a brief "Copied!" affordance. The button degrades gracefully when
|
|
436
|
+
* scripting is unavailable (the URL is still selectable + copyable from the
|
|
437
|
+
* `<code>` block via the OS clipboard).
|
|
438
|
+
*/
|
|
439
|
+
function renderUnauthenticatedApproveCtas(hubOrigin: string, clientId: string): string {
|
|
440
|
+
const approvalPath = `/admin/approve-client/${encodeURIComponent(clientId)}`;
|
|
441
|
+
const loginHref = `/login?next=${encodeURIComponent(approvalPath)}`;
|
|
442
|
+
const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
|
|
443
|
+
const deepLink = `${trimmedOrigin}${approvalPath}`;
|
|
444
|
+
return `
|
|
445
|
+
<div class="approve-actions">
|
|
446
|
+
<a href="${escapeHtml(loginHref)}" class="btn btn-primary approve-signin-cta">
|
|
447
|
+
Sign in as admin to approve
|
|
448
|
+
</a>
|
|
449
|
+
</div>
|
|
450
|
+
<section class="approve-share">
|
|
451
|
+
<h2 class="scopes-title">Or send this link to your hub admin</h2>
|
|
452
|
+
<p class="approve-share-help">
|
|
453
|
+
Anyone with admin access on this hub can open the link below to approve the app.
|
|
454
|
+
</p>
|
|
455
|
+
<div class="approve-share-row">
|
|
456
|
+
<code class="approve-share-link" id="approve-share-link">${escapeHtml(deepLink)}</code>
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
class="btn btn-secondary approve-share-copy"
|
|
460
|
+
id="approve-share-copy"
|
|
461
|
+
data-link="${escapeHtml(deepLink)}"
|
|
462
|
+
>Copy link</button>
|
|
463
|
+
</div>
|
|
464
|
+
</section>
|
|
465
|
+
<script>
|
|
466
|
+
(function () {
|
|
467
|
+
var btn = document.getElementById('approve-share-copy');
|
|
468
|
+
if (!btn) return;
|
|
469
|
+
var defaultLabel = btn.textContent;
|
|
470
|
+
btn.addEventListener('click', function () {
|
|
471
|
+
var link = btn.dataset.link || '';
|
|
472
|
+
var done = function (ok) {
|
|
473
|
+
btn.textContent = ok ? 'Copied!' : 'Copy failed';
|
|
474
|
+
setTimeout(function () { btn.textContent = defaultLabel; }, 1600);
|
|
475
|
+
};
|
|
476
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
477
|
+
navigator.clipboard.writeText(link).then(function () { done(true); }, function () { done(false); });
|
|
478
|
+
} else {
|
|
479
|
+
// Fallback for browsers without async clipboard (older Safari,
|
|
480
|
+
// sandboxed iframes). Select the code block so the user can
|
|
481
|
+
// hit cmd/ctrl-C to copy manually.
|
|
482
|
+
try {
|
|
483
|
+
var range = document.createRange();
|
|
484
|
+
range.selectNode(document.getElementById('approve-share-link'));
|
|
485
|
+
var sel = window.getSelection();
|
|
486
|
+
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
|
487
|
+
done(true);
|
|
488
|
+
} catch (e) {
|
|
489
|
+
done(false);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
})();
|
|
494
|
+
</script>`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Substitute an unnamed `vault:<verb>` scope with the resolved named form
|
|
499
|
+
* (`vault:<displayVault>:<verb>`) so consent / approval screens render
|
|
500
|
+
* what'll actually appear in the token rather than the raw OAuth request.
|
|
501
|
+
*
|
|
502
|
+
* - `displayVault === undefined` → no substitution (keep input as-is).
|
|
503
|
+
* Use this when the caller doesn't know what vault will be picked yet
|
|
504
|
+
* and prefers the raw OAuth form. (Currently unused; reserved for the
|
|
505
|
+
* pre-narrowing approve flow where the vault isn't known until consent.)
|
|
506
|
+
* - `displayVault === null` → render with a `<TBD>` placeholder. Used on
|
|
507
|
+
* the admin consent screen when the picker hasn't been touched yet, and
|
|
508
|
+
* on the operator approval page where the vault is selected at the
|
|
509
|
+
* per-user sign-in step, not at approve time.
|
|
510
|
+
* - `displayVault === "name"` → render as `vault:name:verb` literally.
|
|
511
|
+
*
|
|
512
|
+
* Non-vault scopes pass through untouched. Already-named `vault:<x>:<verb>`
|
|
513
|
+
* scopes also pass through — the OAuth request already specified a vault,
|
|
514
|
+
* so there's nothing to resolve.
|
|
515
|
+
*/
|
|
516
|
+
export function substituteVaultDisplay(
|
|
517
|
+
scope: string,
|
|
518
|
+
displayVault: string | null | undefined,
|
|
519
|
+
): string {
|
|
520
|
+
if (displayVault === undefined) return scope;
|
|
521
|
+
const parts = scope.split(":");
|
|
522
|
+
if (parts.length !== 2 || parts[0] !== "vault") return scope;
|
|
523
|
+
const verb = parts[1];
|
|
524
|
+
if (verb !== "read" && verb !== "write") return scope;
|
|
525
|
+
const vaultLabel = displayVault === null ? "<TBD>" : displayVault;
|
|
526
|
+
return `vault:${vaultLabel}:${verb}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
328
529
|
export function renderError(props: ErrorViewProps): string {
|
|
329
530
|
const body = `
|
|
330
531
|
<div class="card">
|
|
@@ -344,8 +545,127 @@ export function renderError(props: ErrorViewProps): string {
|
|
|
344
545
|
return baseDocument(props.title, body);
|
|
345
546
|
}
|
|
346
547
|
|
|
548
|
+
export interface UnknownClientViewProps {
|
|
549
|
+
/** The unknown client_id the request carried. Surfaced verbatim for debugging. */
|
|
550
|
+
clientId: string;
|
|
551
|
+
/**
|
|
552
|
+
* The redirect_uri the request carried, when present + parseable as
|
|
553
|
+
* `<one-of-our-bound-origins>/<path>`. Triggers the inline "Reset
|
|
554
|
+
* connection" affordance: the page emits a tiny JS snippet that clears
|
|
555
|
+
* the requesting SPA's DCR localStorage cache (any key prefixed
|
|
556
|
+
* `lens:dcr:`) on our own origin, then navigates to the redirect_uri's
|
|
557
|
+
* mount path so the SPA picks up a fresh DCR.
|
|
558
|
+
*
|
|
559
|
+
* Set to `null` when the redirect_uri is missing, malformed, or points
|
|
560
|
+
* at an origin we don't serve — those reach a third-party SPA we can't
|
|
561
|
+
* safely interact with from our DOM and we fall back to the static
|
|
562
|
+
* error variant.
|
|
563
|
+
*/
|
|
564
|
+
selfOriginRedirectPath: string | null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* "Unknown application" page rendered when `/oauth/authorize` receives a
|
|
569
|
+
* `client_id` that's not in the hub's `clients` table. The single most-
|
|
570
|
+
* reported cause is a stale localStorage entry on the SPA side: the
|
|
571
|
+
* operator wiped `~/.parachute/hub.db` between testing iterations, but the
|
|
572
|
+
* browser still holds the old hub's DCR-cached client_id and keeps using
|
|
573
|
+
* it. The hub's behaviour is correct (reject unknown client_id, never
|
|
574
|
+
* grant an authorize request against an unregistered client) but the
|
|
575
|
+
* operator is stranded — they need to clear the SPA's cache and the SPA
|
|
576
|
+
* has no signal to do that on its own.
|
|
577
|
+
*
|
|
578
|
+
* Recovery affordance: when the redirect_uri points at an origin the hub
|
|
579
|
+
* itself serves (any entry in `hubBoundOrigins`), the page renders a
|
|
580
|
+
* "Reset connection" button. The button runs an inline JS snippet that
|
|
581
|
+
* clears every `lens:dcr:*` key from localStorage on the hub's own origin
|
|
582
|
+
* — since Notes (and any future Parachute SPA) is mounted at the hub's
|
|
583
|
+
* origin, they share localStorage with this error page — then navigates
|
|
584
|
+
* back to the redirect_uri's mount path. The SPA loads fresh, finds no
|
|
585
|
+
* cached client_id, and runs a brand-new DCR against the current hub.
|
|
586
|
+
*
|
|
587
|
+
* `lens:dcr:` is intentionally hardcoded: Notes' storage layer uses that
|
|
588
|
+
* prefix (see `parachute-notes/src/lib/vault/storage.ts`'s `DCR_PREFIX`).
|
|
589
|
+
* Future SPAs that follow the same hub-origin-mounted shape would need
|
|
590
|
+
* their prefix added here, or we extend the snippet to clear any key
|
|
591
|
+
* matching `.*:dcr:.*`. Today it's just Notes.
|
|
592
|
+
*
|
|
593
|
+
* No JS used outside the optional reset button — the static parts stay
|
|
594
|
+
* form-free + accessible to readers/screen-readers without script.
|
|
595
|
+
*/
|
|
596
|
+
export function renderUnknownClient(props: UnknownClientViewProps): string {
|
|
597
|
+
const safeClientId = escapeHtml(props.clientId);
|
|
598
|
+
const resetSection =
|
|
599
|
+
props.selfOriginRedirectPath !== null
|
|
600
|
+
? `
|
|
601
|
+
<p>Most often this means the app's local connection state was saved
|
|
602
|
+
against a previous installation of this hub. Resetting the
|
|
603
|
+
connection clears the stale state and lets the app register
|
|
604
|
+
afresh.</p>
|
|
605
|
+
<div class="unknown-client-actions">
|
|
606
|
+
<button type="button" class="btn btn-primary" id="unknown-client-reset"
|
|
607
|
+
data-target="${escapeHtml(props.selfOriginRedirectPath)}">Reset connection & reload</button>
|
|
608
|
+
</div>
|
|
609
|
+
<p class="fine">If the button doesn't help, clear site data for this
|
|
610
|
+
hub in your browser and reload the app.</p>
|
|
611
|
+
<script>
|
|
612
|
+
(function () {
|
|
613
|
+
var btn = document.getElementById('unknown-client-reset');
|
|
614
|
+
if (!btn) return;
|
|
615
|
+
btn.addEventListener('click', function () {
|
|
616
|
+
try {
|
|
617
|
+
// Notes (and other Parachute SPAs mounted at this hub's
|
|
618
|
+
// origin) cache DCR client_ids under the 'lens:dcr:' key
|
|
619
|
+
// prefix. Clear them all — a stale entry against a wiped
|
|
620
|
+
// hub.db is the canonical cause of this error.
|
|
621
|
+
var keys = [];
|
|
622
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
623
|
+
var k = localStorage.key(i);
|
|
624
|
+
if (k && k.indexOf('lens:dcr:') === 0) keys.push(k);
|
|
625
|
+
}
|
|
626
|
+
keys.forEach(function (k) { localStorage.removeItem(k); });
|
|
627
|
+
} catch (e) {
|
|
628
|
+
// localStorage may be unavailable (private mode, sandbox).
|
|
629
|
+
// The redirect still happens — the SPA will try DCR again
|
|
630
|
+
// and either succeed or surface its own diagnostic.
|
|
631
|
+
}
|
|
632
|
+
var target = btn.dataset.target || '/';
|
|
633
|
+
window.location.assign(target);
|
|
634
|
+
});
|
|
635
|
+
})();
|
|
636
|
+
</script>`
|
|
637
|
+
: `
|
|
638
|
+
<p class="error-help">
|
|
639
|
+
If you reached this from a third-party app, the app's OAuth
|
|
640
|
+
configuration may be wrong. You can safely close this window.
|
|
641
|
+
</p>`;
|
|
642
|
+
const body = `
|
|
643
|
+
<div class="card">
|
|
644
|
+
<div class="card-header">
|
|
645
|
+
<div class="brand">
|
|
646
|
+
<span class="brand-mark">⌬</span>
|
|
647
|
+
<span class="brand-name">Parachute</span>
|
|
648
|
+
</div>
|
|
649
|
+
<h1 class="error-title">Unknown application</h1>
|
|
650
|
+
<p class="subtitle">
|
|
651
|
+
This <code>client_id</code> is not registered with this hub:
|
|
652
|
+
<code>${safeClientId}</code>.
|
|
653
|
+
</p>
|
|
654
|
+
</div>
|
|
655
|
+
${resetSection}
|
|
656
|
+
</div>`;
|
|
657
|
+
return baseDocument("Unknown application", body);
|
|
658
|
+
}
|
|
659
|
+
|
|
347
660
|
function renderScopeRow(scope: string): string {
|
|
348
|
-
|
|
661
|
+
// Special-case the `<TBD>` placeholder substituted by `substituteVaultDisplay`
|
|
662
|
+
// when the consent picker hasn't bound a vault yet — `explainScope` doesn't
|
|
663
|
+
// match it because `<` / `>` aren't in the vault-name charset, but the
|
|
664
|
+
// canonical verb-form does. Look up by the unnamed verb form so the
|
|
665
|
+
// explanation + level styling are still correct.
|
|
666
|
+
const tbdMatch = scope.match(/^vault:<TBD>:(read|write)$/);
|
|
667
|
+
const lookup = tbdMatch ? `vault:${tbdMatch[1]}` : scope;
|
|
668
|
+
const explanation = explainScope(lookup);
|
|
349
669
|
if (!explanation) {
|
|
350
670
|
return `<li class="scope scope-unknown">
|
|
351
671
|
<code class="scope-name">${escapeHtml(scope)}</code>
|
|
@@ -354,12 +674,21 @@ function renderScopeRow(scope: string): string {
|
|
|
354
674
|
}
|
|
355
675
|
const cls = `scope scope-${explanation.level}`;
|
|
356
676
|
const badge = badgeForLevel(explanation);
|
|
677
|
+
// Pending-vault hint surfaces the silent-narrowing semantics for admin
|
|
678
|
+
// operators who land on the consent screen before touching the picker.
|
|
679
|
+
// Once they pick, the form submission narrows the scope to the chosen
|
|
680
|
+
// vault — the rendered placeholder reflects that the vault is still
|
|
681
|
+
// open at this moment in the flow.
|
|
682
|
+
const pendingNote = tbdMatch
|
|
683
|
+
? `<span class="scope-pending-note">A specific vault is picked below before approving.</span>`
|
|
684
|
+
: "";
|
|
357
685
|
return `<li class="${cls}">
|
|
358
686
|
<div class="scope-head">
|
|
359
687
|
<code class="scope-name">${escapeHtml(scope)}</code>
|
|
360
688
|
${badge}
|
|
361
689
|
</div>
|
|
362
690
|
<span class="scope-label">${escapeHtml(explanation.label)}</span>
|
|
691
|
+
${pendingNote}
|
|
363
692
|
</li>`;
|
|
364
693
|
}
|
|
365
694
|
|
|
@@ -561,6 +890,16 @@ const STYLES = `
|
|
|
561
890
|
color: ${PALETTE.fgMuted};
|
|
562
891
|
font-size: 0.88rem;
|
|
563
892
|
}
|
|
893
|
+
.unknown-client-actions {
|
|
894
|
+
margin: 1.25rem 0 0.5rem;
|
|
895
|
+
display: flex;
|
|
896
|
+
flex-direction: column;
|
|
897
|
+
gap: 0.5rem;
|
|
898
|
+
}
|
|
899
|
+
.fine {
|
|
900
|
+
color: ${PALETTE.fgMuted};
|
|
901
|
+
font-size: 0.85rem;
|
|
902
|
+
}
|
|
564
903
|
|
|
565
904
|
.scopes { margin: 0 0 1.5rem; }
|
|
566
905
|
.scopes-title {
|
|
@@ -662,6 +1001,40 @@ const STYLES = `
|
|
|
662
1001
|
}
|
|
663
1002
|
.vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
|
|
664
1003
|
.vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
|
|
1004
|
+
.vault-picker-locked .picker-help { color: ${PALETTE.fgMuted}; }
|
|
1005
|
+
.vault-locked-row {
|
|
1006
|
+
display: flex;
|
|
1007
|
+
align-items: center;
|
|
1008
|
+
gap: 0.5rem;
|
|
1009
|
+
padding: 0.5rem 0.6rem;
|
|
1010
|
+
border: 1px solid ${PALETTE.border};
|
|
1011
|
+
border-radius: 6px;
|
|
1012
|
+
background: ${PALETTE.cardBg};
|
|
1013
|
+
margin: 0.25rem 0 0.5rem;
|
|
1014
|
+
}
|
|
1015
|
+
.vault-locked-name {
|
|
1016
|
+
font-family: ${FONT_MONO};
|
|
1017
|
+
font-size: 0.9rem;
|
|
1018
|
+
color: ${PALETTE.fg};
|
|
1019
|
+
flex: 1;
|
|
1020
|
+
}
|
|
1021
|
+
.vault-locked-badge {
|
|
1022
|
+
display: inline-block;
|
|
1023
|
+
font-size: 0.68rem;
|
|
1024
|
+
text-transform: uppercase;
|
|
1025
|
+
letter-spacing: 0.06em;
|
|
1026
|
+
font-weight: 600;
|
|
1027
|
+
padding: 0.1rem 0.5rem;
|
|
1028
|
+
border-radius: 999px;
|
|
1029
|
+
background: ${PALETTE.accentSoft};
|
|
1030
|
+
color: ${PALETTE.accent};
|
|
1031
|
+
}
|
|
1032
|
+
.vault-locked-note {
|
|
1033
|
+
margin: 0;
|
|
1034
|
+
font-size: 0.82rem;
|
|
1035
|
+
color: ${PALETTE.fgDim};
|
|
1036
|
+
font-style: italic;
|
|
1037
|
+
}
|
|
665
1038
|
|
|
666
1039
|
.approve-meta {
|
|
667
1040
|
margin: 0 0 1.25rem;
|
|
@@ -713,23 +1086,63 @@ const STYLES = `
|
|
|
713
1086
|
word-break: break-all;
|
|
714
1087
|
}
|
|
715
1088
|
.approve-form { gap: 0; }
|
|
716
|
-
.approve-
|
|
1089
|
+
.approve-actions {
|
|
717
1090
|
margin-top: 1rem;
|
|
718
|
-
|
|
1091
|
+
display: flex;
|
|
1092
|
+
flex-direction: column;
|
|
1093
|
+
gap: 0.5rem;
|
|
1094
|
+
}
|
|
1095
|
+
.approve-signin-cta {
|
|
1096
|
+
display: inline-block;
|
|
1097
|
+
text-align: center;
|
|
1098
|
+
text-decoration: none;
|
|
1099
|
+
line-height: 1.4;
|
|
1100
|
+
}
|
|
1101
|
+
.approve-signin-cta:hover {
|
|
1102
|
+
background: ${PALETTE.accentHover};
|
|
1103
|
+
color: ${PALETTE.cardBg};
|
|
1104
|
+
}
|
|
1105
|
+
.approve-share {
|
|
1106
|
+
margin-top: 1.5rem;
|
|
1107
|
+
padding-top: 1.25rem;
|
|
719
1108
|
border-top: 1px solid ${PALETTE.borderLight};
|
|
1109
|
+
}
|
|
1110
|
+
.approve-share-help {
|
|
1111
|
+
margin: 0 0 0.6rem;
|
|
720
1112
|
color: ${PALETTE.fgMuted};
|
|
721
|
-
font-size: 0.
|
|
1113
|
+
font-size: 0.88rem;
|
|
722
1114
|
}
|
|
723
|
-
.approve-
|
|
1115
|
+
.approve-share-row {
|
|
1116
|
+
display: flex;
|
|
1117
|
+
gap: 0.5rem;
|
|
1118
|
+
align-items: stretch;
|
|
1119
|
+
flex-wrap: wrap;
|
|
1120
|
+
}
|
|
1121
|
+
.approve-share-link {
|
|
1122
|
+
flex: 1;
|
|
1123
|
+
min-width: 0;
|
|
724
1124
|
font-family: ${FONT_MONO};
|
|
725
|
-
font-size: 0.
|
|
1125
|
+
font-size: 0.82rem;
|
|
726
1126
|
background: ${PALETTE.bgSoft};
|
|
727
|
-
padding: 0.
|
|
728
|
-
border-radius:
|
|
1127
|
+
padding: 0.55rem 0.65rem;
|
|
1128
|
+
border-radius: 6px;
|
|
729
1129
|
color: ${PALETTE.fg};
|
|
730
1130
|
word-break: break-all;
|
|
1131
|
+
border: 1px solid ${PALETTE.border};
|
|
1132
|
+
}
|
|
1133
|
+
.approve-share-copy {
|
|
1134
|
+
flex-shrink: 0;
|
|
1135
|
+
min-height: 0;
|
|
1136
|
+
padding: 0.5rem 0.9rem;
|
|
1137
|
+
font-size: 0.85rem;
|
|
1138
|
+
}
|
|
1139
|
+
.scope-pending-note {
|
|
1140
|
+
display: block;
|
|
1141
|
+
margin-top: 0.25rem;
|
|
1142
|
+
font-size: 0.78rem;
|
|
1143
|
+
color: ${PALETTE.fgDim};
|
|
1144
|
+
font-style: italic;
|
|
731
1145
|
}
|
|
732
|
-
|
|
733
1146
|
.badge {
|
|
734
1147
|
display: inline-block;
|
|
735
1148
|
font-size: 0.7rem;
|
package/src/operator-token.ts
CHANGED
|
@@ -160,6 +160,10 @@ export async function mintOperatorToken(
|
|
|
160
160
|
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
161
161
|
issuer: opts.issuer,
|
|
162
162
|
ttlSeconds: opts.ttlSeconds ?? OPERATOR_TOKEN_TTL_SECONDS,
|
|
163
|
+
// Operator token — the hub-operator bearer. No per-user vault pin; the
|
|
164
|
+
// operator can act against any vault in this hub. Empty `vault_scope`
|
|
165
|
+
// is the "no restriction" sentinel (matches the admin OAuth shape).
|
|
166
|
+
vaultScope: [],
|
|
163
167
|
extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: scopeSet },
|
|
164
168
|
...(opts.jti !== undefined ? { jti: opts.jti } : {}),
|
|
165
169
|
...(opts.now !== undefined ? { now: opts.now } : {}),
|
|
@@ -159,8 +159,33 @@ export function isRequestableScope(scope: string): boolean {
|
|
|
159
159
|
return !isNonRequestableScope(scope);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Recognize narrowed vault scopes (`vault:<name>:<verb>`) and the wildcard
|
|
164
|
+
* display form (`vault:*:<verb>`) — both render with the same explanation as
|
|
165
|
+
* the corresponding unnamed `vault:<verb>` row, since they describe the same
|
|
166
|
+
* permission scoped to a specific (or unspecified-at-mint-time) vault.
|
|
167
|
+
*
|
|
168
|
+
* The hub narrows unnamed `vault:read` → `vault:<name>:read` at consent /
|
|
169
|
+
* token-mint via the picker (Q1 of the vault-config-and-scopes design); the
|
|
170
|
+
* consent screen now surfaces that narrowed form so the user sees the scope
|
|
171
|
+
* shape that will appear in the token. `vault:*:read` is the display-only
|
|
172
|
+
* shape we use on the operator approval page where no per-user vault has
|
|
173
|
+
* been selected yet (a specific vault is chosen during sign-in).
|
|
174
|
+
*
|
|
175
|
+
* Verb-only — admin verbs on a per-vault basis (`vault:<name>:admin`) are
|
|
176
|
+
* `NON_REQUESTABLE_SCOPES` by policy and never reach the consent screen, so
|
|
177
|
+
* we don't substitute for them here. Read / write get the matching label.
|
|
178
|
+
*/
|
|
179
|
+
const VAULT_VERB_RE = /^vault:[a-zA-Z0-9_*-]+:(read|write)$/;
|
|
180
|
+
|
|
162
181
|
export function explainScope(scope: string): ScopeExplanation | null {
|
|
163
|
-
|
|
182
|
+
const direct = SCOPE_EXPLANATIONS[scope];
|
|
183
|
+
if (direct) return direct;
|
|
184
|
+
if (VAULT_VERB_RE.test(scope)) {
|
|
185
|
+
const verb = scope.split(":")[2] as "read" | "write";
|
|
186
|
+
return SCOPE_EXPLANATIONS[`vault:${verb}`] ?? null;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
164
189
|
}
|
|
165
190
|
|
|
166
191
|
export function scopeIsAdmin(scope: string): boolean {
|