@openparachute/hub 0.5.11-rc.1 → 0.5.12-rc.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.11-rc.1",
3
+ "version": "0.5.12-rc.4",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Tests for `/api/modules/:short/config[/schema]` — admin-SPA module-config
3
+ * surface (hub#260).
4
+ *
5
+ * Coverage:
6
+ * - path parser: shape + curated-only
7
+ * - auth: 401 / 403 / 405 boundary
8
+ * - module-not-installed → 404 with "module_not_installed" code
9
+ * - module without config schema (upstream 404) → "no_config_schema"
10
+ * - mint-and-forward (Option A): SPA bearer dropped, `<short>:admin`
11
+ * proxy bearer carried upstream; verified by decoding the JWT the
12
+ * fake upstream receives
13
+ * - GET schema / GET values / PUT values pass through verbatim
14
+ * - upstream unreachable → 502
15
+ * - stripPrefix true (scribe-shape) vs false (notes-shape) → correct
16
+ * upstream path
17
+ * - 4xx upstream body forwarded verbatim so SPA can render module's
18
+ * validation message inline
19
+ */
20
+
21
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { decodeJwt } from "jose";
26
+ import {
27
+ API_MODULES_CONFIG_REQUIRED_SCOPE,
28
+ MODULE_CONFIG_PROXY_CLIENT_ID,
29
+ handleApiModulesConfig,
30
+ parseModulesConfigPath,
31
+ } from "../api-modules-config.ts";
32
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
33
+ import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
34
+ import { rotateSigningKey } from "../signing-keys.ts";
35
+ import { createUser } from "../users.ts";
36
+
37
+ const ISSUER = "http://127.0.0.1:1939";
38
+
39
+ interface Harness {
40
+ dir: string;
41
+ manifestPath: string;
42
+ db: ReturnType<typeof openHubDb>;
43
+ userId: string;
44
+ cleanup: () => void;
45
+ }
46
+
47
+ async function makeHarness(): Promise<Harness> {
48
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-modules-config-"));
49
+ const db = openHubDb(hubDbPath(dir));
50
+ rotateSigningKey(db);
51
+ const user = await createUser(db, "owner", "pw");
52
+ return {
53
+ dir,
54
+ manifestPath: join(dir, "services.json"),
55
+ db,
56
+ userId: user.id,
57
+ cleanup: () => {
58
+ db.close();
59
+ rmSync(dir, { recursive: true, force: true });
60
+ },
61
+ };
62
+ }
63
+
64
+ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
65
+ const signed = await signAccessToken(h.db, {
66
+ sub: h.userId,
67
+ scopes,
68
+ audience: "parachute-hub",
69
+ clientId: "parachute-hub",
70
+ issuer: ISSUER,
71
+ ttlSeconds: 3600,
72
+ });
73
+ recordTokenMint(h.db, {
74
+ jti: signed.jti,
75
+ createdVia: "operator_mint",
76
+ subject: h.userId,
77
+ clientId: "parachute-hub",
78
+ scopes,
79
+ expiresAt: signed.expiresAt,
80
+ });
81
+ return signed.token;
82
+ }
83
+
84
+ function writeManifest(path: string, services: unknown[]): void {
85
+ writeFileSync(path, JSON.stringify({ services }));
86
+ }
87
+
88
+ function makeReq(
89
+ url: string,
90
+ init: { method?: string; headers?: Record<string, string>; body?: string } = {},
91
+ ): Request {
92
+ return new Request(`http://localhost${url}`, {
93
+ method: init.method ?? "GET",
94
+ headers: init.headers,
95
+ body: init.body,
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Fake upstream fetch: records every call (so tests can assert on the
101
+ * URL, method, and Authorization header forwarded) and returns a
102
+ * canned Response.
103
+ */
104
+ function makeFakeUpstream(responder: (url: string, init: RequestInit) => Response): {
105
+ fetchFn: (url: string, init: RequestInit) => Promise<Response>;
106
+ calls: Array<{ url: string; method: string; authorization: string | null; body: string | null }>;
107
+ } {
108
+ const calls: Array<{
109
+ url: string;
110
+ method: string;
111
+ authorization: string | null;
112
+ body: string | null;
113
+ }> = [];
114
+ return {
115
+ fetchFn: async (url, init) => {
116
+ const headers = new Headers(init.headers);
117
+ let body: string | null = null;
118
+ if (init.body && typeof init.body === "string") body = init.body;
119
+ else if (init.body) {
120
+ try {
121
+ // ReadableStream from forwarded req.body — drain via Response
122
+ // for inspectability in tests.
123
+ body = await new Response(init.body as ReadableStream<Uint8Array> | null).text();
124
+ } catch {
125
+ body = null;
126
+ }
127
+ }
128
+ calls.push({
129
+ url,
130
+ method: init.method ?? "GET",
131
+ authorization: headers.get("authorization"),
132
+ body,
133
+ });
134
+ return responder(url, init);
135
+ },
136
+ calls,
137
+ };
138
+ }
139
+
140
+ describe("parseModulesConfigPath", () => {
141
+ test("matches /api/modules/<short>/config", () => {
142
+ expect(parseModulesConfigPath("/api/modules/scribe/config")).toEqual({
143
+ short: "scribe",
144
+ suffix: "",
145
+ });
146
+ });
147
+
148
+ test("matches /api/modules/<short>/config/schema", () => {
149
+ expect(parseModulesConfigPath("/api/modules/scribe/config/schema")).toEqual({
150
+ short: "scribe",
151
+ suffix: "schema",
152
+ });
153
+ });
154
+
155
+ test("matches vault and notes (curated modules)", () => {
156
+ expect(parseModulesConfigPath("/api/modules/vault/config")?.short).toBe("vault");
157
+ expect(parseModulesConfigPath("/api/modules/notes/config/schema")?.short).toBe("notes");
158
+ });
159
+
160
+ test("rejects unknown short (non-curated)", () => {
161
+ expect(parseModulesConfigPath("/api/modules/unknown/config")).toBeUndefined();
162
+ expect(parseModulesConfigPath("/api/modules/channel/config")).toBeUndefined();
163
+ });
164
+
165
+ test("rejects non-config suffix shapes", () => {
166
+ expect(parseModulesConfigPath("/api/modules/scribe/install")).toBeUndefined();
167
+ expect(parseModulesConfigPath("/api/modules/scribe/config/extra")).toBeUndefined();
168
+ expect(parseModulesConfigPath("/api/modules/scribe")).toBeUndefined();
169
+ expect(parseModulesConfigPath("/api/modules/scribe/")).toBeUndefined();
170
+ });
171
+
172
+ test("rejects non-/api/modules prefixes", () => {
173
+ expect(parseModulesConfigPath("/api/auth/tokens")).toBeUndefined();
174
+ expect(parseModulesConfigPath("/admin/modules/scribe/config")).toBeUndefined();
175
+ });
176
+ });
177
+
178
+ describe("handleApiModulesConfig — auth", () => {
179
+ let h: Harness;
180
+ beforeEach(async () => {
181
+ h = await makeHarness();
182
+ });
183
+ afterEach(() => h.cleanup());
184
+
185
+ test("405 on POST", async () => {
186
+ const res = await handleApiModulesConfig(
187
+ makeReq("/api/modules/scribe/config", { method: "POST" }),
188
+ { short: "scribe", suffix: "" },
189
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
190
+ );
191
+ expect(res.status).toBe(405);
192
+ });
193
+
194
+ test("405 on PUT to /schema", async () => {
195
+ const res = await handleApiModulesConfig(
196
+ makeReq("/api/modules/scribe/config/schema", { method: "PUT" }),
197
+ { short: "scribe", suffix: "schema" },
198
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
199
+ );
200
+ expect(res.status).toBe(405);
201
+ });
202
+
203
+ test("401 with no Authorization header", async () => {
204
+ const res = await handleApiModulesConfig(
205
+ makeReq("/api/modules/scribe/config"),
206
+ { short: "scribe", suffix: "" },
207
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
208
+ );
209
+ expect(res.status).toBe(401);
210
+ const body = (await res.json()) as { error: string };
211
+ expect(body.error).toBe("unauthenticated");
212
+ });
213
+
214
+ test("403 when bearer lacks parachute:host:admin", async () => {
215
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
216
+ const res = await handleApiModulesConfig(
217
+ makeReq("/api/modules/scribe/config", { headers: { authorization: `Bearer ${bearer}` } }),
218
+ { short: "scribe", suffix: "" },
219
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
220
+ );
221
+ expect(res.status).toBe(403);
222
+ const body = (await res.json()) as { error: string; error_description: string };
223
+ expect(body.error).toBe("insufficient_scope");
224
+ expect(body.error_description).toContain(API_MODULES_CONFIG_REQUIRED_SCOPE);
225
+ });
226
+ });
227
+
228
+ describe("handleApiModulesConfig — module-not-installed", () => {
229
+ let h: Harness;
230
+ beforeEach(async () => {
231
+ h = await makeHarness();
232
+ // No manifest file written → readManifest returns empty services list.
233
+ });
234
+ afterEach(() => h.cleanup());
235
+
236
+ test("404 module_not_installed when scribe absent from services.json", async () => {
237
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
238
+ const res = await handleApiModulesConfig(
239
+ makeReq("/api/modules/scribe/config/schema", {
240
+ headers: { authorization: `Bearer ${bearer}` },
241
+ }),
242
+ { short: "scribe", suffix: "schema" },
243
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
244
+ );
245
+ expect(res.status).toBe(404);
246
+ const body = (await res.json()) as { error: string };
247
+ expect(body.error).toBe("module_not_installed");
248
+ });
249
+ });
250
+
251
+ describe("handleApiModulesConfig — proxy + mint", () => {
252
+ let h: Harness;
253
+ beforeEach(async () => {
254
+ h = await makeHarness();
255
+ // Scribe at port 1943 with `/scribe` mount + stripPrefix true (matches
256
+ // FIRST_PARTY_FALLBACKS — verified upstream paths must be the bare
257
+ // `/.parachute/config/schema` shape).
258
+ writeManifest(h.manifestPath, [
259
+ {
260
+ name: "parachute-scribe",
261
+ port: 1943,
262
+ paths: ["/scribe"],
263
+ health: "/health",
264
+ version: "0.4.0",
265
+ },
266
+ ]);
267
+ });
268
+ afterEach(() => h.cleanup());
269
+
270
+ test("GET /schema mints <short>:admin bearer, drops SPA bearer, hits bare path", async () => {
271
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
272
+ const upstream = makeFakeUpstream(() =>
273
+ Response.json({ type: "object", properties: { transcribeProvider: { type: "string" } } }),
274
+ );
275
+ const res = await handleApiModulesConfig(
276
+ makeReq("/api/modules/scribe/config/schema", {
277
+ headers: { authorization: `Bearer ${bearer}` },
278
+ }),
279
+ { short: "scribe", suffix: "schema" },
280
+ {
281
+ db: h.db,
282
+ issuer: ISSUER,
283
+ manifestPath: h.manifestPath,
284
+ upstreamFetch: upstream.fetchFn,
285
+ },
286
+ );
287
+ expect(res.status).toBe(200);
288
+ const body = (await res.json()) as { type: string };
289
+ expect(body.type).toBe("object");
290
+
291
+ // Exactly one upstream call.
292
+ expect(upstream.calls).toHaveLength(1);
293
+ const call = upstream.calls[0];
294
+ if (!call) throw new Error("upstream not called");
295
+
296
+ // Correct URL: scribe is stripPrefix-true, so the upstream sees the
297
+ // bare `/.parachute/config/schema` — no `/scribe` prefix.
298
+ expect(call.url).toBe("http://127.0.0.1:1943/.parachute/config/schema");
299
+ expect(call.method).toBe("GET");
300
+
301
+ // Authorization is the minted proxy token, NOT the SPA bearer.
302
+ expect(call.authorization).toBeString();
303
+ expect(call.authorization).not.toBe(`Bearer ${bearer}`);
304
+ const proxyJwt = call.authorization?.replace(/^Bearer /, "") ?? "";
305
+ const claims = decodeJwt(proxyJwt);
306
+ // Per-module scope (`scribe:admin`), per-module audience, correct issuer.
307
+ expect(claims.scope).toBe("scribe:admin");
308
+ expect(claims.aud).toBe("scribe");
309
+ expect(claims.iss).toBe(ISSUER);
310
+ expect(claims.client_id).toBe(MODULE_CONFIG_PROXY_CLIENT_ID);
311
+ expect(claims.sub).toBe(h.userId);
312
+ // Short TTL — exp should be ~60s out from iat. Tolerate small drift.
313
+ if (typeof claims.iat === "number" && typeof claims.exp === "number") {
314
+ expect(claims.exp - claims.iat).toBe(60);
315
+ } else {
316
+ throw new Error("proxy JWT missing iat/exp claims");
317
+ }
318
+ });
319
+
320
+ test("GET values returns upstream body verbatim", async () => {
321
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
322
+ const upstream = makeFakeUpstream(() =>
323
+ Response.json({ transcribeProvider: "parakeet-mlx", cleanupProvider: "none" }),
324
+ );
325
+ const res = await handleApiModulesConfig(
326
+ makeReq("/api/modules/scribe/config", {
327
+ headers: { authorization: `Bearer ${bearer}` },
328
+ }),
329
+ { short: "scribe", suffix: "" },
330
+ {
331
+ db: h.db,
332
+ issuer: ISSUER,
333
+ manifestPath: h.manifestPath,
334
+ upstreamFetch: upstream.fetchFn,
335
+ },
336
+ );
337
+ expect(res.status).toBe(200);
338
+ const body = (await res.json()) as { transcribeProvider: string };
339
+ expect(body.transcribeProvider).toBe("parakeet-mlx");
340
+ // Bare `/.parachute/config` (no /schema).
341
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1943/.parachute/config");
342
+ });
343
+
344
+ test("PUT forwards body + uses PUT method", async () => {
345
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
346
+ const upstream = makeFakeUpstream(() =>
347
+ Response.json({ restart_required: ["transcribeProvider"] }),
348
+ );
349
+ const res = await handleApiModulesConfig(
350
+ makeReq("/api/modules/scribe/config", {
351
+ method: "PUT",
352
+ headers: {
353
+ authorization: `Bearer ${bearer}`,
354
+ "content-type": "application/json",
355
+ },
356
+ body: JSON.stringify({ transcribeProvider: "groq" }),
357
+ }),
358
+ { short: "scribe", suffix: "" },
359
+ {
360
+ db: h.db,
361
+ issuer: ISSUER,
362
+ manifestPath: h.manifestPath,
363
+ upstreamFetch: upstream.fetchFn,
364
+ },
365
+ );
366
+ expect(res.status).toBe(200);
367
+ const body = (await res.json()) as { restart_required: string[] };
368
+ expect(body.restart_required).toEqual(["transcribeProvider"]);
369
+
370
+ const call = upstream.calls[0];
371
+ if (!call) throw new Error("upstream not called");
372
+ expect(call.method).toBe("PUT");
373
+ expect(call.body).toBe(JSON.stringify({ transcribeProvider: "groq" }));
374
+ });
375
+
376
+ test("4xx upstream body forwarded verbatim", async () => {
377
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
378
+ const upstream = makeFakeUpstream(() =>
379
+ Response.json(
380
+ {
381
+ error: "validation_failed",
382
+ message: "transcribeProvider: must be one of [parakeet-mlx, ...]",
383
+ errors: [{ path: "transcribeProvider", message: "invalid enum" }],
384
+ },
385
+ { status: 400 },
386
+ ),
387
+ );
388
+ const res = await handleApiModulesConfig(
389
+ makeReq("/api/modules/scribe/config", {
390
+ method: "PUT",
391
+ headers: { authorization: `Bearer ${bearer}` },
392
+ body: JSON.stringify({ transcribeProvider: "bogus" }),
393
+ }),
394
+ { short: "scribe", suffix: "" },
395
+ {
396
+ db: h.db,
397
+ issuer: ISSUER,
398
+ manifestPath: h.manifestPath,
399
+ upstreamFetch: upstream.fetchFn,
400
+ },
401
+ );
402
+ expect(res.status).toBe(400);
403
+ const body = (await res.json()) as { error: string; errors: unknown[] };
404
+ expect(body.error).toBe("validation_failed");
405
+ expect(body.errors).toBeArrayOfSize(1);
406
+ });
407
+
408
+ test("upstream 404 surfaces as no_config_schema (graceful empty-state hint)", async () => {
409
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
410
+ const upstream = makeFakeUpstream(() => new Response("not found", { status: 404 }));
411
+ const res = await handleApiModulesConfig(
412
+ makeReq("/api/modules/scribe/config/schema", {
413
+ headers: { authorization: `Bearer ${bearer}` },
414
+ }),
415
+ { short: "scribe", suffix: "schema" },
416
+ {
417
+ db: h.db,
418
+ issuer: ISSUER,
419
+ manifestPath: h.manifestPath,
420
+ upstreamFetch: upstream.fetchFn,
421
+ },
422
+ );
423
+ expect(res.status).toBe(404);
424
+ const body = (await res.json()) as { error: string };
425
+ expect(body.error).toBe("no_config_schema");
426
+ });
427
+
428
+ test("upstream unreachable → 502", async () => {
429
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
430
+ const upstream = makeFakeUpstream(() => {
431
+ throw new Error("ECONNREFUSED");
432
+ });
433
+ const res = await handleApiModulesConfig(
434
+ makeReq("/api/modules/scribe/config/schema", {
435
+ headers: { authorization: `Bearer ${bearer}` },
436
+ }),
437
+ { short: "scribe", suffix: "schema" },
438
+ {
439
+ db: h.db,
440
+ issuer: ISSUER,
441
+ manifestPath: h.manifestPath,
442
+ upstreamFetch: async () => {
443
+ // Wrapper to allow capturing the throw cleanly.
444
+ await upstream.fetchFn("http://127.0.0.1:1943/.parachute/config/schema", {});
445
+ throw new Error("unreachable");
446
+ },
447
+ },
448
+ );
449
+ expect(res.status).toBe(502);
450
+ const body = (await res.json()) as { error: string };
451
+ expect(body.error).toBe("upstream_unreachable");
452
+ });
453
+ });
454
+
455
+ describe("handleApiModulesConfig — stripPrefix=false (notes-shape)", () => {
456
+ let h: Harness;
457
+ beforeEach(async () => {
458
+ h = await makeHarness();
459
+ // Notes is keep-prefix in FIRST_PARTY_FALLBACKS — the upstream URL
460
+ // should preserve the `/notes` mount. (Hub's notes-serve stub doesn't
461
+ // expose .parachute/config/schema today; this test only asserts the
462
+ // proxy URL shape, not the upstream's behavior.)
463
+ writeManifest(h.manifestPath, [
464
+ {
465
+ name: "parachute-notes",
466
+ port: 1941,
467
+ paths: ["/notes"],
468
+ health: "/health",
469
+ version: "0.5.0",
470
+ },
471
+ ]);
472
+ });
473
+ afterEach(() => h.cleanup());
474
+
475
+ test("keep-prefix module → upstream path includes the mount", async () => {
476
+ const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
477
+ const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
478
+ await handleApiModulesConfig(
479
+ makeReq("/api/modules/notes/config/schema", {
480
+ headers: { authorization: `Bearer ${bearer}` },
481
+ }),
482
+ { short: "notes", suffix: "schema" },
483
+ {
484
+ db: h.db,
485
+ issuer: ISSUER,
486
+ manifestPath: h.manifestPath,
487
+ upstreamFetch: upstream.fetchFn,
488
+ },
489
+ );
490
+ expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1941/notes/.parachute/config/schema");
491
+ });
492
+ });