@openparachute/hub 0.5.2 → 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.
@@ -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 { findServiceUpstream, 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)", () => {
@@ -1144,6 +1169,62 @@ describe("findServiceUpstream (#182)", () => {
1144
1169
  expect(findServiceUpstream(services, "/scribe-admin")).toBeUndefined();
1145
1170
  expect(findServiceUpstream(services, "/scribe-admin/foo")).toBeUndefined();
1146
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
+ });
1147
1228
  });
1148
1229
 
1149
1230
  describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
@@ -1628,6 +1709,572 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
1628
1709
  h.cleanup();
1629
1710
  }
1630
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
+ });
1631
2278
  });
1632
2279
 
1633
2280
  /** Find a port that no one is listening on by binding briefly and releasing. */