@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.
@@ -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>` unchanged by Phase 5b. Reads the per-service logfile
682
- // keyed by short name (the readers §7.5 keeps). Includes the internal `hub`.
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
  });