@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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": {
@@ -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
- * Reverse-proxy a `/vault/<name>/*` request onto the vault backend's loopback
151
- * port. The path is preserved end-to-end (vault since paraclaw#18 expects
152
- * requests at `/vault/<name>/...` not stripped to `/...`), so the upstream URL
153
- * mirrors the incoming pathname exactly.
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
- * `manifestPath` is the services.json path from `HubFetchDeps`. Read on every
156
- * proxied request so a vault created seconds ago is reachable without a
157
- * re-expose same dynamism as the well-known doc (#135).
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 `undefined` when no vault is currently mounted at this pathname so
160
- * the caller falls through to the catch-all 404. Returns a 502 response when
161
- * the upstream connection fails (vault crashed, port shifted) the upstream
162
- * URL was valid; we just couldn't reach it.
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. Vault uses neither today; if a future service
166
- * needs them, switch to a Node http.IncomingMessage / http.request pair.
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 proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
169
- let services: readonly ServiceEntry[];
170
- try {
171
- services = readManifest(manifestPath).services;
172
- } catch (err) {
173
- const msg = err instanceof Error ? err.message : String(err);
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 match = findVaultUpstream(services, url.pathname);
181
- if (!match) return undefined;
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: `vault upstream unreachable: ${msg}` }), {
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
  }
@@ -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
 
@@ -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;
@@ -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