@openparachute/hub 0.7.6-rc.4 → 0.7.7-rc.12
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 +12 -0
- 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-server.test.ts +37 -0
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +279 -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__/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 +2 -1
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/install.ts +86 -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 +247 -52
- package/src/hub-settings.ts +25 -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/scope-explanations.ts +35 -5
- package/src/service-spec.ts +128 -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
|
@@ -56,7 +56,9 @@ describe("safelistEntries", () => {
|
|
|
56
56
|
expect(s.has("vault")).toBe(true);
|
|
57
57
|
expect(s.has("notes")).toBe(true);
|
|
58
58
|
expect(s.has("scribe")).toBe(true);
|
|
59
|
-
expect(s.has("agent")).toBe(
|
|
59
|
+
expect(s.has("agent")).toBe(false);
|
|
60
|
+
expect(KNOWN_ARCHIVABLE_DIRS.has("agent")).toBe(true);
|
|
61
|
+
expect(KNOWN_ARCHIVABLE_DIRS.has("channel")).toBe(true);
|
|
60
62
|
// Internal
|
|
61
63
|
expect(s.has("hub")).toBe(true);
|
|
62
64
|
// CLI state
|
|
@@ -149,6 +149,124 @@ describe("notesFetch with empty mount (root deployment)", () => {
|
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
describe("notesFetch /health (2026-07-11, hub-parity P5)", () => {
|
|
153
|
+
test("GET /notes/health answers 2xx explicitly, not the SPA shell", async () => {
|
|
154
|
+
const h = makeHarness();
|
|
155
|
+
try {
|
|
156
|
+
const res = notesFetch(h.dir, "/notes")(req("/notes/health"));
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
// Explicit JSON, not the index.html SPA-shell fallback — proves the
|
|
159
|
+
// health path is a real handler, not an accident of the catch-all.
|
|
160
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
161
|
+
expect(await res.text()).not.toContain("notes spa");
|
|
162
|
+
} finally {
|
|
163
|
+
h.cleanup();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("GET /health answers 2xx at the mount root too (empty mount)", async () => {
|
|
168
|
+
const h = makeHarness();
|
|
169
|
+
try {
|
|
170
|
+
const res = notesFetch(h.dir, "")(req("/health"));
|
|
171
|
+
expect(res.status).toBe(200);
|
|
172
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
173
|
+
} finally {
|
|
174
|
+
h.cleanup();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("survives a missing dist/index.html — health doesn't depend on the SPA shell existing", async () => {
|
|
179
|
+
// A harness with NO index.html written — the SPA-shell fallback would
|
|
180
|
+
// throw/404 on this dist, but /health is answered before that code path
|
|
181
|
+
// is ever reached.
|
|
182
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-notes-serve-empty-"));
|
|
183
|
+
try {
|
|
184
|
+
const res = notesFetch(dir, "/app")(req("/app/health"));
|
|
185
|
+
expect(res.status).toBe(200);
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(dir, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// hub-parity P5 (2026-07-11): the shim generalized beyond notes to serve
|
|
193
|
+
// @openparachute/parachute-app (mount `/app`, port 1944) via the same
|
|
194
|
+
// FIRST_PARTY_FALLBACKS startCmd shape (`--package @openparachute/parachute-app`).
|
|
195
|
+
// These tests re-run the load-bearing PWA regression (sw.js / manifest
|
|
196
|
+
// content-type, SPA fallback, mount-strip) for a NON-notes package/mount to
|
|
197
|
+
// prove the generalization didn't accidentally hardcode "notes" anywhere in
|
|
198
|
+
// the serving path (only `resolveNotesDistFrom`'s package resolution is
|
|
199
|
+
// notes-specific, and that's parameterized separately below).
|
|
200
|
+
describe("notesFetch generalized for a non-notes package (hub-parity P5 — the app mount)", () => {
|
|
201
|
+
function makeAppHarness(): Harness {
|
|
202
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-app-serve-"));
|
|
203
|
+
writeFileSync(join(dir, "index.html"), "<html><body>app spa</body></html>");
|
|
204
|
+
writeFileSync(join(dir, "sw.js"), "self.addEventListener('install', () => {});");
|
|
205
|
+
writeFileSync(join(dir, "manifest.webmanifest"), '{"name":"Parachute","start_url":"/app/"}');
|
|
206
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test("GET /app/sw.js serves the SW with JS content-type, not text/html", async () => {
|
|
210
|
+
const h = makeAppHarness();
|
|
211
|
+
try {
|
|
212
|
+
const res = notesFetch(h.dir, "/app")(req("/app/sw.js"));
|
|
213
|
+
expect(res.status).toBe(200);
|
|
214
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
215
|
+
expect(ct).not.toContain("text/html");
|
|
216
|
+
expect(ct).toMatch(/javascript/);
|
|
217
|
+
} finally {
|
|
218
|
+
h.cleanup();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("GET /app/manifest.webmanifest serves application/manifest+json", async () => {
|
|
223
|
+
const h = makeAppHarness();
|
|
224
|
+
try {
|
|
225
|
+
const res = notesFetch(h.dir, "/app")(req("/app/manifest.webmanifest"));
|
|
226
|
+
expect(res.status).toBe(200);
|
|
227
|
+
expect(res.headers.get("content-type")).toBe("application/manifest+json");
|
|
228
|
+
expect(await res.text()).toContain('"name":"Parachute"');
|
|
229
|
+
} finally {
|
|
230
|
+
h.cleanup();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("GET /app/ serves the SPA shell", async () => {
|
|
235
|
+
const h = makeAppHarness();
|
|
236
|
+
try {
|
|
237
|
+
const res = notesFetch(h.dir, "/app")(req("/app/"));
|
|
238
|
+
expect(res.status).toBe(200);
|
|
239
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
240
|
+
expect(await res.text()).toContain("app spa");
|
|
241
|
+
} finally {
|
|
242
|
+
h.cleanup();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("GET /app/some/deep/route falls back to the SPA shell (client-side routing)", async () => {
|
|
247
|
+
const h = makeAppHarness();
|
|
248
|
+
try {
|
|
249
|
+
const res = notesFetch(h.dir, "/app")(req("/app/some/deep/route"));
|
|
250
|
+
expect(res.status).toBe(200);
|
|
251
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
252
|
+
expect(await res.text()).toContain("app spa");
|
|
253
|
+
} finally {
|
|
254
|
+
h.cleanup();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("GET /appendix/foo (mount-prefix collision) is not stripped", async () => {
|
|
259
|
+
const h = makeAppHarness();
|
|
260
|
+
try {
|
|
261
|
+
const res = notesFetch(h.dir, "/app")(req("/appendix/foo"));
|
|
262
|
+
expect(res.status).toBe(200);
|
|
263
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
264
|
+
} finally {
|
|
265
|
+
h.cleanup();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
152
270
|
describe("notesDistCandidates", () => {
|
|
153
271
|
test("returns cwd, then global node_modules, then global root", () => {
|
|
154
272
|
const cands = notesDistCandidates("/some/cwd", "/home/user");
|
|
@@ -295,3 +413,101 @@ describe("resolveNotesDistFrom (hub#194)", () => {
|
|
|
295
413
|
).toThrow(/has no dist\/ directory/);
|
|
296
414
|
});
|
|
297
415
|
});
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* `--package` generalization (hub-parity P5, 2026-07-11). `resolveNotesDistFrom`
|
|
419
|
+
* defaults `pkg` to `@openparachute/notes` (every test above omits it and
|
|
420
|
+
* still resolves notes — back-compat), but a caller like the `app`
|
|
421
|
+
* FIRST_PARTY_FALLBACKS entry passes a different package name. These tests
|
|
422
|
+
* pin the resolver against a real on-disk fixture for a NON-notes package,
|
|
423
|
+
* proving the specifier passed to `Bun.resolveSync` (and every error message)
|
|
424
|
+
* is the caller's `pkg`, not a hardcoded "notes" string.
|
|
425
|
+
*/
|
|
426
|
+
describe("resolveNotesDistFrom --package (hub-parity P5)", () => {
|
|
427
|
+
const APP_PKG = "@openparachute/parachute-app";
|
|
428
|
+
|
|
429
|
+
function makeAppFixture(): { home: string; cleanup: () => void; dist: string } {
|
|
430
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "pcli-app-resolve-")));
|
|
431
|
+
const home = join(root, "home");
|
|
432
|
+
const pkgRoot = join(home, ".bun/install/global/node_modules", APP_PKG);
|
|
433
|
+
mkdirSync(pkgRoot, { recursive: true });
|
|
434
|
+
const dist = join(pkgRoot, "dist");
|
|
435
|
+
mkdirSync(dist, { recursive: true });
|
|
436
|
+
writeFileSync(join(pkgRoot, "package.json"), JSON.stringify({ name: APP_PKG }));
|
|
437
|
+
return { home, dist, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
test("resolves a non-notes package's dist/ via the global node_modules fallback", () => {
|
|
441
|
+
const f = makeAppFixture();
|
|
442
|
+
try {
|
|
443
|
+
const out = resolveNotesDistFrom({
|
|
444
|
+
cwd: "/hub-repo-cwd-without-app",
|
|
445
|
+
home: f.home,
|
|
446
|
+
pkg: APP_PKG,
|
|
447
|
+
resolveSync: (specifier, base) => {
|
|
448
|
+
if (base === "/hub-repo-cwd-without-app") {
|
|
449
|
+
throw new Error(`Cannot find module '${specifier}' from '${base}'`);
|
|
450
|
+
}
|
|
451
|
+
return Bun.resolveSync(specifier, base);
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
expect(out).toBe(f.dist);
|
|
455
|
+
} finally {
|
|
456
|
+
f.cleanup();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("default pkg (no --package) still resolves @openparachute/notes — back-compat", () => {
|
|
461
|
+
// Every earlier describe block already exercises this implicitly (none
|
|
462
|
+
// pass `pkg`); this test pins it explicitly against the specifier the
|
|
463
|
+
// resolver hands to `resolveSync`.
|
|
464
|
+
const specifiers: string[] = [];
|
|
465
|
+
expect(() =>
|
|
466
|
+
resolveNotesDistFrom({
|
|
467
|
+
cwd: "/cwd",
|
|
468
|
+
home: "/h",
|
|
469
|
+
resolveSync: (specifier) => {
|
|
470
|
+
specifiers.push(specifier);
|
|
471
|
+
throw new Error("not found");
|
|
472
|
+
},
|
|
473
|
+
}),
|
|
474
|
+
).toThrow();
|
|
475
|
+
expect(specifiers).toEqual([
|
|
476
|
+
"@openparachute/notes/package.json",
|
|
477
|
+
"@openparachute/notes/package.json",
|
|
478
|
+
"@openparachute/notes/package.json",
|
|
479
|
+
]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("error message names the caller's package, not a hardcoded 'notes'", () => {
|
|
483
|
+
let caught: unknown;
|
|
484
|
+
try {
|
|
485
|
+
resolveNotesDistFrom({
|
|
486
|
+
cwd: "/probe-cwd",
|
|
487
|
+
home: "/probe-home",
|
|
488
|
+
pkg: APP_PKG,
|
|
489
|
+
resolveSync: () => {
|
|
490
|
+
throw new Error("nope");
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
} catch (err) {
|
|
494
|
+
caught = err;
|
|
495
|
+
}
|
|
496
|
+
const msg = (caught as Error).message;
|
|
497
|
+
expect(msg).toContain(`Could not resolve ${APP_PKG}`);
|
|
498
|
+
expect(msg).toContain(`bun add -g ${APP_PKG}`);
|
|
499
|
+
expect(msg).not.toContain("@openparachute/notes");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("no-dist/ hard error names the caller's package", () => {
|
|
503
|
+
expect(() =>
|
|
504
|
+
resolveNotesDistFrom({
|
|
505
|
+
cwd: "/cwd-with-app",
|
|
506
|
+
home: "/h",
|
|
507
|
+
pkg: APP_PKG,
|
|
508
|
+
resolveSync: () => "/cwd-with-app/node_modules/@openparachute/parachute-app/package.json",
|
|
509
|
+
existsSync: () => false,
|
|
510
|
+
}),
|
|
511
|
+
).toThrow(new RegExp(`${APP_PKG.replace("/", "\\/")} resolved at .* has no dist/ directory`));
|
|
512
|
+
});
|
|
513
|
+
});
|
|
@@ -3,6 +3,11 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
checkAuthorizationServerMetadata,
|
|
8
|
+
checkProtectedResourceMetadata,
|
|
9
|
+
checkTokenResponseInvariants,
|
|
10
|
+
} from "@openparachute/door-contract";
|
|
6
11
|
import { handleAdminLoginPost, handleAdminLoginTotpPost } from "../admin-handlers.ts";
|
|
7
12
|
import { approveClient, getClient, registerClient } from "../clients.ts";
|
|
8
13
|
import { CSRF_COOKIE_NAME } from "../csrf.ts";
|
|
@@ -122,9 +127,12 @@ describe("authorizationServerMetadata", () => {
|
|
|
122
127
|
// hub:admin + scribe:admin are operator-only (non-requestable) — never advertised (2026-06-30)
|
|
123
128
|
expect(scopesSupported).not.toContain("hub:admin");
|
|
124
129
|
expect(scopesSupported).not.toContain("scribe:admin");
|
|
125
|
-
//
|
|
126
|
-
// (hub#…: optional-module scopes only surface when the module is installed).
|
|
130
|
+
// Retired Agent scopes are no longer part of the first-party catalog.
|
|
127
131
|
expect(scopesSupported).not.toContain("agent:send");
|
|
132
|
+
// H1.2 — door-contract conformance: the static RFC 8414 fields (endpoints,
|
|
133
|
+
// response/grant types, PKCE method, token-auth methods) match the shared
|
|
134
|
+
// contract exactly (V1.4/C1.4 twin coverage, hub half).
|
|
135
|
+
expect(checkAuthorizationServerMetadata(body, ISSUER, scopesSupported)).toEqual([]);
|
|
128
136
|
});
|
|
129
137
|
|
|
130
138
|
test("does NOT advertise non-requestable operator-only scopes", async () => {
|
|
@@ -164,7 +172,7 @@ describe("authorizationServerMetadata", () => {
|
|
|
164
172
|
const body = (await res.json()) as Record<string, unknown>;
|
|
165
173
|
const scopesSupported = body.scopes_supported as string[];
|
|
166
174
|
// Third-party scopes show up (`widget:*` / `mymodule:*` aren't gated
|
|
167
|
-
// optional-module prefixes — only scribe:/
|
|
175
|
+
// optional-module prefixes — only scribe:/surface: are, see OPTIONAL_MODULE_SCOPES).
|
|
168
176
|
expect(scopesSupported).toContain("widget:read");
|
|
169
177
|
expect(scopesSupported).toContain("widget:write");
|
|
170
178
|
expect(scopesSupported).toContain("mymodule:do-thing");
|
|
@@ -178,10 +186,10 @@ describe("authorizationServerMetadata", () => {
|
|
|
178
186
|
});
|
|
179
187
|
|
|
180
188
|
test("advertises an optional module's scopes only when it's installed", async () => {
|
|
181
|
-
// FIRST_PARTY_SCOPES carries scribe:*
|
|
189
|
+
// FIRST_PARTY_SCOPES carries scribe:* statically. On a
|
|
182
190
|
// vault-only hub they must NOT be advertised — a discovery client (e.g.
|
|
183
191
|
// claude.ai's connector UI) lists the catalog verbatim, so a friend
|
|
184
|
-
// connecting one vault was shown Scribe
|
|
192
|
+
// connecting one vault was shown Scribe access the hub can't
|
|
185
193
|
// honor. Vault + hub are core and always advertised.
|
|
186
194
|
const declared = new Set<string>([
|
|
187
195
|
"vault:read",
|
|
@@ -189,7 +197,6 @@ describe("authorizationServerMetadata", () => {
|
|
|
189
197
|
"vault:admin",
|
|
190
198
|
"scribe:transcribe",
|
|
191
199
|
"scribe:admin",
|
|
192
|
-
"agent:send",
|
|
193
200
|
"hub:admin",
|
|
194
201
|
]);
|
|
195
202
|
const vaultOnly = {
|
|
@@ -217,7 +224,6 @@ describe("authorizationServerMetadata", () => {
|
|
|
217
224
|
// uninstalled optional-module scopes are dropped
|
|
218
225
|
expect(scopes).not.toContain("scribe:transcribe");
|
|
219
226
|
expect(scopes).not.toContain("scribe:admin");
|
|
220
|
-
expect(scopes).not.toContain("agent:send");
|
|
221
227
|
|
|
222
228
|
// ...but once scribe is installed, its scopes ARE advertised again.
|
|
223
229
|
const withScribe = {
|
|
@@ -249,7 +255,6 @@ describe("authorizationServerMetadata", () => {
|
|
|
249
255
|
// requestability gate (non-requestable, 2026-06-30) doing the work here, not
|
|
250
256
|
// the optional-module-not-installed gate that drops scribe:transcribe above.
|
|
251
257
|
expect(scopes2).not.toContain("scribe:admin");
|
|
252
|
-
expect(scopes2).not.toContain("agent:send"); // agent still not installed
|
|
253
258
|
});
|
|
254
259
|
});
|
|
255
260
|
|
|
@@ -265,6 +270,8 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
|
|
|
265
270
|
expect(body.resource).toBe(ISSUER);
|
|
266
271
|
expect(body.authorization_servers).toEqual([ISSUER]);
|
|
267
272
|
expect(body.bearer_methods_supported).toEqual(["header"]);
|
|
273
|
+
// H1.2 — door-contract conformance (V1.4/C1.4 twin coverage, hub half).
|
|
274
|
+
expect(checkProtectedResourceMetadata(body, ISSUER)).toEqual([]);
|
|
268
275
|
expect(Array.isArray(body.scopes_supported)).toBe(true);
|
|
269
276
|
expect(body.resource_documentation).toMatch(/parachute\.computer/);
|
|
270
277
|
});
|
|
@@ -2320,6 +2327,10 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
2320
2327
|
expect(tokenBody.token_type).toBe("Bearer");
|
|
2321
2328
|
expect(tokenBody.scope).toBe("vault:default:read");
|
|
2322
2329
|
expect(tokenBody.refresh_token.length).toBeGreaterThan(20);
|
|
2330
|
+
// H1.2 — door-contract conformance: token_type/expires_in/scope/access_token
|
|
2331
|
+
// invariants against a REAL `POST /oauth/token` success body (V1.4/C1.4 twin
|
|
2332
|
+
// coverage, hub half).
|
|
2333
|
+
expect(checkTokenResponseInvariants(tokenBody, "vault:default:read")).toEqual([]);
|
|
2323
2334
|
|
|
2324
2335
|
// JWT must verify against the hub's signing keys, with the right sub +
|
|
2325
2336
|
// aud (named `vault:default:read` → "vault.default" — RFC 8707-style
|
|
@@ -66,7 +66,7 @@ describe("mintOperatorToken", () => {
|
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe
|
|
69
|
+
test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe admin scopes (#213)", () => {
|
|
70
70
|
// OPERATOR_TOKEN_SCOPES === OPERATOR_TOKEN_SCOPE_SETS.admin (back-compat
|
|
71
71
|
// alias). The pre-#213 set was 5 scopes; #213 added the fine-grained
|
|
72
72
|
// parachute:host:install/start/expose/auth/vault scopes to the admin
|
|
@@ -81,7 +81,6 @@ describe("mintOperatorToken", () => {
|
|
|
81
81
|
"parachute:host:vault",
|
|
82
82
|
"vault:admin",
|
|
83
83
|
"scribe:admin",
|
|
84
|
-
"agent:send",
|
|
85
84
|
]);
|
|
86
85
|
});
|
|
87
86
|
});
|
|
@@ -3,7 +3,26 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { assignPort, assignServicePort } from "../port-assign.ts";
|
|
6
|
-
import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN } from "../service-spec.ts";
|
|
6
|
+
import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN, PORT_RESERVATIONS } from "../service-spec.ts";
|
|
7
|
+
|
|
8
|
+
describe("PORT_RESERVATIONS (registry shape)", () => {
|
|
9
|
+
test("every port in the canonical range appears exactly once", () => {
|
|
10
|
+
const ports = PORT_RESERVATIONS.map((r) => r.port);
|
|
11
|
+
expect(new Set(ports).size).toBe(ports.length);
|
|
12
|
+
expect(ports.slice().sort((a, b) => a - b)).toEqual(ports);
|
|
13
|
+
for (const p of ports) {
|
|
14
|
+
expect(p).toBeGreaterThanOrEqual(CANONICAL_PORT_MIN);
|
|
15
|
+
expect(p).toBeLessThanOrEqual(CANONICAL_PORT_MAX);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("1944 (parachute-app, hub-parity P5) doesn't collide with any other reservation", () => {
|
|
20
|
+
const owners = PORT_RESERVATIONS.filter((r) => r.port === 1944);
|
|
21
|
+
expect(owners).toHaveLength(1);
|
|
22
|
+
expect(owners[0]?.name).toBe("parachute-app");
|
|
23
|
+
expect(owners[0]?.status).toBe("assigned");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
7
26
|
|
|
8
27
|
function makeTempDir(): { dir: string; cleanup: () => void } {
|
|
9
28
|
const dir = mkdtempSync(join(tmpdir(), "pcli-port-assign-"));
|
|
@@ -25,17 +44,18 @@ describe("assignPort (pure)", () => {
|
|
|
25
44
|
});
|
|
26
45
|
|
|
27
46
|
test("walks the unassigned reservation range when canonical is occupied", () => {
|
|
28
|
-
// 1940 is taken
|
|
47
|
+
// 1940 is taken. Agent retirement released 1941 as the first walkable slot.
|
|
29
48
|
const result = assignPort(1940, [1940]);
|
|
30
|
-
expect(result.port).toBe(
|
|
49
|
+
expect(result.port).toBe(1941);
|
|
31
50
|
expect(result.source).toBe("fallback-in-range");
|
|
32
51
|
expect(result.warning).toMatch(/canonical port 1940 is in use/);
|
|
33
|
-
expect(result.warning).toMatch(/
|
|
52
|
+
expect(result.warning).toMatch(/1941/);
|
|
34
53
|
});
|
|
35
54
|
|
|
36
55
|
test("skips reservations that are also occupied", () => {
|
|
37
|
-
// Canonical 1940
|
|
38
|
-
|
|
56
|
+
// Canonical 1940 and released slot 1941 are occupied. Assigned slots are
|
|
57
|
+
// skipped; 1945 is reserved-but-occupied; 1947 is reserved-and-free.
|
|
58
|
+
const result = assignPort(1940, [1940, 1941, 1945]);
|
|
39
59
|
expect(result.port).toBe(1947);
|
|
40
60
|
expect(result.source).toBe("fallback-in-range");
|
|
41
61
|
});
|
|
@@ -60,21 +80,19 @@ describe("assignPort (pure)", () => {
|
|
|
60
80
|
});
|
|
61
81
|
|
|
62
82
|
test("third-party (no canonical slot) jumps straight to the reservation range", () => {
|
|
83
|
+
// Agent retirement released 1941 as the first walkable slot.
|
|
63
84
|
const result = assignPort(undefined, []);
|
|
64
|
-
expect(result.port).toBe(
|
|
85
|
+
expect(result.port).toBe(1941);
|
|
65
86
|
expect(result.source).toBe("fallback-in-range");
|
|
66
87
|
expect(result.warning).toMatch(/no canonical slot/);
|
|
67
|
-
expect(result.warning).toMatch(/
|
|
88
|
+
expect(result.warning).toMatch(/1941/);
|
|
68
89
|
});
|
|
69
90
|
|
|
70
91
|
test("third-party with reservations occupied walks further in the range", () => {
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// 1946 slot and lands on the next reserved-and-free port — 1947.
|
|
76
|
-
const result = assignPort(undefined, [1944, 1945]);
|
|
77
|
-
expect(result.port).toBe(1947);
|
|
92
|
+
// With released 1941 occupied, the walker skips assigned 1942–1944 and
|
|
93
|
+
// lands on the next reserved-and-free port, 1945.
|
|
94
|
+
const result = assignPort(undefined, [1941]);
|
|
95
|
+
expect(result.port).toBe(1945);
|
|
78
96
|
expect(result.source).toBe("fallback-in-range");
|
|
79
97
|
});
|
|
80
98
|
});
|
|
@@ -145,7 +163,8 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
|
|
|
145
163
|
canonical: 1940,
|
|
146
164
|
occupied: [1940],
|
|
147
165
|
});
|
|
148
|
-
|
|
166
|
+
// Agent retirement released 1941 as the first walkable slot.
|
|
167
|
+
expect(result.port).toBe(1941);
|
|
149
168
|
expect(result.source).toBe("fallback-in-range");
|
|
150
169
|
expect(result.warning).toMatch(/canonical port 1940 is in use/);
|
|
151
170
|
// .env stays bit-for-bit identical.
|
|
@@ -162,7 +181,8 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
|
|
|
162
181
|
const result = assignServicePort({
|
|
163
182
|
occupied: [],
|
|
164
183
|
});
|
|
165
|
-
|
|
184
|
+
// Agent retirement released 1941 as the first walkable slot.
|
|
185
|
+
expect(result.port).toBe(1941);
|
|
166
186
|
expect(result.source).toBe("fallback-in-range");
|
|
167
187
|
expect(existsSync(envPath)).toBe(false);
|
|
168
188
|
} finally {
|
|
@@ -19,9 +19,11 @@ describe("SCOPE_EXPLANATIONS", () => {
|
|
|
19
19
|
"vault:admin",
|
|
20
20
|
"scribe:transcribe",
|
|
21
21
|
"scribe:admin",
|
|
22
|
-
"agent:send",
|
|
23
22
|
"hub:admin",
|
|
24
23
|
"parachute:host:admin",
|
|
24
|
+
// Account scopes (Parachute App campaign, Phase 2).
|
|
25
|
+
"account:self:admin",
|
|
26
|
+
"account:self:read",
|
|
25
27
|
];
|
|
26
28
|
for (const s of expected) {
|
|
27
29
|
expect(SCOPE_EXPLANATIONS[s]).toBeDefined();
|
|
@@ -156,6 +158,14 @@ describe("NON_REQUESTABLE_SCOPES (#96)", () => {
|
|
|
156
158
|
expect(NON_REQUESTABLE_SCOPES.has("scribe:admin")).toBe(true);
|
|
157
159
|
});
|
|
158
160
|
|
|
161
|
+
test("contains the account scopes (App campaign Phase 2 — cookie-minted only)", () => {
|
|
162
|
+
// account:self:{admin,read} are minted exclusively by POST /account/token
|
|
163
|
+
// (cookie→bearer), never via /oauth/authorize — a third-party app pointed at
|
|
164
|
+
// the hub AS must not be able to consent its way to account authority.
|
|
165
|
+
expect(NON_REQUESTABLE_SCOPES.has("account:self:admin")).toBe(true);
|
|
166
|
+
expect(NON_REQUESTABLE_SCOPES.has("account:self:read")).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
159
169
|
test("every non-requestable scope is a known first-party scope", () => {
|
|
160
170
|
for (const s of NON_REQUESTABLE_SCOPES) {
|
|
161
171
|
expect(FIRST_PARTY_SCOPES).toContain(s);
|
|
@@ -176,7 +186,6 @@ describe("isRequestableScope", () => {
|
|
|
176
186
|
test("true for non-admin first-party scopes", () => {
|
|
177
187
|
expect(isRequestableScope("vault:read")).toBe(true);
|
|
178
188
|
expect(isRequestableScope("vault:admin")).toBe(true);
|
|
179
|
-
expect(isRequestableScope("agent:send")).toBe(true);
|
|
180
189
|
expect(isRequestableScope("scribe:transcribe")).toBe(true);
|
|
181
190
|
});
|
|
182
191
|
|
|
@@ -195,6 +204,17 @@ describe("isRequestableScope", () => {
|
|
|
195
204
|
expect(isRequestableScope("vault:my-techne_2:admin")).toBe(true);
|
|
196
205
|
});
|
|
197
206
|
|
|
207
|
+
test("account scopes are non-requestable (cookie-minted only, App campaign)", () => {
|
|
208
|
+
expect(isRequestableScope("account:self:admin")).toBe(false);
|
|
209
|
+
expect(isRequestableScope("account:self:read")).toBe(false);
|
|
210
|
+
// explainScope resolves them (direct SCOPE_EXPLANATIONS keys) with the
|
|
211
|
+
// right levels, so scopeIsAdmin recognizes the admin form.
|
|
212
|
+
expect(explainScope("account:self:admin")?.level).toBe("admin");
|
|
213
|
+
expect(explainScope("account:self:read")?.level).toBe("read");
|
|
214
|
+
expect(scopeIsAdmin("account:self:admin")).toBe(true);
|
|
215
|
+
expect(scopeIsAdmin("account:self:read")).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
198
218
|
test("host-level operator scopes stay non-requestable", () => {
|
|
199
219
|
// The asymmetry the single-consent change preserved: per-vault admin is
|
|
200
220
|
// now requestable (capped at mint), but host-wide operator authority is
|
|
@@ -395,46 +395,39 @@ describe("bootSupervisedModules", () => {
|
|
|
395
395
|
});
|
|
396
396
|
});
|
|
397
397
|
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
// the reverse-proxy (which reads services.json) routes correctly.
|
|
403
|
-
describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
|
|
398
|
+
// A transiently wrong services.json port for a fixed-port first-party module can
|
|
399
|
+
// self-perpetuate through supervisor env, probes, and proxy routing. The boot path
|
|
400
|
+
// snaps supported modules back to canonical and persists the fix.
|
|
401
|
+
describe("reconcilePortToCanonical", () => {
|
|
404
402
|
let h: Harness;
|
|
405
403
|
beforeEach(() => {
|
|
406
404
|
h = makeHarness();
|
|
407
405
|
});
|
|
408
406
|
afterEach(() => h.cleanup());
|
|
409
407
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const DRIFTED_CHANNEL: ServiceEntry = {
|
|
413
|
-
name: "parachute-agent",
|
|
408
|
+
const DRIFTED_SCRIBE: ServiceEntry = {
|
|
409
|
+
name: "parachute-scribe",
|
|
414
410
|
port: 19415,
|
|
415
|
-
paths: ["/
|
|
411
|
+
paths: ["/scribe"],
|
|
416
412
|
health: "/health",
|
|
417
413
|
version: "0.1.0",
|
|
418
414
|
};
|
|
419
415
|
|
|
420
416
|
test("snaps a drifted fixed-port row back to canonical + persists it", () => {
|
|
421
|
-
writeManifest({ services: [
|
|
417
|
+
writeManifest({ services: [DRIFTED_SCRIBE] }, h.manifestPath);
|
|
422
418
|
const logs: string[] = [];
|
|
423
419
|
|
|
424
|
-
const reconciled = reconcilePortToCanonical(
|
|
420
|
+
const reconciled = reconcilePortToCanonical(DRIFTED_SCRIBE, h.manifestPath, (l) =>
|
|
425
421
|
logs.push(l),
|
|
426
422
|
);
|
|
427
423
|
|
|
428
|
-
|
|
429
|
-
expect(reconciled.port).toBe(1941);
|
|
430
|
-
// And it's PERSISTED — the proxy reads services.json, so the row itself must
|
|
431
|
-
// now point at 1941 or `/agent/*` keeps routing to the dead 19415.
|
|
424
|
+
expect(reconciled.port).toBe(1943);
|
|
432
425
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
433
|
-
(s) => s.name === "parachute-
|
|
426
|
+
(s) => s.name === "parachute-scribe",
|
|
434
427
|
);
|
|
435
|
-
expect(onDisk?.port).toBe(
|
|
428
|
+
expect(onDisk?.port).toBe(1943);
|
|
436
429
|
expect(
|
|
437
|
-
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("
|
|
430
|
+
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1943")),
|
|
438
431
|
).toBe(true);
|
|
439
432
|
});
|
|
440
433
|
|
|
@@ -460,31 +453,30 @@ describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
|
|
|
460
453
|
});
|
|
461
454
|
|
|
462
455
|
test("does NOT steal the canonical port when another row already holds it", () => {
|
|
463
|
-
// Another row
|
|
464
|
-
//
|
|
465
|
-
// drift; the supervisor's squatter detection surfaces it.
|
|
456
|
+
// Another row occupies Scribe's canonical 1943, so reconciliation must not
|
|
457
|
+
// steal it; leave the drift for squatter diagnostics.
|
|
466
458
|
const squatter: ServiceEntry = {
|
|
467
459
|
name: "parachute-vault",
|
|
468
|
-
port:
|
|
460
|
+
port: 1943,
|
|
469
461
|
paths: ["/vault/default"],
|
|
470
462
|
health: "/vault/default/health",
|
|
471
463
|
version: "0.4.5",
|
|
472
464
|
};
|
|
473
|
-
writeManifest({ services: [squatter,
|
|
465
|
+
writeManifest({ services: [squatter, DRIFTED_SCRIBE] }, h.manifestPath);
|
|
474
466
|
const logs: string[] = [];
|
|
475
467
|
|
|
476
|
-
const out = reconcilePortToCanonical(
|
|
468
|
+
const out = reconcilePortToCanonical(DRIFTED_SCRIBE, h.manifestPath, (l) => logs.push(l));
|
|
477
469
|
|
|
478
470
|
expect(out.port).toBe(19415); // unchanged
|
|
479
471
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
480
|
-
(s) => s.name === "parachute-
|
|
472
|
+
(s) => s.name === "parachute-scribe",
|
|
481
473
|
);
|
|
482
474
|
expect(onDisk?.port).toBe(19415); // not rewritten
|
|
483
475
|
expect(logs.some((l) => l.includes("held by another row"))).toBe(true);
|
|
484
476
|
});
|
|
485
477
|
|
|
486
|
-
test("boot path injects
|
|
487
|
-
writeManifest({ services: [
|
|
478
|
+
test("boot path injects and persists the supported module's canonical port", async () => {
|
|
479
|
+
writeManifest({ services: [DRIFTED_SCRIBE] }, h.manifestPath);
|
|
488
480
|
const recorder = makeRecorder();
|
|
489
481
|
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
490
482
|
|
|
@@ -493,15 +485,12 @@ describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
|
|
|
493
485
|
configDir: h.dir,
|
|
494
486
|
});
|
|
495
487
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
expect(recorder.calls[0]?.short).toBe("agent");
|
|
499
|
-
expect(recorder.calls[0]?.env?.PORT).toBe("1941");
|
|
500
|
-
// services.json row is reconciled → proxy routes /agent/* to 1941.
|
|
488
|
+
expect(recorder.calls[0]?.short).toBe("scribe");
|
|
489
|
+
expect(recorder.calls[0]?.env?.PORT).toBe("1943");
|
|
501
490
|
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
502
|
-
(s) => s.name === "parachute-
|
|
491
|
+
(s) => s.name === "parachute-scribe",
|
|
503
492
|
);
|
|
504
|
-
expect(onDisk?.port).toBe(
|
|
493
|
+
expect(onDisk?.port).toBe(1943);
|
|
505
494
|
});
|
|
506
495
|
|
|
507
496
|
test("boot path leaves a non-drifted vault row's port untouched", async () => {
|