@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +92 -0
  3. package/src/__tests__/expose-2fa-warning.test.ts +125 -0
  4. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  5. package/src/__tests__/expose.test.ts +199 -340
  6. package/src/__tests__/hub-server.test.ts +1227 -1
  7. package/src/__tests__/install.test.ts +50 -31
  8. package/src/__tests__/lifecycle.test.ts +97 -2
  9. package/src/__tests__/module-manifest.test.ts +13 -0
  10. package/src/__tests__/notes-serve.test.ts +154 -2
  11. package/src/__tests__/oauth-handlers.test.ts +737 -1
  12. package/src/__tests__/port-assign.test.ts +41 -52
  13. package/src/__tests__/rate-limit.test.ts +190 -0
  14. package/src/__tests__/services-manifest.test.ts +367 -0
  15. package/src/__tests__/setup.test.ts +12 -9
  16. package/src/__tests__/status.test.ts +173 -0
  17. package/src/admin-handlers.ts +38 -13
  18. package/src/commands/expose-2fa-warning.ts +82 -0
  19. package/src/commands/expose-cloudflare.ts +27 -0
  20. package/src/commands/expose-public-auto.ts +3 -7
  21. package/src/commands/expose.ts +88 -173
  22. package/src/commands/install.ts +11 -13
  23. package/src/commands/lifecycle.ts +53 -4
  24. package/src/commands/status.ts +28 -1
  25. package/src/help.ts +3 -3
  26. package/src/hub-server.ts +266 -32
  27. package/src/module-manifest.ts +19 -0
  28. package/src/notes-serve.ts +70 -9
  29. package/src/oauth-handlers.ts +249 -12
  30. package/src/oauth-ui.ts +167 -0
  31. package/src/port-assign.ts +28 -35
  32. package/src/rate-limit.ts +163 -0
  33. package/src/service-spec.ts +66 -13
  34. package/src/services-manifest.ts +83 -3
  35. package/src/sessions.ts +19 -0
@@ -112,7 +112,11 @@ function seedServices(path: string): void {
112
112
  const allServicesUp = async () => true;
113
113
 
114
114
  describe("expose tailnet up", () => {
115
- test("mounts hub proxy at /, one proxy per service, plus well-known proxy", async () => {
115
+ test("emits exactly one catchall mount: / http://127.0.0.1:<hubPort>/", async () => {
116
+ // Single-rule symmetry with cloudflare ingress (#178). Hub does all
117
+ // internal dispatch (UI, OAuth, well-known, vault SPA + per-vault proxy,
118
+ // generic /<svc>/* services dispatch) so the tailscale plan stays at
119
+ // one mount regardless of how many services are installed.
116
120
  const h = makeHarness();
117
121
  try {
118
122
  seedServices(h.manifestPath);
@@ -136,47 +140,20 @@ describe("expose tailnet up", () => {
136
140
  const serveCalls = calls.filter(
137
141
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
138
142
  );
139
- // 4 baseline (hub + wk + vault + notes) + 4 OAuth proxies (vault present).
140
- expect(serveCalls).toHaveLength(8);
143
+ // Exactly one bringup: `/ http://127.0.0.1:<hubPort>/`.
144
+ expect(serveCalls).toHaveLength(1);
141
145
  // Tailnet mode never uses funnel — neither the old flag nor the new subcommand.
142
146
  expect(serveCalls.every((c) => !c.includes("--funnel"))).toBe(true);
143
147
  expect(calls.every((c) => c[1] !== "funnel")).toBe(true);
144
148
 
145
- const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path="))).sort();
146
- // Vault paths consolidate to a single `/vault/` mount → hub (#144);
147
- // the hub then picks the specific vault instance per request from
148
- // services.json. Notes (and other non-vault services) keep their
149
- // direct mount.
150
- expect(mounts).toEqual([
151
- "--set-path=/",
152
- "--set-path=/.well-known/oauth-authorization-server",
153
- "--set-path=/.well-known/parachute.json",
154
- "--set-path=/notes",
155
- "--set-path=/oauth/authorize",
156
- "--set-path=/oauth/register",
157
- "--set-path=/oauth/token",
158
- "--set-path=/vault/",
159
- ]);
160
-
161
- // Hub + well-known now point at localhost HTTP, not a file path.
162
- // Target path mirrors mount exactly so tailscale's strip-then-forward
163
- // is a no-op; otherwise SPAs at /<mount>/ redirect-loop.
164
- const hubCall = serveCalls.find((c) => c.includes("--set-path=/"));
165
- expect(hubCall?.[hubCall.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
166
-
167
- const wkCall = serveCalls.find((c) => c.includes("--set-path=/.well-known/parachute.json"));
168
- expect(wkCall?.[wkCall.length - 1]).toMatch(
169
- /^http:\/\/127\.0\.0\.1:\d+\/\.well-known\/parachute\.json$/,
170
- );
149
+ const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
150
+ expect(mounts).toEqual(["--set-path=/"]);
171
151
 
172
- // Non-vault service targets include the mount path so tailscale's
173
- // strip-then-forward is a no-op against base-aware backends.
174
- const notesCall = serveCalls.find((c) => c.includes("--set-path=/notes"));
175
- expect(notesCall?.[notesCall.length - 1]).toBe("http://127.0.0.1:5173/notes");
176
- // Vault mount targets the hub's loopback port (the hub re-proxies to
177
- // the right vault backend on each request) — port is dynamic per test.
178
- const vaultCall = serveCalls.find((c) => c.includes("--set-path=/vault/"));
179
- expect(vaultCall?.[vaultCall.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/vault\/$/);
152
+ // Hub catchall target is the hub loopback root with trailing slash so
153
+ // tailscale's strip-then-forward is a no-op (mount and target match
154
+ // byte-for-byte).
155
+ const hubCall = serveCalls[0] ?? [];
156
+ expect(hubCall[hubCall.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
180
157
 
181
158
  expect(existsSync(h.wellKnownPath)).toBe(true);
182
159
  expect(existsSync(h.hubPath)).toBe(true);
@@ -186,9 +163,9 @@ describe("expose tailnet up", () => {
186
163
  const state = readExposeState(h.statePath);
187
164
  expect(state?.layer).toBe("tailnet");
188
165
  expect(state?.mode).toBe("path");
189
- expect(state?.entries).toHaveLength(8);
190
- // All entries are proxy now — no file-backed tailscale serve.
191
- expect(state?.entries.every((e) => e.kind === "proxy")).toBe(true);
166
+ expect(state?.entries).toHaveLength(1);
167
+ expect(state?.entries[0]?.mount).toBe("/");
168
+ expect(state?.entries[0]?.kind).toBe("proxy");
192
169
  } finally {
193
170
  h.cleanup();
194
171
  }
@@ -224,10 +201,11 @@ describe("expose tailnet up", () => {
224
201
  }
225
202
  });
226
203
 
227
- test("trailing-slash mount preserves trailing slash in target URL", async () => {
228
- // Aaron hit ERR_TOO_MANY_REDIRECTS on /notes/ because tailscale strips
229
- // the prefix, Vite (base=/notes) redirects back to /notes/, tailscale
230
- // strips again, loop. Pinning target = mount byte-for-byte breaks that.
204
+ test("hub catchall target ends in `/` so tailscale strip-then-forward is a no-op", async () => {
205
+ // Aaron hit ERR_TOO_MANY_REDIRECTS on /notes/ pre-collapse because
206
+ // tailscale strips the prefix and Vite (base=/notes) redirects back to
207
+ // /notes/. Mount and target byte-equal breaks that loop. Now that there's
208
+ // one catchall, the same rule applies to the hub root: `/ → http://…/`.
231
209
  const h = makeHarness();
232
210
  try {
233
211
  upsertService(
@@ -258,15 +236,20 @@ describe("expose tailnet up", () => {
258
236
  const serveCalls = calls.filter(
259
237
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
260
238
  );
261
- const notesCall = serveCalls.find((c) => c.includes("--set-path=/notes/"));
262
- expect(notesCall).toBeDefined();
263
- expect(notesCall?.[notesCall.length - 1]).toBe("http://127.0.0.1:5173/notes/");
239
+ expect(serveCalls).toHaveLength(1);
240
+ const call = serveCalls[0] ?? [];
241
+ expect(call).toContain("--set-path=/");
242
+ expect(call[call.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
264
243
  } finally {
265
244
  h.cleanup();
266
245
  }
267
246
  });
268
247
 
269
- test("legacy paths:[/] entry is remapped to /<shortname> with warning", async () => {
248
+ test("legacy paths:[/] entry warns operator (no rewrite hub dispatches per request)", async () => {
249
+ // Pre-collapse this remapped to /<shortname>; now the hub does dispatch
250
+ // per services.json, so a paths:["/"] entry would still collide with the
251
+ // hub UI but the failure surface is hub-side, not tailscale-plan-side.
252
+ // Keep the warn so operators know to re-install.
270
253
  const h = makeHarness();
271
254
  try {
272
255
  upsertService(
@@ -290,16 +273,12 @@ describe("expose tailnet up", () => {
290
273
  });
291
274
  expect(code).toBe(0);
292
275
 
276
+ // Plan is still exactly one catchall regardless of the legacy entry.
293
277
  const serveCalls = calls.filter(
294
278
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
295
279
  );
296
- const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path="))).sort();
297
- // Even with the legacy `/` remap, the vault rolls into the consolidated
298
- // `/vault/` mount → hub. The remap warning still fires; the mount shape
299
- // just doesn't reflect the original `/<shortname>` since #144.
300
- expect(mounts).toContain("--set-path=/vault/");
301
- expect(mounts).toContain("--set-path=/");
302
- expect(mounts.filter((m) => m === "--set-path=/")).toHaveLength(1);
280
+ expect(serveCalls).toHaveLength(1);
281
+ expect(serveCalls[0]).toContain("--set-path=/");
303
282
 
304
283
  expect(logs.join("\n")).toMatch(/parachute-vault claims "\/"; hub page lives there/);
305
284
  } finally {
@@ -435,11 +414,12 @@ describe("expose tailnet up", () => {
435
414
  expect(joined).toMatch(/parachute-notes \(port 5173\) is not responding/);
436
415
  expect(joined).toMatch(/parachute start notes/);
437
416
  expect(joined).not.toMatch(/parachute-vault.*not responding/);
438
- // Bringup still happened — 4 service entries + 4 OAuth proxies got staged.
417
+ // Bringup still happened — single hub catchall regardless of which
418
+ // services responded to the probe.
439
419
  const serveCalls = calls.filter(
440
420
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
441
421
  );
442
- expect(serveCalls).toHaveLength(8);
422
+ expect(serveCalls).toHaveLength(1);
443
423
  } finally {
444
424
  h.cleanup();
445
425
  }
@@ -484,7 +464,10 @@ describe("expose tailnet up", () => {
484
464
  }
485
465
  });
486
466
 
487
- test("emits 4 OAuth proxies targeting the hub origin (hub IS the IdP)", async () => {
467
+ test("hub catchall serves OAuth + well-known internally no separate mount per endpoint", async () => {
468
+ // OAuth (hub IS the IdP) and well-known (parachute.json + JWKS +
469
+ // oauth-authorization-server metadata) are dispatched by the hub from
470
+ // the single catchall. State + bringup carry exactly one entry.
488
471
  const h = makeHarness();
489
472
  try {
490
473
  seedServices(h.manifestPath);
@@ -507,26 +490,10 @@ describe("expose tailnet up", () => {
507
490
  const serveCalls = calls.filter(
508
491
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
509
492
  );
510
- const oauthTargets = new Map<string, string>();
511
- for (const c of serveCalls) {
512
- const mount = c.find((a) => a.startsWith("--set-path="))?.slice("--set-path=".length);
513
- if (
514
- mount &&
515
- (mount.startsWith("/oauth/") || mount === "/.well-known/oauth-authorization-server")
516
- ) {
517
- oauthTargets.set(mount, c[c.length - 1] ?? "");
518
- }
519
- }
520
- expect(oauthTargets.get("/.well-known/oauth-authorization-server")).toMatch(
521
- /^http:\/\/127\.0\.0\.1:\d+\/\.well-known\/oauth-authorization-server$/,
522
- );
523
- expect(oauthTargets.get("/oauth/authorize")).toMatch(
524
- /^http:\/\/127\.0\.0\.1:\d+\/oauth\/authorize$/,
525
- );
526
- expect(oauthTargets.get("/oauth/token")).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/token$/);
527
- expect(oauthTargets.get("/oauth/register")).toMatch(
528
- /^http:\/\/127\.0\.0\.1:\d+\/oauth\/register$/,
529
- );
493
+ // Single catchall no per-endpoint OAuth or well-known mounts.
494
+ expect(serveCalls).toHaveLength(1);
495
+ const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
496
+ expect(mounts).toEqual(["--set-path=/"]);
530
497
 
531
498
  const state = readExposeState(h.statePath);
532
499
  expect(state?.hubOrigin).toBe("https://parachute.taildf9ce2.ts.net");
@@ -535,7 +502,10 @@ describe("expose tailnet up", () => {
535
502
  }
536
503
  });
537
504
 
538
- test("emits OAuth proxies even when no vault is installed (hub IS the IdP)", async () => {
505
+ test("plan is one catchall regardless of which services are installed", async () => {
506
+ // Pre-collapse this varied: with vault installed we got more mounts than
507
+ // without. Now the count is constant — the hub dispatches from
508
+ // services.json per request, so the tailscale plan doesn't enumerate.
539
509
  const h = makeHarness();
540
510
  try {
541
511
  upsertService(
@@ -567,15 +537,7 @@ describe("expose tailnet up", () => {
567
537
  const serveCalls = calls.filter(
568
538
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
569
539
  );
570
- // Hub + well-known + notes + 4 OAuth proxies = 7. The hub serves OAuth
571
- // regardless of which services are installed.
572
- expect(serveCalls).toHaveLength(7);
573
- const oauthMounts = serveCalls
574
- .map((c) => c.find((a) => a.startsWith("--set-path=")))
575
- .filter(
576
- (m): m is string => !!m && (m.includes("/oauth/") || m.endsWith("authorization-server")),
577
- );
578
- expect(oauthMounts).toHaveLength(4);
540
+ expect(serveCalls).toHaveLength(1);
579
541
  } finally {
580
542
  h.cleanup();
581
543
  }
@@ -608,6 +570,41 @@ describe("expose tailnet up", () => {
608
570
  h.cleanup();
609
571
  }
610
572
  });
573
+
574
+ // 2FA warning is public-layer only (#186). Tailnet ingress is
575
+ // tailscale-authed at the proxy, so /admin/login isn't on the open
576
+ // internet — the warning is moot here even when 2FA is unset.
577
+ test("tailnet bringup does NOT fire the 2FA warning even when not enrolled", async () => {
578
+ const h = makeHarness();
579
+ try {
580
+ seedServices(h.manifestPath);
581
+ const { runner } = makeRunner();
582
+ const { spawner } = makeHubSpawner(1111);
583
+ const logs: string[] = [];
584
+ const code = await exposeTailnet("up", {
585
+ runner,
586
+ manifestPath: h.manifestPath,
587
+ statePath: h.statePath,
588
+ wellKnownPath: h.wellKnownPath,
589
+ hubPath: h.hubPath,
590
+ wellKnownDir: h.wellKnownDir,
591
+ configDir: h.configDir,
592
+ hubEnsureOpts: hubEnsureOpts(spawner),
593
+ servicePortProbe: allServicesUp,
594
+ log: (l) => logs.push(l),
595
+ vaultAuthStatus: {
596
+ hasOwnerPassword: false,
597
+ hasTotp: false,
598
+ tokenCount: 0,
599
+ vaultNames: [],
600
+ },
601
+ });
602
+ expect(code).toBe(0);
603
+ expect(logs.join("\n")).not.toContain("2FA is not enrolled");
604
+ } finally {
605
+ h.cleanup();
606
+ }
607
+ });
611
608
  });
612
609
 
613
610
  describe("expose tailnet off", () => {
@@ -821,8 +818,9 @@ describe("expose public up", () => {
821
818
  const funnelCalls = calls.filter(
822
819
  (c) => c[0] === "tailscale" && c[1] === "funnel" && c.includes("--bg"),
823
820
  );
824
- // 4 baseline mounts + 4 OAuth proxies (vault seeded).
825
- expect(funnelCalls).toHaveLength(8);
821
+ // Single hub catchall public mode shape now matches tailnet (#178
822
+ // landed the cloudflare-side single-rule; this closes tailnet).
823
+ expect(funnelCalls).toHaveLength(1);
826
824
  // Never emit the legacy `serve --funnel` shape.
827
825
  expect(calls.every((c) => !c.includes("--funnel"))).toBe(true);
828
826
  expect(calls.every((c) => !(c[1] === "serve" && c.includes("--bg")))).toBe(true);
@@ -830,7 +828,7 @@ describe("expose public up", () => {
830
828
  const state = readExposeState(h.statePath);
831
829
  expect(state?.layer).toBe("public");
832
830
  expect(state?.funnel).toBe(true);
833
- expect(state?.entries).toHaveLength(8);
831
+ expect(state?.entries).toHaveLength(1);
834
832
 
835
833
  expect(logs.join("\n")).toMatch(/Public exposure active/);
836
834
  } finally {
@@ -932,6 +930,77 @@ describe("expose public up", () => {
932
930
  h.cleanup();
933
931
  }
934
932
  });
933
+
934
+ // 2FA-enrollment warning (#186). Funnel exposure makes /admin/login
935
+ // reachable on the open internet just like cloudflare; the warning lives in
936
+ // a shared helper (`expose-2fa-warning.ts`) wired into both paths.
937
+ test("2FA not enrolled → warning fires on the public layer", async () => {
938
+ const h = makeHarness();
939
+ try {
940
+ seedServices(h.manifestPath);
941
+ const { runner } = makeRunner();
942
+ const { spawner } = makeHubSpawner(1111);
943
+ const logs: string[] = [];
944
+ const code = await exposePublic("up", {
945
+ runner,
946
+ manifestPath: h.manifestPath,
947
+ statePath: h.statePath,
948
+ wellKnownPath: h.wellKnownPath,
949
+ hubPath: h.hubPath,
950
+ wellKnownDir: h.wellKnownDir,
951
+ configDir: h.configDir,
952
+ hubEnsureOpts: hubEnsureOpts(spawner),
953
+ servicePortProbe: allServicesUp,
954
+ log: (l) => logs.push(l),
955
+ vaultAuthStatus: {
956
+ hasOwnerPassword: true,
957
+ hasTotp: false,
958
+ tokenCount: 0,
959
+ vaultNames: ["default"],
960
+ },
961
+ });
962
+ expect(code).toBe(0);
963
+ const joined = logs.join("\n");
964
+ expect(joined).toContain("2FA is not enrolled");
965
+ expect(joined).toContain("parachute auth 2fa enroll");
966
+ // /admin/login pointer uses the canonical https://<fqdn> origin.
967
+ expect(joined).toContain("https://parachute.taildf9ce2.ts.net/admin/login");
968
+ } finally {
969
+ h.cleanup();
970
+ }
971
+ });
972
+
973
+ test("2FA enrolled → warning suppressed on the public layer", async () => {
974
+ const h = makeHarness();
975
+ try {
976
+ seedServices(h.manifestPath);
977
+ const { runner } = makeRunner();
978
+ const { spawner } = makeHubSpawner(1111);
979
+ const logs: string[] = [];
980
+ const code = await exposePublic("up", {
981
+ runner,
982
+ manifestPath: h.manifestPath,
983
+ statePath: h.statePath,
984
+ wellKnownPath: h.wellKnownPath,
985
+ hubPath: h.hubPath,
986
+ wellKnownDir: h.wellKnownDir,
987
+ configDir: h.configDir,
988
+ hubEnsureOpts: hubEnsureOpts(spawner),
989
+ servicePortProbe: allServicesUp,
990
+ log: (l) => logs.push(l),
991
+ vaultAuthStatus: {
992
+ hasOwnerPassword: true,
993
+ hasTotp: true,
994
+ tokenCount: 1,
995
+ vaultNames: ["default"],
996
+ },
997
+ });
998
+ expect(code).toBe(0);
999
+ expect(logs.join("\n")).not.toContain("2FA is not enrolled");
1000
+ } finally {
1001
+ h.cleanup();
1002
+ }
1003
+ });
935
1004
  });
936
1005
 
937
1006
  describe("expose public off", () => {
@@ -1023,15 +1092,21 @@ describe("expose public off", () => {
1023
1092
  });
1024
1093
  });
1025
1094
 
1026
- describe("expose publicExposure filter", () => {
1027
- // Launch-blocker: services without auth should never be mounted on
1028
- // tailnet/funnel. The filter reads `publicExposure` from each entry (or
1029
- // derives a safe default from the service spec) and withholds non-"allowed"
1030
- // services from the tailscale serve plan.
1031
- test("explicit loopback keeps the service off the serve plan", async () => {
1095
+ describe("expose plan is layer-agnostic — gating moved to hub", () => {
1096
+ // Pre-collapse the tailscale plan partitioned services by publicExposure
1097
+ // and withheld loopback/auth-required entries. Now the plan is always one
1098
+ // catchall to the hub, which gates per request via `effectivePublicExposure`
1099
+ // + `layerOf` (see hub-server.ts). These tests confirm the plan stays
1100
+ // single-rule regardless of services' exposure declarations; per-request
1101
+ // gating is exercised in hub-server.test.ts.
1102
+
1103
+ test("plan stays one catchall when a loopback-only service is installed", async () => {
1104
+ // Pre-collapse, scribe (publicExposure: "loopback") was withheld from the
1105
+ // plan with an operator-visible warning. Now scribe is on the plan via
1106
+ // the hub catchall — the hub returns 404 to non-loopback callers per
1107
+ // hub-server's `proxyToService` layer-gate. Plan stays one mount.
1032
1108
  const h = makeHarness();
1033
1109
  try {
1034
- // Vault is mounted as usual; scribe declares loopback and is withheld.
1035
1110
  upsertService(
1036
1111
  {
1037
1112
  name: "parachute-vault",
@@ -1056,7 +1131,6 @@ describe("expose publicExposure filter", () => {
1056
1131
  );
1057
1132
  const { runner, calls } = makeRunner();
1058
1133
  const { spawner } = makeHubSpawner(1111);
1059
- const logs: string[] = [];
1060
1134
  const code = await exposeTailnet("up", {
1061
1135
  runner,
1062
1136
  manifestPath: h.manifestPath,
@@ -1067,41 +1141,29 @@ describe("expose publicExposure filter", () => {
1067
1141
  configDir: h.configDir,
1068
1142
  hubEnsureOpts: hubEnsureOpts(spawner),
1069
1143
  servicePortProbe: allServicesUp,
1070
- log: (l) => logs.push(l),
1144
+ log: () => {},
1071
1145
  });
1072
1146
  expect(code).toBe(0);
1073
-
1074
1147
  const serveCalls = calls.filter(
1075
1148
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
1076
1149
  );
1077
- const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
1078
- // Vault rolls into the consolidated `/vault/` mount → hub (#144);
1079
- // its 4 OAuth proxies, hub, and well-known are still individually
1080
- // mounted. /scribe is loopback-only and absent.
1081
- expect(mounts).toContain("--set-path=/vault/");
1082
- expect(mounts).not.toContain("--set-path=/scribe");
1083
-
1084
- // Operator-visible notice explaining the withhold.
1085
- expect(logs.join("\n")).toMatch(
1086
- /parachute-scribe is loopback-only — loopback-only by service declaration/,
1087
- );
1150
+ expect(serveCalls).toHaveLength(1);
1151
+ expect(serveCalls[0]).toContain("--set-path=/");
1088
1152
 
1089
- // State file reflects the reduced plan so teardown doesn't trip on
1090
- // entries that were never brought up.
1153
+ // State carries one entry hub catchall. No /scribe or /vault/* in state.
1091
1154
  const state = readExposeState(h.statePath);
1092
- expect(state?.entries.some((e) => e.mount === "/scribe")).toBe(false);
1155
+ expect(state?.entries).toHaveLength(1);
1156
+ expect(state?.entries[0]?.mount).toBe("/");
1093
1157
  } finally {
1094
1158
  h.cleanup();
1095
1159
  }
1096
1160
  });
1097
1161
 
1098
- test("explicit auth-required behaves like loopback at launch", async () => {
1099
- // auth-required is the future-looking declaration for a service that
1100
- // wants auth but hasn't confirmed it's configured. Today the CLI treats
1101
- // it identically to loopback — still no funnel/tailnet exposure.
1162
+ test("plan stays one catchall regardless of mix of publicExposure values", async () => {
1163
+ // Mix: allowed, loopback, auth-required, and absent. Plan is one mount.
1102
1164
  const h = makeHarness();
1103
1165
  try {
1104
- seedServices(h.manifestPath); // vault + notes, both allowed by default
1166
+ seedServices(h.manifestPath); // vault + notes
1105
1167
  upsertService(
1106
1168
  {
1107
1169
  name: "parachute-channel",
@@ -1113,40 +1175,6 @@ describe("expose publicExposure filter", () => {
1113
1175
  },
1114
1176
  h.manifestPath,
1115
1177
  );
1116
- const { runner, calls } = makeRunner();
1117
- const { spawner } = makeHubSpawner(1111);
1118
- const logs: string[] = [];
1119
- const code = await exposeTailnet("up", {
1120
- runner,
1121
- manifestPath: h.manifestPath,
1122
- statePath: h.statePath,
1123
- wellKnownPath: h.wellKnownPath,
1124
- hubPath: h.hubPath,
1125
- wellKnownDir: h.wellKnownDir,
1126
- configDir: h.configDir,
1127
- hubEnsureOpts: hubEnsureOpts(spawner),
1128
- servicePortProbe: allServicesUp,
1129
- log: (l) => logs.push(l),
1130
- });
1131
- expect(code).toBe(0);
1132
- const mounts = calls
1133
- .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1134
- .map((c) => c.find((a) => a.startsWith("--set-path=")));
1135
- expect(mounts).not.toContain("--set-path=/channel");
1136
- expect(logs.join("\n")).toMatch(/parachute-channel is loopback-only — auth-required/);
1137
- } finally {
1138
- h.cleanup();
1139
- }
1140
- });
1141
-
1142
- test("missing publicExposure + spec kind=api, hasAuth=false → loopback default (scribe)", async () => {
1143
- // Scribe today has no auth gate; its ServiceSpec says so (kind: "api",
1144
- // hasAuth: false). With publicExposure absent we should still withhold.
1145
- // This is the safe-by-default case for services that haven't yet been
1146
- // updated to declare their exposure.
1147
- const h = makeHarness();
1148
- try {
1149
- seedServices(h.manifestPath);
1150
1178
  upsertService(
1151
1179
  {
1152
1180
  name: "parachute-scribe",
@@ -1160,51 +1188,6 @@ describe("expose publicExposure filter", () => {
1160
1188
  );
1161
1189
  const { runner, calls } = makeRunner();
1162
1190
  const { spawner } = makeHubSpawner(1111);
1163
- const logs: string[] = [];
1164
- const code = await exposeTailnet("up", {
1165
- runner,
1166
- manifestPath: h.manifestPath,
1167
- statePath: h.statePath,
1168
- wellKnownPath: h.wellKnownPath,
1169
- hubPath: h.hubPath,
1170
- wellKnownDir: h.wellKnownDir,
1171
- configDir: h.configDir,
1172
- hubEnsureOpts: hubEnsureOpts(spawner),
1173
- servicePortProbe: allServicesUp,
1174
- log: (l) => logs.push(l),
1175
- });
1176
- expect(code).toBe(0);
1177
- const mounts = calls
1178
- .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1179
- .map((c) => c.find((a) => a.startsWith("--set-path=")));
1180
- expect(mounts).not.toContain("--set-path=/scribe");
1181
- // Reason text points operators at the missing auth gate.
1182
- expect(logs.join("\n")).toMatch(
1183
- /parachute-scribe is loopback-only — auth-required: service has no auth gate/,
1184
- );
1185
- } finally {
1186
- h.cleanup();
1187
- }
1188
- });
1189
-
1190
- test("missing publicExposure on a known auth'd api service (vault) still exposes", async () => {
1191
- // vault's ServiceSpec has hasAuth: true, so the absence of publicExposure
1192
- // should not hide it — the back-compat path for every vault entry written
1193
- // before this field existed.
1194
- const h = makeHarness();
1195
- try {
1196
- upsertService(
1197
- {
1198
- name: "parachute-vault",
1199
- port: 1940,
1200
- paths: ["/vault/default"],
1201
- health: "/vault/default/health",
1202
- version: "0.2.4",
1203
- },
1204
- h.manifestPath,
1205
- );
1206
- const { runner, calls } = makeRunner();
1207
- const { spawner } = makeHubSpawner(1111);
1208
1191
  const code = await exposeTailnet("up", {
1209
1192
  runner,
1210
1193
  manifestPath: h.manifestPath,
@@ -1218,53 +1201,10 @@ describe("expose publicExposure filter", () => {
1218
1201
  log: () => {},
1219
1202
  });
1220
1203
  expect(code).toBe(0);
1221
- const mounts = calls
1222
- .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1223
- .map((c) => c.find((a) => a.startsWith("--set-path=")));
1224
- // Vault → consolidated `/vault/` mount (#144).
1225
- expect(mounts).toContain("--set-path=/vault/");
1226
- } finally {
1227
- h.cleanup();
1228
- }
1229
- });
1230
-
1231
- test("unknown third-party service without publicExposure defaults to allowed", async () => {
1232
- // A service not in SERVICE_SPECS has no kind/hasAuth signal. We err on
1233
- // the side of preserving current behavior (back-compat) so operators'
1234
- // existing exposures don't silently stop working on upgrade. If the
1235
- // third-party wants to opt out, they can write publicExposure: "loopback"
1236
- // into their services.json entry.
1237
- const h = makeHarness();
1238
- try {
1239
- upsertService(
1240
- {
1241
- name: "parachute-rando",
1242
- port: 1947,
1243
- paths: ["/rando"],
1244
- health: "/rando/health",
1245
- version: "0.0.1",
1246
- },
1247
- h.manifestPath,
1204
+ const serveCalls = calls.filter(
1205
+ (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
1248
1206
  );
1249
- const { runner, calls } = makeRunner();
1250
- const { spawner } = makeHubSpawner(1111);
1251
- const code = await exposeTailnet("up", {
1252
- runner,
1253
- manifestPath: h.manifestPath,
1254
- statePath: h.statePath,
1255
- wellKnownPath: h.wellKnownPath,
1256
- hubPath: h.hubPath,
1257
- wellKnownDir: h.wellKnownDir,
1258
- configDir: h.configDir,
1259
- hubEnsureOpts: hubEnsureOpts(spawner),
1260
- servicePortProbe: allServicesUp,
1261
- log: () => {},
1262
- });
1263
- expect(code).toBe(0);
1264
- const mounts = calls
1265
- .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1266
- .map((c) => c.find((a) => a.startsWith("--set-path=")));
1267
- expect(mounts).toContain("--set-path=/rando");
1207
+ expect(serveCalls).toHaveLength(1);
1268
1208
  } finally {
1269
1209
  h.cleanup();
1270
1210
  }
@@ -1595,12 +1535,15 @@ describe("expose teardown tolerance for already-gone entries", () => {
1595
1535
  });
1596
1536
  });
1597
1537
 
1598
- describe("expose vault mount consolidation (#144)", () => {
1599
- // Pre-#144: tailscale plan emitted one mount per vault path. New vault
1600
- // had to re-run `parachute expose` to get a tailnet route. Post-#144: a
1601
- // single `/vault/` hub mount, hub does the per-request lookup.
1538
+ describe("expose: vault routing fully internal to hub", () => {
1539
+ // Pre-#144: one tailscale mount per vault path. #144 collapsed those to a
1540
+ // single `/vault/ hub` mount. This PR collapses one step further: vault
1541
+ // routing is just a slice of the hub catchall now. Hub's `proxyToVault`
1542
+ // (in hub-server.ts) still dispatches per services.json on each request,
1543
+ // so `parachute vault create <name>` is reachable without re-expose; the
1544
+ // tailscale plan just no longer carries a vault-specific entry.
1602
1545
 
1603
- test("single vault, single path → exactly one /vault/ mount, no per-instance entry", async () => {
1546
+ test("single vault, single path → still one catchall, no /vault/* tailscale rule", async () => {
1604
1547
  const h = makeHarness();
1605
1548
  try {
1606
1549
  upsertService(
@@ -1631,59 +1574,16 @@ describe("expose vault mount consolidation (#144)", () => {
1631
1574
  const mounts = calls
1632
1575
  .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1633
1576
  .map((c) => c.find((a) => a.startsWith("--set-path=")));
1634
- expect(mounts).toContain("--set-path=/vault/");
1635
- expect(mounts).not.toContain("--set-path=/vault/default");
1636
- } finally {
1637
- h.cleanup();
1638
- }
1639
- });
1640
-
1641
- test("multi-path single ServiceEntry still emits exactly one /vault/ mount", async () => {
1642
- // The current vault manifest shape: one ServiceEntry whose `paths` lists
1643
- // every instance. The plan must collapse them all to the hub-routed
1644
- // `/vault/` instead of one mount per instance.
1645
- const h = makeHarness();
1646
- try {
1647
- upsertService(
1648
- {
1649
- name: "parachute-vault",
1650
- port: 1940,
1651
- paths: ["/vault/default", "/vault/techne"],
1652
- health: "/vault/default/health",
1653
- version: "0.4.0",
1654
- },
1655
- h.manifestPath,
1656
- );
1657
- const { runner, calls } = makeRunner();
1658
- const { spawner } = makeHubSpawner(1111);
1659
- const code = await exposeTailnet("up", {
1660
- runner,
1661
- manifestPath: h.manifestPath,
1662
- statePath: h.statePath,
1663
- wellKnownPath: h.wellKnownPath,
1664
- hubPath: h.hubPath,
1665
- wellKnownDir: h.wellKnownDir,
1666
- configDir: h.configDir,
1667
- hubEnsureOpts: hubEnsureOpts(spawner),
1668
- servicePortProbe: allServicesUp,
1669
- log: () => {},
1670
- });
1671
- expect(code).toBe(0);
1672
- const mounts = calls
1673
- .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1674
- .map((c) => c.find((a) => a.startsWith("--set-path=")));
1675
- const vaultMounts = mounts.filter((m) => m?.startsWith("--set-path=/vault"));
1676
- expect(vaultMounts).toEqual(["--set-path=/vault/"]);
1577
+ expect(mounts).toEqual(["--set-path=/"]);
1677
1578
  } finally {
1678
1579
  h.cleanup();
1679
1580
  }
1680
1581
  });
1681
1582
 
1682
- test("multiple separate vault ServiceEntries still emit exactly one /vault/ mount", async () => {
1683
- // Pathological but representable: someone might install a second
1684
- // parachute-vault-archive backend alongside the bare parachute-vault.
1685
- // Both fold into the single `/vault/` mount; hub disambiguates per
1686
- // request.
1583
+ test("multiple separate vault ServiceEntries still one catchall", async () => {
1584
+ // Pathological but representable: a second parachute-vault-archive
1585
+ // alongside the bare parachute-vault. Both reachable via the hub
1586
+ // catchall; hub picks the backend per request.
1687
1587
  const h = makeHarness();
1688
1588
  try {
1689
1589
  upsertService(
@@ -1724,48 +1624,7 @@ describe("expose vault mount consolidation (#144)", () => {
1724
1624
  const mounts = calls
1725
1625
  .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1726
1626
  .map((c) => c.find((a) => a.startsWith("--set-path=")));
1727
- const vaultMounts = mounts.filter((m) => m?.startsWith("--set-path=/vault"));
1728
- expect(vaultMounts).toEqual(["--set-path=/vault/"]);
1729
- } finally {
1730
- h.cleanup();
1731
- }
1732
- });
1733
-
1734
- test("no vault installed → no /vault/ mount in the plan", async () => {
1735
- const h = makeHarness();
1736
- try {
1737
- upsertService(
1738
- {
1739
- name: "parachute-notes",
1740
- port: 5173,
1741
- paths: ["/notes"],
1742
- health: "/notes/health",
1743
- version: "0.0.1",
1744
- },
1745
- h.manifestPath,
1746
- );
1747
- const { runner, calls } = makeRunner();
1748
- const { spawner } = makeHubSpawner(1111);
1749
- const code = await exposeTailnet("up", {
1750
- runner,
1751
- manifestPath: h.manifestPath,
1752
- statePath: h.statePath,
1753
- wellKnownPath: h.wellKnownPath,
1754
- hubPath: h.hubPath,
1755
- wellKnownDir: h.wellKnownDir,
1756
- configDir: h.configDir,
1757
- hubEnsureOpts: hubEnsureOpts(spawner),
1758
- servicePortProbe: allServicesUp,
1759
- log: () => {},
1760
- });
1761
- expect(code).toBe(0);
1762
- const mounts = calls
1763
- .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1764
- .map((c) => c.find((a) => a.startsWith("--set-path=")));
1765
- expect(mounts).not.toContain("--set-path=/vault/");
1766
- // /notes is still individually mounted — non-vault services keep
1767
- // their direct route.
1768
- expect(mounts).toContain("--set-path=/notes");
1627
+ expect(mounts).toEqual(["--set-path=/"]);
1769
1628
  } finally {
1770
1629
  h.cleanup();
1771
1630
  }