@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
@@ -5,13 +5,15 @@ import { join } from "node:path";
5
5
  import { hubDbPath, openHubDb } from "../hub-db.ts";
6
6
  import {
7
7
  SESSION_COOKIE_NAME,
8
- SESSION_MAX_LIFETIME_MS,
8
+ SESSION_SLIDE_THRESHOLD_MS,
9
+ SESSION_TTL_MS,
9
10
  buildSessionClearCookie,
10
11
  buildSessionCookie,
11
12
  createSession,
12
13
  deleteSession,
13
14
  findSession,
14
15
  parseSessionCookie,
16
+ shouldSlideSession,
15
17
  touchSession,
16
18
  } from "../sessions.ts";
17
19
  import { createUser } from "../users.ts";
@@ -57,11 +59,11 @@ describe("createSession + findSession", () => {
57
59
  try {
58
60
  const epoch = new Date("2026-01-01T00:00:00Z");
59
61
  const s = createSession(db, { userId, now: () => epoch });
60
- // Past TTL (24h).
61
- const later = new Date(epoch.getTime() + 25 * 3600 * 1000);
62
+ // Past TTL (90 days).
63
+ const later = new Date(epoch.getTime() + SESSION_TTL_MS + 3600 * 1000);
62
64
  expect(findSession(db, s.id, () => later)).toBeNull();
63
65
  // Still valid one second before expiry.
64
- const justBefore = new Date(epoch.getTime() + 24 * 3600 * 1000 - 1000);
66
+ const justBefore = new Date(epoch.getTime() + SESSION_TTL_MS - 1000);
65
67
  expect(findSession(db, s.id, () => justBefore)?.id).toBe(s.id);
66
68
  } finally {
67
69
  cleanup();
@@ -78,60 +80,64 @@ describe("touchSession (sliding renewal)", () => {
78
80
  try {
79
81
  const t0 = new Date("2026-01-01T00:00:00Z");
80
82
  const s = createSession(db, { userId, now: () => t0 });
81
- // Original expiry: t0 + 24h.
82
- expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + DAY);
83
- // Touch 1h later → expiry becomes (t0 + 1h) + 24h.
83
+ // Original expiry: t0 + 90d.
84
+ expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + SESSION_TTL_MS);
85
+ // Touch 1h later → expiry becomes (t0 + 1h) + 90d.
84
86
  const t1 = new Date(t0.getTime() + HOUR);
85
87
  touchSession(db, s.id, () => t1);
86
88
  const found = findSession(db, s.id, () => t1);
87
- expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + DAY);
89
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + SESSION_TTL_MS);
88
90
  } finally {
89
91
  cleanup();
90
92
  }
91
93
  });
92
94
 
93
- test("a touched session outlives the ORIGINAL 24h expiry", async () => {
95
+ test("a touched session outlives the ORIGINAL 90d expiry", async () => {
94
96
  const { db, userId, cleanup } = await makeDb();
95
97
  try {
96
98
  const t0 = new Date("2026-01-01T00:00:00Z");
97
99
  const s = createSession(db, { userId, now: () => t0 });
98
- // Activity at +12h slides expiry to +36h.
99
- touchSession(db, s.id, () => new Date(t0.getTime() + 12 * HOUR));
100
- // At +30h — PAST the original +24h — the session is still alive.
101
- const at30h = new Date(t0.getTime() + 30 * HOUR);
102
- expect(findSession(db, s.id, () => at30h)?.id).toBe(s.id);
100
+ // Activity at +45d slides expiry to +135d.
101
+ touchSession(db, s.id, () => new Date(t0.getTime() + 45 * DAY));
102
+ // At +100d — PAST the original +90d — the session is still alive.
103
+ const at100d = new Date(t0.getTime() + 100 * DAY);
104
+ expect(findSession(db, s.id, () => at100d)?.id).toBe(s.id);
103
105
  } finally {
104
106
  cleanup();
105
107
  }
106
108
  });
107
109
 
108
- test("an UNtouched session still expires at the original 24h", async () => {
110
+ test("an UNtouched session still expires at the original 90d", async () => {
109
111
  const { db, userId, cleanup } = await makeDb();
110
112
  try {
111
113
  const t0 = new Date("2026-01-01T00:00:00Z");
112
114
  const s = createSession(db, { userId, now: () => t0 });
113
- // No touch — at +25h it's gone (today's absolute-TTL behavior preserved
114
- // for idle / closed tabs that stop re-minting).
115
- const at25h = new Date(t0.getTime() + 25 * HOUR);
116
- expect(findSession(db, s.id, () => at25h)).toBeNull();
115
+ // No touch — one day past the 90d TTL it's gone (idle / closed tabs
116
+ // that stop re-minting still lapse at the full TTL).
117
+ const past = new Date(t0.getTime() + SESSION_TTL_MS + DAY);
118
+ expect(findSession(db, s.id, () => past)).toBeNull();
117
119
  } finally {
118
120
  cleanup();
119
121
  }
120
122
  });
121
123
 
122
- test("caps at created_at + SESSION_MAX_LIFETIME_MS (sliding can't run forever)", async () => {
124
+ test("NO ceiling (Q4) an actively-touched session rolls forward indefinitely", async () => {
125
+ // The old SESSION_MAX_LIFETIME_MS (30d) absolute cap is gone: repeatedly
126
+ // touching a session keeps sliding its expiry to now + 90d with no upper
127
+ // bound, matching cloud's rolling posture (an idle session still lapses
128
+ // at the TTL; an active one never does).
123
129
  const { db, userId, cleanup } = await makeDb();
124
130
  try {
125
131
  const t0 = new Date("2026-01-01T00:00:00Z");
126
132
  const s = createSession(db, { userId, now: () => t0 });
127
- const ceiling = t0.getTime() + SESSION_MAX_LIFETIME_MS;
128
- // A touch near the ceiling would slide to now + 24h, but the cap pins it.
129
- const nearCeiling = new Date(ceiling - HOUR); // raw slide would be ceiling + 23h
130
- touchSession(db, s.id, () => nearCeiling);
131
- const found = findSession(db, s.id, () => nearCeiling);
132
- expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(ceiling);
133
- // Past the ceiling the session is dead even though it was just "active".
134
- expect(findSession(db, s.id, () => new Date(ceiling + 1000))).toBeNull();
133
+ // Touch well past where the old 30-day ceiling would have pinned it.
134
+ const farOut = new Date(t0.getTime() + 200 * DAY);
135
+ touchSession(db, s.id, () => farOut);
136
+ const found = findSession(db, s.id, () => farOut);
137
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(farOut.getTime() + SESSION_TTL_MS);
138
+ // Still alive nearly 90 more days out, right up to the fresh slide's TTL.
139
+ const stillAlive = new Date(farOut.getTime() + SESSION_TTL_MS - DAY);
140
+ expect(findSession(db, s.id, () => stillAlive)?.id).toBe(s.id);
135
141
  } finally {
136
142
  cleanup();
137
143
  }
@@ -147,6 +153,47 @@ describe("touchSession (sliding renewal)", () => {
147
153
  });
148
154
  });
149
155
 
156
+ describe("shouldSlideSession (bounded slide, G3 twin)", () => {
157
+ test("false for a freshly-created session (full TTL remaining)", async () => {
158
+ const { db, userId, cleanup } = await makeDb();
159
+ try {
160
+ const t0 = new Date("2026-01-01T00:00:00Z");
161
+ const s = createSession(db, { userId, now: () => t0 });
162
+ expect(shouldSlideSession(s, () => t0)).toBe(false);
163
+ } finally {
164
+ cleanup();
165
+ }
166
+ });
167
+
168
+ test("false just before crossing the slide threshold", async () => {
169
+ const { db, userId, cleanup } = await makeDb();
170
+ try {
171
+ const t0 = new Date("2026-01-01T00:00:00Z");
172
+ const s = createSession(db, { userId, now: () => t0 });
173
+ // Elapsed just UNDER the threshold (30d) → remaining life still above
174
+ // (TTL - threshold) → not yet due to slide.
175
+ const justBefore = new Date(t0.getTime() + SESSION_SLIDE_THRESHOLD_MS - 1000);
176
+ expect(shouldSlideSession(s, () => justBefore)).toBe(false);
177
+ } finally {
178
+ cleanup();
179
+ }
180
+ });
181
+
182
+ test("true once remaining life drops below the slide threshold", async () => {
183
+ const { db, userId, cleanup } = await makeDb();
184
+ try {
185
+ const t0 = new Date("2026-01-01T00:00:00Z");
186
+ const s = createSession(db, { userId, now: () => t0 });
187
+ // Elapsed just OVER the threshold (30d) → remaining life below
188
+ // (TTL - threshold) → due to slide.
189
+ const justAfter = new Date(t0.getTime() + SESSION_SLIDE_THRESHOLD_MS + 1000);
190
+ expect(shouldSlideSession(s, () => justAfter)).toBe(true);
191
+ } finally {
192
+ cleanup();
193
+ }
194
+ });
195
+ });
196
+
150
197
  describe("deleteSession", () => {
151
198
  test("removes the session row", async () => {
152
199
  const { db, userId, cleanup } = await makeDb();
@@ -2066,19 +2066,16 @@ describe("handleSetupVaultPost", () => {
2066
2066
  test("scribe transcribe=local resolves to the host platform's backend (the Linux trap fix)", async () => {
2067
2067
  // The bug: `local` mapped UNCONDITIONALLY to parakeet-mlx (macOS-only),
2068
2068
  // silently broken on every Linux box. After the fix it resolves to the
2069
- // platform backend. We assert against the actual host so it's correct on
2070
- // both Mac (parakeet-mlx) and Linux CI (onnx-asr). The RAM gate only kicks
2071
- // in below 2 GB — CI/dev boxes clear it, so the choice stays `local`.
2072
- const { platformLocalProvider } = await import("../scribe-config.ts");
2073
- const expected = platformLocalProvider(process.platform);
2069
+ // platform backend. The expectation uses the same host RAM decision so
2070
+ // constrained CI boxes correctly assert the cloud fallback instead.
2071
+ const { decideLocalProvider, readAvailableRamMib } = await import("../scribe-config.ts");
2072
+ const decision = decideLocalProvider(process.platform, readAvailableRamMib());
2074
2073
  const { response } = await postVaultWithFields(h, { scribe_provider: "local" });
2075
2074
  expect(response.status).toBe(303);
2076
2075
  const cfg = readScribeConfig(h.dir);
2077
- if (expected !== null) {
2078
- // On a supported platform with enough RAM, `local` resolves to the
2079
- // platform backend — NOT a hardcoded parakeet-mlx on Linux.
2080
- expect(cfg?.transcribe).toEqual({ provider: expected });
2081
- }
2076
+ expect(cfg?.transcribe).toEqual({
2077
+ provider: decision.ok ? decision.provider : decision.steerTo,
2078
+ });
2082
2079
  });
2083
2080
  });
2084
2081
 
@@ -142,7 +142,10 @@ describe("setup", () => {
142
142
  // rejects duplicate ports between distinct services (hub#195).
143
143
  // parachute-runner rides along as a LEGACY row (runner left the
144
144
  // registries 2026-07-01): it must neither block the all-installed exit
145
- // nor crash the survey.
145
+ // nor crash the survey. parachute-app (hub-parity P5, 2026-07-11) is
146
+ // included so this fixture stays truly "everything installed" — without
147
+ // it, `app` is a genuinely offered-but-uninstalled service and the
148
+ // non-tty exit code flips from 0 to 1 (nothing left vs. work-to-offer).
146
149
  const seeds: Array<{ name: string; port: number }> = [
147
150
  { name: "parachute-vault", port: 1940 },
148
151
  { name: "parachute-notes", port: 1942 },
@@ -150,6 +153,7 @@ describe("setup", () => {
150
153
  { name: "parachute-agent", port: 1941 },
151
154
  { name: "parachute-runner", port: 1945 },
152
155
  { name: "parachute-surface", port: 1946 },
156
+ { name: "parachute-app", port: 1944 },
153
157
  ];
154
158
  for (const s of seeds) {
155
159
  upsertService(
@@ -181,18 +185,14 @@ describe("setup", () => {
181
185
  }
182
186
  });
183
187
 
184
- test("fresh box: the offered 'Available to install' list excludes deprecated notes + removed runner", async () => {
188
+ test("fresh box: the offered list excludes deprecated notes and removed Agent/runner", async () => {
185
189
  const h = makeHarness();
186
190
  try {
187
- // 'all' picks every OFFERED service. With a clean services.json the survey
188
- // sees every known short; the offered filter must drop notes (deprecated,
189
- // 2026-06-25) while runner never even reaches the survey (registry
190
- // removal, 2026-07-01) — keeping vault/scribe/surface/agent. Only vault +
191
- // scribe have pre-install follow-up prompts (vault name, scribe provider);
192
- // surface + agent have none — so the scripted answers below are complete.
191
+ // 'all' picks every offered service. The clean-box offer excludes notes
192
+ // (deprecated) and retired Agent/runner, keeping vault/scribe/surface/app.
193
193
  const availability = scriptedAvailability([
194
- "all", // pick everything offered
195
- "default", // vault name (vault is in the offered set)
194
+ "all",
195
+ "default", // vault name
196
196
  "1", // scribe provider
197
197
  ]);
198
198
  const code = await setup({
@@ -206,20 +206,19 @@ describe("setup", () => {
206
206
  },
207
207
  });
208
208
  expect(code).toBe(0);
209
- // The "Available to install" banner must NOT list notes / runner.
210
209
  const joined = h.logs.join("\n");
211
210
  const availableBlock = joined.slice(joined.indexOf("Available to install:"));
212
211
  expect(availableBlock).not.toMatch(/\bnotes\b/);
213
212
  expect(availableBlock).not.toMatch(/\brunner\b/);
214
- // …and `install()` is never invoked for the deprecated shorts.
213
+ expect(availableBlock).not.toMatch(/\bagent\b/);
215
214
  const installedShorts = h.calls.map((c) => c.short);
216
215
  expect(installedShorts).not.toContain("notes");
217
216
  expect(installedShorts).not.toContain("runner");
218
- // The non-deprecated set is still offered + installed.
217
+ expect(installedShorts).not.toContain("agent");
219
218
  expect(installedShorts).toContain("vault");
220
219
  expect(installedShorts).toContain("scribe");
221
220
  expect(installedShorts).toContain("surface");
222
- expect(installedShorts).toContain("agent");
221
+ expect(installedShorts).toContain("app");
223
222
  } finally {
224
223
  h.cleanup();
225
224
  }
@@ -225,11 +225,6 @@ describe("status — per-module URL deep-links (manifestRowBase / urlForEntry)",
225
225
  expect(out).toMatch(/:5173\/notes/);
226
226
  });
227
227
 
228
- test("channel row prints port + /channel mount", async () => {
229
- const out = await urlFor("parachute-channel", 1943, ["/channel"]);
230
- expect(out).toMatch(/:1943\/channel/);
231
- });
232
-
233
228
  test("unknown third-party service falls back to bare host:port + paths[0]", async () => {
234
229
  const { path, configDir, cleanup } = makeTempPath();
235
230
  try {
@@ -0,0 +1,296 @@
1
+ /**
2
+ * W2-12 — `/surface/notes` → `/surface/parachute` conditional alias.
3
+ *
4
+ * The condition is decided on `uis{}` MOUNT PATHS (the values
5
+ * `resolveUiMount` routes on), not map keys: fire only when no sub-unit
6
+ * resolves at/under `/surface/notes` AND one is mounted at exactly
7
+ * `/surface/parachute`. Unit tests pin the helper's condition + target
8
+ * shape; the integration tests drive `hubFetch` end-to-end to prove the
9
+ * dispatch placement (before the generic services proxy) and — the
10
+ * load-bearing half — that an EXISTING notes-ui install (a `/surface/notes`
11
+ * uis mount) passes through to its upstream untouched.
12
+ */
13
+ import { 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 { hubFetch } from "../hub-server.ts";
18
+ import { clearNotesRedirectLogState } from "../notes-redirect.ts";
19
+ import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
20
+ import { isSurfaceNotesPath, maybeRedirectSurfaceNotes } from "../surface-notes-alias.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Manifest fixtures
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** A surface-host row carrying the given uis map (or none). */
27
+ function surfaceRow(uis: ServiceEntry["uis"] | undefined, port = 1946): ServiceEntry {
28
+ const entry: ServiceEntry = {
29
+ name: "parachute-surface",
30
+ port,
31
+ paths: ["/surface"],
32
+ health: "/surface/healthz",
33
+ version: "0.4.0",
34
+ };
35
+ if (uis !== undefined) entry.uis = uis;
36
+ return entry;
37
+ }
38
+
39
+ /** Post-rename install: the app surface registered at /surface/parachute. */
40
+ const PARACHUTE_UIS: ServiceEntry["uis"] = {
41
+ parachute: { displayName: "Parachute", path: "/surface/parachute", audience: "public" },
42
+ };
43
+
44
+ /** Existing install: notes-ui still registered at /surface/notes. */
45
+ const NOTES_UIS: ServiceEntry["uis"] = {
46
+ notes: { displayName: "Notes", path: "/surface/notes", audience: "public" },
47
+ };
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // isSurfaceNotesPath — boundary shape
51
+ // ---------------------------------------------------------------------------
52
+
53
+ describe("isSurfaceNotesPath", () => {
54
+ test("matches the bare mount, trailing slash, and sub-paths", () => {
55
+ expect(isSurfaceNotesPath("/surface/notes")).toBe(true);
56
+ expect(isSurfaceNotesPath("/surface/notes/")).toBe(true);
57
+ expect(isSurfaceNotesPath("/surface/notes/index.html")).toBe(true);
58
+ expect(isSurfaceNotesPath("/surface/notes/a/b?ignored-not-part-of-path")).toBe(true);
59
+ });
60
+
61
+ test("does NOT match sibling prefixes or other mounts", () => {
62
+ expect(isSurfaceNotesPath("/surface/notesy")).toBe(false);
63
+ expect(isSurfaceNotesPath("/surface/notes-archive/")).toBe(false);
64
+ expect(isSurfaceNotesPath("/surface/parachute")).toBe(false);
65
+ expect(isSurfaceNotesPath("/notes/")).toBe(false);
66
+ expect(isSurfaceNotesPath("/surface")).toBe(false);
67
+ });
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // maybeRedirectSurfaceNotes — the condition + target shape
72
+ // ---------------------------------------------------------------------------
73
+
74
+ describe("maybeRedirectSurfaceNotes", () => {
75
+ test("fires when /surface/parachute is mounted and /surface/notes is not", () => {
76
+ const services = [surfaceRow(PARACHUTE_UIS)];
77
+ expect(maybeRedirectSurfaceNotes("/surface/notes", "", services)).toBe("/surface/parachute");
78
+ expect(maybeRedirectSurfaceNotes("/surface/notes/", "", services)).toBe("/surface/parachute/");
79
+ expect(maybeRedirectSurfaceNotes("/surface/notes/a/b.html", "", services)).toBe(
80
+ "/surface/parachute/a/b.html",
81
+ );
82
+ });
83
+
84
+ test("preserves the query string on the target", () => {
85
+ const services = [surfaceRow(PARACHUTE_UIS)];
86
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "?q=1&n=2", services)).toBe(
87
+ "/surface/parachute/x?q=1&n=2",
88
+ );
89
+ });
90
+
91
+ test("INERT when a /surface/notes mount exists (existing notes-ui install)", () => {
92
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(NOTES_UIS)])).toBe(
93
+ undefined,
94
+ );
95
+ // ... even when a parachute mount ALSO exists (transitional both-present
96
+ // install) — a live legacy mount is never preempted.
97
+ const both = [surfaceRow({ ...NOTES_UIS, ...PARACHUTE_UIS })];
98
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", both)).toBe(undefined);
99
+ });
100
+
101
+ test("INERT when no uis{} exist at all (pre-uis surface-host rows — today's live shape)", () => {
102
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(undefined)])).toBe(
103
+ undefined,
104
+ );
105
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [])).toBe(undefined);
106
+ });
107
+
108
+ test("INERT when the target mount is absent — never redirects into a 404", () => {
109
+ // A `parachute` KEY whose path was customized elsewhere does not count:
110
+ // the redirect target is the literal /surface/parachute mount, so its
111
+ // existence (by path) is part of the condition.
112
+ const customized: ServiceEntry["uis"] = {
113
+ parachute: { displayName: "Parachute", path: "/surface/app", audience: "public" },
114
+ };
115
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(customized)])).toBe(
116
+ undefined,
117
+ );
118
+ });
119
+
120
+ test("fires in the in-place-upgrade caveat: uis KEY still `notes`, PATH flipped to /surface/parachute", () => {
121
+ // Re-adding the renamed package over the old instance with
122
+ // `instance_name=notes` and no `mount_path` keeps the map key `notes`
123
+ // while the mount flips — exactly the orphaned-bookmark scenario this
124
+ // alias exists for. The path-based condition (not key-based) covers it.
125
+ const upgraded: ServiceEntry["uis"] = {
126
+ notes: { displayName: "Parachute", path: "/surface/parachute", audience: "public" },
127
+ };
128
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "?a=1", [surfaceRow(upgraded)])).toBe(
129
+ "/surface/parachute/x?a=1",
130
+ );
131
+ });
132
+
133
+ test("a sub-unit mounted UNDER /surface/notes counts as the legacy identity resolving", () => {
134
+ // A deeper mount like /surface/notes/foo still serves real content under
135
+ // the legacy prefix; redirecting across it would hijack a live route.
136
+ const deep: ServiceEntry["uis"] = {
137
+ ...PARACHUTE_UIS,
138
+ foo: { displayName: "Foo", path: "/surface/notes/foo", audience: "public" },
139
+ };
140
+ expect(maybeRedirectSurfaceNotes("/surface/notes/foo/x", "", [surfaceRow(deep)])).toBe(
141
+ undefined,
142
+ );
143
+ });
144
+
145
+ test("non-matching pathnames return undefined regardless of manifest state", () => {
146
+ const services = [surfaceRow(PARACHUTE_UIS)];
147
+ expect(maybeRedirectSurfaceNotes("/surface/notesy", "", services)).toBe(undefined);
148
+ expect(maybeRedirectSurfaceNotes("/surface/parachute/x", "", services)).toBe(undefined);
149
+ expect(maybeRedirectSurfaceNotes("/notes/x", "", services)).toBe(undefined);
150
+ });
151
+
152
+ test("normalizes trailing slashes on declared uis paths", () => {
153
+ const slashed: ServiceEntry["uis"] = {
154
+ parachute: { displayName: "Parachute", path: "/surface/parachute/", audience: "public" },
155
+ };
156
+ expect(maybeRedirectSurfaceNotes("/surface/notes/x", "", [surfaceRow(slashed)])).toBe(
157
+ "/surface/parachute/x",
158
+ );
159
+ });
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // hubFetch integration — dispatch placement + passthrough
164
+ // ---------------------------------------------------------------------------
165
+
166
+ interface Harness {
167
+ dir: string;
168
+ manifestPath: string;
169
+ cleanup: () => void;
170
+ }
171
+
172
+ function makeHarness(): Harness {
173
+ const dir = mkdtempSync(join(tmpdir(), "pcli-surface-notes-alias-"));
174
+ return {
175
+ dir,
176
+ manifestPath: join(dir, "services.json"),
177
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
178
+ };
179
+ }
180
+
181
+ function req(path: string): Request {
182
+ return new Request(`http://127.0.0.1${path}`);
183
+ }
184
+
185
+ // Loopback peer so the default publicExposure cloak never interferes —
186
+ // same shape audience-gate.test.ts uses.
187
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
188
+
189
+ function startEchoUpstream(): { port: number; stop: () => void } {
190
+ const server = Bun.serve({
191
+ port: 0,
192
+ hostname: "127.0.0.1",
193
+ fetch: (r) =>
194
+ new Response(JSON.stringify({ upstream: "notes-ui", path: new URL(r.url).pathname }), {
195
+ status: 200,
196
+ headers: { "content-type": "application/json" },
197
+ }),
198
+ });
199
+ return { port: server.port as number, stop: () => server.stop(true) };
200
+ }
201
+
202
+ describe("hubFetch × surface-notes alias (W2-12)", () => {
203
+ test("301: /surface/notes/* → /surface/parachute/* when only the parachute mount exists (tail + query preserved)", async () => {
204
+ clearNotesRedirectLogState();
205
+ const h = makeHarness();
206
+ try {
207
+ writeManifest({ services: [surfaceRow(PARACHUTE_UIS)] }, h.manifestPath);
208
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
209
+ const res = await f(req("/surface/notes/some/path?q=1&n=2"), fakeServer("127.0.0.1"));
210
+ expect(res.status).toBe(301);
211
+ expect(res.headers.get("location")).toBe("/surface/parachute/some/path?q=1&n=2");
212
+ } finally {
213
+ h.cleanup();
214
+ }
215
+ });
216
+
217
+ test("301: bare /surface/notes → /surface/parachute", async () => {
218
+ clearNotesRedirectLogState();
219
+ const h = makeHarness();
220
+ try {
221
+ writeManifest({ services: [surfaceRow(PARACHUTE_UIS)] }, h.manifestPath);
222
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
223
+ const res = await f(req("/surface/notes"), fakeServer("127.0.0.1"));
224
+ expect(res.status).toBe(301);
225
+ expect(res.headers.get("location")).toBe("/surface/parachute");
226
+ } finally {
227
+ h.cleanup();
228
+ }
229
+ });
230
+
231
+ test("200 passthrough: an existing notes-ui install (notes uis mount) proxies to its upstream untouched", async () => {
232
+ clearNotesRedirectLogState();
233
+ const upstream = startEchoUpstream();
234
+ const h = makeHarness();
235
+ try {
236
+ writeManifest({ services: [surfaceRow(NOTES_UIS, upstream.port)] }, h.manifestPath);
237
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
238
+ const res = await f(req("/surface/notes/index.html"), fakeServer("127.0.0.1"));
239
+ expect(res.status).toBe(200);
240
+ const body = (await res.json()) as { upstream: string; path: string };
241
+ // The blind proxy forwarded the ORIGINAL path — no rewrite rode along.
242
+ expect(body.upstream).toBe("notes-ui");
243
+ expect(body.path).toBe("/surface/notes/index.html");
244
+ } finally {
245
+ upstream.stop();
246
+ h.cleanup();
247
+ }
248
+ });
249
+
250
+ test("404: /surface/notesy (sibling prefix) is untouched by the alias", async () => {
251
+ clearNotesRedirectLogState();
252
+ const h = makeHarness();
253
+ try {
254
+ // parachute-mount-only manifest, but NO row claims /surface/notesy —
255
+ // the boundary check keeps the alias out of the way and the dispatch
256
+ // falls through to the branded 404.
257
+ writeManifest(
258
+ {
259
+ services: [
260
+ {
261
+ name: "parachute-app",
262
+ port: 1944,
263
+ paths: ["/app"],
264
+ health: "/app",
265
+ version: "0.5.0",
266
+ uis: PARACHUTE_UIS,
267
+ },
268
+ ],
269
+ },
270
+ h.manifestPath,
271
+ );
272
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
273
+ const res = await f(req("/surface/notesy"), fakeServer("127.0.0.1"));
274
+ expect(res.status).toBe(404);
275
+ } finally {
276
+ h.cleanup();
277
+ }
278
+ });
279
+
280
+ test("404 (not 301): no uis at all — today's live shape stays inert end-to-end", async () => {
281
+ clearNotesRedirectLogState();
282
+ const h = makeHarness();
283
+ try {
284
+ // A pre-uis surface row would normally proxy /surface/* blind; with no
285
+ // live upstream the proxy path yields a 502/404-class response — the
286
+ // assertion here is only that NO 301 fires. Use an empty manifest so
287
+ // the outcome is a deterministic 404.
288
+ writeManifest({ services: [] }, h.manifestPath);
289
+ const f = hubFetch(h.dir, { manifestPath: h.manifestPath });
290
+ const res = await f(req("/surface/notes/"), fakeServer("127.0.0.1"));
291
+ expect(res.status).toBe(404);
292
+ } finally {
293
+ h.cleanup();
294
+ }
295
+ });
296
+ });