@openparachute/hub 0.5.7 → 0.5.10-rc.2

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.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -114,6 +114,27 @@ export interface ModuleManifest {
114
114
  * as `hasAuth` / `init` / `urlForEntry`.
115
115
  */
116
116
  readonly managementUrl?: string;
117
+ /**
118
+ * Where the module's primary user-facing UI lives. Hub renders a tile on
119
+ * the discovery page (`/`) Services section when set (see
120
+ * `parachute-patterns/patterns/module-json-extensibility.md` and the
121
+ * `loadUiUrls` resolver in `hub-server.ts`).
122
+ *
123
+ * Two shapes — same rules as `managementUrl`:
124
+ * - A relative path (e.g. `"/notes"`, `"/agent"`) — hub resolves
125
+ * against the canonical hub origin.
126
+ * - A full absolute URL — hub uses verbatim.
127
+ *
128
+ * Absent = no Services tile rendered (the module is API-only or surfaces
129
+ * its UI via a sibling module — e.g. vault content browses through Notes,
130
+ * so vault has no `uiUrl`).
131
+ *
132
+ * Read at every discovery render via `installDir/.parachute/module.json`
133
+ * (mirrors how `managementUrl` is sourced for vaults). Not persisted in
134
+ * services.json — that file's "services own the write side" semantics
135
+ * would clobber any install-time copy on the next service boot.
136
+ */
137
+ readonly uiUrl?: string;
117
138
  /**
118
139
  * When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
119
140
  * before forwarding (so the backend sees `/health` rather than
@@ -374,6 +395,7 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
374
395
  const dependencies = asDependencies(m.dependencies, where);
375
396
  const configSchema = asConfigSchema(m.configSchema, where);
376
397
  const managementUrl = asManagementUrl(m.managementUrl, where);
398
+ const uiUrl = asUiUrl(m.uiUrl, where);
377
399
  let stripPrefix: boolean | undefined;
378
400
  if (m.stripPrefix !== undefined) {
379
401
  if (typeof m.stripPrefix !== "boolean") {
@@ -396,6 +418,9 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
396
418
  if (managementUrl !== undefined) {
397
419
  (out as { managementUrl?: string }).managementUrl = managementUrl;
398
420
  }
421
+ if (uiUrl !== undefined) {
422
+ (out as { uiUrl?: string }).uiUrl = uiUrl;
423
+ }
399
424
  if (stripPrefix !== undefined) {
400
425
  (out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
401
426
  }
@@ -403,26 +428,39 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
403
428
  }
404
429
 
405
430
  function asManagementUrl(v: unknown, where: string): string | undefined {
431
+ return asPathOrUrl(v, where, "managementUrl");
432
+ }
433
+
434
+ function asUiUrl(v: unknown, where: string): string | undefined {
435
+ return asPathOrUrl(v, where, "uiUrl");
436
+ }
437
+
438
+ /**
439
+ * Validate a "path or http(s) URL" field. Both `managementUrl` and `uiUrl`
440
+ * follow the same shape per the module-json-extensibility pattern doc;
441
+ * factored so the next URL-shaped field doesn't have to copy-paste.
442
+ */
443
+ function asPathOrUrl(v: unknown, where: string, field: string): string | undefined {
406
444
  if (v === undefined) return undefined;
407
445
  if (typeof v !== "string" || v.length === 0) {
408
- throw new ModuleManifestError(
409
- `${where}: "managementUrl" must be a non-empty string if present`,
410
- );
411
- }
412
- // Two valid shapes: a path starting with "/" or a full http(s) URL.
413
- if (v.startsWith("/")) return v;
446
+ throw new ModuleManifestError(`${where}: "${field}" must be a non-empty string if present`);
447
+ }
448
+ // Two valid shapes: an absolute path starting with a single "/" or a full
449
+ // http(s) URL. Reject protocol-relative forms like "//evil.com" — they
450
+ // start with "/" but `new URL("//evil.com", base)` resolves to the foreign
451
+ // origin, which would let a malicious module render an off-origin tile and
452
+ // turn the discovery page into an open-redirect surface.
453
+ if (v.startsWith("/") && !v.startsWith("//")) return v;
414
454
  try {
415
455
  const u = new URL(v);
416
456
  if (u.protocol !== "http:" && u.protocol !== "https:") {
417
- throw new ModuleManifestError(
418
- `${where}: "managementUrl" absolute form must use http: or https:`,
419
- );
457
+ throw new ModuleManifestError(`${where}: "${field}" absolute form must use http: or https:`);
420
458
  }
421
459
  return v;
422
460
  } catch (err) {
423
461
  if (err instanceof ModuleManifestError) throw err;
424
462
  throw new ModuleManifestError(
425
- `${where}: "managementUrl" must be a path starting with "/" or a full http(s) URL`,
463
+ `${where}: "${field}" must be a path starting with "/" or a full http(s) URL`,
426
464
  );
427
465
  }
428
466
  }
@@ -62,6 +62,7 @@ import {
62
62
  renderError,
63
63
  renderLogin,
64
64
  } from "./oauth-ui.ts";
65
+ import { isSameOriginRequest } from "./origin-check.ts";
65
66
  import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
66
67
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
67
68
  import {
@@ -143,6 +144,18 @@ export interface OAuthDeps {
143
144
  * `~/.parachute/services.json`; tests inject a fixture.
144
145
  */
145
146
  loadServicesManifest?: () => ServicesManifest;
147
+ /**
148
+ * Set of origins (`scheme://host:port`) the hub considers itself bound to.
149
+ * Drives the same-origin defense on cookie-based POST endpoints: a request
150
+ * whose Origin/Referer matches any bound origin is accepted; everything
151
+ * else is rejected as cross-origin. Production wires this from
152
+ * `buildHubBoundOrigins` with the hub's port + expose-state hostname so
153
+ * loopback + tailnet + funnel access all work without restarting hub
154
+ * after `parachute expose`. Tests inject deterministic sets. When absent,
155
+ * the gate falls back to `[issuer]` — pre-#245 behavior — so callers that
156
+ * don't yet thread this through stay correct on a single-origin hub.
157
+ */
158
+ hubBoundOrigins?: () => readonly string[];
146
159
  }
147
160
 
148
161
  export interface ServicesCatalogEntry {
@@ -158,41 +171,129 @@ export type ServicesCatalog = Record<string, ServicesCatalogEntry>;
158
171
  * version, so OAuth clients don't have to re-probe `/.well-known/parachute.json`
159
172
  * to know where vault lives.
160
173
  *
161
- * URL source: `entry.paths[0]` from services.json verbatim — never hardcode
174
+ * URL source: `entry.paths[*]` from services.json verbatim — never hardcode
162
175
  * `/vault/default`. Users who installed with `parachute install vault
163
176
  * --vault-name work` have `paths: ["/vault/work"]` in their manifest, and the
164
177
  * catalog URL must follow that. The custom-vault-name regression test in
165
- * oauth-handlers.test.ts pins this.
178
+ * oauth-handlers.test.ts pins this for single-vault.
166
179
  *
167
180
  * Filtering: only services for which the token has at least one scope are
168
181
  * included. A scope `vault:read` admits the `vault` service; a token with only
169
182
  * `scribe:transcribe` gets a catalog with no vault entry. The check is on the
170
183
  * audience prefix (`<aud>:<verb>`) — same shape `inferAudience` uses.
171
184
  *
172
- * Multi-vault: Phase 1 collapses every vault entry under the single key
173
- * `vault`, first matching `parachute-vault*` row wins. Per-vault keys
174
- * (`services.vault.work.url` or `services["vault:work"].url`) are deferred
175
- * to a future design once notes ships its vault picker; multi-vault clients
176
- * need to probe `/.well-known/parachute.json` for the full vaults array
177
- * until then.
185
+ * Multi-vault (closes #247): emits per-vault keys `vault:<name>` alongside
186
+ * the collapsed `vault` key. A scope `vault:boulder:write` admits only
187
+ * boulder → emits `vault:boulder` (and the legacy `vault` key, pointing at
188
+ * boulder so it resolves consistently). A broad scope `vault:read` admits
189
+ * every vault on the hub → emits `vault:<name>` for each vault path plus
190
+ * the legacy `vault` key (pointing at `entry.paths[0]` of the first vault,
191
+ * unchanged from Phase 1). Notes' OAuthCallback (notes#115 ships the
192
+ * picker; per-vault consumer change is the post-#247 Notes-side PR) reads
193
+ * `services["vault:<name>"]` so it stops collapsing multi-vault grants
194
+ * onto a single VaultRecord URL.
195
+ *
196
+ * Pre-popover clients still see `services.vault` and behave unchanged —
197
+ * that key never goes away. Per-vault keys are additive.
178
198
  */
179
199
  export function buildServicesCatalog(
180
200
  manifest: ServicesManifest,
181
201
  issuer: string,
182
202
  scopes: readonly string[],
183
203
  ): ServicesCatalog {
204
+ // Two scope-derived sets:
205
+ // - audiences: bare service prefix (`vault`, `scribe`) → admits the
206
+ // collapsed key + every per-vault key.
207
+ // - namedVaults: per-vault narrowed scopes (`vault:<name>:<verb>`) →
208
+ // admits only `vault:<name>` and the collapsed `vault`.
209
+ //
210
+ // A token with both `vault:read` and `vault:boulder:write` should land in
211
+ // the "any vault" bucket — the bare scope is permissive, the named one
212
+ // is informational. Detect this via the bare-prefix presence; the named
213
+ // scope's per-vault narrowing still works for clients that prefer it.
184
214
  const audiences = new Set<string>();
215
+ const namedVaults = new Set<string>();
185
216
  for (const s of scopes) {
217
+ const parts = s.split(":");
218
+ if (
219
+ parts.length === 3 &&
220
+ parts[0] === "vault" &&
221
+ parts[1] &&
222
+ parts[2] &&
223
+ VAULT_VERBS.has(parts[2])
224
+ ) {
225
+ namedVaults.add(parts[1]);
226
+ audiences.add("vault");
227
+ continue;
228
+ }
186
229
  const colon = s.indexOf(":");
187
230
  if (colon > 0) audiences.add(s.slice(0, colon));
188
231
  }
232
+ const broadVaultScope =
233
+ audiences.has("vault") &&
234
+ scopes.some((s) => {
235
+ const parts = s.split(":");
236
+ return (
237
+ parts.length === 2 &&
238
+ parts[0] === "vault" &&
239
+ parts[1] !== undefined &&
240
+ VAULT_VERBS.has(parts[1])
241
+ );
242
+ });
243
+
244
+ // Count total admitted vault paths across the manifest. Per-vault keys
245
+ // are only worth emitting when there are >1 admitted vaults to
246
+ // disambiguate (or when the token's own scopes are per-vault narrowed —
247
+ // a per-vault scope is an explicit consumer signal that the per-vault
248
+ // key matters even on a single-vault hub). The check is on admitted
249
+ // paths, not raw vault rows: a broad token on a multi-path vault row
250
+ // sees N paths; a per-vault token sees only its own.
251
+ let admittedVaultPathCount = 0;
252
+ if (audiences.has("vault")) {
253
+ for (const entry of manifest.services) {
254
+ if (!isVaultEntry(entry)) continue;
255
+ const paths = entry.paths.length > 0 ? entry.paths : ["/"];
256
+ for (const path of paths) {
257
+ const instance = vaultInstanceNameFor(entry.name, path);
258
+ if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
259
+ }
260
+ }
261
+ }
262
+ const emitPerVaultKeys = admittedVaultPathCount > 1 || namedVaults.size > 0;
263
+
189
264
  const base = issuer.replace(/\/$/, "");
190
265
  const catalog: ServicesCatalog = {};
191
266
  for (const entry of manifest.services) {
192
- const path = entry.paths[0] ?? "/";
193
- const key = isVaultEntry(entry) ? "vault" : shortName(entry.name);
267
+ if (isVaultEntry(entry)) {
268
+ if (!audiences.has("vault")) continue;
269
+ // Walk every path the row exposes. Real multi-vault on the hub is a
270
+ // single `parachute-vault` row with N paths (one per vault instance);
271
+ // legacy per-vault rows (`parachute-vault-<name>`) are handled by the
272
+ // same loop because each contributes one path.
273
+ const paths = entry.paths.length > 0 ? entry.paths : ["/"];
274
+ for (const path of paths) {
275
+ const instance = vaultInstanceNameFor(entry.name, path);
276
+ const admit = broadVaultScope || namedVaults.has(instance);
277
+ if (!admit) continue;
278
+ if (emitPerVaultKeys) {
279
+ const perVaultKey = `vault:${instance}`;
280
+ if (!catalog[perVaultKey]) {
281
+ catalog[perVaultKey] = { url: `${base}${path}`, version: entry.version };
282
+ }
283
+ }
284
+ // Collapsed `vault` key stays for backwards compat. First admitted
285
+ // vault wins (deterministic — `entry.paths[0]` for a broad scope,
286
+ // or the only admitted instance for a per-vault scope).
287
+ if (!catalog.vault) {
288
+ catalog.vault = { url: `${base}${path}`, version: entry.version };
289
+ }
290
+ }
291
+ continue;
292
+ }
293
+ const key = shortName(entry.name);
194
294
  if (!audiences.has(key)) continue;
195
- if (catalog[key]) continue; // first vault wins; deterministic for clients
295
+ if (catalog[key]) continue;
296
+ const path = entry.paths[0] ?? "/";
196
297
  catalog[key] = { url: `${base}${path}`, version: entry.version };
197
298
  }
198
299
  return catalog;
@@ -330,8 +431,14 @@ function pendingClientResponse(
330
431
  const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
331
432
  .split(" ")
332
433
  .filter((s) => s.length > 0);
434
+ // Vault hint (closes #244): Notes' VaultPopover (notes#115) sets this on
435
+ // the authorize URL when kicking OAuth for a specific vault. Empty-string
436
+ // values normalize to undefined so the approve UI omits the row rather
437
+ // than rendering a blank vault label.
438
+ const vaultParam = authorizeUrl.searchParams.get("vault");
439
+ const requestedVault = vaultParam && vaultParam.length > 0 ? vaultParam : undefined;
333
440
  const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
334
- const sameOrigin = originMatchesIssuer(req, deps.issuer);
441
+ const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
335
442
  const csrf = ensureCsrfToken(req);
336
443
  const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
337
444
  if (session && sameOrigin) {
@@ -342,6 +449,7 @@ function pendingClientResponse(
342
449
  clientId: client.clientId,
343
450
  redirectUris: client.redirectUris,
344
451
  requestedScopes,
452
+ ...(requestedVault !== undefined && { requestedVault }),
345
453
  approveForm: { csrfToken: csrf.token, returnTo },
346
454
  }),
347
455
  403,
@@ -354,18 +462,39 @@ function pendingClientResponse(
354
462
  clientId: client.clientId,
355
463
  redirectUris: client.redirectUris,
356
464
  requestedScopes,
465
+ ...(requestedVault !== undefined && { requestedVault }),
357
466
  }),
358
467
  403,
359
468
  extra,
360
469
  );
361
470
  }
362
471
 
363
- /** JSON response for pending clients hitting /oauth/token. */
364
- function pendingClientJson(): Response {
472
+ /**
473
+ * JSON response for pending clients hitting /oauth/token. Carries two
474
+ * actionability hints alongside the OAuth error so consumers (Notes, future
475
+ * cross-origin SPAs) can surface an inline approval path instead of dead-
476
+ * ending on a CLI message:
477
+ *
478
+ * - `approve_url` — hub-served SPA route the operator can open in a
479
+ * browser to approve the client in one click. Same-origin to the hub.
480
+ * - `cli_alternative` — the `parachute auth approve-client <id>` shell
481
+ * command, retained for terminal-first operators or scripted flows.
482
+ *
483
+ * Spec note: the OAuth error class stays `invalid_client` per RFC 6749 §5.2
484
+ * — "this client cannot use this endpoint right now" is the semantic match.
485
+ * `access_denied` is reserved for /authorize "user said no" flows; using it
486
+ * here would conflate two distinct error families and break clients doing
487
+ * strict spec-shaped handling. The extra fields are spec-permitted
488
+ * extensions ("other parameters").
489
+ */
490
+ function pendingClientJson(clientId: string, issuer: string): Response {
491
+ const base = issuer.replace(/\/$/, "");
365
492
  return jsonResponse(
366
493
  {
367
494
  error: "invalid_client",
368
495
  error_description: "client is registered but has not been approved by the hub operator (#74)",
496
+ approve_url: `${base}/admin/approve-client/${encodeURIComponent(clientId)}`,
497
+ cli_alternative: `parachute auth approve-client ${clientId}`,
369
498
  },
370
499
  401,
371
500
  );
@@ -376,6 +505,61 @@ function pendingClientJson(): Response {
376
505
  * either renders the login form (no session) or the consent screen (session
377
506
  * present). All authorize-time params are echoed back via hidden inputs so
378
507
  * the form POST keeps the binding intact.
508
+ *
509
+ * ## Silent-approve flow (skip-consent gate, hub#75, hub#236)
510
+ *
511
+ * Cross-surface session smoothness ("first Notes use prompts for consent;
512
+ * subsequent uses are seamless") rides on a single gate further down in
513
+ * this function. The end-to-end flow:
514
+ *
515
+ * 1. **First use.** A client lands on `/oauth/authorize` with scope `S`.
516
+ * The user has a session but no prior `grants` row for this
517
+ * (user, client) pair. `isCoveredByGrant` returns false; the gate
518
+ * falls through; the consent screen renders. User clicks approve →
519
+ * `handleAuthorizePost` records a `grants` row keyed on
520
+ * (user_id, client_id) with the approved scopes, then mints the
521
+ * auth code.
522
+ * 2. **Subsequent use, same scopes.** Same client lands on
523
+ * `/oauth/authorize` with scope `S` again. `isCoveredByGrant` finds
524
+ * the row and returns true. The gate fires: auth code minted
525
+ * directly via `issueAuthCodeRedirect`; no consent screen renders;
526
+ * operator sees a silent redirect. This is the seamless second-use
527
+ * experience.
528
+ * 3. **Subsequent use, subset.** Client asks for scope `S' ⊂ S`. The
529
+ * grant covers every requested scope; gate fires.
530
+ * 4. **Subsequent use, novel scope.** Client asks for scope `S''`
531
+ * where `S'' ⊄ S` (a strict superset, or any new scope). The grant
532
+ * doesn't cover the new ask; gate falls through; consent re-renders
533
+ * with the new scope explicit. User must approve to extend the grant.
534
+ * 5. **Grant revoked.** Operator revokes via `/admin/permissions` or
535
+ * `parachute auth revoke-grant`. The next /authorize re-renders
536
+ * consent — already-minted refresh tokens keep working until they
537
+ * expire (or are revoked separately via `/oauth/revoke`).
538
+ *
539
+ * Two important constraints on the gate itself:
540
+ *
541
+ * - **Unnamed vault verbs (`vault:read`) always render consent.** The
542
+ * vault-picker UI is the only path that binds an unnamed scope to a
543
+ * specific vault (grants store narrowed `vault:<name>:<verb>`, so
544
+ * `vault:read` never matches a stored grant literally). Re-flowing
545
+ * with `vault:read` must always show the picker even if any prior
546
+ * grant exists.
547
+ * - **Client re-registration breaks the grant link.** Dynamic Client
548
+ * Registration mints a fresh `client_id` each time; grants are keyed
549
+ * on `(user_id, client_id)` so a re-registered client looks brand-
550
+ * new and re-prompts for consent. (Intentional: the operator should
551
+ * re-consent to an app whose registration was destroyed and re-made
552
+ * — that's a stronger signal of "this is the same app I trusted"
553
+ * than the redirect URI alone.)
554
+ *
555
+ * The full grant-scope subset semantics live in `grants.ts`
556
+ * `isCoveredByGrant`; the gate itself is the if-block below the
557
+ * "Skip-consent gate" comment in this function.
558
+ *
559
+ * Pinned by the regression test "first-use consent → silent-approve →
560
+ * novel scope re-prompts" in `oauth-handlers.test.ts` (hub#236), plus
561
+ * the per-branch tests in the same describe block (subset / superset /
562
+ * revoke / unnamed-vault / re-registered-client).
379
563
  */
380
564
  export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps): Response {
381
565
  const url = new URL(req.url);
@@ -692,9 +876,12 @@ async function handleConsentSubmit(
692
876
  * 2. Active operator session (`findActiveSession`). The operator must be
693
877
  * logged into this hub from the browser submitting the form — no
694
878
  * session means no operator authority to approve anything.
695
- * 3. Origin/Referer matches the issuer (`originMatchesIssuer`). Same
696
- * shape as the DCR auto-approve gate (#199, #200): a same-origin POST
697
- * proves the form was rendered by *this hub*, not a forged page.
879
+ * 3. Origin/Referer matches a hub-bound origin (`isSameOriginRequest`).
880
+ * Same shape as the DCR auto-approve gate (#199, #200, #245): a same-
881
+ * origin POST proves the form was rendered by *this hub*, not a forged
882
+ * page. Bound origins include issuer + loopback + tailnet hostname
883
+ * (#245); pre-#245 was issuer-only and rejected legitimate operator
884
+ * paths from loopback / tailnet.
698
885
  *
699
886
  * `return_to` validation: the form embeds the original authorize URL so
700
887
  * the post-approve redirect lands the operator back on `/oauth/authorize`
@@ -725,7 +912,7 @@ export async function handleApproveClientPost(
725
912
  401,
726
913
  );
727
914
  }
728
- if (!originMatchesIssuer(req, deps.issuer)) {
915
+ if (!isSameOriginRequest(req, resolveBoundOrigins(deps))) {
729
916
  return htmlError(
730
917
  "Cross-origin request rejected",
731
918
  "The approve form must be submitted from this hub's own origin.",
@@ -931,7 +1118,7 @@ async function handleTokenAuthorizationCode(
931
1118
  if (!client) {
932
1119
  return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
933
1120
  }
934
- if (client.status !== "approved") return pendingClientJson();
1121
+ if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
935
1122
  const authFailure = authenticateClient(client, req, form, clientId);
936
1123
  if (authFailure) return authFailure;
937
1124
  let redeemed: ReturnType<typeof redeemAuthCode>;
@@ -966,6 +1153,13 @@ async function handleTokenAuthorizationCode(
966
1153
  issuer: deps.issuer,
967
1154
  now: deps.now,
968
1155
  });
1156
+ // Phase 1 (#212) registry exemption: code-grant access tokens piggyback
1157
+ // on the paired refresh token's `tokens` row (they share `jti` by
1158
+ // design). We don't write a separate access-token row — revocation acts
1159
+ // on the shared jti / family, and the 15-min access TTL bounds the
1160
+ // window before per-jti re-validation is needed. A separate per-jti
1161
+ // access-token row would double registry write volume on every OAuth
1162
+ // grant + every refresh rotation; not worth the trade today.
969
1163
  const refresh = signRefreshToken(db, {
970
1164
  jti: access.jti,
971
1165
  userId: redeemed.userId,
@@ -1006,7 +1200,7 @@ async function handleTokenRefresh(
1006
1200
  if (!client) {
1007
1201
  return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
1008
1202
  }
1009
- if (client.status !== "approved") return pendingClientJson();
1203
+ if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
1010
1204
  const authFailure = authenticateClient(client, req, form, clientId);
1011
1205
  if (authFailure) return authFailure;
1012
1206
  const row = findRefreshToken(db, refreshToken);
@@ -1019,6 +1213,18 @@ async function handleTokenRefresh(
1019
1213
  if (row.clientId !== clientId) {
1020
1214
  return jsonResponse({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
1021
1215
  }
1216
+ // Refresh-token rows always have a non-null user_id (the caller's hub
1217
+ // user). Post-v6 the column is nullable to accommodate non-OAuth mints
1218
+ // (operator/cli mints), but those rows have no `refresh_token_hash` so
1219
+ // `findRefreshToken` can't return them. Defensive: surface a clean
1220
+ // invalid_grant if a hand-crafted row shows up here without a user.
1221
+ if (!row.userId) {
1222
+ return jsonResponse(
1223
+ { error: "invalid_grant", error_description: "refresh_token has no associated user" },
1224
+ 400,
1225
+ );
1226
+ }
1227
+ const refreshUserId: string = row.userId;
1022
1228
  const now = deps.now?.() ?? new Date();
1023
1229
  if (row.revokedAt) {
1024
1230
  // Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
@@ -1053,7 +1259,7 @@ async function handleTokenRefresh(
1053
1259
  // (#107).
1054
1260
  const audience = inferAudience(row.scopes);
1055
1261
  const access = await signAccessToken(db, {
1056
- sub: row.userId,
1262
+ sub: refreshUserId,
1057
1263
  scopes: row.scopes,
1058
1264
  audience,
1059
1265
  clientId: row.clientId,
@@ -1066,7 +1272,7 @@ async function handleTokenRefresh(
1066
1272
  db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
1067
1273
  return signRefreshToken(db, {
1068
1274
  jti: access.jti,
1069
- userId: row.userId,
1275
+ userId: refreshUserId,
1070
1276
  clientId: row.clientId,
1071
1277
  scopes: row.scopes,
1072
1278
  familyId: row.familyId,
@@ -1236,37 +1442,15 @@ interface RegisterRequestBody {
1236
1442
  }
1237
1443
 
1238
1444
  /**
1239
- * CSRF defense for the cookie-based DCR auto-approve path (closes #199).
1240
- *
1241
- * Compares the request's `Origin` (or `Referer` as fallback) against the
1242
- * configured issuer origin. URL.origin compares scheme + host + port
1243
- * port-only mismatches reject. A request with neither header is treated as
1244
- * suspicious and rejected: cookie-bearing POSTs from same-origin browsers
1245
- * always send Origin (per Fetch standard) and almost always send Referer,
1246
- * so a header-stripped request is more likely a curl probe or a privacy
1247
- * extension on a third-party site than a legitimate same-origin caller.
1248
- *
1249
- * SameSite=Lax on the session cookie (sessions.ts:buildSessionCookie) is the
1250
- * browser-side defense layer; this function is the server-side belt.
1445
+ * Resolve the hub-bound origin set for a given `OAuthDeps`. Pre-#245 this
1446
+ * was implicit (just `deps.issuer`); post-#245 callers can thread a richer
1447
+ * set through `deps.hubBoundOrigins` so loopback + tailnet + funnel access
1448
+ * all match. Fallback to `[issuer]` keeps callers that haven't migrated
1449
+ * correct on single-origin hubs.
1251
1450
  */
1252
- function originMatchesIssuer(req: Request, issuer: string): boolean {
1253
- const origin = req.headers.get("origin");
1254
- if (origin) {
1255
- try {
1256
- return new URL(origin).origin === new URL(issuer).origin;
1257
- } catch {
1258
- return false;
1259
- }
1260
- }
1261
- const referer = req.headers.get("referer");
1262
- if (referer) {
1263
- try {
1264
- return new URL(referer).origin === new URL(issuer).origin;
1265
- } catch {
1266
- return false;
1267
- }
1268
- }
1269
- return false;
1451
+ function resolveBoundOrigins(deps: OAuthDeps): readonly string[] {
1452
+ if (deps.hubBoundOrigins) return deps.hubBoundOrigins();
1453
+ return [deps.issuer];
1270
1454
  }
1271
1455
 
1272
1456
  /**
@@ -1284,7 +1468,7 @@ function originMatchesIssuer(req: Request, issuer: string): boolean {
1284
1468
  * plus a same-origin `Origin`/`Referer` header. The browser path: an
1285
1469
  * operator hitting their own SPA from their own browser is by definition
1286
1470
  * operator-authenticated, so re-requiring approval is friction without
1287
- * benefit. CSRF defense is `originMatchesIssuer` + the cookie's
1471
+ * benefit. CSRF defense is `isSameOriginRequest` + the cookie's
1288
1472
  * `SameSite=Lax` attribute.
1289
1473
  *
1290
1474
  * If a bearer is presented but invalid or insufficient, we reject with the
@@ -1357,7 +1541,7 @@ export async function handleRegister(
1357
1541
  // public-DCR shape.
1358
1542
  if (status === "pending") {
1359
1543
  const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
1360
- if (session && originMatchesIssuer(req, deps.issuer)) {
1544
+ if (session && isSameOriginRequest(req, resolveBoundOrigins(deps))) {
1361
1545
  status = "approved";
1362
1546
  }
1363
1547
  }
package/src/oauth-ui.ts CHANGED
@@ -111,6 +111,14 @@ export interface ApprovePendingViewProps {
111
111
  redirectUris: string[];
112
112
  /** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
113
113
  requestedScopes: string[];
114
+ /**
115
+ * Vault hint from the original `/oauth/authorize?vault=<name>` query param,
116
+ * passed by Notes' VaultPopover (notes#115) when kicking the OAuth flow for
117
+ * a specific vault. Rendered alongside scopes so the operator can tell
118
+ * which vault they're approving access for on a multi-vault hub (closes
119
+ * #244). Single-vault hubs leave this absent and the section omits.
120
+ */
121
+ requestedVault?: string;
114
122
  /**
115
123
  * When set, render the inline approve form. The form posts to
116
124
  * `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
@@ -245,12 +253,25 @@ function renderVaultPicker(picker: VaultPicker): string {
245
253
  * context). The button is the easy path; the CLI is always-available.
246
254
  */
247
255
  export function renderApprovePending(props: ApprovePendingViewProps): string {
248
- const { clientName, clientId, redirectUris, requestedScopes, approveForm } = props;
256
+ const { clientName, clientId, redirectUris, requestedScopes, requestedVault, approveForm } =
257
+ props;
249
258
  const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
250
259
  const scopeRows =
251
260
  requestedScopes.length === 0
252
261
  ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
253
262
  : requestedScopes.map(renderScopeRow).join("\n");
263
+ // Vault hint (closes #244): Notes' VaultPopover (notes#115) passes
264
+ // `vault=<name>` on `/oauth/authorize` for per-vault grants. Surface it
265
+ // alongside scopes so a multi-vault operator can tell which vault they're
266
+ // approving for. Missing on single-vault hubs / pre-vault-popover clients —
267
+ // section omits when absent.
268
+ const vaultRow = requestedVault
269
+ ? `
270
+ <p class="approve-meta-row">
271
+ <span class="approve-meta-label">vault</span>
272
+ <code class="approve-meta-value">${escapeHtml(requestedVault)}</code>
273
+ </p>`
274
+ : "";
254
275
  const formSection = approveForm
255
276
  ? `
256
277
  <form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
@@ -289,7 +310,7 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
289
310
  <p class="approve-meta-row">
290
311
  <span class="approve-meta-label">client_id</span>
291
312
  <code class="approve-meta-value">${escapeHtml(clientId)}</code>
292
- </p>
313
+ </p>${vaultRow}
293
314
  <div class="approve-meta-row approve-meta-row-block">
294
315
  <span class="approve-meta-label">redirect_uris</span>
295
316
  <ul class="approve-redirect-list">${redirectList}</ul>