@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.
- package/README.md +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -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__/door-contract-parity.test.ts +46 -0
- 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 +22 -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__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- 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/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -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-invites.ts +19 -0
- 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/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- 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 +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- 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
|
@@ -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
|
-
|
|
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 (
|
|
61
|
-
const later = new Date(epoch.getTime() +
|
|
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() +
|
|
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 +
|
|
82
|
-
expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() +
|
|
83
|
-
// Touch 1h later → expiry becomes (t0 + 1h) +
|
|
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() +
|
|
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
|
|
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 +
|
|
99
|
-
touchSession(db, s.id, () => new Date(t0.getTime() +
|
|
100
|
-
// At +
|
|
101
|
-
const
|
|
102
|
-
expect(findSession(db, 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
|
|
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 —
|
|
114
|
-
//
|
|
115
|
-
const
|
|
116
|
-
expect(findSession(db, s.id, () =>
|
|
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("
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
expect(findSession(db, s.id, () =>
|
|
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.
|
|
2070
|
-
//
|
|
2071
|
-
|
|
2072
|
-
const
|
|
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
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
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
|
|
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
|
|
188
|
-
//
|
|
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",
|
|
195
|
-
"default", // vault name
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
+
});
|