@openparachute/hub 0.7.7-rc.9 → 0.7.7

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 (78) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -12
  3. package/src/__tests__/account-api.test.ts +29 -2
  4. package/src/__tests__/account-session.test.ts +9 -1
  5. package/src/__tests__/account-token.test.ts +26 -2
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-lock.test.ts +3 -14
  8. package/src/__tests__/admin-module-token.test.ts +10 -30
  9. package/src/__tests__/admin-surfaces.test.ts +21 -0
  10. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  11. package/src/__tests__/api-mint-token.test.ts +25 -0
  12. package/src/__tests__/api-modules-ops.test.ts +34 -29
  13. package/src/__tests__/api-modules.test.ts +50 -58
  14. package/src/__tests__/api-revoke-token.test.ts +23 -0
  15. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  16. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  17. package/src/__tests__/api-tokens.test.ts +44 -0
  18. package/src/__tests__/audience-gate.test.ts +24 -0
  19. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  20. package/src/__tests__/chrome-strip.test.ts +18 -1
  21. package/src/__tests__/doctor.test.ts +10 -17
  22. package/src/__tests__/hub-command.test.ts +70 -2
  23. package/src/__tests__/hub-server.test.ts +322 -1
  24. package/src/__tests__/hub-settings.test.ts +110 -6
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +210 -38
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/oauth-handlers.test.ts +19 -8
  29. package/src/__tests__/operator-token.test.ts +1 -2
  30. package/src/__tests__/port-assign.test.ts +17 -26
  31. package/src/__tests__/root-serve.test.ts +139 -0
  32. package/src/__tests__/scope-explanations.test.ts +0 -2
  33. package/src/__tests__/serve-boot.test.ts +25 -36
  34. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  35. package/src/__tests__/services-manifest.test.ts +22 -92
  36. package/src/__tests__/setup-wizard.test.ts +301 -22
  37. package/src/__tests__/setup.test.ts +8 -13
  38. package/src/__tests__/status.test.ts +0 -5
  39. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  40. package/src/__tests__/wizard-transcription.test.ts +35 -0
  41. package/src/__tests__/wizard.test.ts +79 -0
  42. package/src/account-api.ts +6 -0
  43. package/src/admin-connections.ts +4 -2
  44. package/src/admin-lock.ts +1 -2
  45. package/src/admin-module-token.ts +13 -8
  46. package/src/admin-surfaces.ts +2 -1
  47. package/src/api-hub-upgrade.ts +2 -1
  48. package/src/api-mint-token.ts +2 -1
  49. package/src/api-modules-ops.ts +2 -1
  50. package/src/api-modules.ts +9 -7
  51. package/src/api-revoke-token.ts +2 -1
  52. package/src/api-settings-hub-origin.ts +2 -1
  53. package/src/api-settings-root-redirect.ts +97 -20
  54. package/src/api-tokens.ts +2 -1
  55. package/src/audience-gate.ts +2 -1
  56. package/src/chrome-strip.ts +16 -4
  57. package/src/commands/hub.ts +104 -0
  58. package/src/commands/install.ts +72 -27
  59. package/src/commands/migrate.ts +5 -1
  60. package/src/commands/setup.ts +0 -2
  61. package/src/commands/wizard-transcription.ts +24 -0
  62. package/src/commands/wizard.ts +34 -1
  63. package/src/help.ts +0 -1
  64. package/src/hub-server.ts +111 -54
  65. package/src/hub-settings.ts +147 -0
  66. package/src/hub.ts +64 -31
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/oauth-handlers.ts +1 -11
  69. package/src/operator-token.ts +0 -1
  70. package/src/origin-check.ts +2 -2
  71. package/src/root-serve.ts +156 -0
  72. package/src/scope-explanations.ts +2 -5
  73. package/src/service-spec.ts +18 -47
  74. package/src/services-manifest.ts +9 -42
  75. package/src/setup-wizard.ts +144 -31
  76. package/src/surface-notes-alias.ts +126 -0
  77. package/src/__tests__/admin-agent-token.test.ts +0 -173
  78. package/src/admin-agent-token.ts +0 -147
@@ -41,6 +41,7 @@ import type { Database } from "bun:sqlite";
41
41
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
42
  import { join } from "node:path";
43
43
  import { recordLoginUnlock } from "./admin-lock.ts";
44
+ import { provisionVault } from "./admin-vaults.ts";
44
45
  import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
45
46
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
46
47
  import {
@@ -307,15 +308,34 @@ export function deriveWizardState(deps: {
307
308
  // phantom `vaults[]` row at SEED_VERSION); both surfaces must agree that a
308
309
  // placeholder is not a real vault.
309
310
  const vaultIsPlaceholder = vaultEntry !== undefined && vaultEntry.version === SEED_VERSION;
311
+ // hub#607-followup (the e2e wizard-skip bug): the SEED_VERSION check alone is
312
+ // NOT sufficient to distinguish "real vault" from "module installed, no
313
+ // instance". When the hub boot-spawns the vault module child, vault's
314
+ // `selfRegister` writes its services.json row at the REAL package version
315
+ // (e.g. 0.7.4-rc.1) but with `paths: []` (no vault instance created yet) —
316
+ // defeating the SEED_VERSION sentinel. A bare `version !== SEED_VERSION`
317
+ // check then read that empty-paths row as a real vault, so the wizard
318
+ // SILENTLY SKIPPED its vault step and `--vault-mode create --vault-name` was
319
+ // ignored (the operator finished setup with no vault). Make the check
320
+ // INSTANCE-aware: a real vault also requires at least one mount path. This is
321
+ // the SAME empty-paths discrimination `findExistingVault` /
322
+ // `listVaultInstanceNames` already use (#478) — a servable instance carries a
323
+ // canonical `/vault/<name>` path; a self-registered-but-uncreated row has
324
+ // `paths: []` and must read as "no real vault yet." A SEED_VERSION seed row
325
+ // carries `/vault/default`, so it's caught by `vaultIsPlaceholder` above and
326
+ // unaffected by this clause.
327
+ const vaultHasInstancePath = vaultEntry !== undefined && vaultEntry.paths.length > 0;
310
328
  // INVARIANT (B5 re-enterable vault step): hasRealVault means "a real
311
- // instance row exists" — placeholder excluded here, skip-marker excluded
312
- // below (skip flips hasVault, never hasRealVault). THREE sites key on this
313
- // same placeholder logic and must move together: this derivation,
314
- // handleSetupGet's `?step=vault` re-entry gate, and handleSetupVaultPost's
315
- // already-provisioned short-circuit. Changing one without the others
316
- // either re-opens a provisioning form over a real vault or dead-ends the
317
- // post-skip re-entry path.
318
- const hasRealVault = vaultEntry !== undefined && !vaultIsPlaceholder;
329
+ // instance row exists" — placeholder excluded here, empty-paths excluded via
330
+ // vaultHasInstancePath, skip-marker excluded below (skip flips hasVault,
331
+ // never hasRealVault). THREE sites key on this same `hasRealVault` value and
332
+ // must move together: this derivation, handleSetupGet's `?step=vault`
333
+ // re-entry gate, and handleSetupVaultPost's already-provisioned short-circuit
334
+ // all three read `deriveWizardState(...).hasRealVault`, so tightening it
335
+ // here moves all three at once. Changing the meaning here without checking
336
+ // those consumers either re-opens a provisioning form over a real vault or
337
+ // dead-ends the post-skip re-entry path.
338
+ const hasRealVault = vaultEntry !== undefined && !vaultIsPlaceholder && vaultHasInstancePath;
319
339
  // hub#168 Cut 2: `setup_vault_skipped === "true"` advances the wizard
320
340
  // past the vault step even when no vault row exists. The operator
321
341
  // explicitly chose Skip; the module is installed (Cut 1) but no
@@ -416,6 +436,14 @@ export interface SetupWizardDeps {
416
436
  registry?: OperationsRegistry;
417
437
  /** Test seam: stub `bun add` / `bun remove` runner. */
418
438
  run?: (cmd: readonly string[]) => Promise<number>;
439
+ /**
440
+ * Test seam: provision a vault for the already-supervised short-circuit in
441
+ * `handleSetupVaultPost`. Production omits this and uses the real
442
+ * {@link provisionVault} (which shells to `parachute-vault create <name>
443
+ * --json`). Tests inject a stub so the short-circuit can be asserted without
444
+ * spawning the vault CLI.
445
+ */
446
+ provisionVaultImpl?: typeof provisionVault;
419
447
  /**
420
448
  * Test seam: stub the bun-link detection used by `runInstall` to
421
449
  * short-circuit `bun add -g` when a package is already linked
@@ -2305,45 +2333,130 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2305
2333
  const registry = deps.registry;
2306
2334
  const vaultSpec = specFor(FIRST_VAULT_SHORT);
2307
2335
 
2308
- // Idempotent short-circuit: if the supervisor is already running (or
2309
- // mid-spawn) for vault i.e. a previous POST already kicked off
2310
- // `runInstall` and beat us to spawningreturn a synthesized
2311
- // succeeded op instead of firing a second `bun add -g`. Mirrors the
2312
- // pattern in `handleInstall` (api-modules-ops.ts). Without this,
2313
- // two concurrent POSTs both pass `state.hasVault === false` (the
2314
- // services.json seed is the only signal that step exits, and it's
2315
- // written by `runInstall` *after* `bun add` returns), and each
2316
- // fires its own install — wasted work and a possible race on the
2317
- // seed/spawn writes. Low risk on first-boot in practice, but the
2318
- // fix is cheap and matches the API surface's posture.
2336
+ // Already-supervised handling: is the vault MODULE child already under the
2337
+ // supervisor (running / starting / restarting)? Two ways that happens:
2338
+ // (a) the concurrent-POST race this guard was written for — a prior POST
2339
+ // already fired `runInstall` and beat us to spawning the module; and
2340
+ // (b) the boot-spawned-child case the Tier-1 e2e exposed — the hub started
2341
+ // the vault module on boot so it could `selfRegister`, but NO vault
2342
+ // INSTANCE has been created yet (its services.json row carries
2343
+ // `paths: []`, which is exactly why `hasRealVault` was false above and
2344
+ // we reached this create path).
2345
+ // When the module is already supervised we must NOT fire a second
2346
+ // `bun add -g` / `runInstall`. The three create/import sub-cases are handled
2347
+ // below (import → reject; create+registered → provision the instance;
2348
+ // create+unregistered → let the in-flight install create it).
2319
2349
  const supervisorState = deps.supervisor.get(FIRST_VAULT_SHORT);
2320
- if (
2350
+ const alreadySupervised =
2321
2351
  supervisorState?.status === "running" ||
2322
2352
  supervisorState?.status === "starting" ||
2323
- supervisorState?.status === "restarting"
2324
- ) {
2353
+ supervisorState?.status === "restarting";
2354
+
2355
+ // MUST-FIX (review of #768): import-during-setup on an already-supervised box
2356
+ // isn't supported yet. On such a box (now the normal init'd-box path that
2357
+ // reaches this vault form) `provisionVault` only creates an EMPTY vault —
2358
+ // running it for an IMPORT request and reporting success would leave the
2359
+ // operator with an empty vault named after their import target plus a false
2360
+ // "vault created" banner (a data-integrity footgun). Reject with an
2361
+ // actionable message instead. Known limitation — see CHANGELOG; the fuller
2362
+ // fix (run the mirror-import follow-up after provision) is deferred because
2363
+ // it isn't exercised by the e2e and can't be verified end-to-end here.
2364
+ if (alreadySupervised && rawMode === "import") {
2365
+ const detail =
2366
+ "importing a vault during setup isn't supported yet on a box whose vault module is already running. Create the vault here instead, then import into it with `parachute vault …` or the admin UI.";
2325
2367
  if (registry) {
2326
- const op = registry.create("install", FIRST_VAULT_SHORT);
2368
+ const rejectOp = registry.create("install", FIRST_VAULT_SHORT);
2327
2369
  registry.update(
2328
- op.id,
2329
- { status: "succeeded" },
2330
- `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState.status})`,
2370
+ rejectOp.id,
2371
+ { status: "failed", error: detail },
2372
+ `vault import unsupported on an already-supervised box: ${detail}`,
2331
2373
  );
2332
2374
  if (form.isJson) {
2333
- return jsonOkResponse({ op_id: op.id, step: "vault", message: "vault already supervised" });
2375
+ return jsonOkResponse({ op_id: rejectOp.id, step: "vault", message: detail });
2334
2376
  }
2335
- return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
2377
+ return redirect(`/admin/setup?op=${encodeURIComponent(rejectOp.id)}`);
2336
2378
  }
2337
2379
  if (form.isJson) {
2338
- return jsonOkResponse({ step: "vault", message: "vault already supervised" });
2380
+ return jsonErrorResponse(409, "Import not supported here", detail);
2339
2381
  }
2340
- return redirect("/admin/setup");
2382
+ return badRequestPage("Import not supported here", detail);
2341
2383
  }
2342
2384
 
2385
+ // NIT (reviewer N2): only provision if vault is ALREADY registered in
2386
+ // services.json. If it isn't, we're in the narrow pre-seed window of a
2387
+ // CONCURRENT create POST whose `runInstall` spawned the module but hasn't
2388
+ // written its row yet — firing `provisionVault` here would run
2389
+ // `parachute install vault` a SECOND time. In that case we synthesize
2390
+ // success below and let the in-flight install create the vault.
2391
+ const vaultAlreadyRegistered =
2392
+ findService(vaultSpec.manifestName, deps.manifestPath) !== undefined;
2393
+
2343
2394
  const op = registry
2344
2395
  ? registry.create("install", FIRST_VAULT_SHORT)
2345
2396
  : { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
2346
- if (registry) {
2397
+
2398
+ if (alreadySupervised && vaultAlreadyRegistered) {
2399
+ // Case (b): supervised module, real-version row with `paths: []` (no
2400
+ // instance). Provision the named vault via the shipped admin-SPA path
2401
+ // (`parachute-vault create <name> --json`, idempotent + name-validated).
2402
+ // FIRE-AND-FORGET, matching the normal `void runInstall` path: return the
2403
+ // op_id immediately and mark the op from the BACKGROUND result. This keeps
2404
+ // the POST fast so the CLI's 30s per-request ceiling only bounds the quick
2405
+ // POST/poll round-trip — a slow cold `parachute-vault create` on a sluggish
2406
+ // VPS is polled to completion within POLL_TIMEOUT_MS (5 min), never aborted
2407
+ // mid-create. No `bun add -g` fires (module already supervised).
2408
+ const provision = deps.provisionVaultImpl ?? provisionVault;
2409
+ if (registry) {
2410
+ registry.update(
2411
+ op.id,
2412
+ { status: "running" },
2413
+ `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState?.status}) — creating vault "${vaultName}"`,
2414
+ );
2415
+ void provision(vaultName, { issuer: deps.issuer, manifestPath: deps.manifestPath })
2416
+ .then((provisioned) => {
2417
+ if (provisioned.ok) {
2418
+ registry.update(
2419
+ op.id,
2420
+ { status: "succeeded" },
2421
+ provisioned.created
2422
+ ? `vault "${vaultName}" created`
2423
+ : `vault "${vaultName}" already exists`,
2424
+ );
2425
+ } else {
2426
+ registry.update(
2427
+ op.id,
2428
+ { status: "failed", error: provisioned.message },
2429
+ `vault create failed: ${provisioned.message}`,
2430
+ );
2431
+ }
2432
+ })
2433
+ .catch((err) => {
2434
+ const msg = err instanceof Error ? err.message : String(err);
2435
+ registry.update(op.id, { status: "failed", error: msg }, `vault create failed: ${msg}`);
2436
+ });
2437
+ } else {
2438
+ // No registry (test-only path): provision synchronously so the vault is
2439
+ // actually created; there's no op to poll.
2440
+ const provisioned = await provision(vaultName, {
2441
+ issuer: deps.issuer,
2442
+ manifestPath: deps.manifestPath,
2443
+ });
2444
+ if (!provisioned.ok) {
2445
+ console.warn(`[setup-wizard] vault create failed: ${provisioned.message}`);
2446
+ }
2447
+ }
2448
+ } else if (alreadySupervised) {
2449
+ // Case (a): supervised but NOT yet registered — the pre-seed window of a
2450
+ // concurrent create POST. Synthesize success; the in-flight `runInstall`
2451
+ // creates the vault (reviewer N2 — the original guard's job).
2452
+ if (registry) {
2453
+ registry.update(
2454
+ op.id,
2455
+ { status: "succeeded" },
2456
+ `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState?.status}) — a concurrent install is provisioning the vault`,
2457
+ );
2458
+ }
2459
+ } else if (registry) {
2347
2460
  // hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
2348
2461
  // vault's first-boot path (vault#342) names the created vault
2349
2462
  // accordingly.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `/surface/notes` → `/surface/parachute` conditional alias (W2-12).
3
+ *
4
+ * The app's surface identity renamed from `notes` to `parachute`
5
+ * (parachute-app W2-12): a FUTURE install of the renamed surface package
6
+ * mounts at `/surface/parachute`, while every EXISTING install keeps
7
+ * serving notes-ui at `/surface/notes` — mounts are per-install on-disk
8
+ * identities, so nothing moves until an operator re-installs.
9
+ *
10
+ * The failure this alias exists for: an in-place upgrade that re-adds the
11
+ * renamed package over an existing `notes` instance flips the mount to
12
+ * `/surface/parachute`, orphaning every `/surface/notes/*` bookmark, PWA
13
+ * install, and in-vault link. This helper is the safety net — when (and
14
+ * only when) the manifest shows the legacy mount GONE and the new mount
15
+ * PRESENT, `/surface/notes/*` 301s to `/surface/parachute/*` with the
16
+ * sub-path tail and query string preserved.
17
+ *
18
+ * Condition — decided on the `uis{}` sub-units' **mount paths**, not their
19
+ * map keys:
20
+ *
21
+ * - fire ⇔ no `uis{}` sub-unit (across every services.json row) is
22
+ * mounted at or under `/surface/notes`, AND some sub-unit is
23
+ * mounted at exactly `/surface/parachute`.
24
+ *
25
+ * The W2-12 plan phrased this as "uis has no `notes` key and has a
26
+ * `parachute` key". Keys and paths coincide in the default install shape
27
+ * (surface-host keys `uis{}` by the surface's effective `meta.name` and
28
+ * defaults the mount to `/surface/<name>` — see parachute-surface
29
+ * admin-routes.ts `buildUisExtraField`), but they diverge exactly in the
30
+ * scenario this alias protects: re-adding the renamed package with
31
+ * `instance_name=notes` and no `mount_path` keeps the key `notes` while
32
+ * the PATH flips to `/surface/parachute`. A key-based condition would sit
33
+ * inert there and let the bookmarks 404; the path-based condition asks the
34
+ * routing question directly (the same `ui.path` values `resolveUiMount`
35
+ * matches on), so it fires precisely when `/surface/notes` stopped
36
+ * resolving and `/surface/parachute` started. It also can never redirect
37
+ * INTO a 404: the target mount's existence is part of the condition.
38
+ *
39
+ * INERT TODAY on every live install, on both branches of the condition:
40
+ * an existing notes-ui install either carries a `uis{}` sub-unit mounted
41
+ * at `/surface/notes` (legacy-mount-present → no redirect) or — as on
42
+ * installs whose surface-host row predates `uis{}` self-registration —
43
+ * carries no `/surface/parachute` mount (new-mount-absent → no redirect).
44
+ * Nothing fires until a surface actually registers at `/surface/parachute`.
45
+ *
46
+ * Dispatch placement (hub-server.ts): immediately BEFORE the generic
47
+ * services.json proxy — the same spot in the order where `resolveUiMount`
48
+ * reads the manifest — so every hub-owned prefix has had its turn and a
49
+ * live `/surface/notes` mount is never preempted (the condition already
50
+ * guarantees that, but the ordering keeps the alias out of the hot path
51
+ * for all non-`/surface/notes` traffic).
52
+ *
53
+ * Relationship to `notes-redirect.ts`: that module bridges the OLDER
54
+ * `/notes/*` → `/surface/notes/*` rename (notes-as-app Phase 2) and is
55
+ * untouched by W2-12 — its target keeps serving notes-ui on existing
56
+ * installs. On a post-rename install the two compose: `/notes/x` → 301
57
+ * `/surface/notes/x` → 301 `/surface/parachute/x`.
58
+ */
59
+
60
+ import type { ServiceEntry } from "./services-manifest.ts";
61
+
62
+ /** The legacy mount — the app's pre-W2-12 surface identity (notes-ui). */
63
+ export const LEGACY_SURFACE_NOTES_MOUNT = "/surface/notes";
64
+
65
+ /** The app's surface mount — the post-W2-12 identity. */
66
+ export const APP_SURFACE_PARACHUTE_MOUNT = "/surface/parachute";
67
+
68
+ /**
69
+ * Matches the legacy app-surface mount: bare `/surface/notes`, the
70
+ * trailing-slash form, and any sub-path. Boundary-checked — `/surface/notesy`
71
+ * and `/surface/notes-archive` do NOT match. Same shape as
72
+ * `isLegacyNotesPath` in notes-redirect.ts.
73
+ */
74
+ export function isSurfaceNotesPath(pathname: string): boolean {
75
+ return (
76
+ pathname === LEGACY_SURFACE_NOTES_MOUNT || pathname.startsWith(`${LEGACY_SURFACE_NOTES_MOUNT}/`)
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Decide whether a `/surface/notes*` request should 301 to the
82
+ * `/surface/parachute*` twin. Returns the target (path + preserved query)
83
+ * when the condition holds, `undefined` otherwise.
84
+ *
85
+ * `services` is the lenient manifest read (`readManifestLenient(...).services`)
86
+ * — the same source `resolveUiMount` consumes, re-read per request so the
87
+ * alias tracks a surface-host re-registration without a hub restart.
88
+ *
89
+ * Normalization mirrors `resolveUiMount`: trailing slashes stripped before
90
+ * comparison. The legacy-mount check treats a sub-unit mounted at OR UNDER
91
+ * `/surface/notes` as "the legacy identity still resolves" (a deeper mount
92
+ * like `/surface/notes/foo` would still serve real content under the
93
+ * prefix — redirecting across it would hijack a live route). The target
94
+ * check requires an EXACT `/surface/parachute` mount — a deeper-only mount
95
+ * would leave the redirect landing on a 404, which this alias exists to
96
+ * prevent, not cause.
97
+ */
98
+ export function maybeRedirectSurfaceNotes(
99
+ pathname: string,
100
+ search: string,
101
+ services: readonly ServiceEntry[],
102
+ ): string | undefined {
103
+ if (!isSurfaceNotesPath(pathname)) return undefined;
104
+
105
+ let legacyMountResolves = false;
106
+ let appMountPresent = false;
107
+ for (const entry of services) {
108
+ if (!entry.uis) continue;
109
+ for (const ui of Object.values(entry.uis)) {
110
+ const norm = ui.path.replace(/\/+$/, "") || "/";
111
+ if (
112
+ norm === LEGACY_SURFACE_NOTES_MOUNT ||
113
+ norm.startsWith(`${LEGACY_SURFACE_NOTES_MOUNT}/`)
114
+ ) {
115
+ legacyMountResolves = true;
116
+ }
117
+ if (norm === APP_SURFACE_PARACHUTE_MOUNT) {
118
+ appMountPresent = true;
119
+ }
120
+ }
121
+ }
122
+ if (legacyMountResolves || !appMountPresent) return undefined;
123
+
124
+ const tail = pathname.slice(LEGACY_SURFACE_NOTES_MOUNT.length);
125
+ return `${APP_SURFACE_PARACHUTE_MOUNT}${tail}${search}`;
126
+ }
@@ -1,173 +0,0 @@
1
- /**
2
- * Tests for the agent UI session→bearer mint endpoint. Mirrors
3
- * `admin-host-admin-token.test.ts` shape (agent has a single bare audience,
4
- * no per-vault name). Covers:
5
- * - 401 when no admin session cookie is present.
6
- * - 401 when the cookie names a deleted session.
7
- * - 405 on POST.
8
- * - 200 + JWT carrying `aud: "agent"` and `agent:read agent:send agent:admin`.
9
- * - First-admin gate: 403 for a signed-in non-first-admin (friend); the
10
- * admin's happy path still mints when a friend exists alongside.
11
- */
12
- import type { Database } from "bun:sqlite";
13
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
- import { mkdtempSync, rmSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
- import { AGENT_TOKEN_TTL_SECONDS, handleAgentToken } from "../admin-agent-token.ts";
18
- import { hubDbPath, openHubDb } from "../hub-db.ts";
19
- import { validateAccessToken } from "../jwt-sign.ts";
20
- import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
21
- import { rotateSigningKey } from "../signing-keys.ts";
22
- import { createUser } from "../users.ts";
23
-
24
- const ISSUER = "https://hub.test";
25
-
26
- interface Harness {
27
- db: Database;
28
- cleanup: () => void;
29
- }
30
-
31
- function makeHarness(): Harness {
32
- const dir = mkdtempSync(join(tmpdir(), "phub-agent-token-"));
33
- const db = openHubDb(hubDbPath(dir));
34
- return {
35
- db,
36
- cleanup: () => {
37
- db.close();
38
- rmSync(dir, { recursive: true, force: true });
39
- },
40
- };
41
- }
42
-
43
- let harness: Harness;
44
- beforeEach(() => {
45
- harness = makeHarness();
46
- });
47
- afterEach(() => {
48
- harness.cleanup();
49
- });
50
-
51
- async function withSession(): Promise<{ cookie: string; userId: string }> {
52
- const user = await createUser(harness.db, "operator", "hunter2");
53
- const session = createSession(harness.db, { userId: user.id });
54
- const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
55
- return { cookie, userId: user.id };
56
- }
57
-
58
- /**
59
- * Seed an admin (first-created user) + a second non-admin "friend" account,
60
- * return cookies + ids for both. Used by the first-admin-gate tests.
61
- */
62
- async function withAdminAndFriend(): Promise<{
63
- adminCookie: string;
64
- adminId: string;
65
- friendCookie: string;
66
- friendId: string;
67
- }> {
68
- const admin = await createUser(harness.db, "admin", "admin-passphrase");
69
- const friend = await createUser(harness.db, "alice", "alice-passphrase", {
70
- allowMulti: true,
71
- });
72
- const adminSession = createSession(harness.db, { userId: admin.id });
73
- const friendSession = createSession(harness.db, { userId: friend.id });
74
- return {
75
- adminCookie: buildSessionCookie(adminSession.id, Math.floor(SESSION_TTL_MS / 1000)),
76
- adminId: admin.id,
77
- friendCookie: buildSessionCookie(friendSession.id, Math.floor(SESSION_TTL_MS / 1000)),
78
- friendId: friend.id,
79
- };
80
- }
81
-
82
- describe("handleAgentToken", () => {
83
- test("401 when no session cookie is present", async () => {
84
- const req = new Request(`${ISSUER}/admin/agent-token`);
85
- const res = await handleAgentToken(req, { db: harness.db, issuer: ISSUER });
86
- expect(res.status).toBe(401);
87
- const body = (await res.json()) as { error: string };
88
- expect(body.error).toBe("unauthenticated");
89
- });
90
-
91
- test("401 when the cookie names a deleted session", async () => {
92
- const { cookie } = await withSession();
93
- const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
94
- deleteSession(harness.db, sid);
95
- const req = new Request(`${ISSUER}/admin/agent-token`, { headers: { cookie } });
96
- const res = await handleAgentToken(req, { db: harness.db, issuer: ISSUER });
97
- expect(res.status).toBe(401);
98
- });
99
-
100
- test("405 on POST", async () => {
101
- const { cookie } = await withSession();
102
- const req = new Request(`${ISSUER}/admin/agent-token`, {
103
- method: "POST",
104
- headers: { cookie },
105
- });
106
- const res = await handleAgentToken(req, { db: harness.db, issuer: ISSUER });
107
- expect(res.status).toBe(405);
108
- });
109
-
110
- test("200 mints a JWT carrying aud:agent + agent:read agent:send agent:admin", async () => {
111
- const { cookie, userId } = await withSession();
112
- rotateSigningKey(harness.db);
113
- const req = new Request(`${ISSUER}/admin/agent-token`, { headers: { cookie } });
114
- const res = await handleAgentToken(req, { db: harness.db, issuer: ISSUER });
115
- expect(res.status).toBe(200);
116
- expect(res.headers.get("cache-control")).toBe("no-store");
117
-
118
- const body = (await res.json()) as { token: string; expires_at: string; scopes: string[] };
119
- // `agent:send` (post) + `agent:read` (SSE replies) + `agent:admin`
120
- // (config UI list/edit — 2026-06-09 modular-UI architecture P3). Deliberately
121
- // NOT `agent:write` — that's the session-reply scope a UI token must not hold.
122
- expect(body.scopes).toEqual(["agent:read", "agent:send", "agent:admin"]);
123
- expect(body.scopes).not.toContain("agent:write");
124
- expect(body.token.length).toBeGreaterThan(20);
125
-
126
- const expMs = new Date(body.expires_at).getTime();
127
- const skew = expMs - Date.now();
128
- expect(skew).toBeGreaterThan((AGENT_TOKEN_TTL_SECONDS - 30) * 1000);
129
- expect(skew).toBeLessThan((AGENT_TOKEN_TTL_SECONDS + 30) * 1000);
130
-
131
- const validated = await validateAccessToken(harness.db, body.token, ISSUER);
132
- expect(validated.payload.sub).toBe(userId);
133
- expect(validated.payload.iss).toBe(ISSUER);
134
- // Bare service audience — agent validates `aud === "agent"`
135
- // (parachute-agent src/hub-jwt.ts).
136
- expect(validated.payload.aud).toBe("agent");
137
- const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
138
- const scopes = scopeClaim.split(/\s+/);
139
- expect(scopes).toContain("agent:read");
140
- expect(scopes).toContain("agent:send");
141
- expect(scopes).toContain("agent:admin");
142
- expect(scopes).not.toContain("agent:write");
143
- });
144
-
145
- test("403 not_admin when a signed-in non-first-admin (friend) hits the endpoint", async () => {
146
- // Privesc closure (mirrors host/vault-admin-token). The friend's session
147
- // is valid; the endpoint must refuse because session.userId isn't the
148
- // first-admin row.
149
- const { friendCookie } = await withAdminAndFriend();
150
- rotateSigningKey(harness.db);
151
- const req = new Request(`${ISSUER}/admin/agent-token`, {
152
- headers: { cookie: friendCookie },
153
- });
154
- const res = await handleAgentToken(req, { db: harness.db, issuer: ISSUER });
155
- expect(res.status).toBe(403);
156
- const body = (await res.json()) as { error: string; error_description: string };
157
- expect(body.error).toBe("not_admin");
158
- expect(body.error_description).toContain("/account/");
159
- });
160
-
161
- test("first-admin path still succeeds when a friend exists alongside", async () => {
162
- const { adminCookie, adminId } = await withAdminAndFriend();
163
- rotateSigningKey(harness.db);
164
- const req = new Request(`${ISSUER}/admin/agent-token`, {
165
- headers: { cookie: adminCookie },
166
- });
167
- const res = await handleAgentToken(req, { db: harness.db, issuer: ISSUER });
168
- expect(res.status).toBe(200);
169
- const body = (await res.json()) as { token: string };
170
- const validated = await validateAccessToken(harness.db, body.token, ISSUER);
171
- expect(validated.payload.sub).toBe(adminId);
172
- });
173
- });