@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +34 -0
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-server.ts +349 -59
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
|
@@ -1,882 +0,0 @@
|
|
|
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 type { CuratedModuleShort } from "../api-modules.ts";
|
|
27
|
-
import {
|
|
28
|
-
API_MODULES_CONFIG_REQUIRED_SCOPE,
|
|
29
|
-
MODULE_CONFIG_PROXY_CLIENT_ID,
|
|
30
|
-
handleApiModulesConfig,
|
|
31
|
-
parseModulesConfigPath,
|
|
32
|
-
} from "../api-modules-config.ts";
|
|
33
|
-
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
34
|
-
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
35
|
-
import { rotateSigningKey } from "../signing-keys.ts";
|
|
36
|
-
import { createUser } from "../users.ts";
|
|
37
|
-
|
|
38
|
-
const ISSUER = "http://127.0.0.1:1939";
|
|
39
|
-
|
|
40
|
-
interface Harness {
|
|
41
|
-
dir: string;
|
|
42
|
-
manifestPath: string;
|
|
43
|
-
db: ReturnType<typeof openHubDb>;
|
|
44
|
-
userId: string;
|
|
45
|
-
cleanup: () => void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function makeHarness(): Promise<Harness> {
|
|
49
|
-
const dir = mkdtempSync(join(tmpdir(), "phub-api-modules-config-"));
|
|
50
|
-
const db = openHubDb(hubDbPath(dir));
|
|
51
|
-
rotateSigningKey(db);
|
|
52
|
-
const user = await createUser(db, "owner", "pw");
|
|
53
|
-
return {
|
|
54
|
-
dir,
|
|
55
|
-
manifestPath: join(dir, "services.json"),
|
|
56
|
-
db,
|
|
57
|
-
userId: user.id,
|
|
58
|
-
cleanup: () => {
|
|
59
|
-
db.close();
|
|
60
|
-
rmSync(dir, { recursive: true, force: true });
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
66
|
-
const signed = await signAccessToken(h.db, {
|
|
67
|
-
sub: h.userId,
|
|
68
|
-
scopes,
|
|
69
|
-
audience: "parachute-hub",
|
|
70
|
-
clientId: "parachute-hub",
|
|
71
|
-
issuer: ISSUER,
|
|
72
|
-
ttlSeconds: 3600,
|
|
73
|
-
});
|
|
74
|
-
recordTokenMint(h.db, {
|
|
75
|
-
jti: signed.jti,
|
|
76
|
-
createdVia: "operator_mint",
|
|
77
|
-
subject: h.userId,
|
|
78
|
-
clientId: "parachute-hub",
|
|
79
|
-
scopes,
|
|
80
|
-
expiresAt: signed.expiresAt,
|
|
81
|
-
});
|
|
82
|
-
return signed.token;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function writeManifest(path: string, services: unknown[]): void {
|
|
86
|
-
writeFileSync(path, JSON.stringify({ services }));
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function makeReq(
|
|
90
|
-
url: string,
|
|
91
|
-
init: { method?: string; headers?: Record<string, string>; body?: string } = {},
|
|
92
|
-
): Request {
|
|
93
|
-
return new Request(`http://localhost${url}`, {
|
|
94
|
-
method: init.method ?? "GET",
|
|
95
|
-
headers: init.headers,
|
|
96
|
-
body: init.body,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Fake upstream fetch: records every call (so tests can assert on the
|
|
102
|
-
* URL, method, and Authorization header forwarded) and returns a
|
|
103
|
-
* canned Response.
|
|
104
|
-
*/
|
|
105
|
-
function makeFakeUpstream(responder: (url: string, init: RequestInit) => Response): {
|
|
106
|
-
fetchFn: (url: string, init: RequestInit) => Promise<Response>;
|
|
107
|
-
calls: Array<{ url: string; method: string; authorization: string | null; body: string | null }>;
|
|
108
|
-
} {
|
|
109
|
-
const calls: Array<{
|
|
110
|
-
url: string;
|
|
111
|
-
method: string;
|
|
112
|
-
authorization: string | null;
|
|
113
|
-
body: string | null;
|
|
114
|
-
}> = [];
|
|
115
|
-
return {
|
|
116
|
-
fetchFn: async (url, init) => {
|
|
117
|
-
const headers = new Headers(init.headers);
|
|
118
|
-
let body: string | null = null;
|
|
119
|
-
if (init.body && typeof init.body === "string") body = init.body;
|
|
120
|
-
else if (init.body) {
|
|
121
|
-
try {
|
|
122
|
-
// ReadableStream from forwarded req.body — drain via Response
|
|
123
|
-
// for inspectability in tests.
|
|
124
|
-
body = await new Response(init.body as ReadableStream<Uint8Array> | null).text();
|
|
125
|
-
} catch {
|
|
126
|
-
body = null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
calls.push({
|
|
130
|
-
url,
|
|
131
|
-
method: init.method ?? "GET",
|
|
132
|
-
authorization: headers.get("authorization"),
|
|
133
|
-
body,
|
|
134
|
-
});
|
|
135
|
-
return responder(url, init);
|
|
136
|
-
},
|
|
137
|
-
calls,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
describe("parseModulesConfigPath", () => {
|
|
142
|
-
test("matches /api/modules/<short>/config", () => {
|
|
143
|
-
expect(parseModulesConfigPath("/api/modules/scribe/config")).toEqual({
|
|
144
|
-
short: "scribe",
|
|
145
|
-
suffix: "",
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("matches /api/modules/<short>/config/schema", () => {
|
|
150
|
-
expect(parseModulesConfigPath("/api/modules/scribe/config/schema")).toEqual({
|
|
151
|
-
short: "scribe",
|
|
152
|
-
suffix: "schema",
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("matches vault and scribe (curated modules)", () => {
|
|
157
|
-
expect(parseModulesConfigPath("/api/modules/vault/config")?.short).toBe("vault");
|
|
158
|
-
expect(parseModulesConfigPath("/api/modules/scribe/config/schema")?.short).toBe("scribe");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("rejects unknown short (non-curated)", () => {
|
|
162
|
-
expect(parseModulesConfigPath("/api/modules/unknown/config")).toBeUndefined();
|
|
163
|
-
expect(parseModulesConfigPath("/api/modules/channel/config")).toBeUndefined();
|
|
164
|
-
// Curated list trimmed 2026-05-27: notes / runner / surface are no
|
|
165
|
-
// longer curated and reject at the parse boundary.
|
|
166
|
-
expect(parseModulesConfigPath("/api/modules/notes/config")).toBeUndefined();
|
|
167
|
-
expect(parseModulesConfigPath("/api/modules/runner/config")).toBeUndefined();
|
|
168
|
-
expect(parseModulesConfigPath("/api/modules/surface/config")).toBeUndefined();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("rejects non-config suffix shapes", () => {
|
|
172
|
-
expect(parseModulesConfigPath("/api/modules/scribe/install")).toBeUndefined();
|
|
173
|
-
expect(parseModulesConfigPath("/api/modules/scribe/config/extra")).toBeUndefined();
|
|
174
|
-
expect(parseModulesConfigPath("/api/modules/scribe")).toBeUndefined();
|
|
175
|
-
expect(parseModulesConfigPath("/api/modules/scribe/")).toBeUndefined();
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("rejects non-/api/modules prefixes", () => {
|
|
179
|
-
expect(parseModulesConfigPath("/api/auth/tokens")).toBeUndefined();
|
|
180
|
-
expect(parseModulesConfigPath("/admin/modules/scribe/config")).toBeUndefined();
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
describe("handleApiModulesConfig — auth", () => {
|
|
185
|
-
let h: Harness;
|
|
186
|
-
beforeEach(async () => {
|
|
187
|
-
h = await makeHarness();
|
|
188
|
-
});
|
|
189
|
-
afterEach(() => h.cleanup());
|
|
190
|
-
|
|
191
|
-
test("405 on POST", async () => {
|
|
192
|
-
const res = await handleApiModulesConfig(
|
|
193
|
-
makeReq("/api/modules/scribe/config", { method: "POST" }),
|
|
194
|
-
{ short: "scribe", suffix: "" },
|
|
195
|
-
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
|
|
196
|
-
);
|
|
197
|
-
expect(res.status).toBe(405);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("405 on PUT to /schema", async () => {
|
|
201
|
-
const res = await handleApiModulesConfig(
|
|
202
|
-
makeReq("/api/modules/scribe/config/schema", { method: "PUT" }),
|
|
203
|
-
{ short: "scribe", suffix: "schema" },
|
|
204
|
-
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
|
|
205
|
-
);
|
|
206
|
-
expect(res.status).toBe(405);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test("401 with no Authorization header", async () => {
|
|
210
|
-
const res = await handleApiModulesConfig(
|
|
211
|
-
makeReq("/api/modules/scribe/config"),
|
|
212
|
-
{ short: "scribe", suffix: "" },
|
|
213
|
-
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
|
|
214
|
-
);
|
|
215
|
-
expect(res.status).toBe(401);
|
|
216
|
-
const body = (await res.json()) as { error: string };
|
|
217
|
-
expect(body.error).toBe("unauthenticated");
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test("403 when bearer lacks parachute:host:admin", async () => {
|
|
221
|
-
const bearer = await mintBearer(h, ["parachute:host:auth"]);
|
|
222
|
-
const res = await handleApiModulesConfig(
|
|
223
|
-
makeReq("/api/modules/scribe/config", { headers: { authorization: `Bearer ${bearer}` } }),
|
|
224
|
-
{ short: "scribe", suffix: "" },
|
|
225
|
-
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
|
|
226
|
-
);
|
|
227
|
-
expect(res.status).toBe(403);
|
|
228
|
-
const body = (await res.json()) as { error: string; error_description: string };
|
|
229
|
-
expect(body.error).toBe("insufficient_scope");
|
|
230
|
-
expect(body.error_description).toContain(API_MODULES_CONFIG_REQUIRED_SCOPE);
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
describe("handleApiModulesConfig — module-not-installed", () => {
|
|
235
|
-
let h: Harness;
|
|
236
|
-
beforeEach(async () => {
|
|
237
|
-
h = await makeHarness();
|
|
238
|
-
// No manifest file written → readManifest returns empty services list.
|
|
239
|
-
});
|
|
240
|
-
afterEach(() => h.cleanup());
|
|
241
|
-
|
|
242
|
-
test("404 module_not_installed when scribe absent from services.json", async () => {
|
|
243
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
244
|
-
const res = await handleApiModulesConfig(
|
|
245
|
-
makeReq("/api/modules/scribe/config/schema", {
|
|
246
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
247
|
-
}),
|
|
248
|
-
{ short: "scribe", suffix: "schema" },
|
|
249
|
-
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
|
|
250
|
-
);
|
|
251
|
-
expect(res.status).toBe(404);
|
|
252
|
-
const body = (await res.json()) as { error: string };
|
|
253
|
-
expect(body.error).toBe("module_not_installed");
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Regression suite for hub#310 — vault / scribe / runner retired their
|
|
259
|
-
* FIRST_PARTY_FALLBACKS entries because each module now self-registers its
|
|
260
|
-
* services.json row at boot (vault#356, scribe#50, runner#3). The contract:
|
|
261
|
-
*
|
|
262
|
-
* - **services.json has a row** → operations work using its fields
|
|
263
|
-
* (operator-authoritative).
|
|
264
|
-
* - **services.json has no row** → `module_not_installed` 404. Hub no
|
|
265
|
-
* longer falls back to vendored manifest data — pretending a module is
|
|
266
|
-
* installed when it isn't was the anti-pattern we're retiring.
|
|
267
|
-
*
|
|
268
|
-
* These tests pin both halves of that contract per FALLBACK-retired short
|
|
269
|
-
* (vault / scribe / runner) so a future re-introduction of vendored data
|
|
270
|
-
* would have to explicitly delete them.
|
|
271
|
-
*/
|
|
272
|
-
describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
|
|
273
|
-
let h: Harness;
|
|
274
|
-
beforeEach(async () => {
|
|
275
|
-
h = await makeHarness();
|
|
276
|
-
});
|
|
277
|
-
afterEach(() => h.cleanup());
|
|
278
|
-
|
|
279
|
-
test("vault not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
|
|
280
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
281
|
-
const res = await handleApiModulesConfig(
|
|
282
|
-
makeReq("/api/modules/vault/config/schema", {
|
|
283
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
284
|
-
}),
|
|
285
|
-
{ short: "vault", suffix: "schema" },
|
|
286
|
-
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
|
|
287
|
-
);
|
|
288
|
-
expect(res.status).toBe(404);
|
|
289
|
-
expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
test("scribe not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
|
|
293
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
294
|
-
const res = await handleApiModulesConfig(
|
|
295
|
-
makeReq("/api/modules/scribe/config/schema", {
|
|
296
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
297
|
-
}),
|
|
298
|
-
{ short: "scribe", suffix: "schema" },
|
|
299
|
-
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
|
|
300
|
-
);
|
|
301
|
-
expect(res.status).toBe(404);
|
|
302
|
-
expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
test("runner not in services.json → 404 module_not_installed (no vendored fallback)", async () => {
|
|
306
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
307
|
-
const res = await handleApiModulesConfig(
|
|
308
|
-
makeReq("/api/modules/runner/config/schema", {
|
|
309
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
310
|
-
}),
|
|
311
|
-
{ short: "runner" as CuratedModuleShort, suffix: "schema" },
|
|
312
|
-
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
|
|
313
|
-
);
|
|
314
|
-
expect(res.status).toBe(404);
|
|
315
|
-
expect(((await res.json()) as { error: string }).error).toBe("module_not_installed");
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
test("vault in services.json with self-registered fields → upstream URL composed from entry", async () => {
|
|
319
|
-
// Self-registered vault row (mirrors what vault#356's `selfRegister` writes):
|
|
320
|
-
// installDir + canonical paths + version + stripPrefix omitted (vault doesn't
|
|
321
|
-
// strip). The config proxy must build `/vault/default/.parachute/config/schema`
|
|
322
|
-
// — vault's per-mount routing requires the prefix.
|
|
323
|
-
writeManifest(h.manifestPath, [
|
|
324
|
-
{
|
|
325
|
-
name: "parachute-vault",
|
|
326
|
-
port: 1940,
|
|
327
|
-
paths: ["/vault/default"],
|
|
328
|
-
health: "/vault/default/health",
|
|
329
|
-
version: "0.4.8-rc.4",
|
|
330
|
-
installDir: "/parachute/modules/node_modules/@openparachute/vault",
|
|
331
|
-
},
|
|
332
|
-
]);
|
|
333
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
334
|
-
const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
|
|
335
|
-
const res = await handleApiModulesConfig(
|
|
336
|
-
makeReq("/api/modules/vault/config/schema", {
|
|
337
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
338
|
-
}),
|
|
339
|
-
{ short: "vault", suffix: "schema" },
|
|
340
|
-
{
|
|
341
|
-
db: h.db,
|
|
342
|
-
issuer: ISSUER,
|
|
343
|
-
manifestPath: h.manifestPath,
|
|
344
|
-
upstreamFetch: upstream.fetchFn,
|
|
345
|
-
},
|
|
346
|
-
);
|
|
347
|
-
expect(res.status).toBe(200);
|
|
348
|
-
expect(upstream.calls[0]?.url).toBe(
|
|
349
|
-
"http://127.0.0.1:1940/vault/default/.parachute/config/schema",
|
|
350
|
-
);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
test("runner in services.json with self-registered fields → routes to bare /.parachute path", async () => {
|
|
354
|
-
// Self-registered runner row (mirrors what runner#3's `selfRegister` writes):
|
|
355
|
-
// multi-path declaration with `/.parachute` second → hub#307 routes the
|
|
356
|
-
// config proxy to the bare URL regardless of stripPrefix.
|
|
357
|
-
writeManifest(h.manifestPath, [
|
|
358
|
-
{
|
|
359
|
-
name: "parachute-runner",
|
|
360
|
-
port: 1945,
|
|
361
|
-
paths: ["/runner", "/.parachute"],
|
|
362
|
-
health: "/runner/healthz",
|
|
363
|
-
version: "0.1.0-rc.4",
|
|
364
|
-
stripPrefix: false,
|
|
365
|
-
installDir: "/parachute/modules/node_modules/@openparachute/runner",
|
|
366
|
-
},
|
|
367
|
-
]);
|
|
368
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
369
|
-
const upstream = makeFakeUpstream(() => Response.json({ type: "object" }));
|
|
370
|
-
const res = await handleApiModulesConfig(
|
|
371
|
-
makeReq("/api/modules/runner/config/schema", {
|
|
372
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
373
|
-
}),
|
|
374
|
-
{ short: "runner" as CuratedModuleShort, suffix: "schema" },
|
|
375
|
-
{
|
|
376
|
-
db: h.db,
|
|
377
|
-
issuer: ISSUER,
|
|
378
|
-
manifestPath: h.manifestPath,
|
|
379
|
-
upstreamFetch: upstream.fetchFn,
|
|
380
|
-
},
|
|
381
|
-
);
|
|
382
|
-
expect(res.status).toBe(200);
|
|
383
|
-
// Bare path — runner hosts /.parachute at root regardless of stripPrefix.
|
|
384
|
-
expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
describe("handleApiModulesConfig — proxy + mint", () => {
|
|
389
|
-
let h: Harness;
|
|
390
|
-
beforeEach(async () => {
|
|
391
|
-
h = await makeHarness();
|
|
392
|
-
// Scribe at port 1943 with `/scribe` mount + `stripPrefix: true`.
|
|
393
|
-
// Post hub#310 (vault/scribe/runner FALLBACK retirement), services.json
|
|
394
|
-
// is the authoritative source for `stripPrefix` — scribe#50 self-
|
|
395
|
-
// registers the flag at boot, so the canonical post-self-register row
|
|
396
|
-
// carries it. Verified upstream paths must be the bare
|
|
397
|
-
// `/.parachute/config[/schema]` shape.
|
|
398
|
-
writeManifest(h.manifestPath, [
|
|
399
|
-
{
|
|
400
|
-
name: "parachute-scribe",
|
|
401
|
-
port: 1943,
|
|
402
|
-
paths: ["/scribe"],
|
|
403
|
-
health: "/health",
|
|
404
|
-
version: "0.4.4-rc.4",
|
|
405
|
-
stripPrefix: true,
|
|
406
|
-
},
|
|
407
|
-
]);
|
|
408
|
-
});
|
|
409
|
-
afterEach(() => h.cleanup());
|
|
410
|
-
|
|
411
|
-
test("GET /schema mints <short>:admin bearer, drops SPA bearer, hits bare path", async () => {
|
|
412
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
413
|
-
const upstream = makeFakeUpstream(() =>
|
|
414
|
-
Response.json({ type: "object", properties: { transcribeProvider: { type: "string" } } }),
|
|
415
|
-
);
|
|
416
|
-
const res = await handleApiModulesConfig(
|
|
417
|
-
makeReq("/api/modules/scribe/config/schema", {
|
|
418
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
419
|
-
}),
|
|
420
|
-
{ short: "scribe", suffix: "schema" },
|
|
421
|
-
{
|
|
422
|
-
db: h.db,
|
|
423
|
-
issuer: ISSUER,
|
|
424
|
-
manifestPath: h.manifestPath,
|
|
425
|
-
upstreamFetch: upstream.fetchFn,
|
|
426
|
-
},
|
|
427
|
-
);
|
|
428
|
-
expect(res.status).toBe(200);
|
|
429
|
-
const body = (await res.json()) as { type: string };
|
|
430
|
-
expect(body.type).toBe("object");
|
|
431
|
-
|
|
432
|
-
// Exactly one upstream call.
|
|
433
|
-
expect(upstream.calls).toHaveLength(1);
|
|
434
|
-
const call = upstream.calls[0];
|
|
435
|
-
if (!call) throw new Error("upstream not called");
|
|
436
|
-
|
|
437
|
-
// Correct URL: scribe is stripPrefix-true, so the upstream sees the
|
|
438
|
-
// bare `/.parachute/config/schema` — no `/scribe` prefix.
|
|
439
|
-
expect(call.url).toBe("http://127.0.0.1:1943/.parachute/config/schema");
|
|
440
|
-
expect(call.method).toBe("GET");
|
|
441
|
-
|
|
442
|
-
// Authorization is the minted proxy token, NOT the SPA bearer.
|
|
443
|
-
expect(call.authorization).toBeString();
|
|
444
|
-
expect(call.authorization).not.toBe(`Bearer ${bearer}`);
|
|
445
|
-
const proxyJwt = call.authorization?.replace(/^Bearer /, "") ?? "";
|
|
446
|
-
const claims = decodeJwt(proxyJwt);
|
|
447
|
-
// Per-module scope (`scribe:admin`), per-module audience, correct issuer.
|
|
448
|
-
expect(claims.scope).toBe("scribe:admin");
|
|
449
|
-
expect(claims.aud).toBe("scribe");
|
|
450
|
-
expect(claims.iss).toBe(ISSUER);
|
|
451
|
-
expect(claims.client_id).toBe(MODULE_CONFIG_PROXY_CLIENT_ID);
|
|
452
|
-
expect(claims.sub).toBe(h.userId);
|
|
453
|
-
// Short TTL — exp should be ~60s out from iat. Tolerate small drift.
|
|
454
|
-
if (typeof claims.iat === "number" && typeof claims.exp === "number") {
|
|
455
|
-
expect(claims.exp - claims.iat).toBe(60);
|
|
456
|
-
} else {
|
|
457
|
-
throw new Error("proxy JWT missing iat/exp claims");
|
|
458
|
-
}
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
test("GET values returns upstream body verbatim", async () => {
|
|
462
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
463
|
-
const upstream = makeFakeUpstream(() =>
|
|
464
|
-
Response.json({ transcribeProvider: "parakeet-mlx", cleanupProvider: "none" }),
|
|
465
|
-
);
|
|
466
|
-
const res = await handleApiModulesConfig(
|
|
467
|
-
makeReq("/api/modules/scribe/config", {
|
|
468
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
469
|
-
}),
|
|
470
|
-
{ short: "scribe", suffix: "" },
|
|
471
|
-
{
|
|
472
|
-
db: h.db,
|
|
473
|
-
issuer: ISSUER,
|
|
474
|
-
manifestPath: h.manifestPath,
|
|
475
|
-
upstreamFetch: upstream.fetchFn,
|
|
476
|
-
},
|
|
477
|
-
);
|
|
478
|
-
expect(res.status).toBe(200);
|
|
479
|
-
const body = (await res.json()) as { transcribeProvider: string };
|
|
480
|
-
expect(body.transcribeProvider).toBe("parakeet-mlx");
|
|
481
|
-
// Bare `/.parachute/config` (no /schema).
|
|
482
|
-
expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1943/.parachute/config");
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
test("PUT forwards body + uses PUT method", async () => {
|
|
486
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
487
|
-
const upstream = makeFakeUpstream(() =>
|
|
488
|
-
Response.json({ restart_required: ["transcribeProvider"] }),
|
|
489
|
-
);
|
|
490
|
-
const res = await handleApiModulesConfig(
|
|
491
|
-
makeReq("/api/modules/scribe/config", {
|
|
492
|
-
method: "PUT",
|
|
493
|
-
headers: {
|
|
494
|
-
authorization: `Bearer ${bearer}`,
|
|
495
|
-
"content-type": "application/json",
|
|
496
|
-
},
|
|
497
|
-
body: JSON.stringify({ transcribeProvider: "groq" }),
|
|
498
|
-
}),
|
|
499
|
-
{ short: "scribe", suffix: "" },
|
|
500
|
-
{
|
|
501
|
-
db: h.db,
|
|
502
|
-
issuer: ISSUER,
|
|
503
|
-
manifestPath: h.manifestPath,
|
|
504
|
-
upstreamFetch: upstream.fetchFn,
|
|
505
|
-
},
|
|
506
|
-
);
|
|
507
|
-
expect(res.status).toBe(200);
|
|
508
|
-
const body = (await res.json()) as { restart_required: string[] };
|
|
509
|
-
expect(body.restart_required).toEqual(["transcribeProvider"]);
|
|
510
|
-
|
|
511
|
-
const call = upstream.calls[0];
|
|
512
|
-
if (!call) throw new Error("upstream not called");
|
|
513
|
-
expect(call.method).toBe("PUT");
|
|
514
|
-
expect(call.body).toBe(JSON.stringify({ transcribeProvider: "groq" }));
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
test("4xx upstream body forwarded verbatim", async () => {
|
|
518
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
519
|
-
const upstream = makeFakeUpstream(() =>
|
|
520
|
-
Response.json(
|
|
521
|
-
{
|
|
522
|
-
error: "validation_failed",
|
|
523
|
-
message: "transcribeProvider: must be one of [parakeet-mlx, ...]",
|
|
524
|
-
errors: [{ path: "transcribeProvider", message: "invalid enum" }],
|
|
525
|
-
},
|
|
526
|
-
{ status: 400 },
|
|
527
|
-
),
|
|
528
|
-
);
|
|
529
|
-
const res = await handleApiModulesConfig(
|
|
530
|
-
makeReq("/api/modules/scribe/config", {
|
|
531
|
-
method: "PUT",
|
|
532
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
533
|
-
body: JSON.stringify({ transcribeProvider: "bogus" }),
|
|
534
|
-
}),
|
|
535
|
-
{ short: "scribe", suffix: "" },
|
|
536
|
-
{
|
|
537
|
-
db: h.db,
|
|
538
|
-
issuer: ISSUER,
|
|
539
|
-
manifestPath: h.manifestPath,
|
|
540
|
-
upstreamFetch: upstream.fetchFn,
|
|
541
|
-
},
|
|
542
|
-
);
|
|
543
|
-
expect(res.status).toBe(400);
|
|
544
|
-
const body = (await res.json()) as { error: string; errors: unknown[] };
|
|
545
|
-
expect(body.error).toBe("validation_failed");
|
|
546
|
-
expect(body.errors).toBeArrayOfSize(1);
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
test("upstream 404 surfaces as no_config_schema (graceful empty-state hint)", async () => {
|
|
550
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
551
|
-
const upstream = makeFakeUpstream(() => new Response("not found", { status: 404 }));
|
|
552
|
-
const res = await handleApiModulesConfig(
|
|
553
|
-
makeReq("/api/modules/scribe/config/schema", {
|
|
554
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
555
|
-
}),
|
|
556
|
-
{ short: "scribe", suffix: "schema" },
|
|
557
|
-
{
|
|
558
|
-
db: h.db,
|
|
559
|
-
issuer: ISSUER,
|
|
560
|
-
manifestPath: h.manifestPath,
|
|
561
|
-
upstreamFetch: upstream.fetchFn,
|
|
562
|
-
},
|
|
563
|
-
);
|
|
564
|
-
expect(res.status).toBe(404);
|
|
565
|
-
const body = (await res.json()) as { error: string };
|
|
566
|
-
expect(body.error).toBe("no_config_schema");
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
test("upstream unreachable → 502", async () => {
|
|
570
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
571
|
-
const upstream = makeFakeUpstream(() => {
|
|
572
|
-
throw new Error("ECONNREFUSED");
|
|
573
|
-
});
|
|
574
|
-
const res = await handleApiModulesConfig(
|
|
575
|
-
makeReq("/api/modules/scribe/config/schema", {
|
|
576
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
577
|
-
}),
|
|
578
|
-
{ short: "scribe", suffix: "schema" },
|
|
579
|
-
{
|
|
580
|
-
db: h.db,
|
|
581
|
-
issuer: ISSUER,
|
|
582
|
-
manifestPath: h.manifestPath,
|
|
583
|
-
upstreamFetch: async () => {
|
|
584
|
-
// Wrapper to allow capturing the throw cleanly.
|
|
585
|
-
await upstream.fetchFn("http://127.0.0.1:1943/.parachute/config/schema", {});
|
|
586
|
-
throw new Error("unreachable");
|
|
587
|
-
},
|
|
588
|
-
},
|
|
589
|
-
);
|
|
590
|
-
expect(res.status).toBe(502);
|
|
591
|
-
const body = (await res.json()) as { error: string };
|
|
592
|
-
expect(body.error).toBe("upstream_unreachable");
|
|
593
|
-
});
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
describe("handleApiModulesConfig — stripPrefix=false (notes-shape)", () => {
|
|
597
|
-
let h: Harness;
|
|
598
|
-
beforeEach(async () => {
|
|
599
|
-
h = await makeHarness();
|
|
600
|
-
// Notes is keep-prefix in FIRST_PARTY_FALLBACKS — the upstream URL
|
|
601
|
-
// should preserve the `/notes` mount. (Hub's notes-serve stub doesn't
|
|
602
|
-
// expose .parachute/config/schema today; this test only asserts the
|
|
603
|
-
// proxy URL shape, not the upstream's behavior.)
|
|
604
|
-
writeManifest(h.manifestPath, [
|
|
605
|
-
{
|
|
606
|
-
name: "parachute-notes",
|
|
607
|
-
port: 1941,
|
|
608
|
-
paths: ["/notes"],
|
|
609
|
-
health: "/health",
|
|
610
|
-
version: "0.5.0",
|
|
611
|
-
},
|
|
612
|
-
]);
|
|
613
|
-
});
|
|
614
|
-
afterEach(() => h.cleanup());
|
|
615
|
-
|
|
616
|
-
test("keep-prefix module → upstream path includes the mount", async () => {
|
|
617
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
618
|
-
const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
|
|
619
|
-
await handleApiModulesConfig(
|
|
620
|
-
makeReq("/api/modules/notes/config/schema", {
|
|
621
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
622
|
-
}),
|
|
623
|
-
{ short: "notes" as CuratedModuleShort, suffix: "schema" },
|
|
624
|
-
{
|
|
625
|
-
db: h.db,
|
|
626
|
-
issuer: ISSUER,
|
|
627
|
-
manifestPath: h.manifestPath,
|
|
628
|
-
upstreamFetch: upstream.fetchFn,
|
|
629
|
-
},
|
|
630
|
-
);
|
|
631
|
-
expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1941/notes/.parachute/config/schema");
|
|
632
|
-
});
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* hub#307: modules that declare `/.parachute` in their `paths[]` host the
|
|
637
|
-
* universal protocol endpoints at the bare URL — runner is the first
|
|
638
|
-
* example. Before this fix the proxy built `/runner/.parachute/config`
|
|
639
|
-
* (mount-prefixed because stripPrefix is false) and runner returned 404.
|
|
640
|
-
*
|
|
641
|
-
* The fix detects the `/.parachute` declaration in `paths[]` and routes
|
|
642
|
-
* to the bare URL regardless of `stripPrefix`. These tests pin that
|
|
643
|
-
* behavior + verify vault (mount-routed per-vault) keeps its prefixed
|
|
644
|
-
* path so the fix doesn't regress vault config.
|
|
645
|
-
*/
|
|
646
|
-
describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
|
|
647
|
-
let h: Harness;
|
|
648
|
-
beforeEach(async () => {
|
|
649
|
-
h = await makeHarness();
|
|
650
|
-
});
|
|
651
|
-
afterEach(() => h.cleanup());
|
|
652
|
-
|
|
653
|
-
test("runner (stripPrefix:false + /.parachute in paths) → bare /.parachute/config", async () => {
|
|
654
|
-
// Runner's FIRST_PARTY_FALLBACKS shape: paths includes `/.parachute`
|
|
655
|
-
// explicitly because runner serves the universal protocol at the bare
|
|
656
|
-
// URL. The services.json entry can carry either path first; we put
|
|
657
|
-
// `/runner` first to mirror what `parachute install runner` writes
|
|
658
|
-
// (matches the FIRST_PARTY_FALLBACKS manifest paths order).
|
|
659
|
-
writeManifest(h.manifestPath, [
|
|
660
|
-
{
|
|
661
|
-
name: "parachute-runner",
|
|
662
|
-
port: 1945,
|
|
663
|
-
paths: ["/runner", "/.parachute"],
|
|
664
|
-
health: "/runner/healthz",
|
|
665
|
-
version: "0.1.0",
|
|
666
|
-
stripPrefix: false,
|
|
667
|
-
},
|
|
668
|
-
]);
|
|
669
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
670
|
-
const upstream = makeFakeUpstream(() =>
|
|
671
|
-
Response.json({ type: "object", properties: { intervalSeconds: { type: "number" } } }),
|
|
672
|
-
);
|
|
673
|
-
const res = await handleApiModulesConfig(
|
|
674
|
-
makeReq("/api/modules/runner/config/schema", {
|
|
675
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
676
|
-
}),
|
|
677
|
-
{ short: "runner" as CuratedModuleShort, suffix: "schema" },
|
|
678
|
-
{
|
|
679
|
-
db: h.db,
|
|
680
|
-
issuer: ISSUER,
|
|
681
|
-
manifestPath: h.manifestPath,
|
|
682
|
-
upstreamFetch: upstream.fetchFn,
|
|
683
|
-
},
|
|
684
|
-
);
|
|
685
|
-
expect(res.status).toBe(200);
|
|
686
|
-
// No /runner prefix — bare /.parachute/config/schema. This is the
|
|
687
|
-
// hub#307 fix: pre-fix the URL was http://127.0.0.1:1945/runner/.parachute/config/schema
|
|
688
|
-
// and runner returned 404.
|
|
689
|
-
expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
test("runner GET /config (no schema) also routes bare", async () => {
|
|
693
|
-
writeManifest(h.manifestPath, [
|
|
694
|
-
{
|
|
695
|
-
name: "parachute-runner",
|
|
696
|
-
port: 1945,
|
|
697
|
-
paths: ["/runner", "/.parachute"],
|
|
698
|
-
health: "/runner/healthz",
|
|
699
|
-
version: "0.1.0",
|
|
700
|
-
stripPrefix: false,
|
|
701
|
-
},
|
|
702
|
-
]);
|
|
703
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
704
|
-
const upstream = makeFakeUpstream(() => Response.json({ intervalSeconds: 60 }));
|
|
705
|
-
await handleApiModulesConfig(
|
|
706
|
-
makeReq("/api/modules/runner/config", {
|
|
707
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
708
|
-
}),
|
|
709
|
-
{ short: "runner" as CuratedModuleShort, suffix: "" },
|
|
710
|
-
{
|
|
711
|
-
db: h.db,
|
|
712
|
-
issuer: ISSUER,
|
|
713
|
-
manifestPath: h.manifestPath,
|
|
714
|
-
upstreamFetch: upstream.fetchFn,
|
|
715
|
-
},
|
|
716
|
-
);
|
|
717
|
-
expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config");
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
test("runner PUT /config also routes bare with body", async () => {
|
|
721
|
-
writeManifest(h.manifestPath, [
|
|
722
|
-
{
|
|
723
|
-
name: "parachute-runner",
|
|
724
|
-
port: 1945,
|
|
725
|
-
paths: ["/runner", "/.parachute"],
|
|
726
|
-
health: "/runner/healthz",
|
|
727
|
-
version: "0.1.0",
|
|
728
|
-
stripPrefix: false,
|
|
729
|
-
},
|
|
730
|
-
]);
|
|
731
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
732
|
-
const upstream = makeFakeUpstream(() => Response.json({ restart_required: [] }));
|
|
733
|
-
await handleApiModulesConfig(
|
|
734
|
-
makeReq("/api/modules/runner/config", {
|
|
735
|
-
method: "PUT",
|
|
736
|
-
headers: {
|
|
737
|
-
authorization: `Bearer ${bearer}`,
|
|
738
|
-
"content-type": "application/json",
|
|
739
|
-
},
|
|
740
|
-
body: JSON.stringify({ intervalSeconds: 120 }),
|
|
741
|
-
}),
|
|
742
|
-
{ short: "runner" as CuratedModuleShort, suffix: "" },
|
|
743
|
-
{
|
|
744
|
-
db: h.db,
|
|
745
|
-
issuer: ISSUER,
|
|
746
|
-
manifestPath: h.manifestPath,
|
|
747
|
-
upstreamFetch: upstream.fetchFn,
|
|
748
|
-
},
|
|
749
|
-
);
|
|
750
|
-
const call = upstream.calls[0];
|
|
751
|
-
if (!call) throw new Error("upstream not called");
|
|
752
|
-
expect(call.url).toBe("http://127.0.0.1:1945/.parachute/config");
|
|
753
|
-
expect(call.method).toBe("PUT");
|
|
754
|
-
expect(call.body).toBe(JSON.stringify({ intervalSeconds: 120 }));
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
test("runner fallback (no services.json entry) — picks up /.parachute from FIRST_PARTY_FALLBACKS paths", async () => {
|
|
758
|
-
// bun-link / fresh-install case: the runner row isn't in services.json
|
|
759
|
-
// yet but the fallback declares the shape. resolveUpstream returns
|
|
760
|
-
// not-installed when neither the row nor the fallback can prove the
|
|
761
|
-
// module is up — so this case actually 404s. Pinned as the expected
|
|
762
|
-
// shape: hub#307 only changes the upstream-URL math, not the
|
|
763
|
-
// installed-detection contract.
|
|
764
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
765
|
-
const res = await handleApiModulesConfig(
|
|
766
|
-
makeReq("/api/modules/runner/config", {
|
|
767
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
768
|
-
}),
|
|
769
|
-
{ short: "runner" as CuratedModuleShort, suffix: "" },
|
|
770
|
-
{
|
|
771
|
-
db: h.db,
|
|
772
|
-
issuer: ISSUER,
|
|
773
|
-
manifestPath: h.manifestPath,
|
|
774
|
-
},
|
|
775
|
-
);
|
|
776
|
-
expect(res.status).toBe(404);
|
|
777
|
-
const body = (await res.json()) as { error: string };
|
|
778
|
-
expect(body.error).toBe("module_not_installed");
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
test("vault (stripPrefix:false, no /.parachute in paths) — keeps /vault/<name> prefix (unchanged)", async () => {
|
|
782
|
-
// Vault's `.parachute/config` is per-vault, scoped under the
|
|
783
|
-
// `/vault/<name>` mount. Routing it bare would lose the vault-name
|
|
784
|
-
// context. This test pins that hub#307 doesn't regress vault.
|
|
785
|
-
writeManifest(h.manifestPath, [
|
|
786
|
-
{
|
|
787
|
-
name: "parachute-vault",
|
|
788
|
-
port: 1940,
|
|
789
|
-
paths: ["/vault/default"],
|
|
790
|
-
health: "/vault/default/health",
|
|
791
|
-
version: "0.5.0",
|
|
792
|
-
},
|
|
793
|
-
]);
|
|
794
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
795
|
-
const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
|
|
796
|
-
await handleApiModulesConfig(
|
|
797
|
-
makeReq("/api/modules/vault/config/schema", {
|
|
798
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
799
|
-
}),
|
|
800
|
-
{ short: "vault", suffix: "schema" },
|
|
801
|
-
{
|
|
802
|
-
db: h.db,
|
|
803
|
-
issuer: ISSUER,
|
|
804
|
-
manifestPath: h.manifestPath,
|
|
805
|
-
upstreamFetch: upstream.fetchFn,
|
|
806
|
-
},
|
|
807
|
-
);
|
|
808
|
-
// Preserved mount — same as pre-hub#307.
|
|
809
|
-
expect(upstream.calls[0]?.url).toBe(
|
|
810
|
-
"http://127.0.0.1:1940/vault/default/.parachute/config/schema",
|
|
811
|
-
);
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
test("scribe (stripPrefix:true) — bare URL preserved (unchanged)", async () => {
|
|
815
|
-
// Pre-hub#307: stripPrefix:true produced /.parachute/config (via the
|
|
816
|
-
// stripPrefix branch). Post-fix: same result via the hostsBareParachute
|
|
817
|
-
// branch when /.parachute is in paths, or via the stripPrefix branch
|
|
818
|
-
// when it isn't. Scribe ships `paths: ["/scribe"]` (no /.parachute),
|
|
819
|
-
// so it takes the stripPrefix branch. Either way, the upstream URL is
|
|
820
|
-
// identical to pre-fix behavior.
|
|
821
|
-
writeManifest(h.manifestPath, [
|
|
822
|
-
{
|
|
823
|
-
name: "parachute-scribe",
|
|
824
|
-
port: 1943,
|
|
825
|
-
paths: ["/scribe"],
|
|
826
|
-
health: "/health",
|
|
827
|
-
version: "0.4.0",
|
|
828
|
-
stripPrefix: true,
|
|
829
|
-
},
|
|
830
|
-
]);
|
|
831
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
832
|
-
const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
|
|
833
|
-
await handleApiModulesConfig(
|
|
834
|
-
makeReq("/api/modules/scribe/config/schema", {
|
|
835
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
836
|
-
}),
|
|
837
|
-
{ short: "scribe", suffix: "schema" },
|
|
838
|
-
{
|
|
839
|
-
db: h.db,
|
|
840
|
-
issuer: ISSUER,
|
|
841
|
-
manifestPath: h.manifestPath,
|
|
842
|
-
upstreamFetch: upstream.fetchFn,
|
|
843
|
-
},
|
|
844
|
-
);
|
|
845
|
-
// Unchanged from pre-hub#307.
|
|
846
|
-
expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1943/.parachute/config/schema");
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
test("mixed: stripPrefix:false module with both /custom and /.parachute → bare for protocol, prefix for others", async () => {
|
|
850
|
-
// The hostsBareParachute branch only governs the `/.parachute/config*`
|
|
851
|
-
// proxy here. Other proxy code-paths (the generic services-proxy in
|
|
852
|
-
// hub-server.ts) handle non-protocol requests; this surface only ever
|
|
853
|
-
// forwards to `/.parachute/config[/schema]`, so verifying just that
|
|
854
|
-
// route is the right scope.
|
|
855
|
-
writeManifest(h.manifestPath, [
|
|
856
|
-
{
|
|
857
|
-
name: "parachute-runner",
|
|
858
|
-
port: 1945,
|
|
859
|
-
// Order doesn't matter for hostsBareParachute detection.
|
|
860
|
-
paths: ["/.parachute", "/runner"],
|
|
861
|
-
health: "/runner/healthz",
|
|
862
|
-
version: "0.1.0",
|
|
863
|
-
stripPrefix: false,
|
|
864
|
-
},
|
|
865
|
-
]);
|
|
866
|
-
const bearer = await mintBearer(h, [API_MODULES_CONFIG_REQUIRED_SCOPE]);
|
|
867
|
-
const upstream = makeFakeUpstream(() => Response.json({ type: "object", properties: {} }));
|
|
868
|
-
await handleApiModulesConfig(
|
|
869
|
-
makeReq("/api/modules/runner/config/schema", {
|
|
870
|
-
headers: { authorization: `Bearer ${bearer}` },
|
|
871
|
-
}),
|
|
872
|
-
{ short: "runner" as CuratedModuleShort, suffix: "schema" },
|
|
873
|
-
{
|
|
874
|
-
db: h.db,
|
|
875
|
-
issuer: ISSUER,
|
|
876
|
-
manifestPath: h.manifestPath,
|
|
877
|
-
upstreamFetch: upstream.fetchFn,
|
|
878
|
-
},
|
|
879
|
-
);
|
|
880
|
-
expect(upstream.calls[0]?.url).toBe("http://127.0.0.1:1945/.parachute/config/schema");
|
|
881
|
-
});
|
|
882
|
-
});
|