@openparachute/hub 0.7.0 → 0.7.1
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__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +266 -0
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-connections.ts +916 -14
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-vaults.ts +9 -0
- package/src/api-invites.ts +92 -12
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/connections-store.ts +32 -2
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +501 -12
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +7 -1
- package/src/module-manifest.ts +107 -0
- package/src/origin-check.ts +7 -4
- package/src/services-manifest.ts +97 -0
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
|
@@ -380,6 +380,42 @@ describe("openHubDb + migrate", () => {
|
|
|
380
380
|
}
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
+
test("v13 adds invites.username; pre-existing invite rows keep NULL (no backfill)", () => {
|
|
384
|
+
const h = makeHarness();
|
|
385
|
+
try {
|
|
386
|
+
const db = openHubDb(h.dbPath);
|
|
387
|
+
try {
|
|
388
|
+
// Simulate a real v12 install: strip the v13 column, mark v13
|
|
389
|
+
// unapplied, and seed an invite row that pre-dates pre-naming.
|
|
390
|
+
db.exec(`
|
|
391
|
+
ALTER TABLE invites DROP COLUMN username;
|
|
392
|
+
INSERT INTO invites (token, created_by, vault_name, role, provision_vault,
|
|
393
|
+
default_mirror, expires_at, created_at)
|
|
394
|
+
VALUES ('hash-v12', NULL, 'maya', 'write', 1, NULL,
|
|
395
|
+
'2099-01-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z');
|
|
396
|
+
`);
|
|
397
|
+
db.exec("DELETE FROM schema_version WHERE version = 13");
|
|
398
|
+
migrate(db);
|
|
399
|
+
const cols = db
|
|
400
|
+
.query<{ name: string }, []>("SELECT name FROM pragma_table_info('invites')")
|
|
401
|
+
.all()
|
|
402
|
+
.map((c) => c.name);
|
|
403
|
+
expect(cols).toContain("username");
|
|
404
|
+
// Grandfathered row → NULL (redeemer picks their own name).
|
|
405
|
+
const row = db
|
|
406
|
+
.query<{ username: string | null }, [string]>(
|
|
407
|
+
"SELECT username FROM invites WHERE token = ?",
|
|
408
|
+
)
|
|
409
|
+
.get("hash-v12");
|
|
410
|
+
expect(row?.username).toBeNull();
|
|
411
|
+
} finally {
|
|
412
|
+
db.close();
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
h.cleanup();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
383
419
|
test("v10 FK cascade: deleting a user drops their user_vaults rows", () => {
|
|
384
420
|
const h = makeHarness();
|
|
385
421
|
try {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
hubFetch,
|
|
14
14
|
layerOf,
|
|
15
15
|
parseArgs,
|
|
16
|
+
resolveClientIp,
|
|
16
17
|
} from "../hub-server.ts";
|
|
17
18
|
import { setNotesRedirectDisabled } from "../hub-settings.ts";
|
|
18
19
|
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
@@ -5580,3 +5581,268 @@ describe("force-change-password per-request gate (#469)", () => {
|
|
|
5580
5581
|
}
|
|
5581
5582
|
});
|
|
5582
5583
|
});
|
|
5584
|
+
|
|
5585
|
+
describe("substrate trust headers — X-Parachute-Layer / X-Parachute-Client-IP (H2)", () => {
|
|
5586
|
+
// The hub stamps the layerOf classification + resolved client IP on every
|
|
5587
|
+
// request it forwards to a module upstream, STRIPPING any inbound
|
|
5588
|
+
// occurrences first (a public client must not be able to inject a forged
|
|
5589
|
+
// "loopback" trust signal past the proxy). Surface-runtime design §10.
|
|
5590
|
+
|
|
5591
|
+
function startEchoUpstream(): { port: number; stop: () => void } {
|
|
5592
|
+
const server = Bun.serve({
|
|
5593
|
+
port: 0,
|
|
5594
|
+
hostname: "127.0.0.1",
|
|
5595
|
+
fetch: (req) =>
|
|
5596
|
+
new Response(
|
|
5597
|
+
JSON.stringify({
|
|
5598
|
+
layer: req.headers.get("x-parachute-layer"),
|
|
5599
|
+
clientIp: req.headers.get("x-parachute-client-ip"),
|
|
5600
|
+
}),
|
|
5601
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
5602
|
+
),
|
|
5603
|
+
});
|
|
5604
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
5608
|
+
|
|
5609
|
+
function writeEchoService(h: Harness, port: number): void {
|
|
5610
|
+
writeManifest(
|
|
5611
|
+
{
|
|
5612
|
+
services: [
|
|
5613
|
+
{
|
|
5614
|
+
name: "echo-svc",
|
|
5615
|
+
port,
|
|
5616
|
+
paths: ["/echo-svc"],
|
|
5617
|
+
health: "/echo-svc/health",
|
|
5618
|
+
version: "0.1.0",
|
|
5619
|
+
},
|
|
5620
|
+
],
|
|
5621
|
+
},
|
|
5622
|
+
h.manifestPath,
|
|
5623
|
+
);
|
|
5624
|
+
}
|
|
5625
|
+
|
|
5626
|
+
test("loopback direct-to-hub → stamped loopback + peer IP (generic proxy)", async () => {
|
|
5627
|
+
const h = makeHarness();
|
|
5628
|
+
const upstream = startEchoUpstream();
|
|
5629
|
+
try {
|
|
5630
|
+
writeEchoService(h, upstream.port);
|
|
5631
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5632
|
+
const res = await fetcher(req("/echo-svc/x"), fakeServer("127.0.0.1"));
|
|
5633
|
+
expect(res.status).toBe(200);
|
|
5634
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5635
|
+
expect(body.layer).toBe("loopback");
|
|
5636
|
+
expect(body.clientIp).toBe("127.0.0.1");
|
|
5637
|
+
} finally {
|
|
5638
|
+
upstream.stop();
|
|
5639
|
+
h.cleanup();
|
|
5640
|
+
}
|
|
5641
|
+
});
|
|
5642
|
+
|
|
5643
|
+
test("tailnet request → stamped tailnet + X-Forwarded-For client IP", async () => {
|
|
5644
|
+
const h = makeHarness();
|
|
5645
|
+
const upstream = startEchoUpstream();
|
|
5646
|
+
try {
|
|
5647
|
+
writeEchoService(h, upstream.port);
|
|
5648
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5649
|
+
const res = await fetcher(
|
|
5650
|
+
req("/echo-svc/x", {
|
|
5651
|
+
headers: {
|
|
5652
|
+
"tailscale-user-login": "aaron@example.com",
|
|
5653
|
+
"x-forwarded-for": "100.64.0.7",
|
|
5654
|
+
},
|
|
5655
|
+
}),
|
|
5656
|
+
fakeServer("127.0.0.1"),
|
|
5657
|
+
);
|
|
5658
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5659
|
+
expect(body.layer).toBe("tailnet");
|
|
5660
|
+
expect(body.clientIp).toBe("100.64.0.7");
|
|
5661
|
+
} finally {
|
|
5662
|
+
upstream.stop();
|
|
5663
|
+
h.cleanup();
|
|
5664
|
+
}
|
|
5665
|
+
});
|
|
5666
|
+
|
|
5667
|
+
test("public (cloudflared) request → stamped public + CF-Connecting-IP", async () => {
|
|
5668
|
+
const h = makeHarness();
|
|
5669
|
+
const upstream = startEchoUpstream();
|
|
5670
|
+
try {
|
|
5671
|
+
writeEchoService(h, upstream.port);
|
|
5672
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5673
|
+
const res = await fetcher(
|
|
5674
|
+
req("/echo-svc/x", {
|
|
5675
|
+
headers: { "cf-ray": "8abc123", "cf-connecting-ip": "203.0.113.9" },
|
|
5676
|
+
}),
|
|
5677
|
+
fakeServer("127.0.0.1"),
|
|
5678
|
+
);
|
|
5679
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5680
|
+
expect(body.layer).toBe("public");
|
|
5681
|
+
expect(body.clientIp).toBe("203.0.113.9");
|
|
5682
|
+
} finally {
|
|
5683
|
+
upstream.stop();
|
|
5684
|
+
h.cleanup();
|
|
5685
|
+
}
|
|
5686
|
+
});
|
|
5687
|
+
|
|
5688
|
+
test("inbound spoof is STRIPPED — public peer injecting loopback headers gets re-stamped", async () => {
|
|
5689
|
+
// The attack H2's strip exists for: a direct network peer (0.0.0.0 bind)
|
|
5690
|
+
// sends X-Parachute-Layer: loopback + a forged client IP. The hub must
|
|
5691
|
+
// strip both and stamp its own fail-closed classification.
|
|
5692
|
+
const h = makeHarness();
|
|
5693
|
+
const upstream = startEchoUpstream();
|
|
5694
|
+
try {
|
|
5695
|
+
writeEchoService(h, upstream.port);
|
|
5696
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5697
|
+
const res = await fetcher(
|
|
5698
|
+
req("/echo-svc/x", {
|
|
5699
|
+
headers: {
|
|
5700
|
+
"x-parachute-layer": "loopback",
|
|
5701
|
+
"x-parachute-client-ip": "127.0.0.1",
|
|
5702
|
+
},
|
|
5703
|
+
}),
|
|
5704
|
+
fakeServer("198.51.100.20"),
|
|
5705
|
+
);
|
|
5706
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5707
|
+
expect(body.layer).toBe("public"); // header-absent non-loopback peer → public
|
|
5708
|
+
expect(body.clientIp).toBe("198.51.100.20"); // peer address, not the forgery
|
|
5709
|
+
} finally {
|
|
5710
|
+
upstream.stop();
|
|
5711
|
+
h.cleanup();
|
|
5712
|
+
}
|
|
5713
|
+
});
|
|
5714
|
+
|
|
5715
|
+
test("direct caller injecting CF-Connecting-IP: layer stamp stays sound (public), client IP is best-effort attribution", async () => {
|
|
5716
|
+
// Pins the documented property (resolveClientIp's "Known limitation"):
|
|
5717
|
+
// a DIRECT caller can spoof the forwarded-IP headers and misattribute
|
|
5718
|
+
// its own address — X-Parachute-Client-IP reflects the injected value —
|
|
5719
|
+
// but it CANNOT spoof the LAYER: the CF header's presence classifies the
|
|
5720
|
+
// request "public" regardless of the peer (here even a loopback one),
|
|
5721
|
+
// so spoofing only ever moves the trust signal DOWN. Layer is the
|
|
5722
|
+
// security signal; client IP is attribution.
|
|
5723
|
+
const h = makeHarness();
|
|
5724
|
+
const upstream = startEchoUpstream();
|
|
5725
|
+
try {
|
|
5726
|
+
writeEchoService(h, upstream.port);
|
|
5727
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5728
|
+
const res = await fetcher(
|
|
5729
|
+
req("/echo-svc/x", {
|
|
5730
|
+
headers: { "cf-connecting-ip": "203.0.113.50" },
|
|
5731
|
+
}),
|
|
5732
|
+
fakeServer("127.0.0.1"),
|
|
5733
|
+
);
|
|
5734
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5735
|
+
expect(body.layer).toBe("public"); // CF header presence → public, not loopback
|
|
5736
|
+
expect(body.clientIp).toBe("203.0.113.50"); // injected value — best-effort by design
|
|
5737
|
+
} finally {
|
|
5738
|
+
upstream.stop();
|
|
5739
|
+
h.cleanup();
|
|
5740
|
+
}
|
|
5741
|
+
});
|
|
5742
|
+
|
|
5743
|
+
test("unknown peer (no Server threaded) → fail-closed public, header for client IP omitted", async () => {
|
|
5744
|
+
const h = makeHarness();
|
|
5745
|
+
const upstream = startEchoUpstream();
|
|
5746
|
+
try {
|
|
5747
|
+
writeEchoService(h, upstream.port);
|
|
5748
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5749
|
+
// No second arg — peerAddr resolves null; layer fails closed to public.
|
|
5750
|
+
const res = await fetcher(req("/echo-svc/x"));
|
|
5751
|
+
const body = (await res.json()) as { layer: string; clientIp: string | null };
|
|
5752
|
+
expect(body.layer).toBe("public");
|
|
5753
|
+
expect(body.clientIp).toBeNull();
|
|
5754
|
+
} finally {
|
|
5755
|
+
upstream.stop();
|
|
5756
|
+
h.cleanup();
|
|
5757
|
+
}
|
|
5758
|
+
});
|
|
5759
|
+
|
|
5760
|
+
test("per-vault proxy stamps the same headers (shared proxyRequest path)", async () => {
|
|
5761
|
+
const h = makeHarness();
|
|
5762
|
+
const upstream = startEchoUpstream();
|
|
5763
|
+
try {
|
|
5764
|
+
writeManifest(
|
|
5765
|
+
{
|
|
5766
|
+
services: [
|
|
5767
|
+
{
|
|
5768
|
+
name: "parachute-vault-default",
|
|
5769
|
+
port: upstream.port,
|
|
5770
|
+
paths: ["/vault/default"],
|
|
5771
|
+
health: "/health",
|
|
5772
|
+
version: "0.4.0",
|
|
5773
|
+
},
|
|
5774
|
+
],
|
|
5775
|
+
},
|
|
5776
|
+
h.manifestPath,
|
|
5777
|
+
);
|
|
5778
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5779
|
+
const res = await fetcher(req("/vault/default/api/notes"), fakeServer("127.0.0.1"));
|
|
5780
|
+
const body = (await res.json()) as { layer: string; clientIp: string };
|
|
5781
|
+
expect(body.layer).toBe("loopback");
|
|
5782
|
+
expect(body.clientIp).toBe("127.0.0.1");
|
|
5783
|
+
} finally {
|
|
5784
|
+
upstream.stop();
|
|
5785
|
+
h.cleanup();
|
|
5786
|
+
}
|
|
5787
|
+
});
|
|
5788
|
+
|
|
5789
|
+
test("/vault/admin route stamps the same headers (proxyToVaultAdmin path)", async () => {
|
|
5790
|
+
const h = makeHarness();
|
|
5791
|
+
const upstream = startEchoUpstream();
|
|
5792
|
+
try {
|
|
5793
|
+
writeManifest(
|
|
5794
|
+
{
|
|
5795
|
+
services: [
|
|
5796
|
+
{
|
|
5797
|
+
name: "parachute-vault",
|
|
5798
|
+
port: upstream.port,
|
|
5799
|
+
paths: ["/vault/default"],
|
|
5800
|
+
health: "/health",
|
|
5801
|
+
version: "0.5.0",
|
|
5802
|
+
},
|
|
5803
|
+
],
|
|
5804
|
+
},
|
|
5805
|
+
h.manifestPath,
|
|
5806
|
+
);
|
|
5807
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
5808
|
+
const res = await fetcher(
|
|
5809
|
+
req("/vault/admin/", {
|
|
5810
|
+
headers: { "tailscale-user-login": "aaron@example.com" },
|
|
5811
|
+
}),
|
|
5812
|
+
fakeServer("127.0.0.1"),
|
|
5813
|
+
);
|
|
5814
|
+
const body = (await res.json()) as { layer: string };
|
|
5815
|
+
expect(body.layer).toBe("tailnet");
|
|
5816
|
+
} finally {
|
|
5817
|
+
upstream.stop();
|
|
5818
|
+
h.cleanup();
|
|
5819
|
+
}
|
|
5820
|
+
});
|
|
5821
|
+
});
|
|
5822
|
+
|
|
5823
|
+
describe("resolveClientIp (H2)", () => {
|
|
5824
|
+
test("CF-Connecting-IP wins over X-Forwarded-For and peer", () => {
|
|
5825
|
+
const r = req("/", {
|
|
5826
|
+
headers: { "cf-connecting-ip": "203.0.113.1", "x-forwarded-for": "10.0.0.1" },
|
|
5827
|
+
});
|
|
5828
|
+
expect(resolveClientIp(r, "127.0.0.1")).toBe("203.0.113.1");
|
|
5829
|
+
});
|
|
5830
|
+
|
|
5831
|
+
test("X-Forwarded-For first hop wins over peer", () => {
|
|
5832
|
+
const r = req("/", { headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2" } });
|
|
5833
|
+
expect(resolveClientIp(r, "127.0.0.1")).toBe("10.0.0.1");
|
|
5834
|
+
});
|
|
5835
|
+
|
|
5836
|
+
test("falls back to the peer address", () => {
|
|
5837
|
+
expect(resolveClientIp(req("/"), "198.51.100.7")).toBe("198.51.100.7");
|
|
5838
|
+
});
|
|
5839
|
+
|
|
5840
|
+
test("null when nothing resolves", () => {
|
|
5841
|
+
expect(resolveClientIp(req("/"), null)).toBeNull();
|
|
5842
|
+
});
|
|
5843
|
+
|
|
5844
|
+
test("whitespace-only header values are treated as absent", () => {
|
|
5845
|
+
const r = req("/", { headers: { "x-forwarded-for": " " } });
|
|
5846
|
+
expect(resolveClientIp(r, null)).toBeNull();
|
|
5847
|
+
});
|
|
5848
|
+
});
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
listInvites,
|
|
29
29
|
revokeInvite,
|
|
30
30
|
revokeInvitesForVault,
|
|
31
|
+
usernameReservedByPendingInvite,
|
|
31
32
|
} from "../invites.ts";
|
|
32
33
|
import { createUser } from "../users.ts";
|
|
33
34
|
|
|
@@ -68,7 +69,7 @@ describe("issueInvite", () => {
|
|
|
68
69
|
}
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
test("defaults: role=write, provision_vault=1, 7-day expiry", async () => {
|
|
72
|
+
test("defaults: role=write, provision_vault=1, 7-day expiry, no pre-named username", async () => {
|
|
72
73
|
const { db, adminId, cleanup } = await makeDb();
|
|
73
74
|
try {
|
|
74
75
|
const now = new Date("2026-06-04T00:00:00Z");
|
|
@@ -76,12 +77,74 @@ describe("issueInvite", () => {
|
|
|
76
77
|
expect(invite.role).toBe("write");
|
|
77
78
|
expect(invite.provisionVault).toBe(true);
|
|
78
79
|
expect(invite.vaultName).toBeNull();
|
|
80
|
+
expect(invite.username).toBeNull();
|
|
79
81
|
const expiry = new Date(invite.expiresAt).getTime() - now.getTime();
|
|
80
82
|
expect(Math.round(expiry / 1000)).toBe(DEFAULT_INVITE_TTL_SECONDS);
|
|
81
83
|
} finally {
|
|
82
84
|
cleanup();
|
|
83
85
|
}
|
|
84
86
|
});
|
|
87
|
+
|
|
88
|
+
test("pre-named username round-trips through the row", async () => {
|
|
89
|
+
const { db, adminId, cleanup } = await makeDb();
|
|
90
|
+
try {
|
|
91
|
+
const { rawToken, invite } = issueInvite(db, {
|
|
92
|
+
createdBy: adminId,
|
|
93
|
+
username: "jonathan",
|
|
94
|
+
vaultName: "shared",
|
|
95
|
+
provisionVault: false,
|
|
96
|
+
role: "read",
|
|
97
|
+
});
|
|
98
|
+
expect(invite.username).toBe("jonathan");
|
|
99
|
+
const found = findInviteByRawToken(db, rawToken);
|
|
100
|
+
expect(found?.username).toBe("jonathan");
|
|
101
|
+
expect(found?.vaultName).toBe("shared");
|
|
102
|
+
expect(found?.provisionVault).toBe(false);
|
|
103
|
+
expect(found?.role).toBe("read");
|
|
104
|
+
} finally {
|
|
105
|
+
cleanup();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("usernameReservedByPendingInvite", () => {
|
|
111
|
+
test("pending reserves; redeemed / revoked / expired / other-name do not", async () => {
|
|
112
|
+
const { db, adminId, cleanup } = await makeDb();
|
|
113
|
+
try {
|
|
114
|
+
const now = new Date("2026-06-10T00:00:00Z");
|
|
115
|
+
// Pending → reserved.
|
|
116
|
+
issueInvite(db, { createdBy: adminId, username: "pending-name", now: () => now });
|
|
117
|
+
expect(usernameReservedByPendingInvite(db, "pending-name", now)).toBe(true);
|
|
118
|
+
expect(usernameReservedByPendingInvite(db, "other-name", now)).toBe(false);
|
|
119
|
+
// Redeemed → free.
|
|
120
|
+
const redeemed = issueInvite(db, {
|
|
121
|
+
createdBy: adminId,
|
|
122
|
+
username: "used-name",
|
|
123
|
+
now: () => now,
|
|
124
|
+
});
|
|
125
|
+
consumeInvite(db, redeemed.invite.tokenHash, adminId, now);
|
|
126
|
+
expect(usernameReservedByPendingInvite(db, "used-name", now)).toBe(false);
|
|
127
|
+
// Revoked → free.
|
|
128
|
+
const revoked = issueInvite(db, {
|
|
129
|
+
createdBy: adminId,
|
|
130
|
+
username: "gone-name",
|
|
131
|
+
now: () => now,
|
|
132
|
+
});
|
|
133
|
+
revokeInvite(db, revoked.invite.tokenHash, now);
|
|
134
|
+
expect(usernameReservedByPendingInvite(db, "gone-name", now)).toBe(false);
|
|
135
|
+
// Expired → free.
|
|
136
|
+
issueInvite(db, {
|
|
137
|
+
createdBy: adminId,
|
|
138
|
+
username: "old-name",
|
|
139
|
+
expiresInSeconds: 60,
|
|
140
|
+
now: () => now,
|
|
141
|
+
});
|
|
142
|
+
const later = new Date(now.getTime() + 120_000);
|
|
143
|
+
expect(usernameReservedByPendingInvite(db, "old-name", later)).toBe(false);
|
|
144
|
+
} finally {
|
|
145
|
+
cleanup();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
85
148
|
});
|
|
86
149
|
|
|
87
150
|
describe("findInviteByRawToken", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, openSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -678,10 +678,37 @@ describe("group-aware kill / liveness (hub#88)", () => {
|
|
|
678
678
|
});
|
|
679
679
|
|
|
680
680
|
// ---------------------------------------------------------------------------
|
|
681
|
-
// `parachute logs <svc
|
|
682
|
-
//
|
|
681
|
+
// `parachute logs <svc>`. Under hub-as-supervisor (Phase 5b) a module's output
|
|
682
|
+
// is multiplexed into the HUB log with a `[<svc>] ` line prefix, so `logs <svc>`
|
|
683
|
+
// reads that stream filtered to the service (hub#652). The per-service logfile
|
|
684
|
+
// keyed by short name (the readers §7.5 keeps) survives as the legacy source
|
|
685
|
+
// when it's fresher than the hub log (pre-supervised installs). `logs hub`
|
|
686
|
+
// reads the hub log unfiltered.
|
|
683
687
|
// ---------------------------------------------------------------------------
|
|
684
688
|
|
|
689
|
+
/** The supervisor's multiplexed hub-log shape (supervisor.ts pipeOutput). */
|
|
690
|
+
const INTERLEAVED_HUB_LOG =
|
|
691
|
+
"[vault] vault boot\n" +
|
|
692
|
+
"[scribe] scribe boot\n" +
|
|
693
|
+
"[vault] GET /vault/default/api/notes 200 7ms\n" +
|
|
694
|
+
"[surface] [app-dcr] client registered\n" +
|
|
695
|
+
"[vaultx] not vault's line\n" +
|
|
696
|
+
"[vault] sync ok\n";
|
|
697
|
+
|
|
698
|
+
const VAULT_LINES_STRIPPED = ["vault boot", "GET /vault/default/api/notes 200 7ms", "sync ok"];
|
|
699
|
+
|
|
700
|
+
function writeHubLog(configDir: string, content: string): string {
|
|
701
|
+
const path = ensureLogPath("hub", configDir);
|
|
702
|
+
writeFileSync(path, content);
|
|
703
|
+
return path;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Backdate a file's mtime so freshness comparisons are deterministic. */
|
|
707
|
+
function backdate(path: string, secondsAgo: number): void {
|
|
708
|
+
const t = new Date(Date.now() - secondsAgo * 1000);
|
|
709
|
+
utimesSync(path, t, t);
|
|
710
|
+
}
|
|
711
|
+
|
|
685
712
|
describe("parachute logs", () => {
|
|
686
713
|
test("hint when no log file exists", async () => {
|
|
687
714
|
const h = makeHarness();
|
|
@@ -829,4 +856,212 @@ describe("parachute logs", () => {
|
|
|
829
856
|
h.cleanup();
|
|
830
857
|
}
|
|
831
858
|
});
|
|
859
|
+
|
|
860
|
+
// ---- hub#652: supervised modules read the hub log's [svc]-prefixed stream ----
|
|
861
|
+
|
|
862
|
+
test("supervised module: reads the hub log filtered to its prefix, stripped (hub#652)", async () => {
|
|
863
|
+
const h = makeHarness();
|
|
864
|
+
try {
|
|
865
|
+
seedVault(h.manifestPath);
|
|
866
|
+
writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
|
|
867
|
+
// No per-service vault.log — the supervised steady state.
|
|
868
|
+
const log: string[] = [];
|
|
869
|
+
const code = await logs("vault", {
|
|
870
|
+
configDir: h.configDir,
|
|
871
|
+
manifestPath: h.manifestPath,
|
|
872
|
+
log: (l) => log.push(l),
|
|
873
|
+
});
|
|
874
|
+
expect(code).toBe(0);
|
|
875
|
+
// Exact-prefix match: `[vaultx]` noise excluded; `[vault] ` stripped.
|
|
876
|
+
expect(log).toEqual(VAULT_LINES_STRIPPED);
|
|
877
|
+
} finally {
|
|
878
|
+
h.cleanup();
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test("stale per-service file + fresher hub log: the hub stream wins (the live hub#652 shape)", async () => {
|
|
883
|
+
const h = makeHarness();
|
|
884
|
+
try {
|
|
885
|
+
seedVault(h.manifestPath);
|
|
886
|
+
const legacy = ensureLogPath("vault", h.configDir);
|
|
887
|
+
writeFileSync(legacy, "stale pre-cutover line\n");
|
|
888
|
+
backdate(legacy, 3600);
|
|
889
|
+
writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
|
|
890
|
+
const log: string[] = [];
|
|
891
|
+
const code = await logs("vault", {
|
|
892
|
+
configDir: h.configDir,
|
|
893
|
+
manifestPath: h.manifestPath,
|
|
894
|
+
log: (l) => log.push(l),
|
|
895
|
+
});
|
|
896
|
+
expect(code).toBe(0);
|
|
897
|
+
expect(log).toEqual(VAULT_LINES_STRIPPED);
|
|
898
|
+
expect(log.join("\n")).not.toContain("stale pre-cutover line");
|
|
899
|
+
} finally {
|
|
900
|
+
h.cleanup();
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("per-service file fresher than the hub log: legacy file wins (pre-supervised install)", async () => {
|
|
905
|
+
const h = makeHarness();
|
|
906
|
+
try {
|
|
907
|
+
seedVault(h.manifestPath);
|
|
908
|
+
const hubLog = writeHubLog(h.configDir, "[vault] old supervised line\n");
|
|
909
|
+
backdate(hubLog, 3600);
|
|
910
|
+
const legacy = ensureLogPath("vault", h.configDir);
|
|
911
|
+
writeFileSync(legacy, "live detached line\n");
|
|
912
|
+
const log: string[] = [];
|
|
913
|
+
const code = await logs("vault", {
|
|
914
|
+
configDir: h.configDir,
|
|
915
|
+
manifestPath: h.manifestPath,
|
|
916
|
+
log: (l) => log.push(l),
|
|
917
|
+
});
|
|
918
|
+
expect(code).toBe(0);
|
|
919
|
+
expect(log).toEqual(["live detached line"]);
|
|
920
|
+
} finally {
|
|
921
|
+
h.cleanup();
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("lines cap applies to the FILTERED set, not raw hub-log lines", async () => {
|
|
926
|
+
const h = makeHarness();
|
|
927
|
+
try {
|
|
928
|
+
seedVault(h.manifestPath);
|
|
929
|
+
writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
|
|
930
|
+
const log: string[] = [];
|
|
931
|
+
const code = await logs("vault", {
|
|
932
|
+
configDir: h.configDir,
|
|
933
|
+
manifestPath: h.manifestPath,
|
|
934
|
+
lines: 2,
|
|
935
|
+
log: (l) => log.push(l),
|
|
936
|
+
});
|
|
937
|
+
expect(code).toBe(0);
|
|
938
|
+
expect(log).toEqual(VAULT_LINES_STRIPPED.slice(-2));
|
|
939
|
+
} finally {
|
|
940
|
+
h.cleanup();
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
test("hub log has no lines for the service + no per-service file: start hint", async () => {
|
|
945
|
+
const h = makeHarness();
|
|
946
|
+
try {
|
|
947
|
+
seedVault(h.manifestPath);
|
|
948
|
+
writeHubLog(h.configDir, "[scribe] scribe boot\n");
|
|
949
|
+
const log: string[] = [];
|
|
950
|
+
const code = await logs("vault", {
|
|
951
|
+
configDir: h.configDir,
|
|
952
|
+
manifestPath: h.manifestPath,
|
|
953
|
+
log: (l) => log.push(l),
|
|
954
|
+
});
|
|
955
|
+
expect(code).toBe(0);
|
|
956
|
+
expect(log.join("\n")).toMatch(/no logs yet for vault/);
|
|
957
|
+
} finally {
|
|
958
|
+
h.cleanup();
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("hub log has no lines for the service + per-service file exists: legacy shown with a note", async () => {
|
|
963
|
+
const h = makeHarness();
|
|
964
|
+
try {
|
|
965
|
+
seedVault(h.manifestPath);
|
|
966
|
+
const legacy = ensureLogPath("vault", h.configDir);
|
|
967
|
+
writeFileSync(legacy, "old detached line\n");
|
|
968
|
+
backdate(legacy, 3600);
|
|
969
|
+
writeHubLog(h.configDir, "[scribe] scribe boot\n");
|
|
970
|
+
const log: string[] = [];
|
|
971
|
+
const code = await logs("vault", {
|
|
972
|
+
configDir: h.configDir,
|
|
973
|
+
manifestPath: h.manifestPath,
|
|
974
|
+
log: (l) => log.push(l),
|
|
975
|
+
});
|
|
976
|
+
expect(code).toBe(0);
|
|
977
|
+
// The note distinguishes the stale per-service file from the live
|
|
978
|
+
// stream — the exact "stale logs presented as current" trap in hub#652.
|
|
979
|
+
expect(log[0]).toMatch(/no vault lines in the hub log/);
|
|
980
|
+
expect(log).toContain("old detached line");
|
|
981
|
+
} finally {
|
|
982
|
+
h.cleanup();
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test("follow mode filters the hub stream and strips the prefix (hub#652)", async () => {
|
|
987
|
+
const h = makeHarness();
|
|
988
|
+
try {
|
|
989
|
+
seedVault(h.manifestPath);
|
|
990
|
+
writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
|
|
991
|
+
const encoder = new TextEncoder();
|
|
992
|
+
let streamedPath: string | undefined;
|
|
993
|
+
const followStream = (path: string): ReadableStream<Uint8Array> => {
|
|
994
|
+
streamedPath = path;
|
|
995
|
+
return new ReadableStream<Uint8Array>({
|
|
996
|
+
start(controller) {
|
|
997
|
+
controller.enqueue(encoder.encode("[vault] live one\n[scribe] noise\n"));
|
|
998
|
+
// Split a line across chunks to exercise the line buffer.
|
|
999
|
+
controller.enqueue(encoder.encode("[vault] live "));
|
|
1000
|
+
controller.enqueue(encoder.encode("two\n"));
|
|
1001
|
+
controller.close();
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
};
|
|
1005
|
+
const log: string[] = [];
|
|
1006
|
+
const code = await logs("vault", {
|
|
1007
|
+
configDir: h.configDir,
|
|
1008
|
+
manifestPath: h.manifestPath,
|
|
1009
|
+
follow: true,
|
|
1010
|
+
followStream,
|
|
1011
|
+
log: (l) => log.push(l),
|
|
1012
|
+
});
|
|
1013
|
+
expect(code).toBe(0);
|
|
1014
|
+
expect(streamedPath).toContain("hub.log");
|
|
1015
|
+
expect(log).toEqual([...VAULT_LINES_STRIPPED, "live one", "live two"]);
|
|
1016
|
+
} finally {
|
|
1017
|
+
h.cleanup();
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
test("follow mode with a fresher per-service file tails THAT file via tail -f", async () => {
|
|
1022
|
+
const h = makeHarness();
|
|
1023
|
+
try {
|
|
1024
|
+
seedVault(h.manifestPath);
|
|
1025
|
+
const hubLog = writeHubLog(h.configDir, "[vault] old supervised line\n");
|
|
1026
|
+
backdate(hubLog, 3600);
|
|
1027
|
+
const legacy = ensureLogPath("vault", h.configDir);
|
|
1028
|
+
writeFileSync(legacy, "live detached line\n");
|
|
1029
|
+
const spawned: string[][] = [];
|
|
1030
|
+
const code = await logs("vault", {
|
|
1031
|
+
configDir: h.configDir,
|
|
1032
|
+
manifestPath: h.manifestPath,
|
|
1033
|
+
follow: true,
|
|
1034
|
+
tailSpawner: {
|
|
1035
|
+
spawn(cmd) {
|
|
1036
|
+
spawned.push([...cmd]);
|
|
1037
|
+
return 12345;
|
|
1038
|
+
},
|
|
1039
|
+
},
|
|
1040
|
+
log: () => {},
|
|
1041
|
+
});
|
|
1042
|
+
expect(code).toBe(0);
|
|
1043
|
+
expect(spawned).toHaveLength(1);
|
|
1044
|
+
expect(spawned[0]?.[0]).toBe("tail");
|
|
1045
|
+
expect(spawned[0]?.at(-1)).toBe(legacy);
|
|
1046
|
+
} finally {
|
|
1047
|
+
h.cleanup();
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test("logs hub: stays unfiltered — module-prefixed lines included", async () => {
|
|
1052
|
+
const h = makeHarness();
|
|
1053
|
+
try {
|
|
1054
|
+
writeHubLog(h.configDir, INTERLEAVED_HUB_LOG);
|
|
1055
|
+
const log: string[] = [];
|
|
1056
|
+
const code = await logs("hub", {
|
|
1057
|
+
configDir: h.configDir,
|
|
1058
|
+
manifestPath: h.manifestPath,
|
|
1059
|
+
log: (l) => log.push(l),
|
|
1060
|
+
});
|
|
1061
|
+
expect(code).toBe(0);
|
|
1062
|
+
expect(log).toEqual(INTERLEAVED_HUB_LOG.replace(/\n$/, "").split("\n"));
|
|
1063
|
+
} finally {
|
|
1064
|
+
h.cleanup();
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
832
1067
|
});
|