@openparachute/hub 0.6.5-rc.8 → 0.7.0

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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-server.test.ts +319 -21
  12. package/src/__tests__/invites.test.ts +27 -0
  13. package/src/__tests__/module-manifest.test.ts +305 -8
  14. package/src/__tests__/serve-boot.test.ts +133 -2
  15. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  16. package/src/__tests__/setup-gate.test.ts +13 -7
  17. package/src/__tests__/setup-wizard.test.ts +228 -1
  18. package/src/__tests__/vault-name.test.ts +20 -5
  19. package/src/__tests__/well-known.test.ts +44 -8
  20. package/src/account-vault-admin-token.ts +43 -14
  21. package/src/admin-channel-token.ts +135 -0
  22. package/src/admin-connections.ts +980 -0
  23. package/src/admin-module-token.ts +197 -0
  24. package/src/admin-vaults.ts +390 -12
  25. package/src/api-hub-upgrade.ts +4 -3
  26. package/src/api-modules-ops.ts +41 -16
  27. package/src/api-modules.ts +238 -116
  28. package/src/api-tokens.ts +8 -5
  29. package/src/commands/serve-boot.ts +80 -3
  30. package/src/commands/setup.ts +4 -4
  31. package/src/connections-store.ts +161 -0
  32. package/src/grants.ts +50 -0
  33. package/src/hub-server.ts +349 -59
  34. package/src/invites.ts +22 -0
  35. package/src/jwt-sign.ts +41 -1
  36. package/src/module-manifest.ts +429 -23
  37. package/src/origin-check.ts +106 -0
  38. package/src/proxy-error-ui.ts +1 -1
  39. package/src/service-spec.ts +132 -41
  40. package/src/setup-wizard.ts +68 -6
  41. package/src/users.ts +11 -0
  42. package/src/vault-name.ts +27 -7
  43. package/src/well-known.ts +41 -33
  44. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  45. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  46. package/web/ui/dist/index.html +2 -2
  47. package/src/__tests__/api-modules-config.test.ts +0 -882
  48. package/src/api-modules-config.ts +0 -421
  49. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  50. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -0,0 +1,346 @@
1
+ /**
2
+ * CSRF belt on cookie-gated /admin/* JSON mutation endpoints (hub#632,
3
+ * 2026-06-09 hub-module-boundary Phase C1).
4
+ *
5
+ * Two layers:
6
+ *
7
+ * 1. Unit — `assertSameOriginForCookieMutation` semantics: which requests
8
+ * the belt gates (cookie-authed mutations), which it waves through
9
+ * (reads, Bearer-authed, cookie-less), and the two rejection codes
10
+ * (`csrf_origin_required` / `csrf_origin_mismatch`).
11
+ * 2. Integration — the wiring in hub-server.ts dispatch for
12
+ * `/admin/connections`: cross-origin cookie mutations 403 BEFORE the
13
+ * operator gate; same-origin mutations reach the handler; GETs are
14
+ * unaffected; Bearer-authed mutations skip the belt and land on the
15
+ * endpoint's own gate. (The legacy `/admin/channels` wiring was belted
16
+ * here too until boundary D1 retired the endpoint.)
17
+ *
18
+ * The canonical seam consumer is pinned here: channel's admin page POSTs
19
+ * `/admin/connections` as a same-origin `fetch()` with
20
+ * `credentials: "include"` (parachute-channel src/admin-ui.ts) — i.e.
21
+ * session cookie + browser-sent matching Origin. That shape must keep
22
+ * passing the belt without any token dance.
23
+ */
24
+ import type { Database } from "bun:sqlite";
25
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
26
+ import { mkdtempSync, rmSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
30
+ import { hubFetch } from "../hub-server.ts";
31
+ import { assertSameOriginForCookieMutation } from "../origin-check.ts";
32
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
33
+ import { createUser } from "../users.ts";
34
+
35
+ const BOUND = ["https://hub.example", "http://localhost:1939", "http://127.0.0.1:1939"];
36
+ const SESSION_COOKIE = "parachute_hub_session=abc123";
37
+
38
+ function mutReq(opts: {
39
+ method?: string;
40
+ origin?: string;
41
+ cookie?: string;
42
+ authorization?: string;
43
+ url?: string;
44
+ }): Request {
45
+ const headers: Record<string, string> = {};
46
+ if (opts.origin !== undefined) headers.origin = opts.origin;
47
+ if (opts.cookie !== undefined) headers.cookie = opts.cookie;
48
+ if (opts.authorization !== undefined) headers.authorization = opts.authorization;
49
+ return new Request(opts.url ?? "https://hub.example/admin/connections", {
50
+ method: opts.method ?? "POST",
51
+ headers,
52
+ });
53
+ }
54
+
55
+ async function errorCode(res: Response): Promise<string> {
56
+ const body = (await res.json()) as { error?: string };
57
+ return body.error ?? "";
58
+ }
59
+
60
+ describe("assertSameOriginForCookieMutation (unit)", () => {
61
+ test("cookie-authed POST with matching Origin passes", () => {
62
+ const res = mutReq({ cookie: SESSION_COOKIE, origin: "https://hub.example" });
63
+ expect(assertSameOriginForCookieMutation(res, BOUND)).toBeNull();
64
+ });
65
+
66
+ test("matching any bound origin passes (multi-origin hub: loopback alias)", () => {
67
+ const res = mutReq({
68
+ cookie: SESSION_COOKIE,
69
+ origin: "http://127.0.0.1:1939",
70
+ url: "http://127.0.0.1:1939/admin/connections",
71
+ });
72
+ expect(assertSameOriginForCookieMutation(res, BOUND)).toBeNull();
73
+ });
74
+
75
+ test("cookie-authed POST with cross-site Origin → 403 csrf_origin_mismatch", async () => {
76
+ const rejected = assertSameOriginForCookieMutation(
77
+ mutReq({ cookie: SESSION_COOKIE, origin: "https://evil.example" }),
78
+ BOUND,
79
+ );
80
+ expect(rejected).not.toBeNull();
81
+ expect(rejected?.status).toBe(403);
82
+ expect(await errorCode(rejected as Response)).toBe("csrf_origin_mismatch");
83
+ });
84
+
85
+ test("cookie-authed POST with NO Origin → 403 csrf_origin_required", async () => {
86
+ const rejected = assertSameOriginForCookieMutation(mutReq({ cookie: SESSION_COOKIE }), BOUND);
87
+ expect(rejected?.status).toBe(403);
88
+ expect(await errorCode(rejected as Response)).toBe("csrf_origin_required");
89
+ });
90
+
91
+ test("`Origin: null` (opaque origin) is a mismatch, NOT a pass — no Host fallback", async () => {
92
+ // The attacker form-post shape: referrer-policy no-referrer makes the
93
+ // browser send `Origin: null` on a navigation POST; Host always names
94
+ // the target. isSameOriginRequest's Host fallback would pass this —
95
+ // the belt must not (these JSON endpoints carry no double-submit token).
96
+ const rejected = assertSameOriginForCookieMutation(
97
+ mutReq({ cookie: SESSION_COOKIE, origin: "null" }),
98
+ BOUND,
99
+ );
100
+ expect(rejected?.status).toBe(403);
101
+ expect(await errorCode(rejected as Response)).toBe("csrf_origin_mismatch");
102
+ });
103
+
104
+ test("malformed Origin is a mismatch", async () => {
105
+ const rejected = assertSameOriginForCookieMutation(
106
+ mutReq({ cookie: SESSION_COOKIE, origin: "not a url" }),
107
+ BOUND,
108
+ );
109
+ expect(rejected?.status).toBe(403);
110
+ expect(await errorCode(rejected as Response)).toBe("csrf_origin_mismatch");
111
+ });
112
+
113
+ test("PUT / PATCH / DELETE are gated like POST", () => {
114
+ for (const method of ["PUT", "PATCH", "DELETE"]) {
115
+ const rejected = assertSameOriginForCookieMutation(
116
+ mutReq({ method, cookie: SESSION_COOKIE }),
117
+ BOUND,
118
+ );
119
+ expect(rejected?.status).toBe(403);
120
+ }
121
+ });
122
+
123
+ test("GET / HEAD / OPTIONS pass regardless of Origin", () => {
124
+ for (const method of ["GET", "HEAD", "OPTIONS"]) {
125
+ const res = mutReq({ method, cookie: SESSION_COOKIE, origin: "https://evil.example" });
126
+ expect(assertSameOriginForCookieMutation(res, BOUND)).toBeNull();
127
+ }
128
+ });
129
+
130
+ test("Bearer-authed mutation without Origin passes (API clients are CSRF-immune)", () => {
131
+ const res = mutReq({ authorization: "Bearer some-token" });
132
+ expect(assertSameOriginForCookieMutation(res, BOUND)).toBeNull();
133
+ });
134
+
135
+ test("Authorization present + cookie present passes — a custom header cannot ride a CSRF", () => {
136
+ // Cross-site pages cannot attach an Authorization header without a CORS
137
+ // preflight these routes never approve, so its presence proves this is
138
+ // not a browser-forged request. The endpoint's own gate still runs.
139
+ const res = mutReq({ authorization: "Bearer some-token", cookie: SESSION_COOKIE });
140
+ expect(assertSameOriginForCookieMutation(res, BOUND)).toBeNull();
141
+ });
142
+
143
+ test("no cookie + no Authorization passes through (endpoint's own 401 is the right answer)", () => {
144
+ const res = mutReq({});
145
+ expect(assertSameOriginForCookieMutation(res, BOUND)).toBeNull();
146
+ });
147
+
148
+ test("other cookies without the session cookie do not arm the belt", () => {
149
+ const res = mutReq({ cookie: "parachute_hub_csrf=tok; theme=dark" });
150
+ expect(assertSameOriginForCookieMutation(res, BOUND)).toBeNull();
151
+ });
152
+
153
+ test("empty bound-origin set fails closed for cookie-authed mutations", async () => {
154
+ const rejected = assertSameOriginForCookieMutation(
155
+ mutReq({ cookie: SESSION_COOKIE, origin: "https://hub.example" }),
156
+ [],
157
+ );
158
+ expect(rejected?.status).toBe(403);
159
+ expect(await errorCode(rejected as Response)).toBe("csrf_origin_mismatch");
160
+ });
161
+ });
162
+
163
+ // ===========================================================================
164
+ // Integration — the dispatch wiring in hub-server.ts
165
+ // ===========================================================================
166
+
167
+ interface Harness {
168
+ dir: string;
169
+ db: Database;
170
+ cookie: string;
171
+ cleanup: () => void;
172
+ }
173
+
174
+ let h: Harness;
175
+
176
+ beforeEach(async () => {
177
+ const dir = mkdtempSync(join(tmpdir(), "phub-csrf-belt-"));
178
+ const db = openHubDb(hubDbPath(dir));
179
+ const user = await createUser(db, "operator", "hunter2");
180
+ const session = createSession(db, { userId: user.id });
181
+ h = {
182
+ dir,
183
+ db,
184
+ cookie: buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)),
185
+ cleanup: () => {
186
+ db.close();
187
+ rmSync(dir, { recursive: true, force: true });
188
+ },
189
+ };
190
+ });
191
+ afterEach(() => h.cleanup());
192
+
193
+ /** hubFetch with bound origins pinned to the request-derived issuer (no
194
+ * stored hub_origin, no configured issuer, no expose state). */
195
+ function handler() {
196
+ return hubFetch(h.dir, {
197
+ getDb: () => h.db,
198
+ manifestPath: join(h.dir, "services.json"),
199
+ connectionsStorePath: join(h.dir, "connections.json"),
200
+ loadExposeHubOrigin: () => undefined,
201
+ });
202
+ }
203
+
204
+ const ORIGIN = "http://hub.test";
205
+
206
+ function adminReq(
207
+ path: string,
208
+ opts: { method?: string; origin?: string; cookie?: string; auth?: string; body?: unknown } = {},
209
+ ): Request {
210
+ const headers: Record<string, string> = {};
211
+ if (opts.origin !== undefined) headers.origin = opts.origin;
212
+ if (opts.cookie !== undefined) headers.cookie = opts.cookie;
213
+ if (opts.auth !== undefined) headers.authorization = opts.auth;
214
+ if (opts.body !== undefined) headers["content-type"] = "application/json";
215
+ return new Request(`${ORIGIN}${path}`, {
216
+ method: opts.method ?? "POST",
217
+ headers,
218
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
219
+ });
220
+ }
221
+
222
+ describe("CSRF belt wiring — /admin/connections", () => {
223
+ test("cross-origin cookie POST /admin/connections → 403 csrf_origin_mismatch (even with a valid admin session)", async () => {
224
+ const res = await handler()(
225
+ adminReq("/admin/connections", {
226
+ cookie: h.cookie,
227
+ origin: "https://evil.example",
228
+ body: {},
229
+ }),
230
+ );
231
+ expect(res.status).toBe(403);
232
+ expect(await errorCode(res)).toBe("csrf_origin_mismatch");
233
+ });
234
+
235
+ test("missing-Origin cookie POST /admin/connections → 403 csrf_origin_required", async () => {
236
+ const res = await handler()(adminReq("/admin/connections", { cookie: h.cookie, body: {} }));
237
+ expect(res.status).toBe(403);
238
+ expect(await errorCode(res)).toBe("csrf_origin_required");
239
+ });
240
+
241
+ test("same-origin cookie POST /admin/connections passes the belt and reaches handler validation", async () => {
242
+ const res = await handler()(
243
+ adminReq("/admin/connections", { cookie: h.cookie, origin: ORIGIN, body: {} }),
244
+ );
245
+ // Past the belt: the handler's own body validation answers (400), not a
246
+ // csrf_* 403.
247
+ expect(res.status).toBe(400);
248
+ expect(await errorCode(res)).toBe("invalid_request");
249
+ });
250
+
251
+ test("seam pin — the channel link-vault shape (cookie + correct Origin, requestedBy: channel) passes the belt", async () => {
252
+ // parachute-channel/src/admin-ui.ts: fetch(window.location.origin +
253
+ // "/admin/connections", { method: "POST", credentials: "include" }) —
254
+ // same-origin fetch(), so the browser sends Origin = hub origin on the
255
+ // POST. With no modules installed in this harness the engine answers
256
+ // 400 unknown_module — i.e. the request cleared the belt AND the
257
+ // operator gate and reached catalog validation. The full provision flow
258
+ // is covered handler-level in admin-connections.test.ts.
259
+ const res = await handler()(
260
+ adminReq("/admin/connections", {
261
+ cookie: h.cookie,
262
+ origin: ORIGIN,
263
+ body: {
264
+ source: {
265
+ module: "vault",
266
+ vault: "main",
267
+ event: "note.created",
268
+ filter: { tags: ["channel-message/inbound"] },
269
+ },
270
+ sink: { module: "channel", action: "message.deliver", params: { channel: "tg" } },
271
+ requestedBy: "channel",
272
+ },
273
+ }),
274
+ );
275
+ expect(res.status).toBe(400);
276
+ expect(await errorCode(res)).toBe("unknown_module");
277
+ });
278
+
279
+ test("X-Forwarded-Proto public-origin case over the proxy passes", async () => {
280
+ // TLS terminates at the edge; hub sees plain http with
281
+ // X-Forwarded-Proto: https and the public Host preserved end-to-end.
282
+ // resolveIssuer derives https://<host> — the browser's Origin on a
283
+ // same-origin fetch is exactly that, so the belt matches.
284
+ const req = new Request("http://pub.example/admin/connections", {
285
+ method: "POST",
286
+ headers: {
287
+ cookie: h.cookie,
288
+ origin: "https://pub.example",
289
+ "x-forwarded-proto": "https",
290
+ "content-type": "application/json",
291
+ },
292
+ body: JSON.stringify({}),
293
+ });
294
+ const res = await handler()(req);
295
+ expect(res.status).toBe(400); // handler validation, not the belt's 403
296
+ expect(await errorCode(res)).toBe("invalid_request");
297
+ });
298
+
299
+ test("GET /admin/connections with cookie and no Origin is unaffected", async () => {
300
+ const res = await handler()(
301
+ adminReq("/admin/connections", { method: "GET", cookie: h.cookie }),
302
+ );
303
+ expect(res.status).toBe(200);
304
+ const body = (await res.json()) as { ok: boolean; connections: unknown[] };
305
+ expect(body.ok).toBe(true);
306
+ expect(body.connections).toEqual([]);
307
+ });
308
+
309
+ test("Bearer-authed POST /admin/connections without Origin skips the belt; the endpoint's own gate answers", async () => {
310
+ const res = await handler()(
311
+ adminReq("/admin/connections", { auth: "Bearer junk-token", body: {} }),
312
+ );
313
+ // Cookie-gated endpoint: the operator gate 401s the Bearer-only caller.
314
+ // The point pinned here: NOT a 403 csrf_* rejection.
315
+ expect(res.status).toBe(401);
316
+ expect(await errorCode(res)).toBe("unauthenticated");
317
+ });
318
+
319
+ test("cross-origin cookie DELETE /admin/connections/<id> → 403 csrf_origin_mismatch", async () => {
320
+ const res = await handler()(
321
+ adminReq("/admin/connections/some-id", {
322
+ method: "DELETE",
323
+ cookie: h.cookie,
324
+ origin: "https://evil.example",
325
+ }),
326
+ );
327
+ expect(res.status).toBe(403);
328
+ expect(await errorCode(res)).toBe("csrf_origin_mismatch");
329
+ });
330
+
331
+ // The legacy `/admin/channels` cases lived here until boundary D1 retired
332
+ // the endpoint (superseded by /admin/connections, covered above). A POST
333
+ // there now falls through dispatch to the generic `/admin/*` SPA mount,
334
+ // which rejects non-GET (405) — the pin here is "no provisioning handler
335
+ // answers anymore", not the precise fallthrough status.
336
+ test("retired /admin/channels no longer provisions (D1) — falls through dispatch", async () => {
337
+ const res = await handler()(
338
+ adminReq("/admin/channels", {
339
+ cookie: h.cookie,
340
+ origin: ORIGIN,
341
+ body: { channelName: "x", vault: "main" },
342
+ }),
343
+ );
344
+ expect([404, 405]).toContain(res.status);
345
+ });
346
+ });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Tests for the GENERIC per-module config-UI session→bearer mint endpoint
3
+ * (`GET /admin/module-token/<short>`, 2026-06-09 modular-UI architecture P3).
4
+ * Mirrors `admin-channel-token.test.ts` shape (single bare audience per
5
+ * module). Covers:
6
+ * - 401 when no admin session cookie is present.
7
+ * - 401 when the cookie names a deleted session.
8
+ * - 405 on POST.
9
+ * - 200 + JWT carrying `aud: "<short>"` and `<short>:admin` for known modules
10
+ * (scribe / runner / surface).
11
+ * - 400 for `vault` (per-instance — points at /admin/vault-admin-token/<name>).
12
+ * - 404 for an unknown short.
13
+ * - First-admin gate: 403 for a signed-in non-first-admin (friend).
14
+ * - Self-registration gate (boundary C5): a genuinely third-party module
15
+ * with a services.json row + readable module.json mints; a registered row
16
+ * WITHOUT a readable manifest 404s.
17
+ */
18
+ import type { Database } from "bun:sqlite";
19
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
20
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { MODULE_TOKEN_TTL_SECONDS, handleModuleToken } from "../admin-module-token.ts";
24
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
25
+ import { validateAccessToken } from "../jwt-sign.ts";
26
+ import type { ServiceEntry } from "../services-manifest.ts";
27
+ import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
28
+ import { rotateSigningKey } from "../signing-keys.ts";
29
+ import { createUser } from "../users.ts";
30
+
31
+ const ISSUER = "https://hub.test";
32
+
33
+ interface Harness {
34
+ db: Database;
35
+ cleanup: () => void;
36
+ }
37
+
38
+ function makeHarness(): Harness {
39
+ const dir = mkdtempSync(join(tmpdir(), "phub-module-token-"));
40
+ const db = openHubDb(hubDbPath(dir));
41
+ return {
42
+ db,
43
+ cleanup: () => {
44
+ db.close();
45
+ rmSync(dir, { recursive: true, force: true });
46
+ },
47
+ };
48
+ }
49
+
50
+ let harness: Harness;
51
+ beforeEach(() => {
52
+ harness = makeHarness();
53
+ });
54
+ afterEach(() => {
55
+ harness.cleanup();
56
+ });
57
+
58
+ async function withSession(): Promise<{ cookie: string; userId: string }> {
59
+ const user = await createUser(harness.db, "operator", "hunter2");
60
+ const session = createSession(harness.db, { userId: user.id });
61
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
62
+ return { cookie, userId: user.id };
63
+ }
64
+
65
+ async function withAdminAndFriend(): Promise<{ friendCookie: string }> {
66
+ const admin = await createUser(harness.db, "admin", "admin-passphrase");
67
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", { allowMulti: true });
68
+ createSession(harness.db, { userId: admin.id });
69
+ const friendSession = createSession(harness.db, { userId: friend.id });
70
+ return {
71
+ friendCookie: buildSessionCookie(friendSession.id, Math.floor(SESSION_TTL_MS / 1000)),
72
+ };
73
+ }
74
+
75
+ function urlFor(short: string): string {
76
+ return `${ISSUER}/admin/module-token/${short}`;
77
+ }
78
+
79
+ /** Default deps — no services.json rows (registry-only resolution). */
80
+ function depsWith(services: ServiceEntry[] = []): {
81
+ db: Database;
82
+ issuer: string;
83
+ readServices: () => readonly ServiceEntry[];
84
+ } {
85
+ return { db: harness.db, issuer: ISSUER, readServices: () => services };
86
+ }
87
+
88
+ describe("handleModuleToken", () => {
89
+ test("401 when no session cookie is present", async () => {
90
+ const req = new Request(urlFor("scribe"));
91
+ const res = await handleModuleToken(req, "scribe", depsWith());
92
+ expect(res.status).toBe(401);
93
+ const body = (await res.json()) as { error: string };
94
+ expect(body.error).toBe("unauthenticated");
95
+ });
96
+
97
+ test("401 when the cookie names a deleted session", async () => {
98
+ const { cookie } = await withSession();
99
+ const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
100
+ deleteSession(harness.db, sid);
101
+ const req = new Request(urlFor("scribe"), { headers: { cookie } });
102
+ const res = await handleModuleToken(req, "scribe", depsWith());
103
+ expect(res.status).toBe(401);
104
+ });
105
+
106
+ test("405 on POST", async () => {
107
+ const { cookie } = await withSession();
108
+ const req = new Request(urlFor("scribe"), { method: "POST", headers: { cookie } });
109
+ const res = await handleModuleToken(req, "scribe", depsWith());
110
+ expect(res.status).toBe(405);
111
+ });
112
+
113
+ // The known single-audience modules the generic mint serves. Each gets
114
+ // `<short>:admin` with `aud: <short>`.
115
+ for (const short of ["scribe", "runner", "surface", "channel"]) {
116
+ test(`200 mints a JWT carrying aud:${short} + ${short}:admin`, async () => {
117
+ const { cookie, userId } = await withSession();
118
+ rotateSigningKey(harness.db);
119
+ const req = new Request(urlFor(short), { headers: { cookie } });
120
+ const res = await handleModuleToken(req, short, depsWith());
121
+ expect(res.status).toBe(200);
122
+ expect(res.headers.get("cache-control")).toBe("no-store");
123
+
124
+ const body = (await res.json()) as { token: string; expires_at: string; scopes: string[] };
125
+ expect(body.scopes).toEqual([`${short}:admin`]);
126
+ expect(body.token.length).toBeGreaterThan(20);
127
+
128
+ const expMs = new Date(body.expires_at).getTime();
129
+ const skew = expMs - Date.now();
130
+ expect(skew).toBeGreaterThan((MODULE_TOKEN_TTL_SECONDS - 30) * 1000);
131
+ expect(skew).toBeLessThan((MODULE_TOKEN_TTL_SECONDS + 30) * 1000);
132
+
133
+ const validated = await validateAccessToken(harness.db, body.token, ISSUER);
134
+ expect(validated.payload.sub).toBe(userId);
135
+ expect(validated.payload.iss).toBe(ISSUER);
136
+ // Bare service audience — modules validate `aud === <short>`.
137
+ expect(validated.payload.aud).toBe(short);
138
+ const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
139
+ expect(scopeClaim.split(/\s+/)).toContain(`${short}:admin`);
140
+ });
141
+ }
142
+
143
+ test("400 use_vault_admin_token for vault (per-instance)", async () => {
144
+ const { cookie } = await withSession();
145
+ const req = new Request(urlFor("vault"), { headers: { cookie } });
146
+ const res = await handleModuleToken(req, "vault", depsWith());
147
+ expect(res.status).toBe(400);
148
+ const body = (await res.json()) as { error: string };
149
+ expect(body.error).toBe("use_vault_admin_token");
150
+ });
151
+
152
+ test("404 for an unknown short", async () => {
153
+ const { cookie } = await withSession();
154
+ const req = new Request(urlFor("totally-made-up"), { headers: { cookie } });
155
+ const res = await handleModuleToken(req, "totally-made-up", depsWith());
156
+ expect(res.status).toBe(404);
157
+ const body = (await res.json()) as { error: string };
158
+ expect(body.error).toBe("not_found");
159
+ });
160
+
161
+ test("400 for an invalid identifier", async () => {
162
+ const { cookie } = await withSession();
163
+ const req = new Request(`${ISSUER}/admin/module-token/Not%20Valid`, { headers: { cookie } });
164
+ const res = await handleModuleToken(req, "Not Valid", depsWith());
165
+ expect(res.status).toBe(400);
166
+ const body = (await res.json()) as { error: string };
167
+ expect(body.error).toBe("invalid_request");
168
+ });
169
+
170
+ test("403 not_admin when a signed-in non-first-admin (friend) hits the endpoint", async () => {
171
+ const { friendCookie } = await withAdminAndFriend();
172
+ rotateSigningKey(harness.db);
173
+ const req = new Request(urlFor("scribe"), { headers: { cookie: friendCookie } });
174
+ const res = await handleModuleToken(req, "scribe", depsWith());
175
+ expect(res.status).toBe(403);
176
+ const body = (await res.json()) as { error: string };
177
+ expect(body.error).toBe("not_admin");
178
+ });
179
+
180
+ // -------------------------------------------------------------------------
181
+ // Self-registration gate (boundary C5). A genuinely third-party module —
182
+ // NOT in KNOWN_MODULES / FIRST_PARTY_FALLBACKS — mints when its
183
+ // services.json row's installDir carries a readable module.json. This is
184
+ // the charter's third-party test: zero hub code changes to get the mint.
185
+ // -------------------------------------------------------------------------
186
+
187
+ /** Write a real `.parachute/module.json` into a temp install dir. */
188
+ function writeManifestDir(name: string): string {
189
+ const dir = mkdtempSync(join(tmpdir(), "phub-module-token-installdir-"));
190
+ mkdirSync(join(dir, ".parachute"), { recursive: true });
191
+ writeFileSync(
192
+ join(dir, ".parachute", "module.json"),
193
+ JSON.stringify({
194
+ name,
195
+ manifestName: name,
196
+ port: 1947,
197
+ paths: [`/${name}`],
198
+ health: `/${name}/health`,
199
+ }),
200
+ );
201
+ return dir;
202
+ }
203
+
204
+ test("200 mints for a self-registered third-party module (row + readable module.json)", async () => {
205
+ const { cookie, userId } = await withSession();
206
+ rotateSigningKey(harness.db);
207
+ const installDir = writeManifestDir("widgets");
208
+ try {
209
+ const services: ServiceEntry[] = [
210
+ {
211
+ name: "widgets",
212
+ port: 1947,
213
+ paths: ["/widgets"],
214
+ health: "/widgets/health",
215
+ version: "1.0.0",
216
+ installDir,
217
+ },
218
+ ];
219
+ const req = new Request(urlFor("widgets"), { headers: { cookie } });
220
+ const res = await handleModuleToken(req, "widgets", depsWith(services));
221
+ expect(res.status).toBe(200);
222
+ const body = (await res.json()) as { token: string; scopes: string[] };
223
+ expect(body.scopes).toEqual(["widgets:admin"]);
224
+ const validated = await validateAccessToken(harness.db, body.token, ISSUER);
225
+ expect(validated.payload.sub).toBe(userId);
226
+ expect(validated.payload.aud).toBe("widgets");
227
+ } finally {
228
+ rmSync(installDir, { recursive: true, force: true });
229
+ }
230
+ });
231
+
232
+ test("404 for a registered row whose installDir has NO readable module.json", async () => {
233
+ const { cookie } = await withSession();
234
+ rotateSigningKey(harness.db);
235
+ const emptyDir = mkdtempSync(join(tmpdir(), "phub-module-token-nomanifest-"));
236
+ try {
237
+ const services: ServiceEntry[] = [
238
+ {
239
+ name: "widgets",
240
+ port: 1947,
241
+ paths: ["/widgets"],
242
+ health: "/widgets/health",
243
+ version: "1.0.0",
244
+ installDir: emptyDir,
245
+ },
246
+ ];
247
+ const req = new Request(urlFor("widgets"), { headers: { cookie } });
248
+ const res = await handleModuleToken(req, "widgets", depsWith(services));
249
+ expect(res.status).toBe(404);
250
+ const body = (await res.json()) as { error: string };
251
+ expect(body.error).toBe("not_found");
252
+ } finally {
253
+ rmSync(emptyDir, { recursive: true, force: true });
254
+ }
255
+ });
256
+
257
+ test("404 for a registered row with no installDir at all", async () => {
258
+ const { cookie } = await withSession();
259
+ rotateSigningKey(harness.db);
260
+ const services: ServiceEntry[] = [
261
+ { name: "widgets", port: 1947, paths: ["/widgets"], health: "/widgets/health", version: "1" },
262
+ ];
263
+ const req = new Request(urlFor("widgets"), { headers: { cookie } });
264
+ const res = await handleModuleToken(req, "widgets", depsWith(services));
265
+ expect(res.status).toBe(404);
266
+ });
267
+
268
+ test("vault still 400-redirects even when a vault row is registered", async () => {
269
+ const { cookie } = await withSession();
270
+ const services: ServiceEntry[] = [
271
+ {
272
+ name: "parachute-vault",
273
+ port: 1940,
274
+ paths: ["/vault/default"],
275
+ health: "/vault/default/health",
276
+ version: "0.5.0",
277
+ installDir: "/tmp/nope",
278
+ },
279
+ ];
280
+ const req = new Request(urlFor("vault"), { headers: { cookie } });
281
+ const res = await handleModuleToken(req, "vault", depsWith(services));
282
+ expect(res.status).toBe(400);
283
+ const body = (await res.json()) as { error: string };
284
+ expect(body.error).toBe("use_vault_admin_token");
285
+ });
286
+
287
+ test("first-party row resolves through the manifest-name map (parachute-channel ↔ channel)", async () => {
288
+ const { cookie } = await withSession();
289
+ rotateSigningKey(harness.db);
290
+ const installDir = writeManifestDir("channel");
291
+ try {
292
+ const services: ServiceEntry[] = [
293
+ {
294
+ name: "parachute-channel",
295
+ port: 1941,
296
+ paths: ["/channel"],
297
+ health: "/health",
298
+ version: "0.1.0",
299
+ installDir,
300
+ },
301
+ ];
302
+ const req = new Request(urlFor("channel"), { headers: { cookie } });
303
+ const res = await handleModuleToken(req, "channel", depsWith(services));
304
+ expect(res.status).toBe(200);
305
+ const body = (await res.json()) as { scopes: string[] };
306
+ expect(body.scopes).toEqual(["channel:admin"]);
307
+ } finally {
308
+ rmSync(installDir, { recursive: true, force: true });
309
+ }
310
+ });
311
+ });