@openparachute/hub 0.7.6 → 0.7.7-rc.13

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 (83) hide show
  1. package/README.md +7 -7
  2. package/package.json +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-command.test.ts +70 -2
  25. package/src/__tests__/hub-server.test.ts +322 -1
  26. package/src/__tests__/hub-settings.test.ts +110 -6
  27. package/src/__tests__/hub.test.ts +29 -0
  28. package/src/__tests__/install.test.ts +359 -5
  29. package/src/__tests__/migrate.test.ts +3 -1
  30. package/src/__tests__/notes-serve.test.ts +216 -0
  31. package/src/__tests__/oauth-handlers.test.ts +19 -8
  32. package/src/__tests__/operator-token.test.ts +1 -2
  33. package/src/__tests__/port-assign.test.ts +37 -17
  34. package/src/__tests__/root-serve.test.ts +139 -0
  35. package/src/__tests__/scope-explanations.test.ts +22 -2
  36. package/src/__tests__/serve-boot.test.ts +25 -36
  37. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  38. package/src/__tests__/services-manifest.test.ts +372 -132
  39. package/src/__tests__/sessions.test.ts +75 -28
  40. package/src/__tests__/setup-wizard.test.ts +7 -10
  41. package/src/__tests__/setup.test.ts +13 -14
  42. package/src/__tests__/status.test.ts +0 -5
  43. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  44. package/src/account-api.ts +677 -0
  45. package/src/account-session.ts +132 -0
  46. package/src/account-token.ts +200 -0
  47. package/src/admin-connections.ts +4 -2
  48. package/src/admin-lock.ts +1 -2
  49. package/src/admin-module-token.ts +13 -8
  50. package/src/admin-surfaces.ts +2 -1
  51. package/src/api-hub-upgrade.ts +2 -1
  52. package/src/api-invites.ts +19 -0
  53. package/src/api-mint-token.ts +2 -1
  54. package/src/api-modules-ops.ts +2 -1
  55. package/src/api-modules.ts +10 -8
  56. package/src/api-revoke-token.ts +2 -1
  57. package/src/api-settings-hub-origin.ts +2 -1
  58. package/src/api-settings-root-redirect.ts +97 -20
  59. package/src/api-tokens.ts +2 -1
  60. package/src/audience-gate.ts +2 -1
  61. package/src/chrome-strip.ts +16 -4
  62. package/src/commands/hub.ts +104 -0
  63. package/src/commands/install.ts +97 -8
  64. package/src/commands/migrate.ts +5 -1
  65. package/src/commands/setup.ts +9 -6
  66. package/src/help.ts +6 -6
  67. package/src/hub-server.ts +318 -55
  68. package/src/hub-settings.ts +162 -1
  69. package/src/hub.ts +64 -31
  70. package/src/invites.ts +42 -0
  71. package/src/module-ops-client.ts +2 -1
  72. package/src/notes-serve.ts +73 -31
  73. package/src/oauth-handlers.ts +1 -11
  74. package/src/operator-token.ts +0 -1
  75. package/src/origin-check.ts +2 -2
  76. package/src/root-serve.ts +156 -0
  77. package/src/scope-explanations.ts +35 -5
  78. package/src/service-spec.ts +129 -74
  79. package/src/services-manifest.ts +112 -52
  80. package/src/sessions.ts +66 -30
  81. package/src/surface-notes-alias.ts +126 -0
  82. package/src/__tests__/admin-agent-token.test.ts +0 -173
  83. package/src/admin-agent-token.ts +0 -147
@@ -623,7 +623,8 @@ export function readManifestLenient(
623
623
  );
624
624
  return { services: [] };
625
625
  }
626
- const afterRetired = dropRetiredModuleRows(raw, path);
626
+ const healed = healStaleUiHostAppRows(raw, path);
627
+ const afterRetired = dropRetiredModuleRows(healed.raw, path);
627
628
  const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
628
629
  // `typeof null === "object"` in JS, so the `!cleaned.raw` part of this
629
630
  // guard is load-bearing for the null case — not a typo or redundancy.
@@ -671,23 +672,29 @@ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesMan
671
672
  `failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
672
673
  );
673
674
  }
674
- // Retired-module + legacy short-name row cleanup runs BEFORE shape
675
+ // Row-heal + retired-module + legacy short-name cleanup all run BEFORE shape
675
676
  // validation because the bugs they heal (rows that conflict on port with
676
677
  // a current row) would otherwise throw inside `validateManifest`'s
677
- // duplicate-port gate. Order matters: drop retired-module rows FIRST so
678
- // their absence can unmask a `parachute-<X>` `<X>` pair underneath that
679
- // `dropLegacyShortNameRows` then handles. The reverse order would leave
680
- // a retired row that the short-name pass doesn't recognize. Both passes
681
- // mutate the raw JSON object's `services` array; we re-validate against
682
- // the cleaned shape. See `dropRetiredModuleRows` + `dropLegacyShortNameRows`
683
- // for the full discipline.
684
- const afterRetired = dropRetiredModuleRows(raw, path);
678
+ // duplicate-port gate. Order matters:
679
+ // 1. `healStaleUiHostAppRows` FIRST a pre-rename operator can carry BOTH
680
+ // a `parachute-app` AND an `app` stale row (2026-05-22 double-write
681
+ // reproducer); dropping by OLD-UI-host shape here clears both, whereas
682
+ // `dropLegacyShortNameRows` would keep the `parachute-app` half. Runs
683
+ // ahead of the retired-module pass too (the `app`/`parachute-app`
684
+ // RETIRED_MODULES entries were removed in hub-parity P5 — the names are
685
+ // the NEW app's now — so this shape-gated heal is their replacement).
686
+ // 2. `dropRetiredModuleRows` — drop genuinely-retired rows so their
687
+ // absence can unmask a `parachute-<X>` ↔ `<X>` pair underneath that
688
+ // 3. `dropLegacyShortNameRows` then handles.
689
+ // Each pass mutates the raw JSON object's `services` array; we re-validate
690
+ // against the cleaned shape. See the three helpers for the full discipline.
691
+ const healed = healStaleUiHostAppRows(raw, path);
692
+ const afterRetired = dropRetiredModuleRows(healed.raw, path);
685
693
  const cleaned = dropLegacyShortNameRows(afterRetired.raw, path);
686
694
  const validated = validateManifest(cleaned.raw, path);
687
- const migrated = migrateClawToAgent(validated);
688
- const changed = afterRetired.changed || cleaned.changed || migrated.changed;
689
- if (changed) writeManifest(migrated.manifest, path);
690
- return migrated.manifest;
695
+ const changed = healed.changed || afterRetired.changed || cleaned.changed;
696
+ if (changed) writeManifest(validated, path);
697
+ return validated;
691
698
  }
692
699
 
693
700
  /**
@@ -724,7 +731,12 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
724
731
  if (!row || typeof row !== "object") return true;
725
732
  const name = (row as Record<string, unknown>).name;
726
733
  if (typeof name !== "string") return true;
727
- const retired = RETIRED_MODULES[name];
734
+ // `claw` is too generic to reserve globally, but the historical Agent row
735
+ // had a structural identity: name=claw mounted at /claw. Retire only that
736
+ // shape; an unrelated third-party `claw` on another mount remains valid.
737
+ const paths = (row as Record<string, unknown>).paths;
738
+ const retiredClawShape = name === "claw" && Array.isArray(paths) && paths[0] === "/claw";
739
+ const retired = RETIRED_MODULES[name] ?? (retiredClawShape ? RETIRED_MODULES.agent : undefined);
728
740
  if (retired === undefined) return true;
729
741
  dropped.push({ name, retiredAt: retired.retiredAt, replacement: retired.replacement });
730
742
  return false;
@@ -745,6 +757,91 @@ function dropRetiredModuleRows(raw: unknown, where: string): { raw: unknown; cha
745
757
  };
746
758
  }
747
759
 
760
+ /**
761
+ * Heal stale OLD-UI-host `app`/`parachute-app` rows on load (hub-parity P5,
762
+ * 2026-07-11).
763
+ *
764
+ * Context: the name `parachute-app` originally belonged to the UI-host module
765
+ * that renamed to `parachute-surface` on 2026-05-27 (patterns#102). Its
766
+ * retirement used to be handled by a `RETIRED_MODULES` entry that
767
+ * `dropRetiredModuleRows` GC'd unconditionally. Hub-parity P5 REUSES the
768
+ * `parachute-app` manifestName (+ the bare `app` short) for a genuinely NEW,
769
+ * unrelated module — the super-surface front door — so that retirement entry
770
+ * had to be removed (keeping it would GC the new module's own row on every
771
+ * read). This heal is the surgically narrow replacement for the ONE case the
772
+ * removed retirement still needs to cover: an operator upgrading DIRECTLY from
773
+ * a pre-rename release (~0.5.13-stable) who skipped every intermediate boot
774
+ * that would have GC'd their stale row. Without a heal, that stale row would:
775
+ * - crash-loop on boot — `reconcilePortToCanonical` rewrites its port to
776
+ * 1944, then the supervisor spawns the app static-serve shim against a
777
+ * `@openparachute/parachute-app` that isn't installed → exit 1 → a
778
+ * forever-restarting "app" unit the operator never installed;
779
+ * - hijack `/surface/*` if a real `parachute-surface` row is also present
780
+ * (both claim `/surface`);
781
+ * - and, after `parachute install app`, keep its stale `/surface` mount so
782
+ * the app serves under `/surface` while the install's `root_redirect=/app/`
783
+ * points the front page at a 404.
784
+ *
785
+ * Discriminant (structural — pinned against the OLD-UI-host row shape verified
786
+ * in `git show origin/main`'s retired-`app` + legacy-short-name test fixtures:
787
+ * `port: 1946`, `paths: ["/surface"]`, `health: "/surface/..."`):
788
+ * - `name` is exactly `"app"` or `"parachute-app"`, AND
789
+ * - it is NOT the NEW app's own row (the new app mounts at `/app` — never
790
+ * healed regardless of port), AND
791
+ * - it carries the OLD-UI-host signature: `paths[0]` is `/surface`-rooted OR
792
+ * `port === 1946` (the old canonical slot; now `parachute-surface`'s).
793
+ *
794
+ * The NEW app row (`paths: ["/app"]`, port 1944) is structurally disjoint on
795
+ * BOTH clauses, so it is always left intact. Matches `dropRetiredModuleRows`'s
796
+ * behavior: a plain DROP + a stderr warning steering the operator to
797
+ * `parachute install surface` (the same replacement the retirement named).
798
+ *
799
+ * Operates on raw JSON (before `validateManifest`) because a stale row can
800
+ * collide on port 1946 with a real `parachute-surface` row and would otherwise
801
+ * trip the duplicate-port gate before this heal runs. Runs FIRST in the load
802
+ * pipeline (ahead of `dropRetiredModuleRows` / `dropLegacyShortNameRows`): a
803
+ * pre-rename operator can carry BOTH a `parachute-app` and an `app` stale row
804
+ * (the 2026-05-22 double-write reproducer) — dropping by shape here clears
805
+ * both, whereas `dropLegacyShortNameRows` would keep the `parachute-app` half.
806
+ */
807
+ function healStaleUiHostAppRows(raw: unknown, where: string): { raw: unknown; changed: boolean } {
808
+ if (!raw || typeof raw !== "object") return { raw, changed: false };
809
+ const services = (raw as Record<string, unknown>).services;
810
+ if (!Array.isArray(services)) return { raw, changed: false };
811
+
812
+ const dropped: string[] = [];
813
+ const nextServices = services.filter((row) => {
814
+ if (!row || typeof row !== "object") return true;
815
+ const rec = row as Record<string, unknown>;
816
+ const name = rec.name;
817
+ if (name !== "app" && name !== "parachute-app") return true;
818
+ const paths = rec.paths;
819
+ const first = Array.isArray(paths) && typeof paths[0] === "string" ? paths[0] : undefined;
820
+ // NEW super-surface app mounts at `/app` (port 1944) — never heal it,
821
+ // whatever its port happens to be after a reconcile/collision walk.
822
+ if (first === "/app" || first?.startsWith("/app/")) return true;
823
+ const port = rec.port;
824
+ const oldSurfaceMount = first === "/surface" || first?.startsWith("/surface/") === true;
825
+ const oldCanonicalPort = port === 1946;
826
+ if (!oldSurfaceMount && !oldCanonicalPort) return true;
827
+ dropped.push(String(name));
828
+ return false;
829
+ });
830
+
831
+ if (dropped.length === 0) return { raw, changed: false };
832
+
833
+ for (const name of dropped) {
834
+ console.error(
835
+ `${where}: dropped stale pre-rename '${name}' row (the OLD UI-host module, renamed to parachute-surface 2026-05-27). The name now belongs to the new Parachute app (mount /app, port 1944). If an old daemon is still running, stop it; install the current UI host with \`parachute install surface\`.`,
836
+ );
837
+ }
838
+
839
+ return {
840
+ raw: { ...(raw as Record<string, unknown>), services: nextServices },
841
+ changed: true,
842
+ };
843
+ }
844
+
748
845
  /**
749
846
  * Drop legacy short-name rows when a same-module manifestName row exists
750
847
  * on the same port. Handles the duplicate-row class introduced when a
@@ -836,43 +933,6 @@ function dropLegacyShortNameRows(raw: unknown, where: string): { raw: unknown; c
836
933
  };
837
934
  }
838
935
 
839
- /**
840
- * Migrate legacy `claw` entries to `agent` in-place. Paraclaw was renamed
841
- * to parachute-agent across the ecosystem (npm package, mount path, short
842
- * name); operators who upgraded hub but still have the old paraclaw row
843
- * in services.json would otherwise see a tile labelled "Claw" and a hub
844
- * route at `/claw` while their newly-upgraded daemon listens on `/agent`.
845
- *
846
- * Idempotent. Only rewrites when both `name === "claw"` AND the first path
847
- * is `/claw` — narrow enough that a deliberately-named third-party module
848
- * (e.g. `name: "claw"` on a different mount) is left alone. Health and any
849
- * `/claw`-rooted paths are rewritten in lockstep.
850
- */
851
- function migrateClawToAgent(manifest: ServicesManifest): {
852
- manifest: ServicesManifest;
853
- changed: boolean;
854
- } {
855
- let changed = false;
856
- const services = manifest.services.map((entry) => {
857
- if (entry.name !== "claw" || entry.paths[0] !== "/claw") return entry;
858
- changed = true;
859
- const next: ServiceEntry = {
860
- ...entry,
861
- name: "agent",
862
- paths: entry.paths.map((p) => rewriteClawPath(p)),
863
- health: rewriteClawPath(entry.health),
864
- };
865
- return next;
866
- });
867
- return { manifest: { services }, changed };
868
- }
869
-
870
- function rewriteClawPath(p: string): string {
871
- if (p === "/claw") return "/agent";
872
- if (p.startsWith("/claw/")) return `/agent${p.slice("/claw".length)}`;
873
- return p;
874
- }
875
-
876
936
  export function writeManifest(
877
937
  manifest: ServicesManifest,
878
938
  path: string = SERVICES_MANIFEST_PATH,
package/src/sessions.ts CHANGED
@@ -4,12 +4,21 @@
4
4
  * with that cookie skip the login form and go straight to consent.
5
5
  *
6
6
  * Stored in `sessions` (one row per active session), so logout / forced
7
- * revocation is just a delete. Sessions are SLIDING: `expires_at` starts at
8
- * `created_at + SESSION_TTL_MS`, and {@link touchSession} pushes it forward on
9
- * genuine activity (the admin SPA re-mints `/admin/host-admin-token` every
10
- * ~10 min while a tab is open). An idle session — no more mints — still
11
- * expires at the original 24h mark, and {@link SESSION_MAX_LIFETIME_MS} caps
12
- * total life so sliding can't keep a left-open tab alive forever.
7
+ * revocation is just a delete. Sessions are ROLLING (hub-parity P1, Q4 —
8
+ * adopts cloud's posture): `expires_at` starts at `created_at + SESSION_TTL_MS`
9
+ * (90 days), and {@link touchSession} pushes it forward on genuine activity
10
+ * (the admin SPA re-mints `/admin/host-admin-token` every ~10 min while a tab
11
+ * is open; `GET /account/session`, the app's boot/poll oracle, slides once it
12
+ * crosses {@link SESSION_SLIDE_THRESHOLD_MS} of its life see
13
+ * `account-session.ts`). There is NO absolute ceiling: an ACTIVELY-used
14
+ * session rolls forward indefinitely, while an idle one (no more touches)
15
+ * still lapses at the 90-day mark. Was 24h sliding / 30d hard cap through
16
+ * 2026-07; the cap is gone. The ONLY thing that terminates a session row today
17
+ * is logout (which deletes it). Note what does NOT: the admin screen-lock gates
18
+ * token MINTS (it does not touch or expire the session row), and per-user
19
+ * session revocation does not exist yet (P6-era). So an actively-used session
20
+ * — including a stolen cookie — currently lives on until 90 idle days or an
21
+ * explicit logout; a per-user revoke lever is the standing gap to close.
13
22
  *
14
23
  * The cookie value is the session id directly. It's a 32-byte base64url
15
24
  * random; collision is statistically impossible. No HMAC needed because the
@@ -20,16 +29,25 @@ import type { Database } from "bun:sqlite";
20
29
  import { randomBytes } from "node:crypto";
21
30
 
22
31
  export const SESSION_COOKIE_NAME = "parachute_hub_session";
23
- export const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
32
+ /**
33
+ * Session lifetime — 90 days (hub-parity P1, Q4: adopts cloud's rolling
34
+ * posture). A session (and its cookie Max-Age) lasts 90 days from its last
35
+ * slide; {@link findSession} still expires it hard past this, and logout
36
+ * deletes the row outright. Was 24h (sliding, 30d hard cap) prior to this PR.
37
+ */
38
+ export const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000;
24
39
 
25
40
  /**
26
- * Absolute ceiling on a session's total lifetime, independent of sliding
27
- * renewal. Sliding ({@link touchSession}) keeps an active console signed in,
28
- * but a left-open-but-idle tab whose background polls keep re-minting must
29
- * still be force-logged-out eventually this caps life at
30
- * `created_at + SESSION_MAX_LIFETIME_MS` so renewal can't extend forever.
41
+ * Roll a session forward once it has been used past this much of its life
42
+ * (30 days) so an ACTIVE session stays alive indefinitely, while an idle one
43
+ * still expires at the full 90-day TTL. Callers that slide on every touch
44
+ * (the admin SPA's host-admin-token re-mint) don't need this threshold; it's
45
+ * for a POLLING caller like `GET /account/session` (the app's `/check-email`
46
+ * screen hits it every few seconds) that must NOT rewrite the row on every
47
+ * request — see `shouldSlideSession` there. Mirrors cloud's
48
+ * `SESSION_REFRESH_THRESHOLD_MS` (`workers/identity/src/sessions.ts`).
31
49
  */
32
- export const SESSION_MAX_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
50
+ export const SESSION_SLIDE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
33
51
 
34
52
  export interface Session {
35
53
  id: string;
@@ -91,30 +109,33 @@ export function findSession(
91
109
  }
92
110
 
93
111
  /**
94
- * Slide a session's expiry forward to `now + SESSION_TTL_MS`, capped at
95
- * `created_at + SESSION_MAX_LIFETIME_MS`. No-op when the session doesn't exist.
112
+ * Slide a session's expiry forward to `now + SESSION_TTL_MS`. No-op when the
113
+ * session doesn't exist. NO ceiling (hub-parity P1, Q4) cloud's posture,
114
+ * adopted here: an ACTIVELY-used session rolls forward forever; a closed tab
115
+ * / idle client (no more touches) still expires `SESSION_TTL_MS` after its
116
+ * last activity. The old absolute cap (`SESSION_MAX_LIFETIME_MS`, 30d) is
117
+ * removed — the bounds on a rolling session are logout, the admin
118
+ * screen-lock, and (P6-era) per-user delete, not a lifetime ceiling.
96
119
  *
97
- * This is what makes sessions sliding rather than fixed-24h: the admin SPA
98
- * re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is open,
99
- * and each successful mint calls this so an actively-used console isn't
100
- * hard-logged-out at the 24h mark, while a closed tab (no more mints) still
101
- * expires 24h after its last activity. The ceiling bounds a left-open-but-idle
102
- * tab (background polls keep re-minting) so sliding can't run forever.
120
+ * This is what makes sessions sliding rather than fixed-TTL: the admin SPA
121
+ * re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is
122
+ * open, and each successful mint calls this unconditionally; `GET
123
+ * /account/session` (the app's boot/poll oracle) instead calls this only
124
+ * past `SESSION_SLIDE_THRESHOLD_MS` of remaining life (bounded slide see
125
+ * `account-session.ts`), since it's polled every few seconds and an
126
+ * unconditional touch there would rewrite the row on every poll.
103
127
  *
104
128
  * Monotonic in practice: the production wall clock only moves forward, so the
105
- * slid value never undershoots a previously-written expiry; once it reaches the
106
- * ceiling it stays pinned there. (The write is unconditional it does not read
107
- * the current expiry — so an injected backward `now` in tests would shorten the
108
- * session: a conservative failure mode, not a security issue.) `now` is
109
- * injectable for tests, matching {@link findSession}.
129
+ * slid value never undershoots a previously-written expiry. (The write is
130
+ * unconditional it does not read the current expiryso an injected
131
+ * backward `now` in tests would shorten the session: a conservative failure
132
+ * mode, not a security issue.) `now` is injectable for tests, matching
133
+ * {@link findSession}.
110
134
  */
111
135
  export function touchSession(db: Database, id: string, now: () => Date = () => new Date()): void {
112
136
  const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
113
137
  if (!row) return;
114
- const nowMs = now().getTime();
115
- const slidMs = nowMs + SESSION_TTL_MS;
116
- const ceilingMs = new Date(row.created_at).getTime() + SESSION_MAX_LIFETIME_MS;
117
- const newExpiresAt = new Date(Math.min(slidMs, ceilingMs)).toISOString();
138
+ const newExpiresAt = new Date(now().getTime() + SESSION_TTL_MS).toISOString();
118
139
  db.prepare("UPDATE sessions SET expires_at = ? WHERE id = ?").run(newExpiresAt, id);
119
140
  }
120
141
 
@@ -122,6 +143,21 @@ export function deleteSession(db: Database, id: string): void {
122
143
  db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
123
144
  }
124
145
 
146
+ /**
147
+ * Should this live session be rolled forward? True once it has been used past
148
+ * {@link SESSION_SLIDE_THRESHOLD_MS} of its life — i.e. its remaining life has
149
+ * dropped below `SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS`. A
150
+ * freshly-created/-slid session is NOT slid; one that's crossed the threshold
151
+ * is. Pure — the caller (`account-session.ts`'s bounded slide, the G3 twin of
152
+ * cloud's `shouldSlideSession`) does the write ({@link touchSession}) + cookie
153
+ * re-issue only when this returns true, bounding both to ~once per threshold
154
+ * per session even under frequent polling.
155
+ */
156
+ export function shouldSlideSession(session: Session, now: () => Date = () => new Date()): boolean {
157
+ const remainingMs = new Date(session.expiresAt).getTime() - now().getTime();
158
+ return remainingMs < SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS;
159
+ }
160
+
125
161
  /**
126
162
  * Build a `Set-Cookie` header value for the given session id. HttpOnly +
127
163
  * SameSite=Lax + Secure (conditional) + Path=/.
@@ -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
- });