@openparachute/hub 0.5.1 → 0.5.7
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__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +1227 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +367 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +266 -32
- package/src/module-manifest.ts +19 -0
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +66 -13
- package/src/services-manifest.ts +83 -3
- package/src/sessions.ts +19 -0
|
@@ -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, layerOf } 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";
|
|
@@ -746,6 +746,31 @@ describe("findVaultUpstream (#144)", () => {
|
|
|
746
746
|
expect(m?.mount).toBe("/vault/inner");
|
|
747
747
|
expect(m?.port).toBe(1941);
|
|
748
748
|
});
|
|
749
|
+
|
|
750
|
+
// #197: a services.json entry written with a trailing slash on the mount
|
|
751
|
+
// path (e.g. `paths: ["/vault/default/"]`) used to only match the exact
|
|
752
|
+
// pathname `/vault/default/` and silently drop every sub-path because
|
|
753
|
+
// `pathname.startsWith("/vault/default//")` is always false. Normalize
|
|
754
|
+
// trailing slashes before comparison so sub-paths route correctly.
|
|
755
|
+
test("trailing-slash mount path matches sub-paths (#197)", () => {
|
|
756
|
+
const trailing: ServiceEntry = {
|
|
757
|
+
name: "parachute-vault",
|
|
758
|
+
port: 1940,
|
|
759
|
+
paths: ["/vault/default/"],
|
|
760
|
+
health: "/vault/default/health",
|
|
761
|
+
version: "0.4.0",
|
|
762
|
+
};
|
|
763
|
+
const exact = findVaultUpstream([trailing], "/vault/default");
|
|
764
|
+
expect(exact?.port).toBe(1940);
|
|
765
|
+
// mount is reported normalized (trailing slash stripped) so callers
|
|
766
|
+
// computing `pathname.slice(match.mount.length)` get the same answer
|
|
767
|
+
// regardless of how the entry was written on disk.
|
|
768
|
+
expect(exact?.mount).toBe("/vault/default");
|
|
769
|
+
|
|
770
|
+
const sub = findVaultUpstream([trailing], "/vault/default/notes/abc");
|
|
771
|
+
expect(sub?.port).toBe(1940);
|
|
772
|
+
expect(sub?.mount).toBe("/vault/default");
|
|
773
|
+
});
|
|
749
774
|
});
|
|
750
775
|
|
|
751
776
|
describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
@@ -1051,6 +1076,1207 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1051
1076
|
});
|
|
1052
1077
|
});
|
|
1053
1078
|
|
|
1079
|
+
describe("findServiceUpstream (#182)", () => {
|
|
1080
|
+
// Generic longest-prefix match across non-vault services.json entries. Vault
|
|
1081
|
+
// entries are filtered out — vault routing is the SPA-fallback-aware path
|
|
1082
|
+
// through findVaultUpstream / proxyToVault.
|
|
1083
|
+
|
|
1084
|
+
test("matches a non-vault entry by exact path", () => {
|
|
1085
|
+
const services: ServiceEntry[] = [
|
|
1086
|
+
{
|
|
1087
|
+
name: "scribe",
|
|
1088
|
+
port: 1942,
|
|
1089
|
+
paths: ["/scribe"],
|
|
1090
|
+
health: "/scribe/health",
|
|
1091
|
+
version: "0.1.0",
|
|
1092
|
+
},
|
|
1093
|
+
];
|
|
1094
|
+
const m = findServiceUpstream(services, "/scribe");
|
|
1095
|
+
expect(m?.port).toBe(1942);
|
|
1096
|
+
expect(m?.mount).toBe("/scribe");
|
|
1097
|
+
expect(m?.entry.name).toBe("scribe");
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test("matches a deeper subpath via prefix", () => {
|
|
1101
|
+
const services: ServiceEntry[] = [
|
|
1102
|
+
{
|
|
1103
|
+
name: "agent",
|
|
1104
|
+
port: 1943,
|
|
1105
|
+
paths: ["/agent"],
|
|
1106
|
+
health: "/agent/api/health",
|
|
1107
|
+
version: "0.1.0",
|
|
1108
|
+
},
|
|
1109
|
+
];
|
|
1110
|
+
expect(findServiceUpstream(services, "/agent/api/health")?.port).toBe(1943);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
test("ignores vault entries — those route via findVaultUpstream", () => {
|
|
1114
|
+
const services: ServiceEntry[] = [
|
|
1115
|
+
{
|
|
1116
|
+
name: "parachute-vault",
|
|
1117
|
+
port: 1940,
|
|
1118
|
+
paths: ["/vault/default"],
|
|
1119
|
+
health: "/vault/default/health",
|
|
1120
|
+
version: "0.4.0",
|
|
1121
|
+
},
|
|
1122
|
+
];
|
|
1123
|
+
expect(findServiceUpstream(services, "/vault/default/health")).toBeUndefined();
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test("returns undefined when no service claims the path", () => {
|
|
1127
|
+
const services: ServiceEntry[] = [
|
|
1128
|
+
{
|
|
1129
|
+
name: "scribe",
|
|
1130
|
+
port: 1942,
|
|
1131
|
+
paths: ["/scribe"],
|
|
1132
|
+
health: "/scribe/health",
|
|
1133
|
+
version: "0.1.0",
|
|
1134
|
+
},
|
|
1135
|
+
];
|
|
1136
|
+
expect(findServiceUpstream(services, "/unknown/foo")).toBeUndefined();
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test("longest-prefix wins when multiple paths could match", () => {
|
|
1140
|
+
// A service registering `/api` and another (older / catch-all) registering
|
|
1141
|
+
// `/` would conflict on every request — longest mount wins so the more
|
|
1142
|
+
// specific one takes precedence.
|
|
1143
|
+
const services: ServiceEntry[] = [
|
|
1144
|
+
{ name: "wide", port: 1950, paths: ["/api"], health: "/api/health", version: "0.1.0" },
|
|
1145
|
+
{
|
|
1146
|
+
name: "deeper",
|
|
1147
|
+
port: 1951,
|
|
1148
|
+
paths: ["/api/v2"],
|
|
1149
|
+
health: "/api/v2/health",
|
|
1150
|
+
version: "0.1.0",
|
|
1151
|
+
},
|
|
1152
|
+
];
|
|
1153
|
+
expect(findServiceUpstream(services, "/api/v2/things")?.port).toBe(1951);
|
|
1154
|
+
expect(findServiceUpstream(services, "/api/v1/things")?.port).toBe(1950);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("does not match a sibling that shares a prefix without a slash boundary", () => {
|
|
1158
|
+
// `/scribe-admin` must NOT match a service mounted at `/scribe`. The
|
|
1159
|
+
// boundary check is `pathname === path || pathname.startsWith(path + '/')`.
|
|
1160
|
+
const services: ServiceEntry[] = [
|
|
1161
|
+
{
|
|
1162
|
+
name: "scribe",
|
|
1163
|
+
port: 1942,
|
|
1164
|
+
paths: ["/scribe"],
|
|
1165
|
+
health: "/scribe/health",
|
|
1166
|
+
version: "0.1.0",
|
|
1167
|
+
},
|
|
1168
|
+
];
|
|
1169
|
+
expect(findServiceUpstream(services, "/scribe-admin")).toBeUndefined();
|
|
1170
|
+
expect(findServiceUpstream(services, "/scribe-admin/foo")).toBeUndefined();
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
// #197: a services.json entry written with a trailing slash on the mount
|
|
1174
|
+
// path (e.g. `paths: ["/notes/"]`) used to only match the exact pathname
|
|
1175
|
+
// `/notes/` and silently drop every sub-path because
|
|
1176
|
+
// `pathname.startsWith("/notes//")` is always false. Notes blank-screen
|
|
1177
|
+
// on Aaron's box (2026-05-08) was the operator-visible symptom: the SPA
|
|
1178
|
+
// shell loaded but every `/notes/assets/*.js` 404'd. Normalize trailing
|
|
1179
|
+
// slashes before comparison.
|
|
1180
|
+
test("trailing-slash mount path matches sub-paths (#197)", () => {
|
|
1181
|
+
const services: ServiceEntry[] = [
|
|
1182
|
+
{
|
|
1183
|
+
name: "parachute-notes",
|
|
1184
|
+
port: 1942,
|
|
1185
|
+
paths: ["/notes/"],
|
|
1186
|
+
health: "/notes/health",
|
|
1187
|
+
version: "0.1.0",
|
|
1188
|
+
},
|
|
1189
|
+
];
|
|
1190
|
+
const exact = findServiceUpstream(services, "/notes");
|
|
1191
|
+
expect(exact?.port).toBe(1942);
|
|
1192
|
+
// mount is reported normalized (trailing slash stripped) so callers
|
|
1193
|
+
// computing `pathname.slice(match.mount.length)` (the stripPrefix path)
|
|
1194
|
+
// get the same answer regardless of how the entry was written on disk.
|
|
1195
|
+
expect(exact?.mount).toBe("/notes");
|
|
1196
|
+
|
|
1197
|
+
const asset = findServiceUpstream(services, "/notes/assets/index-XXX.js");
|
|
1198
|
+
expect(asset?.port).toBe(1942);
|
|
1199
|
+
expect(asset?.mount).toBe("/notes");
|
|
1200
|
+
expect(asset?.entry.name).toBe("parachute-notes");
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
test('mount path "/" survives normalization without collapsing to empty string (#197)', () => {
|
|
1204
|
+
// Edge case: `"/".replace(/\/+$/, "")` yields the empty string; the
|
|
1205
|
+
// `|| "/"` branch keeps it stable so an exact-`/` request still matches.
|
|
1206
|
+
// Pre-fix this branch wasn't reachable (legacy `paths: ["/"]` entries
|
|
1207
|
+
// are already remapped to `/<shortname>` in-memory by services-manifest;
|
|
1208
|
+
// the test pins the lookup-level behavior so a future regression in the
|
|
1209
|
+
// remap layer doesn't silently 404 every catchall request).
|
|
1210
|
+
//
|
|
1211
|
+
// Sub-path matching for `/`-mounted entries is intentionally not asserted
|
|
1212
|
+
// here — that would change the existing "exact match only" behavior
|
|
1213
|
+
// captured in `pathname === path || pathname.startsWith(path + '/')`,
|
|
1214
|
+
// which never matched `/anything` when `path === "/"` (since `"//"` is
|
|
1215
|
+
// not a real URL prefix).
|
|
1216
|
+
const services: ServiceEntry[] = [
|
|
1217
|
+
{
|
|
1218
|
+
name: "catchall",
|
|
1219
|
+
port: 1950,
|
|
1220
|
+
paths: ["/"],
|
|
1221
|
+
health: "/health",
|
|
1222
|
+
version: "0.1.0",
|
|
1223
|
+
},
|
|
1224
|
+
];
|
|
1225
|
+
expect(findServiceUpstream(services, "/")?.port).toBe(1950);
|
|
1226
|
+
expect(findServiceUpstream(services, "/")?.mount).toBe("/");
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
1231
|
+
// hub#182: services.json-driven dispatch for non-vault modules. Lets
|
|
1232
|
+
// `parachute install <svc>` reach the on-box hub at hub:1939/<svc>/* with
|
|
1233
|
+
// no per-service codepath. Vault keeps its own routing for the SPA seam.
|
|
1234
|
+
|
|
1235
|
+
function startUpstream(replyTag: string): { port: number; stop: () => void } {
|
|
1236
|
+
const server = Bun.serve({
|
|
1237
|
+
port: 0,
|
|
1238
|
+
hostname: "127.0.0.1",
|
|
1239
|
+
fetch: async (req) => {
|
|
1240
|
+
const u = new URL(req.url);
|
|
1241
|
+
const body = req.body ? await req.text() : "";
|
|
1242
|
+
return new Response(
|
|
1243
|
+
JSON.stringify({
|
|
1244
|
+
tag: replyTag,
|
|
1245
|
+
method: req.method,
|
|
1246
|
+
pathname: u.pathname,
|
|
1247
|
+
search: u.search,
|
|
1248
|
+
authorization: req.headers.get("authorization") ?? "",
|
|
1249
|
+
contentType: req.headers.get("content-type") ?? "",
|
|
1250
|
+
body,
|
|
1251
|
+
}),
|
|
1252
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1253
|
+
);
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
test("routes /scribe/health to the matching upstream, path preserved", async () => {
|
|
1260
|
+
const h = makeHarness();
|
|
1261
|
+
const upstream = startUpstream("scribe");
|
|
1262
|
+
try {
|
|
1263
|
+
writeManifest(
|
|
1264
|
+
{
|
|
1265
|
+
services: [
|
|
1266
|
+
{
|
|
1267
|
+
name: "scribe",
|
|
1268
|
+
port: upstream.port,
|
|
1269
|
+
paths: ["/scribe"],
|
|
1270
|
+
health: "/scribe/health",
|
|
1271
|
+
version: "0.1.0",
|
|
1272
|
+
},
|
|
1273
|
+
],
|
|
1274
|
+
},
|
|
1275
|
+
h.manifestPath,
|
|
1276
|
+
);
|
|
1277
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1278
|
+
const res = await fetcher(req("/scribe/health"));
|
|
1279
|
+
expect(res.status).toBe(200);
|
|
1280
|
+
const body = (await res.json()) as { tag: string; pathname: string };
|
|
1281
|
+
expect(body.tag).toBe("scribe");
|
|
1282
|
+
// Path-preservation convention: backend sees the full mount-prefixed
|
|
1283
|
+
// path, matching `serviceProxyTarget` in commands/expose.ts.
|
|
1284
|
+
expect(body.pathname).toBe("/scribe/health");
|
|
1285
|
+
} finally {
|
|
1286
|
+
upstream.stop();
|
|
1287
|
+
h.cleanup();
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
test("routes /notes/sw.js to the matching upstream", async () => {
|
|
1292
|
+
// Notes is the canonical path-mount case — the PWA shell has to see the
|
|
1293
|
+
// full `/notes/...` path so its service worker registers correctly (the
|
|
1294
|
+
// motivator for the `--mount` strip in notes-serve.ts).
|
|
1295
|
+
const h = makeHarness();
|
|
1296
|
+
const upstream = startUpstream("notes");
|
|
1297
|
+
try {
|
|
1298
|
+
writeManifest(
|
|
1299
|
+
{
|
|
1300
|
+
services: [
|
|
1301
|
+
{
|
|
1302
|
+
name: "notes",
|
|
1303
|
+
port: upstream.port,
|
|
1304
|
+
paths: ["/notes"],
|
|
1305
|
+
health: "/notes/health",
|
|
1306
|
+
version: "0.1.0",
|
|
1307
|
+
},
|
|
1308
|
+
],
|
|
1309
|
+
},
|
|
1310
|
+
h.manifestPath,
|
|
1311
|
+
);
|
|
1312
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1313
|
+
const res = await fetcher(req("/notes/sw.js"));
|
|
1314
|
+
expect(res.status).toBe(200);
|
|
1315
|
+
const body = (await res.json()) as { tag: string; pathname: string };
|
|
1316
|
+
expect(body.tag).toBe("notes");
|
|
1317
|
+
expect(body.pathname).toBe("/notes/sw.js");
|
|
1318
|
+
} finally {
|
|
1319
|
+
upstream.stop();
|
|
1320
|
+
h.cleanup();
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
test("routes a deep /agent/api/health to the matching upstream", async () => {
|
|
1325
|
+
// Agent registers `/agent`; deeper paths route by prefix.
|
|
1326
|
+
const h = makeHarness();
|
|
1327
|
+
const upstream = startUpstream("agent");
|
|
1328
|
+
try {
|
|
1329
|
+
writeManifest(
|
|
1330
|
+
{
|
|
1331
|
+
services: [
|
|
1332
|
+
{
|
|
1333
|
+
name: "agent",
|
|
1334
|
+
port: upstream.port,
|
|
1335
|
+
paths: ["/agent"],
|
|
1336
|
+
health: "/agent/api/health",
|
|
1337
|
+
version: "0.1.0",
|
|
1338
|
+
},
|
|
1339
|
+
],
|
|
1340
|
+
},
|
|
1341
|
+
h.manifestPath,
|
|
1342
|
+
);
|
|
1343
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1344
|
+
const res = await fetcher(req("/agent/api/health?probe=1"));
|
|
1345
|
+
expect(res.status).toBe(200);
|
|
1346
|
+
const body = (await res.json()) as { tag: string; pathname: string; search: string };
|
|
1347
|
+
expect(body.tag).toBe("agent");
|
|
1348
|
+
expect(body.pathname).toBe("/agent/api/health");
|
|
1349
|
+
expect(body.search).toBe("?probe=1");
|
|
1350
|
+
} finally {
|
|
1351
|
+
upstream.stop();
|
|
1352
|
+
h.cleanup();
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
test("preserves method, multipart body, and Authorization on POSTs", async () => {
|
|
1357
|
+
// Scribe-shaped upload: multipart/form-data with a bearer token. Multipart
|
|
1358
|
+
// is what real scribe clients send; if the proxy strips the boundary or
|
|
1359
|
+
// drops Authorization, scribe rejects the request before transcribing.
|
|
1360
|
+
const h = makeHarness();
|
|
1361
|
+
const upstream = startUpstream("scribe");
|
|
1362
|
+
try {
|
|
1363
|
+
writeManifest(
|
|
1364
|
+
{
|
|
1365
|
+
services: [
|
|
1366
|
+
{
|
|
1367
|
+
name: "scribe",
|
|
1368
|
+
port: upstream.port,
|
|
1369
|
+
paths: ["/scribe"],
|
|
1370
|
+
health: "/scribe/health",
|
|
1371
|
+
version: "0.1.0",
|
|
1372
|
+
},
|
|
1373
|
+
],
|
|
1374
|
+
},
|
|
1375
|
+
h.manifestPath,
|
|
1376
|
+
);
|
|
1377
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1378
|
+
const form = new FormData();
|
|
1379
|
+
form.append("model", "whisper-1");
|
|
1380
|
+
form.append("file", new Blob([new Uint8Array([1, 2, 3, 4])]), "audio.wav");
|
|
1381
|
+
const res = await fetcher(
|
|
1382
|
+
req("/scribe/v1/audio/transcriptions", {
|
|
1383
|
+
method: "POST",
|
|
1384
|
+
headers: { authorization: "Bearer test-token" },
|
|
1385
|
+
body: form,
|
|
1386
|
+
}),
|
|
1387
|
+
);
|
|
1388
|
+
expect(res.status).toBe(200);
|
|
1389
|
+
const body = (await res.json()) as {
|
|
1390
|
+
method: string;
|
|
1391
|
+
authorization: string;
|
|
1392
|
+
contentType: string;
|
|
1393
|
+
body: string;
|
|
1394
|
+
};
|
|
1395
|
+
expect(body.method).toBe("POST");
|
|
1396
|
+
expect(body.authorization).toBe("Bearer test-token");
|
|
1397
|
+
// Bun's fetch sets the boundary; we just need to confirm the
|
|
1398
|
+
// multipart content-type survived.
|
|
1399
|
+
expect(body.contentType).toMatch(/^multipart\/form-data;\s*boundary=/);
|
|
1400
|
+
// And the body bytes — the boundary marker the upstream echoes back
|
|
1401
|
+
// should contain the form fields we sent.
|
|
1402
|
+
expect(body.body).toContain('name="model"');
|
|
1403
|
+
expect(body.body).toContain("whisper-1");
|
|
1404
|
+
expect(body.body).toContain('name="file"');
|
|
1405
|
+
} finally {
|
|
1406
|
+
upstream.stop();
|
|
1407
|
+
h.cleanup();
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
test("stripPrefix=true forwards the bare path (mount removed)", async () => {
|
|
1412
|
+
// The scribe-shaped case from real life: scribe's HTTP routes are
|
|
1413
|
+
// `/health`, `/v1/...` — no `/scribe` prefix. When the entry sets
|
|
1414
|
+
// stripPrefix:true the hub strips the mount before forwarding so the
|
|
1415
|
+
// backend sees `/health` rather than `/scribe/health`. Without this,
|
|
1416
|
+
// every proxied scribe request 404s at the backend.
|
|
1417
|
+
const h = makeHarness();
|
|
1418
|
+
const upstream = startUpstream("scribe");
|
|
1419
|
+
try {
|
|
1420
|
+
writeManifest(
|
|
1421
|
+
{
|
|
1422
|
+
services: [
|
|
1423
|
+
{
|
|
1424
|
+
name: "scribe",
|
|
1425
|
+
port: upstream.port,
|
|
1426
|
+
paths: ["/scribe"],
|
|
1427
|
+
health: "/scribe/health",
|
|
1428
|
+
version: "0.1.0",
|
|
1429
|
+
stripPrefix: true,
|
|
1430
|
+
},
|
|
1431
|
+
],
|
|
1432
|
+
},
|
|
1433
|
+
h.manifestPath,
|
|
1434
|
+
);
|
|
1435
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1436
|
+
const res = await fetcher(req("/scribe/v1/audio/transcriptions?model=whisper-1"));
|
|
1437
|
+
expect(res.status).toBe(200);
|
|
1438
|
+
const body = (await res.json()) as { tag: string; pathname: string; search: string };
|
|
1439
|
+
expect(body.tag).toBe("scribe");
|
|
1440
|
+
// Backend sees the bare path — `/scribe` is stripped.
|
|
1441
|
+
expect(body.pathname).toBe("/v1/audio/transcriptions");
|
|
1442
|
+
// Query string is always preserved verbatim regardless of stripPrefix.
|
|
1443
|
+
expect(body.search).toBe("?model=whisper-1");
|
|
1444
|
+
} finally {
|
|
1445
|
+
upstream.stop();
|
|
1446
|
+
h.cleanup();
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
test("stripPrefix=true: request to bare mount becomes `/`", async () => {
|
|
1451
|
+
// Edge case: pathname === mount. Slicing yields the empty string; the
|
|
1452
|
+
// proxy normalizes to `/` so the backend sees a valid path.
|
|
1453
|
+
const h = makeHarness();
|
|
1454
|
+
const upstream = startUpstream("scribe");
|
|
1455
|
+
try {
|
|
1456
|
+
writeManifest(
|
|
1457
|
+
{
|
|
1458
|
+
services: [
|
|
1459
|
+
{
|
|
1460
|
+
name: "scribe",
|
|
1461
|
+
port: upstream.port,
|
|
1462
|
+
paths: ["/scribe"],
|
|
1463
|
+
health: "/health",
|
|
1464
|
+
version: "0.1.0",
|
|
1465
|
+
stripPrefix: true,
|
|
1466
|
+
},
|
|
1467
|
+
],
|
|
1468
|
+
},
|
|
1469
|
+
h.manifestPath,
|
|
1470
|
+
);
|
|
1471
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1472
|
+
const res = await fetcher(req("/scribe"));
|
|
1473
|
+
expect(res.status).toBe(200);
|
|
1474
|
+
const body = (await res.json()) as { pathname: string };
|
|
1475
|
+
expect(body.pathname).toBe("/");
|
|
1476
|
+
} finally {
|
|
1477
|
+
upstream.stop();
|
|
1478
|
+
h.cleanup();
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
test("stripPrefix absent (default false) preserves the prefix — no behavior change for existing entries", async () => {
|
|
1483
|
+
// Migration safety: a services.json entry written before stripPrefix
|
|
1484
|
+
// existed (e.g. notes / agent rows already on disk) must continue to
|
|
1485
|
+
// forward the full path. The /notes/sw.js test above already exercises
|
|
1486
|
+
// this in the happy case; this test makes the absence-of-flag → keep-
|
|
1487
|
+
// prefix contract explicit.
|
|
1488
|
+
const h = makeHarness();
|
|
1489
|
+
const upstream = startUpstream("notes");
|
|
1490
|
+
try {
|
|
1491
|
+
writeManifest(
|
|
1492
|
+
{
|
|
1493
|
+
services: [
|
|
1494
|
+
{
|
|
1495
|
+
name: "notes",
|
|
1496
|
+
port: upstream.port,
|
|
1497
|
+
paths: ["/notes"],
|
|
1498
|
+
health: "/notes/health",
|
|
1499
|
+
version: "0.1.0",
|
|
1500
|
+
// stripPrefix intentionally omitted — must default to false.
|
|
1501
|
+
},
|
|
1502
|
+
],
|
|
1503
|
+
},
|
|
1504
|
+
h.manifestPath,
|
|
1505
|
+
);
|
|
1506
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1507
|
+
const res = await fetcher(req("/notes/health"));
|
|
1508
|
+
expect(res.status).toBe(200);
|
|
1509
|
+
const body = (await res.json()) as { pathname: string };
|
|
1510
|
+
expect(body.pathname).toBe("/notes/health");
|
|
1511
|
+
} finally {
|
|
1512
|
+
upstream.stop();
|
|
1513
|
+
h.cleanup();
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
test("stripPrefix=false explicitly preserves the prefix", async () => {
|
|
1518
|
+
// The opposite explicit-declaration of the previous test: an operator
|
|
1519
|
+
// who writes `stripPrefix: false` in services.json gets the same
|
|
1520
|
+
// keep-prefix behavior as omitting the field. Confirms validator round-
|
|
1521
|
+
// tripping doesn't lose the explicit-false (separate from the absent
|
|
1522
|
+
// case which is checked above).
|
|
1523
|
+
const h = makeHarness();
|
|
1524
|
+
const upstream = startUpstream("notes");
|
|
1525
|
+
try {
|
|
1526
|
+
writeManifest(
|
|
1527
|
+
{
|
|
1528
|
+
services: [
|
|
1529
|
+
{
|
|
1530
|
+
name: "notes",
|
|
1531
|
+
port: upstream.port,
|
|
1532
|
+
paths: ["/notes"],
|
|
1533
|
+
health: "/notes/health",
|
|
1534
|
+
version: "0.1.0",
|
|
1535
|
+
stripPrefix: false,
|
|
1536
|
+
},
|
|
1537
|
+
],
|
|
1538
|
+
},
|
|
1539
|
+
h.manifestPath,
|
|
1540
|
+
);
|
|
1541
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1542
|
+
const res = await fetcher(req("/notes/sw.js"));
|
|
1543
|
+
expect(res.status).toBe(200);
|
|
1544
|
+
const body = (await res.json()) as { pathname: string };
|
|
1545
|
+
expect(body.pathname).toBe("/notes/sw.js");
|
|
1546
|
+
} finally {
|
|
1547
|
+
upstream.stop();
|
|
1548
|
+
h.cleanup();
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
test("unknown /<svc>/* path returns 404", async () => {
|
|
1553
|
+
const h = makeHarness();
|
|
1554
|
+
const upstream = startUpstream("scribe");
|
|
1555
|
+
try {
|
|
1556
|
+
writeManifest(
|
|
1557
|
+
{
|
|
1558
|
+
services: [
|
|
1559
|
+
{
|
|
1560
|
+
name: "scribe",
|
|
1561
|
+
port: upstream.port,
|
|
1562
|
+
paths: ["/scribe"],
|
|
1563
|
+
health: "/scribe/health",
|
|
1564
|
+
version: "0.1.0",
|
|
1565
|
+
},
|
|
1566
|
+
],
|
|
1567
|
+
},
|
|
1568
|
+
h.manifestPath,
|
|
1569
|
+
);
|
|
1570
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1571
|
+
const res = await fetcher(req("/notinstalled/foo"));
|
|
1572
|
+
expect(res.status).toBe(404);
|
|
1573
|
+
} finally {
|
|
1574
|
+
upstream.stop();
|
|
1575
|
+
h.cleanup();
|
|
1576
|
+
}
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
test("returns 502 when the matching upstream is unreachable", async () => {
|
|
1580
|
+
// Service is in services.json but the port has nothing listening — same
|
|
1581
|
+
// shape as the vault-unreachable test, label is the entry's `name`.
|
|
1582
|
+
const h = makeHarness();
|
|
1583
|
+
try {
|
|
1584
|
+
writeManifest(
|
|
1585
|
+
{
|
|
1586
|
+
services: [
|
|
1587
|
+
{
|
|
1588
|
+
name: "scribe",
|
|
1589
|
+
port: await pickClosedPort(),
|
|
1590
|
+
paths: ["/scribe"],
|
|
1591
|
+
health: "/scribe/health",
|
|
1592
|
+
version: "0.1.0",
|
|
1593
|
+
},
|
|
1594
|
+
],
|
|
1595
|
+
},
|
|
1596
|
+
h.manifestPath,
|
|
1597
|
+
);
|
|
1598
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1599
|
+
const res = await fetcher(req("/scribe/health"));
|
|
1600
|
+
expect(res.status).toBe(502);
|
|
1601
|
+
const body = (await res.json()) as { error: string };
|
|
1602
|
+
expect(body.error).toContain("scribe upstream unreachable");
|
|
1603
|
+
} finally {
|
|
1604
|
+
h.cleanup();
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
test("/oauth/authorize is hub-handled, never reaches service dispatch", async () => {
|
|
1609
|
+
// Even if a (misbehaving) service registers `/oauth`, the hub's own
|
|
1610
|
+
// /oauth/* handlers run first by virtue of dispatch ordering. We don't
|
|
1611
|
+
// need an explicit denylist — ordering enforces it.
|
|
1612
|
+
const h = makeHarness();
|
|
1613
|
+
const upstream = startUpstream("malicious");
|
|
1614
|
+
try {
|
|
1615
|
+
writeManifest(
|
|
1616
|
+
{
|
|
1617
|
+
services: [
|
|
1618
|
+
{
|
|
1619
|
+
name: "malicious",
|
|
1620
|
+
port: upstream.port,
|
|
1621
|
+
paths: ["/oauth"],
|
|
1622
|
+
health: "/oauth/health",
|
|
1623
|
+
version: "0.0.1",
|
|
1624
|
+
},
|
|
1625
|
+
],
|
|
1626
|
+
},
|
|
1627
|
+
h.manifestPath,
|
|
1628
|
+
);
|
|
1629
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1630
|
+
const res = await fetcher(req("/oauth/authorize"));
|
|
1631
|
+
// Hub's own /oauth/authorize handler responds (likely a redirect or
|
|
1632
|
+
// error page rendering) — we just need to verify the upstream was NOT
|
|
1633
|
+
// reached, i.e. `tag: "malicious"` is not in the body.
|
|
1634
|
+
const text = await res.text();
|
|
1635
|
+
expect(text).not.toContain('"tag":"malicious"');
|
|
1636
|
+
} finally {
|
|
1637
|
+
upstream.stop();
|
|
1638
|
+
h.cleanup();
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
test("/.well-known/parachute.json is hub-handled, never reaches service dispatch", async () => {
|
|
1643
|
+
const h = makeHarness();
|
|
1644
|
+
const upstream = startUpstream("malicious");
|
|
1645
|
+
try {
|
|
1646
|
+
writeManifest(
|
|
1647
|
+
{
|
|
1648
|
+
services: [
|
|
1649
|
+
{
|
|
1650
|
+
name: "malicious",
|
|
1651
|
+
port: upstream.port,
|
|
1652
|
+
paths: ["/.well-known"],
|
|
1653
|
+
health: "/.well-known/health",
|
|
1654
|
+
version: "0.0.1",
|
|
1655
|
+
},
|
|
1656
|
+
],
|
|
1657
|
+
},
|
|
1658
|
+
h.manifestPath,
|
|
1659
|
+
);
|
|
1660
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1661
|
+
const res = await fetcher(req("/.well-known/parachute.json"));
|
|
1662
|
+
expect(res.status).toBe(200);
|
|
1663
|
+
// Hub serves the well-known doc as JSON — its body has `vaults`,
|
|
1664
|
+
// `services`, etc., not the upstream's `tag` echo.
|
|
1665
|
+
const text = await res.text();
|
|
1666
|
+
expect(text).not.toContain('"tag":"malicious"');
|
|
1667
|
+
} finally {
|
|
1668
|
+
upstream.stop();
|
|
1669
|
+
h.cleanup();
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
test("vault entries are NOT routed via the generic dispatch (regression for #144)", async () => {
|
|
1674
|
+
// Reach hubFetch with a vault entry but a request shape that the vault
|
|
1675
|
+
// block won't match (e.g. no leading `/vault/`). The generic dispatch
|
|
1676
|
+
// must skip vault entries — confirming via findServiceUpstream-level
|
|
1677
|
+
// unit test isn't enough, we want the integration to stay coherent.
|
|
1678
|
+
const h = makeHarness();
|
|
1679
|
+
const upstream = startUpstream("vault-default");
|
|
1680
|
+
try {
|
|
1681
|
+
writeManifest(
|
|
1682
|
+
{
|
|
1683
|
+
services: [
|
|
1684
|
+
{
|
|
1685
|
+
name: "parachute-vault",
|
|
1686
|
+
port: upstream.port,
|
|
1687
|
+
paths: ["/vault/default"],
|
|
1688
|
+
health: "/vault/default/health",
|
|
1689
|
+
version: "0.4.0",
|
|
1690
|
+
},
|
|
1691
|
+
],
|
|
1692
|
+
},
|
|
1693
|
+
h.manifestPath,
|
|
1694
|
+
);
|
|
1695
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1696
|
+
// /vault/default/health goes through the vault-specific block and
|
|
1697
|
+
// proxies (still works — that's the regression check).
|
|
1698
|
+
const vaultRes = await fetcher(req("/vault/default/health"));
|
|
1699
|
+
expect(vaultRes.status).toBe(200);
|
|
1700
|
+
// /vault/default by itself is the SPA single-segment seam — it does
|
|
1701
|
+
// proxy via proxyToVault per the existing behavior.
|
|
1702
|
+
// The point of this test is the generic dispatch CANNOT mistakenly
|
|
1703
|
+
// match a vault entry. Verify by writing a request that's not under
|
|
1704
|
+
// /vault/* and confirming no fallthrough to the vault upstream.
|
|
1705
|
+
const elsewhere = await fetcher(req("/totally/not/a/vault"));
|
|
1706
|
+
expect(elsewhere.status).toBe(404);
|
|
1707
|
+
} finally {
|
|
1708
|
+
upstream.stop();
|
|
1709
|
+
h.cleanup();
|
|
1710
|
+
}
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
test("trailing-slash entry routes sub-paths end-to-end (#197)", async () => {
|
|
1714
|
+
// Operator-symptom regression: notes blank-screen on Aaron's box
|
|
1715
|
+
// (2026-05-08). services.json had `paths: ["/notes/"]` (trailing slash),
|
|
1716
|
+
// which used to make the matcher return undefined for every sub-path
|
|
1717
|
+
// because `pathname.startsWith("/notes//")` is always false. Hub
|
|
1718
|
+
// returned 404 for `/notes/assets/*.js` even though the SPA shell
|
|
1719
|
+
// loaded fine, breaking the page silently.
|
|
1720
|
+
const h = makeHarness();
|
|
1721
|
+
const upstream = startUpstream("notes");
|
|
1722
|
+
try {
|
|
1723
|
+
writeManifest(
|
|
1724
|
+
{
|
|
1725
|
+
services: [
|
|
1726
|
+
{
|
|
1727
|
+
name: "parachute-notes",
|
|
1728
|
+
port: upstream.port,
|
|
1729
|
+
paths: ["/notes/"],
|
|
1730
|
+
health: "/notes/health",
|
|
1731
|
+
version: "0.1.0",
|
|
1732
|
+
},
|
|
1733
|
+
],
|
|
1734
|
+
},
|
|
1735
|
+
h.manifestPath,
|
|
1736
|
+
);
|
|
1737
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1738
|
+
const res = await fetcher(req("/notes/assets/index-XXX.js"));
|
|
1739
|
+
expect(res.status).toBe(200);
|
|
1740
|
+
const body = (await res.json()) as { tag: string; pathname: string };
|
|
1741
|
+
expect(body.tag).toBe("notes");
|
|
1742
|
+
// Path is forwarded verbatim — no stripPrefix on the notes entry, so
|
|
1743
|
+
// backend sees the full mount-prefixed path.
|
|
1744
|
+
expect(body.pathname).toBe("/notes/assets/index-XXX.js");
|
|
1745
|
+
} finally {
|
|
1746
|
+
upstream.stop();
|
|
1747
|
+
h.cleanup();
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
test("FIRST_PARTY_FALLBACKS supplies stripPrefix when entry omits it (#196)", async () => {
|
|
1752
|
+
// Operator-symptom regression: scribe `/scribe/health` 404 on Aaron's
|
|
1753
|
+
// box (2026-05-08). Scribe v0.4.0 doesn't write `stripPrefix: true` to
|
|
1754
|
+
// its services.json entry; the declaration only lives in hub's
|
|
1755
|
+
// SCRIBE_FALLBACK manifest. Pre-#187 this didn't matter because the
|
|
1756
|
+
// per-service `tailscale serve` plan baked the path into the target
|
|
1757
|
+
// URL; post-#187 routing went through hub which wasn't consulting the
|
|
1758
|
+
// fallback registry. Result: hub forwarded `/scribe/health` verbatim
|
|
1759
|
+
// to scribe at :1943, scribe served bare paths and 404'd. Fix: hub-
|
|
1760
|
+
// side fallback merge in `stripPrefixFor`.
|
|
1761
|
+
//
|
|
1762
|
+
// Use a `parachute-scribe` manifestName so `shortNameForManifest`
|
|
1763
|
+
// resolves to "scribe" → SCRIBE_FALLBACK (which declares
|
|
1764
|
+
// `stripPrefix: true`). The entry itself omits stripPrefix to mirror
|
|
1765
|
+
// what scribe v0.4.0 actually writes today.
|
|
1766
|
+
const h = makeHarness();
|
|
1767
|
+
const upstream = startUpstream("scribe");
|
|
1768
|
+
try {
|
|
1769
|
+
writeManifest(
|
|
1770
|
+
{
|
|
1771
|
+
services: [
|
|
1772
|
+
{
|
|
1773
|
+
name: "parachute-scribe",
|
|
1774
|
+
port: upstream.port,
|
|
1775
|
+
paths: ["/scribe"],
|
|
1776
|
+
health: "/scribe/health",
|
|
1777
|
+
version: "0.4.0",
|
|
1778
|
+
// stripPrefix intentionally omitted — must be derived from
|
|
1779
|
+
// FIRST_PARTY_FALLBACKS.scribe.manifest.stripPrefix.
|
|
1780
|
+
},
|
|
1781
|
+
],
|
|
1782
|
+
},
|
|
1783
|
+
h.manifestPath,
|
|
1784
|
+
);
|
|
1785
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1786
|
+
const res = await fetcher(req("/scribe/health"));
|
|
1787
|
+
expect(res.status).toBe(200);
|
|
1788
|
+
const body = (await res.json()) as { tag: string; pathname: string };
|
|
1789
|
+
expect(body.tag).toBe("scribe");
|
|
1790
|
+
// The mount prefix is stripped — backend sees the bare `/health`
|
|
1791
|
+
// route that scribe v0.4.0 actually serves.
|
|
1792
|
+
expect(body.pathname).toBe("/health");
|
|
1793
|
+
} finally {
|
|
1794
|
+
upstream.stop();
|
|
1795
|
+
h.cleanup();
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
test("explicit stripPrefix:false on entry overrides FIRST_PARTY_FALLBACKS (#196)", async () => {
|
|
1800
|
+
// Explicit-on-entry must win, even when the fallback would default to
|
|
1801
|
+
// stripping. Documents the precedence ordering: explicit > fallback >
|
|
1802
|
+
// false. Without this, an operator who deliberately writes
|
|
1803
|
+
// `"stripPrefix": false` couldn't opt out of the fallback's strip.
|
|
1804
|
+
const h = makeHarness();
|
|
1805
|
+
const upstream = startUpstream("scribe");
|
|
1806
|
+
try {
|
|
1807
|
+
writeManifest(
|
|
1808
|
+
{
|
|
1809
|
+
services: [
|
|
1810
|
+
{
|
|
1811
|
+
name: "parachute-scribe",
|
|
1812
|
+
port: upstream.port,
|
|
1813
|
+
paths: ["/scribe"],
|
|
1814
|
+
health: "/scribe/health",
|
|
1815
|
+
version: "0.4.0",
|
|
1816
|
+
stripPrefix: false,
|
|
1817
|
+
},
|
|
1818
|
+
],
|
|
1819
|
+
},
|
|
1820
|
+
h.manifestPath,
|
|
1821
|
+
);
|
|
1822
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1823
|
+
const res = await fetcher(req("/scribe/health"));
|
|
1824
|
+
expect(res.status).toBe(200);
|
|
1825
|
+
const body = (await res.json()) as { pathname: string };
|
|
1826
|
+
// Explicit false wins — full path forwarded.
|
|
1827
|
+
expect(body.pathname).toBe("/scribe/health");
|
|
1828
|
+
} finally {
|
|
1829
|
+
upstream.stop();
|
|
1830
|
+
h.cleanup();
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
test("third-party service without fallback does not strip (#196)", async () => {
|
|
1835
|
+
// Default behavior contract: a service whose manifestName isn't in
|
|
1836
|
+
// FIRST_PARTY_FALLBACKS and whose entry omits stripPrefix gets the
|
|
1837
|
+
// pre-#196 keep-prefix behavior. No accidental strip on third-party
|
|
1838
|
+
// installs.
|
|
1839
|
+
const h = makeHarness();
|
|
1840
|
+
const upstream = startUpstream("third-party");
|
|
1841
|
+
try {
|
|
1842
|
+
writeManifest(
|
|
1843
|
+
{
|
|
1844
|
+
services: [
|
|
1845
|
+
{
|
|
1846
|
+
name: "third-party-service",
|
|
1847
|
+
port: upstream.port,
|
|
1848
|
+
paths: ["/third"],
|
|
1849
|
+
health: "/third/health",
|
|
1850
|
+
version: "0.1.0",
|
|
1851
|
+
},
|
|
1852
|
+
],
|
|
1853
|
+
},
|
|
1854
|
+
h.manifestPath,
|
|
1855
|
+
);
|
|
1856
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1857
|
+
const res = await fetcher(req("/third/health"));
|
|
1858
|
+
expect(res.status).toBe(200);
|
|
1859
|
+
const body = (await res.json()) as { pathname: string };
|
|
1860
|
+
expect(body.pathname).toBe("/third/health");
|
|
1861
|
+
} finally {
|
|
1862
|
+
upstream.stop();
|
|
1863
|
+
h.cleanup();
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
describe("layerOf — classify trust layer from proxy headers", () => {
|
|
1869
|
+
// Hub binds 127.0.0.1:1939; only trusted forwarders (cloudflared,
|
|
1870
|
+
// tailscaled-serve, tailscaled-funnel) reach the listener. Spoofing isn't
|
|
1871
|
+
// a concern. layerOf inspects the headers each forwarder injects.
|
|
1872
|
+
|
|
1873
|
+
test("no proxy headers → loopback (direct localhost call)", () => {
|
|
1874
|
+
expect(layerOf(req("/"))).toBe("loopback");
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
test("Tailscale-User-Login → tailnet (authed via tailscale serve)", () => {
|
|
1878
|
+
// Set verbatim per Tailscale docs / serve.go addTailscaleIdentityHeaders.
|
|
1879
|
+
const r = req("/", { headers: { "Tailscale-User-Login": "alice@example.com" } });
|
|
1880
|
+
expect(layerOf(r)).toBe("tailnet");
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
test("Tailscale-Funnel-Request: ?1 → public (Tailscale Funnel)", () => {
|
|
1884
|
+
// Tailscale Funnel sets this header on every funneled connection per
|
|
1885
|
+
// serve.go; mutually exclusive with Tailscale-User-Login.
|
|
1886
|
+
const r = req("/", { headers: { "Tailscale-Funnel-Request": "?1" } });
|
|
1887
|
+
expect(layerOf(r)).toBe("public");
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
test("CF-Ray → public (Cloudflare tunnel)", () => {
|
|
1891
|
+
const r = req("/", { headers: { "CF-Ray": "abc123-DEN" } });
|
|
1892
|
+
expect(layerOf(r)).toBe("public");
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
test("CF-Connecting-IP → public (Cloudflare tunnel — alt header shape)", () => {
|
|
1896
|
+
const r = req("/", { headers: { "CF-Connecting-IP": "203.0.113.42" } });
|
|
1897
|
+
expect(layerOf(r)).toBe("public");
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
test("Cloudflare wins over tailscale headers (cloudflared-then-serve hop, defensive)", () => {
|
|
1901
|
+
// If a node ran both forwarders chained, the outer-most public layer
|
|
1902
|
+
// wins. Defensive — not a recommended deployment shape.
|
|
1903
|
+
const r = req("/", {
|
|
1904
|
+
headers: { "CF-Ray": "abc", "Tailscale-User-Login": "alice@example.com" },
|
|
1905
|
+
});
|
|
1906
|
+
expect(layerOf(r)).toBe("public");
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
test("Tailscale-Funnel-Request wins over Tailscale-User-Login (defensive)", () => {
|
|
1910
|
+
// serve.go can't actually set both — funnel returns early. Defensive.
|
|
1911
|
+
const r = req("/", {
|
|
1912
|
+
headers: {
|
|
1913
|
+
"Tailscale-Funnel-Request": "?1",
|
|
1914
|
+
"Tailscale-User-Login": "alice@example.com",
|
|
1915
|
+
},
|
|
1916
|
+
});
|
|
1917
|
+
expect(layerOf(r)).toBe("public");
|
|
1918
|
+
});
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
1922
|
+
// The hub's only layer-gate. effectivePublicExposure(entry) === "loopback"
|
|
1923
|
+
// → 404 on tailnet/public; pass through on loopback. "allowed" /
|
|
1924
|
+
// "auth-required" reach all layers (service does its own auth gate).
|
|
1925
|
+
|
|
1926
|
+
function startUpstream(replyTag: string): { port: number; stop: () => void } {
|
|
1927
|
+
const server = Bun.serve({
|
|
1928
|
+
port: 0,
|
|
1929
|
+
hostname: "127.0.0.1",
|
|
1930
|
+
fetch: () =>
|
|
1931
|
+
new Response(JSON.stringify({ tag: replyTag }), {
|
|
1932
|
+
status: 200,
|
|
1933
|
+
headers: { "content-type": "application/json" },
|
|
1934
|
+
}),
|
|
1935
|
+
});
|
|
1936
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
test("publicExposure: loopback + tailnet header → 404 (gate hides the route)", async () => {
|
|
1940
|
+
const h = makeHarness();
|
|
1941
|
+
const upstream = startUpstream("loopback-only");
|
|
1942
|
+
try {
|
|
1943
|
+
writeManifest(
|
|
1944
|
+
{
|
|
1945
|
+
services: [
|
|
1946
|
+
{
|
|
1947
|
+
name: "loopback-only",
|
|
1948
|
+
port: upstream.port,
|
|
1949
|
+
paths: ["/loopback-only"],
|
|
1950
|
+
health: "/loopback-only/health",
|
|
1951
|
+
version: "0.1.0",
|
|
1952
|
+
publicExposure: "loopback",
|
|
1953
|
+
},
|
|
1954
|
+
],
|
|
1955
|
+
},
|
|
1956
|
+
h.manifestPath,
|
|
1957
|
+
);
|
|
1958
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1959
|
+
const r = req("/loopback-only/anything", {
|
|
1960
|
+
headers: { "Tailscale-User-Login": "alice@example.com" },
|
|
1961
|
+
});
|
|
1962
|
+
const res = await fetcher(r);
|
|
1963
|
+
expect(res.status).toBe(404);
|
|
1964
|
+
} finally {
|
|
1965
|
+
upstream.stop();
|
|
1966
|
+
h.cleanup();
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
test("publicExposure: loopback + public header → 404 (gate hides the route)", async () => {
|
|
1971
|
+
const h = makeHarness();
|
|
1972
|
+
const upstream = startUpstream("loopback-only");
|
|
1973
|
+
try {
|
|
1974
|
+
writeManifest(
|
|
1975
|
+
{
|
|
1976
|
+
services: [
|
|
1977
|
+
{
|
|
1978
|
+
name: "loopback-only",
|
|
1979
|
+
port: upstream.port,
|
|
1980
|
+
paths: ["/loopback-only"],
|
|
1981
|
+
health: "/loopback-only/health",
|
|
1982
|
+
version: "0.1.0",
|
|
1983
|
+
publicExposure: "loopback",
|
|
1984
|
+
},
|
|
1985
|
+
],
|
|
1986
|
+
},
|
|
1987
|
+
h.manifestPath,
|
|
1988
|
+
);
|
|
1989
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1990
|
+
const r = req("/loopback-only/anything", { headers: { "CF-Ray": "abc123" } });
|
|
1991
|
+
const res = await fetcher(r);
|
|
1992
|
+
expect(res.status).toBe(404);
|
|
1993
|
+
} finally {
|
|
1994
|
+
upstream.stop();
|
|
1995
|
+
h.cleanup();
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
test("publicExposure: loopback + no headers → reaches upstream (loopback layer)", async () => {
|
|
2000
|
+
const h = makeHarness();
|
|
2001
|
+
const upstream = startUpstream("loopback-only");
|
|
2002
|
+
try {
|
|
2003
|
+
writeManifest(
|
|
2004
|
+
{
|
|
2005
|
+
services: [
|
|
2006
|
+
{
|
|
2007
|
+
name: "loopback-only",
|
|
2008
|
+
port: upstream.port,
|
|
2009
|
+
paths: ["/loopback-only"],
|
|
2010
|
+
health: "/loopback-only/health",
|
|
2011
|
+
version: "0.1.0",
|
|
2012
|
+
publicExposure: "loopback",
|
|
2013
|
+
},
|
|
2014
|
+
],
|
|
2015
|
+
},
|
|
2016
|
+
h.manifestPath,
|
|
2017
|
+
);
|
|
2018
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2019
|
+
const res = await fetcher(req("/loopback-only/health"));
|
|
2020
|
+
expect(res.status).toBe(200);
|
|
2021
|
+
const body = (await res.json()) as { tag: string };
|
|
2022
|
+
expect(body.tag).toBe("loopback-only");
|
|
2023
|
+
} finally {
|
|
2024
|
+
upstream.stop();
|
|
2025
|
+
h.cleanup();
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
|
|
2030
|
+
const h = makeHarness();
|
|
2031
|
+
const upstream = startUpstream("allowed");
|
|
2032
|
+
try {
|
|
2033
|
+
writeManifest(
|
|
2034
|
+
{
|
|
2035
|
+
services: [
|
|
2036
|
+
{
|
|
2037
|
+
name: "allowed",
|
|
2038
|
+
port: upstream.port,
|
|
2039
|
+
paths: ["/allowed"],
|
|
2040
|
+
health: "/allowed/health",
|
|
2041
|
+
version: "0.1.0",
|
|
2042
|
+
publicExposure: "allowed",
|
|
2043
|
+
},
|
|
2044
|
+
],
|
|
2045
|
+
},
|
|
2046
|
+
h.manifestPath,
|
|
2047
|
+
);
|
|
2048
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2049
|
+
const r = req("/allowed/health", {
|
|
2050
|
+
headers: { "Tailscale-User-Login": "alice@example.com" },
|
|
2051
|
+
});
|
|
2052
|
+
const res = await fetcher(r);
|
|
2053
|
+
expect(res.status).toBe(200);
|
|
2054
|
+
const body = (await res.json()) as { tag: string };
|
|
2055
|
+
expect(body.tag).toBe("allowed");
|
|
2056
|
+
} finally {
|
|
2057
|
+
upstream.stop();
|
|
2058
|
+
h.cleanup();
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
test("publicExposure: auth-required + public header → reaches upstream (service self-gates)", async () => {
|
|
2063
|
+
// The service does its own auth check; the hub passes through.
|
|
2064
|
+
const h = makeHarness();
|
|
2065
|
+
const upstream = startUpstream("auth-required");
|
|
2066
|
+
try {
|
|
2067
|
+
writeManifest(
|
|
2068
|
+
{
|
|
2069
|
+
services: [
|
|
2070
|
+
{
|
|
2071
|
+
name: "auth-required",
|
|
2072
|
+
port: upstream.port,
|
|
2073
|
+
paths: ["/auth-required"],
|
|
2074
|
+
health: "/auth-required/health",
|
|
2075
|
+
version: "0.1.0",
|
|
2076
|
+
publicExposure: "auth-required",
|
|
2077
|
+
},
|
|
2078
|
+
],
|
|
2079
|
+
},
|
|
2080
|
+
h.manifestPath,
|
|
2081
|
+
);
|
|
2082
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2083
|
+
const r = req("/auth-required/health", { headers: { "CF-Ray": "abc123" } });
|
|
2084
|
+
const res = await fetcher(r);
|
|
2085
|
+
expect(res.status).toBe(200);
|
|
2086
|
+
} finally {
|
|
2087
|
+
upstream.stop();
|
|
2088
|
+
h.cleanup();
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
test("scribe (kind=api, hasAuth=false default) → loopback gate fires from public layer", async () => {
|
|
2093
|
+
// Spec-derived default for scribe is "auth-required" (NOT loopback —
|
|
2094
|
+
// see effectivePublicExposure in service-spec.ts). So the hub passes
|
|
2095
|
+
// through; this test confirms the spec-default isn't accidentally
|
|
2096
|
+
// loopback-gating well-known services.
|
|
2097
|
+
const h = makeHarness();
|
|
2098
|
+
const upstream = startUpstream("scribe");
|
|
2099
|
+
try {
|
|
2100
|
+
writeManifest(
|
|
2101
|
+
{
|
|
2102
|
+
services: [
|
|
2103
|
+
{
|
|
2104
|
+
name: "parachute-scribe",
|
|
2105
|
+
port: upstream.port,
|
|
2106
|
+
paths: ["/scribe"],
|
|
2107
|
+
health: "/scribe/health",
|
|
2108
|
+
version: "0.1.0",
|
|
2109
|
+
// publicExposure absent — exercises spec-derived default
|
|
2110
|
+
},
|
|
2111
|
+
],
|
|
2112
|
+
},
|
|
2113
|
+
h.manifestPath,
|
|
2114
|
+
);
|
|
2115
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2116
|
+
const r = req("/scribe/health", { headers: { "CF-Ray": "abc123" } });
|
|
2117
|
+
const res = await fetcher(r);
|
|
2118
|
+
// auth-required → pass through; service does its own gate.
|
|
2119
|
+
expect(res.status).toBe(200);
|
|
2120
|
+
} finally {
|
|
2121
|
+
upstream.stop();
|
|
2122
|
+
h.cleanup();
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
test("unknown third-party service (no SERVICE_SPECS row, no publicExposure) → defaults to allowed, reaches public layer", async () => {
|
|
2127
|
+
// Third-party modules installed via `module.json` aren't in
|
|
2128
|
+
// FIRST_PARTY_FALLBACKS, so effectivePublicExposure has no spec to
|
|
2129
|
+
// derive from. The contract documented on effectivePublicExposure is
|
|
2130
|
+
// "default to 'allowed'", which means the gate must NOT fire from the
|
|
2131
|
+
// public layer for an unknown service that didn't opt into a stricter
|
|
2132
|
+
// exposure. Regression-guards anyone tightening the default to
|
|
2133
|
+
// "loopback" without realizing it would silently 404 every
|
|
2134
|
+
// third-party module on tailnet/public.
|
|
2135
|
+
const h = makeHarness();
|
|
2136
|
+
const upstream = startUpstream("unknown-thirdparty");
|
|
2137
|
+
try {
|
|
2138
|
+
writeManifest(
|
|
2139
|
+
{
|
|
2140
|
+
services: [
|
|
2141
|
+
{
|
|
2142
|
+
name: "parachute-unknown-thirdparty",
|
|
2143
|
+
port: upstream.port,
|
|
2144
|
+
paths: ["/parachute-unknown-thirdparty"],
|
|
2145
|
+
health: "/parachute-unknown-thirdparty/health",
|
|
2146
|
+
version: "0.1.0",
|
|
2147
|
+
// publicExposure absent — exercises the unknown-spec default path
|
|
2148
|
+
// kind absent — no SERVICE_SPECS / FIRST_PARTY_FALLBACKS row matches
|
|
2149
|
+
},
|
|
2150
|
+
],
|
|
2151
|
+
},
|
|
2152
|
+
h.manifestPath,
|
|
2153
|
+
);
|
|
2154
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2155
|
+
const r = req("/parachute-unknown-thirdparty/health", {
|
|
2156
|
+
headers: { "CF-Ray": "abc123" },
|
|
2157
|
+
});
|
|
2158
|
+
const res = await fetcher(r);
|
|
2159
|
+
// Default "allowed" → no gate. Forwarded to upstream.
|
|
2160
|
+
expect(res.status).toBe(200);
|
|
2161
|
+
const body = (await res.json()) as { tag: string };
|
|
2162
|
+
expect(body.tag).toBe("unknown-thirdparty");
|
|
2163
|
+
} finally {
|
|
2164
|
+
upstream.stop();
|
|
2165
|
+
h.cleanup();
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
2171
|
+
// Same gate, applied to /vault/<name>/* dispatch. A vault entry that
|
|
2172
|
+
// declares publicExposure: "loopback" is hidden from non-loopback callers.
|
|
2173
|
+
|
|
2174
|
+
function startVaultUpstream(replyTag: string): { port: number; stop: () => void } {
|
|
2175
|
+
const server = Bun.serve({
|
|
2176
|
+
port: 0,
|
|
2177
|
+
hostname: "127.0.0.1",
|
|
2178
|
+
fetch: () =>
|
|
2179
|
+
new Response(JSON.stringify({ tag: replyTag }), {
|
|
2180
|
+
status: 200,
|
|
2181
|
+
headers: { "content-type": "application/json" },
|
|
2182
|
+
}),
|
|
2183
|
+
});
|
|
2184
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
test("vault publicExposure: loopback + tailnet header → 404", async () => {
|
|
2188
|
+
const h = makeHarness();
|
|
2189
|
+
const upstream = startVaultUpstream("vault-private");
|
|
2190
|
+
try {
|
|
2191
|
+
writeManifest(
|
|
2192
|
+
{
|
|
2193
|
+
services: [
|
|
2194
|
+
{
|
|
2195
|
+
name: "parachute-vault-private",
|
|
2196
|
+
port: upstream.port,
|
|
2197
|
+
paths: ["/vault/private"],
|
|
2198
|
+
health: "/vault/private/health",
|
|
2199
|
+
version: "0.4.0",
|
|
2200
|
+
publicExposure: "loopback",
|
|
2201
|
+
},
|
|
2202
|
+
],
|
|
2203
|
+
},
|
|
2204
|
+
h.manifestPath,
|
|
2205
|
+
);
|
|
2206
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2207
|
+
const r = req("/vault/private/health", {
|
|
2208
|
+
headers: { "Tailscale-User-Login": "alice@example.com" },
|
|
2209
|
+
});
|
|
2210
|
+
const res = await fetcher(r);
|
|
2211
|
+
expect(res.status).toBe(404);
|
|
2212
|
+
} finally {
|
|
2213
|
+
upstream.stop();
|
|
2214
|
+
h.cleanup();
|
|
2215
|
+
}
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
test("vault publicExposure: loopback + no headers → reaches vault backend", async () => {
|
|
2219
|
+
const h = makeHarness();
|
|
2220
|
+
const upstream = startVaultUpstream("vault-private");
|
|
2221
|
+
try {
|
|
2222
|
+
writeManifest(
|
|
2223
|
+
{
|
|
2224
|
+
services: [
|
|
2225
|
+
{
|
|
2226
|
+
name: "parachute-vault-private",
|
|
2227
|
+
port: upstream.port,
|
|
2228
|
+
paths: ["/vault/private"],
|
|
2229
|
+
health: "/vault/private/health",
|
|
2230
|
+
version: "0.4.0",
|
|
2231
|
+
publicExposure: "loopback",
|
|
2232
|
+
},
|
|
2233
|
+
],
|
|
2234
|
+
},
|
|
2235
|
+
h.manifestPath,
|
|
2236
|
+
);
|
|
2237
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2238
|
+
const res = await fetcher(req("/vault/private/health"));
|
|
2239
|
+
expect(res.status).toBe(200);
|
|
2240
|
+
const body = (await res.json()) as { tag: string };
|
|
2241
|
+
expect(body.tag).toBe("vault-private");
|
|
2242
|
+
} finally {
|
|
2243
|
+
upstream.stop();
|
|
2244
|
+
h.cleanup();
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
test("vault publicExposure: allowed + tailnet header → reaches backend", async () => {
|
|
2249
|
+
const h = makeHarness();
|
|
2250
|
+
const upstream = startVaultUpstream("vault-public");
|
|
2251
|
+
try {
|
|
2252
|
+
writeManifest(
|
|
2253
|
+
{
|
|
2254
|
+
services: [
|
|
2255
|
+
{
|
|
2256
|
+
name: "parachute-vault",
|
|
2257
|
+
port: upstream.port,
|
|
2258
|
+
paths: ["/vault/default"],
|
|
2259
|
+
health: "/vault/default/health",
|
|
2260
|
+
version: "0.4.0",
|
|
2261
|
+
publicExposure: "allowed",
|
|
2262
|
+
},
|
|
2263
|
+
],
|
|
2264
|
+
},
|
|
2265
|
+
h.manifestPath,
|
|
2266
|
+
);
|
|
2267
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
2268
|
+
const r = req("/vault/default/health", {
|
|
2269
|
+
headers: { "Tailscale-User-Login": "alice@example.com" },
|
|
2270
|
+
});
|
|
2271
|
+
const res = await fetcher(r);
|
|
2272
|
+
expect(res.status).toBe(200);
|
|
2273
|
+
} finally {
|
|
2274
|
+
upstream.stop();
|
|
2275
|
+
h.cleanup();
|
|
2276
|
+
}
|
|
2277
|
+
});
|
|
2278
|
+
});
|
|
2279
|
+
|
|
1054
2280
|
/** Find a port that no one is listening on by binding briefly and releasing. */
|
|
1055
2281
|
async function pickClosedPort(): Promise<number> {
|
|
1056
2282
|
const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
|