@openparachute/hub 0.5.1 → 0.5.2
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__/hub-server.test.ts +580 -1
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/services-manifest.test.ts +26 -0
- package/src/hub-server.ts +126 -29
- package/src/module-manifest.ts +19 -0
- package/src/service-spec.ts +8 -0
- package/src/services-manifest.ts +21 -0
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
7
7
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
8
|
-
import { findVaultUpstream, hubFetch } from "../hub-server.ts";
|
|
8
|
+
import { findServiceUpstream, findVaultUpstream, hubFetch } from "../hub-server.ts";
|
|
9
9
|
import { pidPath } from "../process-state.ts";
|
|
10
10
|
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
11
11
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
@@ -1051,6 +1051,585 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1051
1051
|
});
|
|
1052
1052
|
});
|
|
1053
1053
|
|
|
1054
|
+
describe("findServiceUpstream (#182)", () => {
|
|
1055
|
+
// Generic longest-prefix match across non-vault services.json entries. Vault
|
|
1056
|
+
// entries are filtered out — vault routing is the SPA-fallback-aware path
|
|
1057
|
+
// through findVaultUpstream / proxyToVault.
|
|
1058
|
+
|
|
1059
|
+
test("matches a non-vault entry by exact path", () => {
|
|
1060
|
+
const services: ServiceEntry[] = [
|
|
1061
|
+
{
|
|
1062
|
+
name: "scribe",
|
|
1063
|
+
port: 1942,
|
|
1064
|
+
paths: ["/scribe"],
|
|
1065
|
+
health: "/scribe/health",
|
|
1066
|
+
version: "0.1.0",
|
|
1067
|
+
},
|
|
1068
|
+
];
|
|
1069
|
+
const m = findServiceUpstream(services, "/scribe");
|
|
1070
|
+
expect(m?.port).toBe(1942);
|
|
1071
|
+
expect(m?.mount).toBe("/scribe");
|
|
1072
|
+
expect(m?.entry.name).toBe("scribe");
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
test("matches a deeper subpath via prefix", () => {
|
|
1076
|
+
const services: ServiceEntry[] = [
|
|
1077
|
+
{
|
|
1078
|
+
name: "agent",
|
|
1079
|
+
port: 1943,
|
|
1080
|
+
paths: ["/agent"],
|
|
1081
|
+
health: "/agent/api/health",
|
|
1082
|
+
version: "0.1.0",
|
|
1083
|
+
},
|
|
1084
|
+
];
|
|
1085
|
+
expect(findServiceUpstream(services, "/agent/api/health")?.port).toBe(1943);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test("ignores vault entries — those route via findVaultUpstream", () => {
|
|
1089
|
+
const services: ServiceEntry[] = [
|
|
1090
|
+
{
|
|
1091
|
+
name: "parachute-vault",
|
|
1092
|
+
port: 1940,
|
|
1093
|
+
paths: ["/vault/default"],
|
|
1094
|
+
health: "/vault/default/health",
|
|
1095
|
+
version: "0.4.0",
|
|
1096
|
+
},
|
|
1097
|
+
];
|
|
1098
|
+
expect(findServiceUpstream(services, "/vault/default/health")).toBeUndefined();
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
test("returns undefined when no service claims the path", () => {
|
|
1102
|
+
const services: ServiceEntry[] = [
|
|
1103
|
+
{
|
|
1104
|
+
name: "scribe",
|
|
1105
|
+
port: 1942,
|
|
1106
|
+
paths: ["/scribe"],
|
|
1107
|
+
health: "/scribe/health",
|
|
1108
|
+
version: "0.1.0",
|
|
1109
|
+
},
|
|
1110
|
+
];
|
|
1111
|
+
expect(findServiceUpstream(services, "/unknown/foo")).toBeUndefined();
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
test("longest-prefix wins when multiple paths could match", () => {
|
|
1115
|
+
// A service registering `/api` and another (older / catch-all) registering
|
|
1116
|
+
// `/` would conflict on every request — longest mount wins so the more
|
|
1117
|
+
// specific one takes precedence.
|
|
1118
|
+
const services: ServiceEntry[] = [
|
|
1119
|
+
{ name: "wide", port: 1950, paths: ["/api"], health: "/api/health", version: "0.1.0" },
|
|
1120
|
+
{
|
|
1121
|
+
name: "deeper",
|
|
1122
|
+
port: 1951,
|
|
1123
|
+
paths: ["/api/v2"],
|
|
1124
|
+
health: "/api/v2/health",
|
|
1125
|
+
version: "0.1.0",
|
|
1126
|
+
},
|
|
1127
|
+
];
|
|
1128
|
+
expect(findServiceUpstream(services, "/api/v2/things")?.port).toBe(1951);
|
|
1129
|
+
expect(findServiceUpstream(services, "/api/v1/things")?.port).toBe(1950);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
test("does not match a sibling that shares a prefix without a slash boundary", () => {
|
|
1133
|
+
// `/scribe-admin` must NOT match a service mounted at `/scribe`. The
|
|
1134
|
+
// boundary check is `pathname === path || pathname.startsWith(path + '/')`.
|
|
1135
|
+
const services: ServiceEntry[] = [
|
|
1136
|
+
{
|
|
1137
|
+
name: "scribe",
|
|
1138
|
+
port: 1942,
|
|
1139
|
+
paths: ["/scribe"],
|
|
1140
|
+
health: "/scribe/health",
|
|
1141
|
+
version: "0.1.0",
|
|
1142
|
+
},
|
|
1143
|
+
];
|
|
1144
|
+
expect(findServiceUpstream(services, "/scribe-admin")).toBeUndefined();
|
|
1145
|
+
expect(findServiceUpstream(services, "/scribe-admin/foo")).toBeUndefined();
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
1150
|
+
// hub#182: services.json-driven dispatch for non-vault modules. Lets
|
|
1151
|
+
// `parachute install <svc>` reach the on-box hub at hub:1939/<svc>/* with
|
|
1152
|
+
// no per-service codepath. Vault keeps its own routing for the SPA seam.
|
|
1153
|
+
|
|
1154
|
+
function startUpstream(replyTag: string): { port: number; stop: () => void } {
|
|
1155
|
+
const server = Bun.serve({
|
|
1156
|
+
port: 0,
|
|
1157
|
+
hostname: "127.0.0.1",
|
|
1158
|
+
fetch: async (req) => {
|
|
1159
|
+
const u = new URL(req.url);
|
|
1160
|
+
const body = req.body ? await req.text() : "";
|
|
1161
|
+
return new Response(
|
|
1162
|
+
JSON.stringify({
|
|
1163
|
+
tag: replyTag,
|
|
1164
|
+
method: req.method,
|
|
1165
|
+
pathname: u.pathname,
|
|
1166
|
+
search: u.search,
|
|
1167
|
+
authorization: req.headers.get("authorization") ?? "",
|
|
1168
|
+
contentType: req.headers.get("content-type") ?? "",
|
|
1169
|
+
body,
|
|
1170
|
+
}),
|
|
1171
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1172
|
+
);
|
|
1173
|
+
},
|
|
1174
|
+
});
|
|
1175
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
test("routes /scribe/health to the matching upstream, path preserved", async () => {
|
|
1179
|
+
const h = makeHarness();
|
|
1180
|
+
const upstream = startUpstream("scribe");
|
|
1181
|
+
try {
|
|
1182
|
+
writeManifest(
|
|
1183
|
+
{
|
|
1184
|
+
services: [
|
|
1185
|
+
{
|
|
1186
|
+
name: "scribe",
|
|
1187
|
+
port: upstream.port,
|
|
1188
|
+
paths: ["/scribe"],
|
|
1189
|
+
health: "/scribe/health",
|
|
1190
|
+
version: "0.1.0",
|
|
1191
|
+
},
|
|
1192
|
+
],
|
|
1193
|
+
},
|
|
1194
|
+
h.manifestPath,
|
|
1195
|
+
);
|
|
1196
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1197
|
+
const res = await fetcher(req("/scribe/health"));
|
|
1198
|
+
expect(res.status).toBe(200);
|
|
1199
|
+
const body = (await res.json()) as { tag: string; pathname: string };
|
|
1200
|
+
expect(body.tag).toBe("scribe");
|
|
1201
|
+
// Path-preservation convention: backend sees the full mount-prefixed
|
|
1202
|
+
// path, matching `serviceProxyTarget` in commands/expose.ts.
|
|
1203
|
+
expect(body.pathname).toBe("/scribe/health");
|
|
1204
|
+
} finally {
|
|
1205
|
+
upstream.stop();
|
|
1206
|
+
h.cleanup();
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test("routes /notes/sw.js to the matching upstream", async () => {
|
|
1211
|
+
// Notes is the canonical path-mount case — the PWA shell has to see the
|
|
1212
|
+
// full `/notes/...` path so its service worker registers correctly (the
|
|
1213
|
+
// motivator for the `--mount` strip in notes-serve.ts).
|
|
1214
|
+
const h = makeHarness();
|
|
1215
|
+
const upstream = startUpstream("notes");
|
|
1216
|
+
try {
|
|
1217
|
+
writeManifest(
|
|
1218
|
+
{
|
|
1219
|
+
services: [
|
|
1220
|
+
{
|
|
1221
|
+
name: "notes",
|
|
1222
|
+
port: upstream.port,
|
|
1223
|
+
paths: ["/notes"],
|
|
1224
|
+
health: "/notes/health",
|
|
1225
|
+
version: "0.1.0",
|
|
1226
|
+
},
|
|
1227
|
+
],
|
|
1228
|
+
},
|
|
1229
|
+
h.manifestPath,
|
|
1230
|
+
);
|
|
1231
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1232
|
+
const res = await fetcher(req("/notes/sw.js"));
|
|
1233
|
+
expect(res.status).toBe(200);
|
|
1234
|
+
const body = (await res.json()) as { tag: string; pathname: string };
|
|
1235
|
+
expect(body.tag).toBe("notes");
|
|
1236
|
+
expect(body.pathname).toBe("/notes/sw.js");
|
|
1237
|
+
} finally {
|
|
1238
|
+
upstream.stop();
|
|
1239
|
+
h.cleanup();
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
test("routes a deep /agent/api/health to the matching upstream", async () => {
|
|
1244
|
+
// Agent registers `/agent`; deeper paths route by prefix.
|
|
1245
|
+
const h = makeHarness();
|
|
1246
|
+
const upstream = startUpstream("agent");
|
|
1247
|
+
try {
|
|
1248
|
+
writeManifest(
|
|
1249
|
+
{
|
|
1250
|
+
services: [
|
|
1251
|
+
{
|
|
1252
|
+
name: "agent",
|
|
1253
|
+
port: upstream.port,
|
|
1254
|
+
paths: ["/agent"],
|
|
1255
|
+
health: "/agent/api/health",
|
|
1256
|
+
version: "0.1.0",
|
|
1257
|
+
},
|
|
1258
|
+
],
|
|
1259
|
+
},
|
|
1260
|
+
h.manifestPath,
|
|
1261
|
+
);
|
|
1262
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1263
|
+
const res = await fetcher(req("/agent/api/health?probe=1"));
|
|
1264
|
+
expect(res.status).toBe(200);
|
|
1265
|
+
const body = (await res.json()) as { tag: string; pathname: string; search: string };
|
|
1266
|
+
expect(body.tag).toBe("agent");
|
|
1267
|
+
expect(body.pathname).toBe("/agent/api/health");
|
|
1268
|
+
expect(body.search).toBe("?probe=1");
|
|
1269
|
+
} finally {
|
|
1270
|
+
upstream.stop();
|
|
1271
|
+
h.cleanup();
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
test("preserves method, multipart body, and Authorization on POSTs", async () => {
|
|
1276
|
+
// Scribe-shaped upload: multipart/form-data with a bearer token. Multipart
|
|
1277
|
+
// is what real scribe clients send; if the proxy strips the boundary or
|
|
1278
|
+
// drops Authorization, scribe rejects the request before transcribing.
|
|
1279
|
+
const h = makeHarness();
|
|
1280
|
+
const upstream = startUpstream("scribe");
|
|
1281
|
+
try {
|
|
1282
|
+
writeManifest(
|
|
1283
|
+
{
|
|
1284
|
+
services: [
|
|
1285
|
+
{
|
|
1286
|
+
name: "scribe",
|
|
1287
|
+
port: upstream.port,
|
|
1288
|
+
paths: ["/scribe"],
|
|
1289
|
+
health: "/scribe/health",
|
|
1290
|
+
version: "0.1.0",
|
|
1291
|
+
},
|
|
1292
|
+
],
|
|
1293
|
+
},
|
|
1294
|
+
h.manifestPath,
|
|
1295
|
+
);
|
|
1296
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1297
|
+
const form = new FormData();
|
|
1298
|
+
form.append("model", "whisper-1");
|
|
1299
|
+
form.append("file", new Blob([new Uint8Array([1, 2, 3, 4])]), "audio.wav");
|
|
1300
|
+
const res = await fetcher(
|
|
1301
|
+
req("/scribe/v1/audio/transcriptions", {
|
|
1302
|
+
method: "POST",
|
|
1303
|
+
headers: { authorization: "Bearer test-token" },
|
|
1304
|
+
body: form,
|
|
1305
|
+
}),
|
|
1306
|
+
);
|
|
1307
|
+
expect(res.status).toBe(200);
|
|
1308
|
+
const body = (await res.json()) as {
|
|
1309
|
+
method: string;
|
|
1310
|
+
authorization: string;
|
|
1311
|
+
contentType: string;
|
|
1312
|
+
body: string;
|
|
1313
|
+
};
|
|
1314
|
+
expect(body.method).toBe("POST");
|
|
1315
|
+
expect(body.authorization).toBe("Bearer test-token");
|
|
1316
|
+
// Bun's fetch sets the boundary; we just need to confirm the
|
|
1317
|
+
// multipart content-type survived.
|
|
1318
|
+
expect(body.contentType).toMatch(/^multipart\/form-data;\s*boundary=/);
|
|
1319
|
+
// And the body bytes — the boundary marker the upstream echoes back
|
|
1320
|
+
// should contain the form fields we sent.
|
|
1321
|
+
expect(body.body).toContain('name="model"');
|
|
1322
|
+
expect(body.body).toContain("whisper-1");
|
|
1323
|
+
expect(body.body).toContain('name="file"');
|
|
1324
|
+
} finally {
|
|
1325
|
+
upstream.stop();
|
|
1326
|
+
h.cleanup();
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
test("stripPrefix=true forwards the bare path (mount removed)", async () => {
|
|
1331
|
+
// The scribe-shaped case from real life: scribe's HTTP routes are
|
|
1332
|
+
// `/health`, `/v1/...` — no `/scribe` prefix. When the entry sets
|
|
1333
|
+
// stripPrefix:true the hub strips the mount before forwarding so the
|
|
1334
|
+
// backend sees `/health` rather than `/scribe/health`. Without this,
|
|
1335
|
+
// every proxied scribe request 404s at the backend.
|
|
1336
|
+
const h = makeHarness();
|
|
1337
|
+
const upstream = startUpstream("scribe");
|
|
1338
|
+
try {
|
|
1339
|
+
writeManifest(
|
|
1340
|
+
{
|
|
1341
|
+
services: [
|
|
1342
|
+
{
|
|
1343
|
+
name: "scribe",
|
|
1344
|
+
port: upstream.port,
|
|
1345
|
+
paths: ["/scribe"],
|
|
1346
|
+
health: "/scribe/health",
|
|
1347
|
+
version: "0.1.0",
|
|
1348
|
+
stripPrefix: true,
|
|
1349
|
+
},
|
|
1350
|
+
],
|
|
1351
|
+
},
|
|
1352
|
+
h.manifestPath,
|
|
1353
|
+
);
|
|
1354
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1355
|
+
const res = await fetcher(req("/scribe/v1/audio/transcriptions?model=whisper-1"));
|
|
1356
|
+
expect(res.status).toBe(200);
|
|
1357
|
+
const body = (await res.json()) as { tag: string; pathname: string; search: string };
|
|
1358
|
+
expect(body.tag).toBe("scribe");
|
|
1359
|
+
// Backend sees the bare path — `/scribe` is stripped.
|
|
1360
|
+
expect(body.pathname).toBe("/v1/audio/transcriptions");
|
|
1361
|
+
// Query string is always preserved verbatim regardless of stripPrefix.
|
|
1362
|
+
expect(body.search).toBe("?model=whisper-1");
|
|
1363
|
+
} finally {
|
|
1364
|
+
upstream.stop();
|
|
1365
|
+
h.cleanup();
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
test("stripPrefix=true: request to bare mount becomes `/`", async () => {
|
|
1370
|
+
// Edge case: pathname === mount. Slicing yields the empty string; the
|
|
1371
|
+
// proxy normalizes to `/` so the backend sees a valid path.
|
|
1372
|
+
const h = makeHarness();
|
|
1373
|
+
const upstream = startUpstream("scribe");
|
|
1374
|
+
try {
|
|
1375
|
+
writeManifest(
|
|
1376
|
+
{
|
|
1377
|
+
services: [
|
|
1378
|
+
{
|
|
1379
|
+
name: "scribe",
|
|
1380
|
+
port: upstream.port,
|
|
1381
|
+
paths: ["/scribe"],
|
|
1382
|
+
health: "/health",
|
|
1383
|
+
version: "0.1.0",
|
|
1384
|
+
stripPrefix: true,
|
|
1385
|
+
},
|
|
1386
|
+
],
|
|
1387
|
+
},
|
|
1388
|
+
h.manifestPath,
|
|
1389
|
+
);
|
|
1390
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1391
|
+
const res = await fetcher(req("/scribe"));
|
|
1392
|
+
expect(res.status).toBe(200);
|
|
1393
|
+
const body = (await res.json()) as { pathname: string };
|
|
1394
|
+
expect(body.pathname).toBe("/");
|
|
1395
|
+
} finally {
|
|
1396
|
+
upstream.stop();
|
|
1397
|
+
h.cleanup();
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
test("stripPrefix absent (default false) preserves the prefix — no behavior change for existing entries", async () => {
|
|
1402
|
+
// Migration safety: a services.json entry written before stripPrefix
|
|
1403
|
+
// existed (e.g. notes / agent rows already on disk) must continue to
|
|
1404
|
+
// forward the full path. The /notes/sw.js test above already exercises
|
|
1405
|
+
// this in the happy case; this test makes the absence-of-flag → keep-
|
|
1406
|
+
// prefix contract explicit.
|
|
1407
|
+
const h = makeHarness();
|
|
1408
|
+
const upstream = startUpstream("notes");
|
|
1409
|
+
try {
|
|
1410
|
+
writeManifest(
|
|
1411
|
+
{
|
|
1412
|
+
services: [
|
|
1413
|
+
{
|
|
1414
|
+
name: "notes",
|
|
1415
|
+
port: upstream.port,
|
|
1416
|
+
paths: ["/notes"],
|
|
1417
|
+
health: "/notes/health",
|
|
1418
|
+
version: "0.1.0",
|
|
1419
|
+
// stripPrefix intentionally omitted — must default to false.
|
|
1420
|
+
},
|
|
1421
|
+
],
|
|
1422
|
+
},
|
|
1423
|
+
h.manifestPath,
|
|
1424
|
+
);
|
|
1425
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1426
|
+
const res = await fetcher(req("/notes/health"));
|
|
1427
|
+
expect(res.status).toBe(200);
|
|
1428
|
+
const body = (await res.json()) as { pathname: string };
|
|
1429
|
+
expect(body.pathname).toBe("/notes/health");
|
|
1430
|
+
} finally {
|
|
1431
|
+
upstream.stop();
|
|
1432
|
+
h.cleanup();
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
test("stripPrefix=false explicitly preserves the prefix", async () => {
|
|
1437
|
+
// The opposite explicit-declaration of the previous test: an operator
|
|
1438
|
+
// who writes `stripPrefix: false` in services.json gets the same
|
|
1439
|
+
// keep-prefix behavior as omitting the field. Confirms validator round-
|
|
1440
|
+
// tripping doesn't lose the explicit-false (separate from the absent
|
|
1441
|
+
// case which is checked above).
|
|
1442
|
+
const h = makeHarness();
|
|
1443
|
+
const upstream = startUpstream("notes");
|
|
1444
|
+
try {
|
|
1445
|
+
writeManifest(
|
|
1446
|
+
{
|
|
1447
|
+
services: [
|
|
1448
|
+
{
|
|
1449
|
+
name: "notes",
|
|
1450
|
+
port: upstream.port,
|
|
1451
|
+
paths: ["/notes"],
|
|
1452
|
+
health: "/notes/health",
|
|
1453
|
+
version: "0.1.0",
|
|
1454
|
+
stripPrefix: false,
|
|
1455
|
+
},
|
|
1456
|
+
],
|
|
1457
|
+
},
|
|
1458
|
+
h.manifestPath,
|
|
1459
|
+
);
|
|
1460
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1461
|
+
const res = await fetcher(req("/notes/sw.js"));
|
|
1462
|
+
expect(res.status).toBe(200);
|
|
1463
|
+
const body = (await res.json()) as { pathname: string };
|
|
1464
|
+
expect(body.pathname).toBe("/notes/sw.js");
|
|
1465
|
+
} finally {
|
|
1466
|
+
upstream.stop();
|
|
1467
|
+
h.cleanup();
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
test("unknown /<svc>/* path returns 404", async () => {
|
|
1472
|
+
const h = makeHarness();
|
|
1473
|
+
const upstream = startUpstream("scribe");
|
|
1474
|
+
try {
|
|
1475
|
+
writeManifest(
|
|
1476
|
+
{
|
|
1477
|
+
services: [
|
|
1478
|
+
{
|
|
1479
|
+
name: "scribe",
|
|
1480
|
+
port: upstream.port,
|
|
1481
|
+
paths: ["/scribe"],
|
|
1482
|
+
health: "/scribe/health",
|
|
1483
|
+
version: "0.1.0",
|
|
1484
|
+
},
|
|
1485
|
+
],
|
|
1486
|
+
},
|
|
1487
|
+
h.manifestPath,
|
|
1488
|
+
);
|
|
1489
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1490
|
+
const res = await fetcher(req("/notinstalled/foo"));
|
|
1491
|
+
expect(res.status).toBe(404);
|
|
1492
|
+
} finally {
|
|
1493
|
+
upstream.stop();
|
|
1494
|
+
h.cleanup();
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
test("returns 502 when the matching upstream is unreachable", async () => {
|
|
1499
|
+
// Service is in services.json but the port has nothing listening — same
|
|
1500
|
+
// shape as the vault-unreachable test, label is the entry's `name`.
|
|
1501
|
+
const h = makeHarness();
|
|
1502
|
+
try {
|
|
1503
|
+
writeManifest(
|
|
1504
|
+
{
|
|
1505
|
+
services: [
|
|
1506
|
+
{
|
|
1507
|
+
name: "scribe",
|
|
1508
|
+
port: await pickClosedPort(),
|
|
1509
|
+
paths: ["/scribe"],
|
|
1510
|
+
health: "/scribe/health",
|
|
1511
|
+
version: "0.1.0",
|
|
1512
|
+
},
|
|
1513
|
+
],
|
|
1514
|
+
},
|
|
1515
|
+
h.manifestPath,
|
|
1516
|
+
);
|
|
1517
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1518
|
+
const res = await fetcher(req("/scribe/health"));
|
|
1519
|
+
expect(res.status).toBe(502);
|
|
1520
|
+
const body = (await res.json()) as { error: string };
|
|
1521
|
+
expect(body.error).toContain("scribe upstream unreachable");
|
|
1522
|
+
} finally {
|
|
1523
|
+
h.cleanup();
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
test("/oauth/authorize is hub-handled, never reaches service dispatch", async () => {
|
|
1528
|
+
// Even if a (misbehaving) service registers `/oauth`, the hub's own
|
|
1529
|
+
// /oauth/* handlers run first by virtue of dispatch ordering. We don't
|
|
1530
|
+
// need an explicit denylist — ordering enforces it.
|
|
1531
|
+
const h = makeHarness();
|
|
1532
|
+
const upstream = startUpstream("malicious");
|
|
1533
|
+
try {
|
|
1534
|
+
writeManifest(
|
|
1535
|
+
{
|
|
1536
|
+
services: [
|
|
1537
|
+
{
|
|
1538
|
+
name: "malicious",
|
|
1539
|
+
port: upstream.port,
|
|
1540
|
+
paths: ["/oauth"],
|
|
1541
|
+
health: "/oauth/health",
|
|
1542
|
+
version: "0.0.1",
|
|
1543
|
+
},
|
|
1544
|
+
],
|
|
1545
|
+
},
|
|
1546
|
+
h.manifestPath,
|
|
1547
|
+
);
|
|
1548
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1549
|
+
const res = await fetcher(req("/oauth/authorize"));
|
|
1550
|
+
// Hub's own /oauth/authorize handler responds (likely a redirect or
|
|
1551
|
+
// error page rendering) — we just need to verify the upstream was NOT
|
|
1552
|
+
// reached, i.e. `tag: "malicious"` is not in the body.
|
|
1553
|
+
const text = await res.text();
|
|
1554
|
+
expect(text).not.toContain('"tag":"malicious"');
|
|
1555
|
+
} finally {
|
|
1556
|
+
upstream.stop();
|
|
1557
|
+
h.cleanup();
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
test("/.well-known/parachute.json is hub-handled, never reaches service dispatch", async () => {
|
|
1562
|
+
const h = makeHarness();
|
|
1563
|
+
const upstream = startUpstream("malicious");
|
|
1564
|
+
try {
|
|
1565
|
+
writeManifest(
|
|
1566
|
+
{
|
|
1567
|
+
services: [
|
|
1568
|
+
{
|
|
1569
|
+
name: "malicious",
|
|
1570
|
+
port: upstream.port,
|
|
1571
|
+
paths: ["/.well-known"],
|
|
1572
|
+
health: "/.well-known/health",
|
|
1573
|
+
version: "0.0.1",
|
|
1574
|
+
},
|
|
1575
|
+
],
|
|
1576
|
+
},
|
|
1577
|
+
h.manifestPath,
|
|
1578
|
+
);
|
|
1579
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1580
|
+
const res = await fetcher(req("/.well-known/parachute.json"));
|
|
1581
|
+
expect(res.status).toBe(200);
|
|
1582
|
+
// Hub serves the well-known doc as JSON — its body has `vaults`,
|
|
1583
|
+
// `services`, etc., not the upstream's `tag` echo.
|
|
1584
|
+
const text = await res.text();
|
|
1585
|
+
expect(text).not.toContain('"tag":"malicious"');
|
|
1586
|
+
} finally {
|
|
1587
|
+
upstream.stop();
|
|
1588
|
+
h.cleanup();
|
|
1589
|
+
}
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
test("vault entries are NOT routed via the generic dispatch (regression for #144)", async () => {
|
|
1593
|
+
// Reach hubFetch with a vault entry but a request shape that the vault
|
|
1594
|
+
// block won't match (e.g. no leading `/vault/`). The generic dispatch
|
|
1595
|
+
// must skip vault entries — confirming via findServiceUpstream-level
|
|
1596
|
+
// unit test isn't enough, we want the integration to stay coherent.
|
|
1597
|
+
const h = makeHarness();
|
|
1598
|
+
const upstream = startUpstream("vault-default");
|
|
1599
|
+
try {
|
|
1600
|
+
writeManifest(
|
|
1601
|
+
{
|
|
1602
|
+
services: [
|
|
1603
|
+
{
|
|
1604
|
+
name: "parachute-vault",
|
|
1605
|
+
port: upstream.port,
|
|
1606
|
+
paths: ["/vault/default"],
|
|
1607
|
+
health: "/vault/default/health",
|
|
1608
|
+
version: "0.4.0",
|
|
1609
|
+
},
|
|
1610
|
+
],
|
|
1611
|
+
},
|
|
1612
|
+
h.manifestPath,
|
|
1613
|
+
);
|
|
1614
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1615
|
+
// /vault/default/health goes through the vault-specific block and
|
|
1616
|
+
// proxies (still works — that's the regression check).
|
|
1617
|
+
const vaultRes = await fetcher(req("/vault/default/health"));
|
|
1618
|
+
expect(vaultRes.status).toBe(200);
|
|
1619
|
+
// /vault/default by itself is the SPA single-segment seam — it does
|
|
1620
|
+
// proxy via proxyToVault per the existing behavior.
|
|
1621
|
+
// The point of this test is the generic dispatch CANNOT mistakenly
|
|
1622
|
+
// match a vault entry. Verify by writing a request that's not under
|
|
1623
|
+
// /vault/* and confirming no fallthrough to the vault upstream.
|
|
1624
|
+
const elsewhere = await fetcher(req("/totally/not/a/vault"));
|
|
1625
|
+
expect(elsewhere.status).toBe(404);
|
|
1626
|
+
} finally {
|
|
1627
|
+
upstream.stop();
|
|
1628
|
+
h.cleanup();
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1054
1633
|
/** Find a port that no one is listening on by binding briefly and releasing. */
|
|
1055
1634
|
async function pickClosedPort(): Promise<number> {
|
|
1056
1635
|
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
|
|
@@ -130,6 +130,19 @@ describe("validateModuleManifest", () => {
|
|
|
130
130
|
const m = validateModuleManifest(VALID, "x");
|
|
131
131
|
expect(m.managementUrl).toBeUndefined();
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
test("stripPrefix accepts boolean true and false; rejects non-boolean", () => {
|
|
135
|
+
expect(validateModuleManifest({ ...VALID, stripPrefix: true }, "x").stripPrefix).toBe(true);
|
|
136
|
+
expect(validateModuleManifest({ ...VALID, stripPrefix: false }, "x").stripPrefix).toBe(false);
|
|
137
|
+
expect(() => validateModuleManifest({ ...VALID, stripPrefix: "yes" }, "x")).toThrow(
|
|
138
|
+
/stripPrefix/,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("stripPrefix absent stays absent", () => {
|
|
143
|
+
const m = validateModuleManifest(VALID, "x");
|
|
144
|
+
expect(m.stripPrefix).toBeUndefined();
|
|
145
|
+
});
|
|
133
146
|
});
|
|
134
147
|
|
|
135
148
|
describe("readModuleManifest", () => {
|
|
@@ -196,6 +196,32 @@ describe("services-manifest", () => {
|
|
|
196
196
|
cleanup();
|
|
197
197
|
}
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
test("round-trips optional stripPrefix (true and false)", () => {
|
|
201
|
+
const { path, cleanup } = makeTempPath();
|
|
202
|
+
try {
|
|
203
|
+
const stripping: ServiceEntry = { ...vault, stripPrefix: true };
|
|
204
|
+
upsertService(stripping, path);
|
|
205
|
+
expect(readManifest(path).services[0]).toEqual(stripping);
|
|
206
|
+
|
|
207
|
+
const explicitFalse: ServiceEntry = { ...vault, stripPrefix: false };
|
|
208
|
+
upsertService(explicitFalse, path);
|
|
209
|
+
expect(readManifest(path).services[0]).toEqual(explicitFalse);
|
|
210
|
+
} finally {
|
|
211
|
+
cleanup();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("rejects non-boolean stripPrefix", () => {
|
|
216
|
+
const { path, cleanup } = makeTempPath();
|
|
217
|
+
try {
|
|
218
|
+
expect(() =>
|
|
219
|
+
upsertService({ ...vault, stripPrefix: "yes" as unknown as boolean }, path),
|
|
220
|
+
).toThrow(/stripPrefix/);
|
|
221
|
+
} finally {
|
|
222
|
+
cleanup();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
199
225
|
});
|
|
200
226
|
|
|
201
227
|
describe("claw → agent migration", () => {
|
package/src/hub-server.ts
CHANGED
|
@@ -147,40 +147,39 @@ export function findVaultUpstream(
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
150
|
+
* Forward a request to a loopback service on `127.0.0.1:<port>`. By default
|
|
151
|
+
* the incoming pathname + query are preserved verbatim; pass `targetPath` to
|
|
152
|
+
* rewrite the path (e.g. when the caller has stripped a mount prefix because
|
|
153
|
+
* the backend serves bare routes). Query string is always preserved from the
|
|
154
|
+
* incoming URL.
|
|
154
155
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
156
|
+
* Note: this is **not** equivalent to the tailscale convention. `tailscale
|
|
157
|
+
* serve <mount>=<target>` strips the mount before forwarding, so
|
|
158
|
+
* `serviceProxyTarget` in `commands/expose.ts` works by making mount and
|
|
159
|
+
* target byte-equal. The hub's fetch-based proxy does no stripping unless the
|
|
160
|
+
* caller asks; per-service preferences vary (scribe wants bare paths, notes
|
|
161
|
+
* / agent / vault want the prefix), so the decision lives one layer up in
|
|
162
|
+
* `proxyToService` / `proxyToVault`.
|
|
158
163
|
*
|
|
159
|
-
* Returns
|
|
160
|
-
*
|
|
161
|
-
* the
|
|
162
|
-
*
|
|
164
|
+
* Returns 502 when the loopback fetch fails — port valid, target unreachable
|
|
165
|
+
* (service crashed, port shifted, mid-restart). `serviceLabel` is folded into
|
|
166
|
+
* the error message so 502 bodies say `vault upstream unreachable` /
|
|
167
|
+
* `scribe upstream unreachable` etc.
|
|
163
168
|
*
|
|
164
169
|
* Hop-by-hop notes: WebSocket upgrades and HTTP/2 trailers don't traverse
|
|
165
|
-
* fetch-based proxies cleanly.
|
|
166
|
-
* needs them, switch to a Node http.IncomingMessage / http.request
|
|
170
|
+
* fetch-based proxies cleanly. No on-box service uses either today; if one
|
|
171
|
+
* eventually needs them, switch to a Node http.IncomingMessage / http.request
|
|
172
|
+
* pair.
|
|
167
173
|
*/
|
|
168
|
-
async function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
|
|
175
|
-
status: 500,
|
|
176
|
-
headers: { "content-type": "application/json" },
|
|
177
|
-
});
|
|
178
|
-
}
|
|
174
|
+
async function proxyRequest(
|
|
175
|
+
req: Request,
|
|
176
|
+
port: number,
|
|
177
|
+
serviceLabel: string,
|
|
178
|
+
targetPath?: string,
|
|
179
|
+
): Promise<Response> {
|
|
179
180
|
const url = new URL(req.url);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const upstream = `http://127.0.0.1:${match.port}${url.pathname}${url.search}`;
|
|
181
|
+
const path = targetPath ?? url.pathname;
|
|
182
|
+
const upstream = `http://127.0.0.1:${port}${path}${url.search}`;
|
|
184
183
|
const headers = new Headers(req.headers);
|
|
185
184
|
// Host comes from the requester (tailnet FQDN); the loopback target wants
|
|
186
185
|
// its own. Bun's fetch fills it in when omitted.
|
|
@@ -199,13 +198,104 @@ async function proxyToVault(req: Request, manifestPath: string): Promise<Respons
|
|
|
199
198
|
return await fetch(upstream, init);
|
|
200
199
|
} catch (err) {
|
|
201
200
|
const msg = err instanceof Error ? err.message : String(err);
|
|
202
|
-
return new Response(JSON.stringify({ error:
|
|
201
|
+
return new Response(JSON.stringify({ error: `${serviceLabel} upstream unreachable: ${msg}` }), {
|
|
203
202
|
status: 502,
|
|
204
203
|
headers: { "content-type": "application/json" },
|
|
205
204
|
});
|
|
206
205
|
}
|
|
207
206
|
}
|
|
208
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Reverse-proxy a `/vault/<name>/*` request onto the vault backend.
|
|
210
|
+
* `manifestPath` is the services.json path from `HubFetchDeps`. Read on every
|
|
211
|
+
* proxied request so a vault created seconds ago is reachable without a
|
|
212
|
+
* re-expose — same dynamism as the well-known doc (#135).
|
|
213
|
+
*
|
|
214
|
+
* Returns `undefined` when no vault claims this pathname so the caller can
|
|
215
|
+
* fall through to the SPA shell fallback for unknown vault names (the seam
|
|
216
|
+
* #173 introduced).
|
|
217
|
+
*/
|
|
218
|
+
async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
|
|
219
|
+
let services: readonly ServiceEntry[];
|
|
220
|
+
try {
|
|
221
|
+
services = readManifest(manifestPath).services;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
224
|
+
return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
|
|
225
|
+
status: 500,
|
|
226
|
+
headers: { "content-type": "application/json" },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const url = new URL(req.url);
|
|
230
|
+
const match = findVaultUpstream(services, url.pathname);
|
|
231
|
+
if (!match) return undefined;
|
|
232
|
+
return proxyRequest(req, match.port, "vault");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resolve which (non-vault) ServiceEntry should handle a given request.
|
|
237
|
+
* Generic longest-prefix match across every service's `paths[]`. Vault
|
|
238
|
+
* entries are filtered out — they're routed by `findVaultUpstream` /
|
|
239
|
+
* `proxyToVault`, which encode the vault-specific SPA-fallback seam.
|
|
240
|
+
*
|
|
241
|
+
* Returns `undefined` when no service claims the pathname; the caller 404s.
|
|
242
|
+
*/
|
|
243
|
+
export function findServiceUpstream(
|
|
244
|
+
services: readonly ServiceEntry[],
|
|
245
|
+
pathname: string,
|
|
246
|
+
): { port: number; mount: string; entry: ServiceEntry } | undefined {
|
|
247
|
+
let best: { port: number; mount: string; entry: ServiceEntry } | undefined;
|
|
248
|
+
for (const s of services) {
|
|
249
|
+
if (isVaultEntry(s)) continue;
|
|
250
|
+
for (const path of s.paths) {
|
|
251
|
+
if (pathname === path || pathname.startsWith(`${path}/`)) {
|
|
252
|
+
if (!best || path.length > best.mount.length) {
|
|
253
|
+
best = { port: s.port, mount: path, entry: s };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return best;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Reverse-proxy a request onto whichever non-vault service registers a
|
|
263
|
+
* matching `paths[]` prefix in services.json. Wired after every specific
|
|
264
|
+
* handler in `hubFetch` so the exclusion list (`/`, `/admin/*`, `/oauth/*`,
|
|
265
|
+
* `/.well-known/*`, `/hub/*`, `/vault/*`, `/api/*`) is enforced by ordering:
|
|
266
|
+
* those specific handlers run first and never reach this dispatch.
|
|
267
|
+
*
|
|
268
|
+
* Read services.json on every request so a `parachute install <svc>` made
|
|
269
|
+
* seconds ago is reachable without a hub restart — same dynamism as the
|
|
270
|
+
* well-known doc and `proxyToVault`.
|
|
271
|
+
*
|
|
272
|
+
* Honors `entry.stripPrefix`: when `true` the matched mount prefix is
|
|
273
|
+
* removed from the forwarded path so the backend sees a bare route
|
|
274
|
+
* (`/scribe/health` becomes `/health`). Default (`false` / absent) forwards
|
|
275
|
+
* the full path — matches what notes / agent / vault expect.
|
|
276
|
+
*
|
|
277
|
+
* Returns `undefined` when no service claims the pathname; caller 404s.
|
|
278
|
+
*/
|
|
279
|
+
async function proxyToService(req: Request, manifestPath: string): Promise<Response | undefined> {
|
|
280
|
+
let services: readonly ServiceEntry[];
|
|
281
|
+
try {
|
|
282
|
+
services = readManifest(manifestPath).services;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
285
|
+
return new Response(JSON.stringify({ error: `service routing failed: ${msg}` }), {
|
|
286
|
+
status: 500,
|
|
287
|
+
headers: { "content-type": "application/json" },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const url = new URL(req.url);
|
|
291
|
+
const match = findServiceUpstream(services, url.pathname);
|
|
292
|
+
if (!match) return undefined;
|
|
293
|
+
const targetPath = match.entry.stripPrefix
|
|
294
|
+
? url.pathname.slice(match.mount.length) || "/"
|
|
295
|
+
: undefined;
|
|
296
|
+
return proxyRequest(req, match.port, match.entry.name, targetPath);
|
|
297
|
+
}
|
|
298
|
+
|
|
209
299
|
export interface HubFetchDeps {
|
|
210
300
|
/**
|
|
211
301
|
* Lazily opens (or returns a cached handle to) the hub DB. Optional so
|
|
@@ -690,6 +780,13 @@ export function hubFetch(
|
|
|
690
780
|
return serveSpa(spaDistDir, pathname, "/vault");
|
|
691
781
|
}
|
|
692
782
|
|
|
783
|
+
// Generic services.json-driven dispatch for non-vault modules. Reaches
|
|
784
|
+
// here only after every hub-owned prefix above has had its turn — so
|
|
785
|
+
// `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
|
|
786
|
+
// `/api/*` are excluded by ordering, not by an explicit denylist (#182).
|
|
787
|
+
const proxied = await proxyToService(req, manifestPath);
|
|
788
|
+
if (proxied) return proxied;
|
|
789
|
+
|
|
693
790
|
return new Response("not found", { status: 404 });
|
|
694
791
|
};
|
|
695
792
|
}
|
package/src/module-manifest.ts
CHANGED
|
@@ -114,6 +114,15 @@ export interface ModuleManifest {
|
|
|
114
114
|
* as `hasAuth` / `init` / `urlForEntry`.
|
|
115
115
|
*/
|
|
116
116
|
readonly managementUrl?: string;
|
|
117
|
+
/**
|
|
118
|
+
* When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
|
|
119
|
+
* before forwarding (so the backend sees `/health` rather than
|
|
120
|
+
* `/<name>/health`). Default `false` matches the prefix-aware convention
|
|
121
|
+
* notes / agent / vault already follow. Carried into services.json via
|
|
122
|
+
* `seedEntryFromManifest`. See `ServiceEntry.stripPrefix` for the full
|
|
123
|
+
* per-module rationale.
|
|
124
|
+
*/
|
|
125
|
+
readonly stripPrefix?: boolean;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
export class ModuleManifestError extends Error {
|
|
@@ -365,6 +374,13 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
|
|
|
365
374
|
const dependencies = asDependencies(m.dependencies, where);
|
|
366
375
|
const configSchema = asConfigSchema(m.configSchema, where);
|
|
367
376
|
const managementUrl = asManagementUrl(m.managementUrl, where);
|
|
377
|
+
let stripPrefix: boolean | undefined;
|
|
378
|
+
if (m.stripPrefix !== undefined) {
|
|
379
|
+
if (typeof m.stripPrefix !== "boolean") {
|
|
380
|
+
throw new ModuleManifestError(`${where}: "stripPrefix" must be a boolean if present`);
|
|
381
|
+
}
|
|
382
|
+
stripPrefix = m.stripPrefix;
|
|
383
|
+
}
|
|
368
384
|
|
|
369
385
|
const out: ModuleManifest = { name, manifestName, kind, port, paths, health };
|
|
370
386
|
if (displayName !== undefined) (out as { displayName?: string }).displayName = displayName;
|
|
@@ -380,6 +396,9 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
|
|
|
380
396
|
if (managementUrl !== undefined) {
|
|
381
397
|
(out as { managementUrl?: string }).managementUrl = managementUrl;
|
|
382
398
|
}
|
|
399
|
+
if (stripPrefix !== undefined) {
|
|
400
|
+
(out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
|
|
401
|
+
}
|
|
383
402
|
return out;
|
|
384
403
|
}
|
|
385
404
|
|
package/src/service-spec.ts
CHANGED
|
@@ -199,6 +199,7 @@ export function seedEntryFromManifest(manifest: ModuleManifest): ServiceEntry {
|
|
|
199
199
|
};
|
|
200
200
|
if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
|
|
201
201
|
if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
|
|
202
|
+
if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
|
|
202
203
|
return entry;
|
|
203
204
|
}
|
|
204
205
|
|
|
@@ -319,6 +320,13 @@ const SCRIBE_FALLBACK: FirstPartyFallback = {
|
|
|
319
320
|
paths: ["/scribe"],
|
|
320
321
|
health: "/scribe/health",
|
|
321
322
|
startCmd: ["parachute-scribe", "serve"],
|
|
323
|
+
// Scribe's HTTP routes are bare (`/health`, `/v1/...`), unlike notes /
|
|
324
|
+
// agent which strip the mount themselves. Until scribe ships a `--mount`
|
|
325
|
+
// flag (tracked upstream in parachute-scribe), the hub strips the
|
|
326
|
+
// `/scribe` prefix before forwarding so a request to
|
|
327
|
+
// `hub:1939/scribe/v1/audio/transcriptions` reaches scribe as
|
|
328
|
+
// `/v1/audio/transcriptions`.
|
|
329
|
+
stripPrefix: true,
|
|
322
330
|
},
|
|
323
331
|
extras: {
|
|
324
332
|
// No auth gate today. Scribe's launch PR adds optional SCRIBE_AUTH_TOKEN;
|
package/src/services-manifest.ts
CHANGED
|
@@ -45,6 +45,22 @@ export interface ServiceEntry {
|
|
|
45
45
|
* can use clean relative paths in their `startCmd`.
|
|
46
46
|
*/
|
|
47
47
|
installDir?: string;
|
|
48
|
+
/**
|
|
49
|
+
* When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
|
|
50
|
+
* before forwarding so the backend sees a bare path (e.g. `/health` rather
|
|
51
|
+
* than `/scribe/health`). Default `false` keeps the prefix intact, which
|
|
52
|
+
* matches what notes / agent / vault expect today.
|
|
53
|
+
*
|
|
54
|
+
* Per-module rather than uniform because conventions differ:
|
|
55
|
+
* - notes-serve.ts strips internally via `--mount`; expects the prefix.
|
|
56
|
+
* - parachute-agent reads PARACHUTE_AGENT_WEB_MOUNT and strips itself.
|
|
57
|
+
* - parachute-vault routes by `/vault/<name>/...` and expects the prefix.
|
|
58
|
+
* - parachute-scribe serves bare paths (`/health`, `/v1/...`); the proxy
|
|
59
|
+
* must strip. Eventually scribe should accept its own `--mount` flag
|
|
60
|
+
* and join the always-prefixed convention; until then this opt-in
|
|
61
|
+
* bridges the gap. Tracked in parachute-scribe (separate issue).
|
|
62
|
+
*/
|
|
63
|
+
stripPrefix?: boolean;
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
export interface ServicesManifest {
|
|
@@ -105,11 +121,16 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
105
121
|
if (installDir !== undefined && (typeof installDir !== "string" || installDir.length === 0)) {
|
|
106
122
|
throw new ServicesManifestError(`${where}: "installDir" must be a non-empty string if present`);
|
|
107
123
|
}
|
|
124
|
+
const stripPrefix = e.stripPrefix;
|
|
125
|
+
if (stripPrefix !== undefined && typeof stripPrefix !== "boolean") {
|
|
126
|
+
throw new ServicesManifestError(`${where}: "stripPrefix" must be a boolean if present`);
|
|
127
|
+
}
|
|
108
128
|
const entry: ServiceEntry = { name, port, paths: paths as string[], health, version };
|
|
109
129
|
if (displayName !== undefined) entry.displayName = displayName;
|
|
110
130
|
if (tagline !== undefined) entry.tagline = tagline;
|
|
111
131
|
if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
|
|
112
132
|
if (installDir !== undefined) entry.installDir = installDir;
|
|
133
|
+
if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
|
|
113
134
|
return entry;
|
|
114
135
|
}
|
|
115
136
|
|