@openparachute/hub 0.7.7-rc.8 → 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.
- package/README.md +7 -7
- package/package.json +4 -12
- package/src/__tests__/account-api.test.ts +29 -2
- package/src/__tests__/account-session.test.ts +9 -1
- package/src/__tests__/account-token.test.ts +26 -2
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +0 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/setup-wizard.test.ts +301 -22
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/__tests__/wizard-transcription.test.ts +35 -0
- package/src/__tests__/wizard.test.ts +79 -0
- package/src/account-api.ts +6 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/commands/wizard-transcription.ts +24 -0
- package/src/commands/wizard.ts +34 -1
- package/src/help.ts +6 -6
- package/src/hub-server.ts +111 -54
- package/src/hub-settings.ts +147 -0
- package/src/hub.ts +64 -31
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +2 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/setup-wizard.ts +144 -31
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
package/src/setup-wizard.ts
CHANGED
|
@@ -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,
|
|
312
|
-
// below (skip flips hasVault,
|
|
313
|
-
//
|
|
314
|
-
// handleSetupGet's `?step=vault`
|
|
315
|
-
// already-provisioned short-circuit
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
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
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
//
|
|
2311
|
-
//
|
|
2312
|
-
//
|
|
2313
|
-
//
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
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
|
-
|
|
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
|
|
2368
|
+
const rejectOp = registry.create("install", FIRST_VAULT_SHORT);
|
|
2327
2369
|
registry.update(
|
|
2328
|
-
|
|
2329
|
-
{ status: "
|
|
2330
|
-
|
|
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:
|
|
2375
|
+
return jsonOkResponse({ op_id: rejectOp.id, step: "vault", message: detail });
|
|
2334
2376
|
}
|
|
2335
|
-
return redirect(`/admin/setup?op=${encodeURIComponent(
|
|
2377
|
+
return redirect(`/admin/setup?op=${encodeURIComponent(rejectOp.id)}`);
|
|
2336
2378
|
}
|
|
2337
2379
|
if (form.isJson) {
|
|
2338
|
-
return
|
|
2380
|
+
return jsonErrorResponse(409, "Import not supported here", detail);
|
|
2339
2381
|
}
|
|
2340
|
-
return
|
|
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
|
-
|
|
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
|
-
});
|