remote-pi 0.1.2 → 0.2.0

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 (99) hide show
  1. package/README.md +195 -36
  2. package/dist/bin/supervisord.d.ts +2 -0
  3. package/dist/bin/supervisord.js +44 -0
  4. package/dist/bin/supervisord.js.map +1 -0
  5. package/dist/config.d.ts +49 -5
  6. package/dist/config.js +73 -9
  7. package/dist/config.js.map +1 -1
  8. package/dist/daemon/client.d.ts +20 -0
  9. package/dist/daemon/client.js +128 -0
  10. package/dist/daemon/client.js.map +1 -0
  11. package/dist/daemon/control_protocol.d.ts +100 -0
  12. package/dist/daemon/control_protocol.js +63 -0
  13. package/dist/daemon/control_protocol.js.map +1 -0
  14. package/dist/daemon/id.d.ts +18 -0
  15. package/dist/daemon/id.js +30 -0
  16. package/dist/daemon/id.js.map +1 -0
  17. package/dist/daemon/install.d.ts +132 -0
  18. package/dist/daemon/install.js +312 -0
  19. package/dist/daemon/install.js.map +1 -0
  20. package/dist/daemon/registry.d.ts +47 -0
  21. package/dist/daemon/registry.js +123 -0
  22. package/dist/daemon/registry.js.map +1 -0
  23. package/dist/daemon/rpc_child.d.ts +76 -0
  24. package/dist/daemon/rpc_child.js +130 -0
  25. package/dist/daemon/rpc_child.js.map +1 -0
  26. package/dist/daemon/supervisor.d.ts +38 -0
  27. package/dist/daemon/supervisor.js +301 -0
  28. package/dist/daemon/supervisor.js.map +1 -0
  29. package/dist/index.d.ts +62 -8
  30. package/dist/index.js +1232 -304
  31. package/dist/index.js.map +1 -1
  32. package/dist/mesh/canonical.d.ts +30 -0
  33. package/dist/mesh/canonical.js +61 -0
  34. package/dist/mesh/canonical.js.map +1 -0
  35. package/dist/mesh/client.d.ts +31 -0
  36. package/dist/mesh/client.js +56 -0
  37. package/dist/mesh/client.js.map +1 -0
  38. package/dist/mesh/encoding.d.ts +36 -0
  39. package/dist/mesh/encoding.js +53 -0
  40. package/dist/mesh/encoding.js.map +1 -0
  41. package/dist/mesh/self_revoke.d.ts +111 -0
  42. package/dist/mesh/self_revoke.js +182 -0
  43. package/dist/mesh/self_revoke.js.map +1 -0
  44. package/dist/mesh/siblings.d.ts +62 -0
  45. package/dist/mesh/siblings.js +95 -0
  46. package/dist/mesh/siblings.js.map +1 -0
  47. package/dist/mesh/types.d.ts +34 -0
  48. package/dist/mesh/types.js +11 -0
  49. package/dist/mesh/types.js.map +1 -0
  50. package/dist/mesh/verify.d.ts +17 -0
  51. package/dist/mesh/verify.js +77 -0
  52. package/dist/mesh/verify.js.map +1 -0
  53. package/dist/pairing/qr.d.ts +16 -5
  54. package/dist/pairing/qr.js +27 -8
  55. package/dist/pairing/qr.js.map +1 -1
  56. package/dist/pairing/storage.d.ts +41 -0
  57. package/dist/pairing/storage.js +158 -21
  58. package/dist/pairing/storage.js.map +1 -1
  59. package/dist/protocol/types.d.ts +23 -0
  60. package/dist/session/broker.d.ts +74 -0
  61. package/dist/session/broker.js +142 -4
  62. package/dist/session/broker.js.map +1 -1
  63. package/dist/session/broker_remote.d.ts +110 -0
  64. package/dist/session/broker_remote.js +397 -0
  65. package/dist/session/broker_remote.js.map +1 -0
  66. package/dist/session/cwd_lock.d.ts +28 -0
  67. package/dist/session/cwd_lock.js +89 -0
  68. package/dist/session/cwd_lock.js.map +1 -0
  69. package/dist/session/global_config.d.ts +9 -0
  70. package/dist/session/global_config.js +9 -0
  71. package/dist/session/global_config.js.map +1 -1
  72. package/dist/session/leader_election.d.ts +16 -0
  73. package/dist/session/leader_election.js +22 -0
  74. package/dist/session/leader_election.js.map +1 -1
  75. package/dist/session/local_config.d.ts +12 -5
  76. package/dist/session/local_config.js +24 -3
  77. package/dist/session/local_config.js.map +1 -1
  78. package/dist/session/peer.d.ts +28 -1
  79. package/dist/session/peer.js +69 -2
  80. package/dist/session/peer.js.map +1 -1
  81. package/dist/session/peer_inventory.d.ts +13 -0
  82. package/dist/session/peer_inventory.js +48 -0
  83. package/dist/session/peer_inventory.js.map +1 -0
  84. package/dist/session/setup_wizard.d.ts +32 -8
  85. package/dist/session/setup_wizard.js +45 -33
  86. package/dist/session/setup_wizard.js.map +1 -1
  87. package/dist/session/tools.d.ts +15 -7
  88. package/dist/session/tools.js +145 -31
  89. package/dist/session/tools.js.map +1 -1
  90. package/dist/transport/pi_forward_client.d.ts +29 -0
  91. package/dist/transport/pi_forward_client.js +62 -0
  92. package/dist/transport/pi_forward_client.js.map +1 -0
  93. package/dist/ui/footer.js +8 -6
  94. package/dist/ui/footer.js.map +1 -1
  95. package/docs/daemon.md +289 -0
  96. package/package.json +10 -3
  97. package/service-templates/launchd.plist.template +35 -0
  98. package/service-templates/systemd.service.template +19 -0
  99. package/skills/agent-network/SKILL.md +273 -294
@@ -0,0 +1,111 @@
1
+ import type { MeshClient } from "./client.js";
2
+ /**
3
+ * Background poller that watches each Owner's `mesh_versions` envelope on
4
+ * the relay and self-revokes this Pi from any Owner that no longer lists
5
+ * it as a member.
6
+ *
7
+ * Behavior per sweep (one entry per unique Owner in peers.json):
8
+ * 1. Compute `hash = sha256(ownerPk)` (lowercase hex) — the URL slug.
9
+ * MUST match the format the relay stores and the app publishes;
10
+ * mismatch results in silent 404 forever.
11
+ * 2. GET /mesh/<hash>?since=<lastSeenVersion>
12
+ * 3. `null` (304/404) → skip silently (no update, or owner never published)
13
+ * 4. Verify Ed25519 signature against the embedded `owner_pk`
14
+ * 5. Defense-in-depth: confirm the blob's `owner_pk` matches what we
15
+ * expected (otherwise a malicious relay could swap blobs across slots)
16
+ * 6. Anti-rollback: drop versions < our last-seen for this Owner
17
+ * 7. Membership check: decode every `members[].remote_epk` to bytes and
18
+ * compare against this Pi's pubkey bytes. Critical: comparing the
19
+ * base64 strings directly would falsely revoke when the app emits
20
+ * url-safe (`-`/`_`, no padding) and the Pi emits standard
21
+ * (`+`/`/`, padded) — same 32 bytes, different strings. See
22
+ * `encoding.ts` for the helpers and `plan/24` Wave 3 fix history.
23
+ * 8. If not a member → `storage.removePeer(ownerEpk)` and fire
24
+ * `onRevoke(ownerEpk)` so the caller can tear down any live WS
25
+ * sessions for that Owner.
26
+ *
27
+ * Backward-compat: an Owner who never published a mesh blob returns 404
28
+ * forever, which we treat as "no update". Old clients keep working
29
+ * untouched until they upgrade.
30
+ *
31
+ * Spec: plan/24-mesh-membership.md Wave 3.
32
+ */
33
+ /** Minimum storage surface the poller needs. Concrete impl lives in
34
+ * `src/pairing/storage.ts` — injecting it via constructor keeps the class
35
+ * testable without filesystem mocking. */
36
+ export interface SelfRevokeStorage {
37
+ listOwnerPubkeys(): Promise<string[]>;
38
+ removePeer(remoteEpk: string): Promise<boolean>;
39
+ }
40
+ export interface SelfRevokeOptions {
41
+ client: MeshClient;
42
+ storage: SelfRevokeStorage;
43
+ /** This Pi's long-term Ed25519 pubkey, raw 32 bytes. */
44
+ myPubkey: Uint8Array;
45
+ /** Polling cadence. Default 60s — matches the app side (plan/24 Q1). */
46
+ intervalMs?: number;
47
+ /** Fired after `storage.removePeer` succeeds, so callers can tear down
48
+ * any active WS channel for the revoked owner. Receives the base64
49
+ * (standard) of the Owner pubkey that revoked us. */
50
+ onRevoke?: (ownerEpk: string) => void | Promise<void>;
51
+ /** Plan/25 Wave D: fired whenever the set of Pi-pubkeys present in any
52
+ * Owner's mesh_versions changes (membership added, removed, or
53
+ * relabeled). The callback receives the **union** of all current
54
+ * Pi-pubkeys across every known Owner, minus this Pi's own pubkey,
55
+ * so callers can keep `broker_remote.setSiblings()` in sync without
56
+ * re-running discovery themselves. Fires once per `checkOnce()` sweep
57
+ * only when the set genuinely differs from the previous sweep. */
58
+ onMembersChanged?: (siblings: SiblingInfo[]) => void | Promise<void>;
59
+ /** Logging surface — defaults to `console.*`. Tests inject a fake. */
60
+ log?: {
61
+ info(msg: string): void;
62
+ warn(msg: string): void;
63
+ error(msg: string): void;
64
+ };
65
+ }
66
+ /** Sibling info surfaced by `onMembersChanged`. Stays bit-identical to the
67
+ * shape `BrokerRemote.setSiblings` accepts so callers can pass through. */
68
+ export interface SiblingInfo {
69
+ pcLabel: string;
70
+ pcPubkey: string;
71
+ }
72
+ export declare class SelfRevoke {
73
+ private readonly client;
74
+ private readonly storage;
75
+ /** Raw Ed25519 pubkey bytes (32 B). Membership checks decode each
76
+ * `members[].remote_epk` and compare byte-wise — avoids the base64
77
+ * encoding-variant trap (standard vs url-safe). */
78
+ private readonly myPubkey;
79
+ private readonly intervalMs;
80
+ private readonly onRevoke?;
81
+ private readonly onMembersChanged?;
82
+ private readonly log;
83
+ /** Anti-rollback floor: never accept a version <= lastSeen per Owner. */
84
+ private readonly lastSeenVersion;
85
+ /** Plan/25 Wave D: snapshot of the sibling union from the previous
86
+ * sweep, used to detect changes without re-firing `onMembersChanged`
87
+ * on every poll. Keyed by `pcPubkey`. */
88
+ private prevSiblings;
89
+ /** Latest member raw data per owner, captured during `_checkOwner`.
90
+ * Stored as `(pcPubkey, nickname?)` (NOT pre-resolved label) so
91
+ * `_computeSiblingUnion` can pick the best nickname across owners
92
+ * — nickname always wins over fallback, regardless of owner iteration
93
+ * order. See `siblings.ts::discoverSiblings` for the same rule. */
94
+ private readonly membersByOwner;
95
+ private timer;
96
+ constructor(opts: SelfRevokeOptions);
97
+ /** Starts the periodic sweep. Idempotent — a second call is a no-op.
98
+ * Fires one sweep immediately so we don't wait `intervalMs` for the
99
+ * first check. */
100
+ start(): void;
101
+ /** Stops the periodic sweep. In-flight `checkOnce()` calls complete
102
+ * normally — only the timer is cleared. */
103
+ stop(): void;
104
+ /** One sweep across all known Owners. Per-Owner errors are logged but
105
+ * do not stop iteration — we want to keep checking other Owners even
106
+ * if one relay times out or one envelope is malformed. */
107
+ checkOnce(): Promise<void>;
108
+ private _computeSiblingUnion;
109
+ private _siblingSetChanged;
110
+ private _checkOwner;
111
+ }
@@ -0,0 +1,182 @@
1
+ import { createHash } from "node:crypto";
2
+ import { verifyEnvelope } from "./verify.js";
3
+ import { bytesEqual, decodeB64Any } from "./encoding.js";
4
+ const DEFAULT_INTERVAL_MS = 60_000;
5
+ const FALLBACK_LABEL_LEN = 8;
6
+ export class SelfRevoke {
7
+ client;
8
+ storage;
9
+ /** Raw Ed25519 pubkey bytes (32 B). Membership checks decode each
10
+ * `members[].remote_epk` and compare byte-wise — avoids the base64
11
+ * encoding-variant trap (standard vs url-safe). */
12
+ myPubkey;
13
+ intervalMs;
14
+ onRevoke;
15
+ onMembersChanged;
16
+ log;
17
+ /** Anti-rollback floor: never accept a version <= lastSeen per Owner. */
18
+ lastSeenVersion = new Map();
19
+ /** Plan/25 Wave D: snapshot of the sibling union from the previous
20
+ * sweep, used to detect changes without re-firing `onMembersChanged`
21
+ * on every poll. Keyed by `pcPubkey`. */
22
+ prevSiblings = new Map();
23
+ /** Latest member raw data per owner, captured during `_checkOwner`.
24
+ * Stored as `(pcPubkey, nickname?)` (NOT pre-resolved label) so
25
+ * `_computeSiblingUnion` can pick the best nickname across owners
26
+ * — nickname always wins over fallback, regardless of owner iteration
27
+ * order. See `siblings.ts::discoverSiblings` for the same rule. */
28
+ membersByOwner = new Map();
29
+ timer = null;
30
+ constructor(opts) {
31
+ this.client = opts.client;
32
+ this.storage = opts.storage;
33
+ this.myPubkey = opts.myPubkey;
34
+ this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
35
+ this.onRevoke = opts.onRevoke;
36
+ this.onMembersChanged = opts.onMembersChanged;
37
+ this.log = opts.log ?? {
38
+ info: (msg) => console.info(msg),
39
+ warn: (msg) => console.warn(msg),
40
+ error: (msg) => console.error(msg),
41
+ };
42
+ }
43
+ /** Starts the periodic sweep. Idempotent — a second call is a no-op.
44
+ * Fires one sweep immediately so we don't wait `intervalMs` for the
45
+ * first check. */
46
+ start() {
47
+ if (this.timer !== null)
48
+ return;
49
+ void this.checkOnce();
50
+ this.timer = setInterval(() => { void this.checkOnce(); }, this.intervalMs);
51
+ }
52
+ /** Stops the periodic sweep. In-flight `checkOnce()` calls complete
53
+ * normally — only the timer is cleared. */
54
+ stop() {
55
+ if (this.timer !== null) {
56
+ clearInterval(this.timer);
57
+ this.timer = null;
58
+ }
59
+ }
60
+ /** One sweep across all known Owners. Per-Owner errors are logged but
61
+ * do not stop iteration — we want to keep checking other Owners even
62
+ * if one relay times out or one envelope is malformed. */
63
+ async checkOnce() {
64
+ const owners = await this.storage.listOwnerPubkeys();
65
+ for (const ownerEpk of owners) {
66
+ try {
67
+ await this._checkOwner(ownerEpk);
68
+ }
69
+ catch (err) {
70
+ this.log.error(`[mesh] self-revoke check failed for ${ownerEpk.slice(0, 8)}…: ${String(err)}`);
71
+ }
72
+ }
73
+ // Plan/25 Wave D: fire onMembersChanged if the union of siblings
74
+ // across all owners changed since the last sweep. Built outside the
75
+ // per-owner loop so a single owner removing a member doesn't fire
76
+ // until we've seen the other owners (which may still list that Pi
77
+ // and keep it as a sibling overall).
78
+ if (this.onMembersChanged) {
79
+ const union = this._computeSiblingUnion();
80
+ if (this._siblingSetChanged(union)) {
81
+ this.prevSiblings = union;
82
+ try {
83
+ await this.onMembersChanged([...union.values()]);
84
+ }
85
+ catch (err) {
86
+ this.log.error(`[mesh] onMembersChanged callback threw: ${String(err)}`);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ _computeSiblingUnion() {
92
+ const myB64 = Buffer.from(this.myPubkey).toString("base64");
93
+ // pcPubkey → first nickname seen across owners (undefined when no
94
+ // owner has labeled this Pi yet).
95
+ const nicknames = new Map();
96
+ for (const members of this.membersByOwner.values()) {
97
+ for (const m of members) {
98
+ if (m.pcPubkey === myB64)
99
+ continue;
100
+ if (nicknames.get(m.pcPubkey))
101
+ continue; // existing nickname wins
102
+ if (m.nickname) {
103
+ nicknames.set(m.pcPubkey, m.nickname);
104
+ }
105
+ else if (!nicknames.has(m.pcPubkey)) {
106
+ nicknames.set(m.pcPubkey, undefined);
107
+ }
108
+ }
109
+ }
110
+ const out = new Map();
111
+ for (const [pcPubkey, nickname] of nicknames) {
112
+ out.set(pcPubkey, {
113
+ pcPubkey,
114
+ pcLabel: nickname ?? pcPubkey.slice(0, FALLBACK_LABEL_LEN),
115
+ });
116
+ }
117
+ return out;
118
+ }
119
+ _siblingSetChanged(next) {
120
+ if (next.size !== this.prevSiblings.size)
121
+ return true;
122
+ for (const [pk, info] of next) {
123
+ const prior = this.prevSiblings.get(pk);
124
+ if (!prior)
125
+ return true;
126
+ if (prior.pcLabel !== info.pcLabel)
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+ async _checkOwner(ownerEpk) {
132
+ const ownerPk = Uint8Array.from(Buffer.from(ownerEpk, "base64"));
133
+ // Lowercase hex per the cross-language contract (relay + app). Node's
134
+ // `digest('hex')` already produces lowercase by default.
135
+ const hash = createHash("sha256").update(ownerPk).digest("hex");
136
+ const since = this.lastSeenVersion.get(ownerEpk);
137
+ const env = await this.client.get(hash, since);
138
+ if (!env)
139
+ return; // 304 or 404 — nothing to do
140
+ const header = await verifyEnvelope(env);
141
+ // Defense-in-depth: a malicious relay could return a valid-but-different
142
+ // owner's envelope at our slot. The relay should reject this on upload
143
+ // (per plan/24), but we double-check here.
144
+ const headerOwnerB64 = Buffer.from(header.ownerPk).toString("base64");
145
+ if (headerOwnerB64 !== ownerEpk) {
146
+ this.log.warn(`[mesh] owner_pk mismatch for slot ${ownerEpk.slice(0, 8)}…: blob says ${headerOwnerB64.slice(0, 8)}… — ignoring`);
147
+ return;
148
+ }
149
+ const lastSeen = this.lastSeenVersion.get(ownerEpk) ?? 0;
150
+ if (header.version < lastSeen) {
151
+ this.log.warn(`[mesh] anti-rollback: dropped v${header.version} < lastSeen v${lastSeen} for ${ownerEpk.slice(0, 8)}…`);
152
+ return;
153
+ }
154
+ this.lastSeenVersion.set(ownerEpk, header.version);
155
+ // Plan/25 Wave D: capture raw nickname (NOT pre-resolved label) per
156
+ // owner. `_computeSiblingUnion` picks the best label across owners
157
+ // — nickname always beats fallback. Pre-resolving here was the bug
158
+ // that produced different pc_labels on different Pis when Owner A
159
+ // labeled but Owner B didn't.
160
+ this.membersByOwner.set(ownerEpk, header.members.map((m) => ({
161
+ pcPubkey: m.remoteEpk,
162
+ ...(m.nickname ? { nickname: m.nickname } : {}),
163
+ })));
164
+ // Decode every member's pubkey to bytes and compare against our own.
165
+ // The app may emit base64 url-safe (`-`/`_`, no padding) while the Pi
166
+ // emits standard (`+`/`/`, padded) — same bytes, different strings.
167
+ // String equality on those would falsely revoke us. See `encoding.ts`.
168
+ const stillMember = header.members.some((m) => bytesEqual(decodeB64Any(m.remoteEpk), this.myPubkey));
169
+ if (stillMember)
170
+ return;
171
+ // Log the EXACT version observed (from the relay's blob) plus the
172
+ // `since` cursor we sent — disambiguates poll-time state from any
173
+ // SQLite snapshot the operator might take later.
174
+ this.log.info(`[mesh] self-revoked from owner ${ownerEpk.slice(0, 8)}… ` +
175
+ `(received v${header.version}, since=${since ?? "<none>"}, ` +
176
+ `members=${header.members.length})`);
177
+ await this.storage.removePeer(ownerEpk);
178
+ if (this.onRevoke)
179
+ await this.onRevoke(ownerEpk);
180
+ }
181
+ }
182
+ //# sourceMappingURL=self_revoke.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"self_revoke.js","sourceRoot":"","sources":["../../src/mesh/self_revoke.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,62 @@
1
+ import type { MeshClient } from "./client.js";
2
+ /**
3
+ * Plan/25 — discover Pis-irmãos of every Owner this Pi is paired with.
4
+ *
5
+ * For each Owner pubkey (from `peers.json`), pulls the latest signed
6
+ * `mesh_versions` blob from the relay, verifies it, and walks the members
7
+ * list to extract all Pi-pubkeys other than this one. The same member may
8
+ * appear under multiple Owners — we de-dupe by Pi-pubkey.
9
+ *
10
+ * Returned `pcLabel` priority:
11
+ * 1. `member.nickname` (set by the Owner at pairing time)
12
+ * 2. First 8 chars of the base64-encoded Pi-pubkey (defensive fallback —
13
+ * keeps cross-PC addressing working even when nicknames are missing)
14
+ *
15
+ * Tolerates per-owner errors: a missing/malformed blob for one Owner does
16
+ * NOT prevent siblings of other Owners from being discovered. Logs and
17
+ * continues.
18
+ */
19
+ export interface SiblingPi {
20
+ pcLabel: string;
21
+ pcPubkey: string;
22
+ }
23
+ export interface DiscoverSelfLabelResult {
24
+ /** This Pi's effective `pc_label` (nickname when any Owner has set one;
25
+ * pubkey prefix fallback otherwise). */
26
+ selfPcLabel: string;
27
+ }
28
+ export interface DiscoverOptions {
29
+ client: MeshClient;
30
+ ownerEpks: string[];
31
+ myPubkey: Uint8Array;
32
+ log?: {
33
+ warn(msg: string): void;
34
+ };
35
+ }
36
+ /** Derive the fallback label from a base64-encoded Pi pubkey. */
37
+ export declare function fallbackLabel(pcPubkey: string): string;
38
+ /**
39
+ * Resolve self pc_label by scanning every Owner's mesh blob for an entry
40
+ * matching `myPubkey`. Returns the first nickname found; falls back to the
41
+ * base64 prefix when no Owner has labeled us.
42
+ */
43
+ export declare function discoverSelfLabel(opts: DiscoverOptions): Promise<DiscoverSelfLabelResult>;
44
+ /**
45
+ * Enumerate Pis-irmãos across all Owners. De-duplicated by `pcPubkey`.
46
+ * Excludes `myPubkey`.
47
+ *
48
+ * Label resolution rule (anti-asymmetry — see plan/25 Wave D fix):
49
+ * 1. Scan EVERY Owner blob first, collecting all distinct sibling
50
+ * pubkeys and any nicknames seen for each.
51
+ * 2. For each pubkey, pick label as: first nickname encountered (if
52
+ * any Owner labeled this Pi), else `fallbackLabel(pubkey)`.
53
+ *
54
+ * Why: if two Pis are paired to the same set of Owners but only some
55
+ * Owners labeled them, naive first-wins dedup can pick the unlabeled
56
+ * occurrence and discard the labeled one — producing different
57
+ * `pc_label`s between Pis (PC-A's `discoverSelfLabel` skips non-labeled,
58
+ * but old `discoverSiblings` didn't). The asymmetry triggers anti-spoof
59
+ * drops in `broker_remote.handleIncoming`. This rule keeps both sides in
60
+ * sync: nickname always wins over fallback.
61
+ */
62
+ export declare function discoverSiblings(opts: DiscoverOptions): Promise<SiblingPi[]>;
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { verifyEnvelope } from "./verify.js";
3
+ import { bytesEqual, decodeB64Any } from "./encoding.js";
4
+ const FALLBACK_LABEL_LEN = 8;
5
+ /** Derive the fallback label from a base64-encoded Pi pubkey. */
6
+ export function fallbackLabel(pcPubkey) {
7
+ return pcPubkey.slice(0, FALLBACK_LABEL_LEN);
8
+ }
9
+ /**
10
+ * Resolve self pc_label by scanning every Owner's mesh blob for an entry
11
+ * matching `myPubkey`. Returns the first nickname found; falls back to the
12
+ * base64 prefix when no Owner has labeled us.
13
+ */
14
+ export async function discoverSelfLabel(opts) {
15
+ const log = opts.log ?? { warn: (m) => console.warn(m) };
16
+ const myB64 = Buffer.from(opts.myPubkey).toString("base64");
17
+ for (const ownerEpk of opts.ownerEpks) {
18
+ try {
19
+ const env = await _fetchOwnerBlob(opts.client, ownerEpk);
20
+ if (!env)
21
+ continue;
22
+ const header = await verifyEnvelope(env);
23
+ for (const m of header.members) {
24
+ if (bytesEqual(decodeB64Any(m.remoteEpk), opts.myPubkey) && m.nickname) {
25
+ return { selfPcLabel: m.nickname };
26
+ }
27
+ }
28
+ }
29
+ catch (err) {
30
+ log.warn(`[siblings] self-label fetch failed for owner ${ownerEpk.slice(0, 8)}…: ${String(err)}`);
31
+ }
32
+ }
33
+ return { selfPcLabel: fallbackLabel(myB64) };
34
+ }
35
+ /**
36
+ * Enumerate Pis-irmãos across all Owners. De-duplicated by `pcPubkey`.
37
+ * Excludes `myPubkey`.
38
+ *
39
+ * Label resolution rule (anti-asymmetry — see plan/25 Wave D fix):
40
+ * 1. Scan EVERY Owner blob first, collecting all distinct sibling
41
+ * pubkeys and any nicknames seen for each.
42
+ * 2. For each pubkey, pick label as: first nickname encountered (if
43
+ * any Owner labeled this Pi), else `fallbackLabel(pubkey)`.
44
+ *
45
+ * Why: if two Pis are paired to the same set of Owners but only some
46
+ * Owners labeled them, naive first-wins dedup can pick the unlabeled
47
+ * occurrence and discard the labeled one — producing different
48
+ * `pc_label`s between Pis (PC-A's `discoverSelfLabel` skips non-labeled,
49
+ * but old `discoverSiblings` didn't). The asymmetry triggers anti-spoof
50
+ * drops in `broker_remote.handleIncoming`. This rule keeps both sides in
51
+ * sync: nickname always wins over fallback.
52
+ */
53
+ export async function discoverSiblings(opts) {
54
+ const log = opts.log ?? { warn: (m) => console.warn(m) };
55
+ // pcPubkey → first nickname seen across owners (or undefined if none).
56
+ const labels = new Map();
57
+ for (const ownerEpk of opts.ownerEpks) {
58
+ try {
59
+ const env = await _fetchOwnerBlob(opts.client, ownerEpk);
60
+ if (!env)
61
+ continue;
62
+ const header = await verifyEnvelope(env);
63
+ for (const m of header.members) {
64
+ if (bytesEqual(decodeB64Any(m.remoteEpk), opts.myPubkey))
65
+ continue;
66
+ const existing = labels.get(m.remoteEpk);
67
+ if (existing)
68
+ continue; // first-nickname wins; never overwrite a real label
69
+ if (m.nickname) {
70
+ labels.set(m.remoteEpk, m.nickname);
71
+ }
72
+ else if (!labels.has(m.remoteEpk)) {
73
+ // Seen pubkey for the first time, no nickname yet — record the
74
+ // slot so later iterations may upgrade it via the `if (existing)
75
+ // continue` check above only matching truthy nicknames.
76
+ labels.set(m.remoteEpk, undefined);
77
+ }
78
+ }
79
+ }
80
+ catch (err) {
81
+ log.warn(`[siblings] discover failed for owner ${ownerEpk.slice(0, 8)}…: ${String(err)}`);
82
+ }
83
+ }
84
+ const out = [];
85
+ for (const [pcPubkey, nickname] of labels) {
86
+ out.push({ pcPubkey, pcLabel: nickname ?? fallbackLabel(pcPubkey) });
87
+ }
88
+ return out;
89
+ }
90
+ async function _fetchOwnerBlob(client, ownerEpk) {
91
+ const ownerPk = Uint8Array.from(Buffer.from(ownerEpk, "base64"));
92
+ const hash = createHash("sha256").update(ownerPk).digest("hex");
93
+ return client.get(hash);
94
+ }
95
+ //# sourceMappingURL=siblings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"siblings.js","sourceRoot":"","sources":["../../src/mesh/siblings.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Mesh-membership types — pi-extension side. Mirrors the wire shape defined
3
+ * in plan/24-mesh-membership.md and must stay bit-compatible with the Dart
4
+ * (app) and Rust (relay) implementations of the same protocol.
5
+ *
6
+ * On the wire, JSON field names are `snake_case` (per the plan); this module
7
+ * exposes `camelCase` for ergonomic TS use. Conversion happens at the
8
+ * (de)serialization boundary in `verify.ts` / `canonical.ts`.
9
+ */
10
+ export interface MeshMember {
11
+ /** Pi long-term Ed25519 pubkey, base64 standard (matches PeerRecord.remote_epk). */
12
+ remoteEpk: string;
13
+ /** Relay URL where this member registers. */
14
+ relayUrl: string;
15
+ /** ISO-8601 timestamp of when the pairing happened. */
16
+ pairedAt: string;
17
+ /** Optional Owner-set label. */
18
+ nickname?: string;
19
+ }
20
+ export interface MeshHeader {
21
+ /** Owner-scoped monotonic counter. Higher = newer. */
22
+ version: number;
23
+ /** Issued-at, ms since epoch. */
24
+ issuedAt: number;
25
+ /** Owner's Ed25519 pubkey, raw 32 bytes. */
26
+ ownerPk: Uint8Array;
27
+ members: MeshMember[];
28
+ }
29
+ export interface MeshEnvelope {
30
+ /** Canonical JSON bytes of the header (snake_case keys, sorted, no whitespace). */
31
+ blob: Uint8Array;
32
+ /** Ed25519 signature of `blob`, 64 bytes. */
33
+ sig: Uint8Array;
34
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Mesh-membership types — pi-extension side. Mirrors the wire shape defined
3
+ * in plan/24-mesh-membership.md and must stay bit-compatible with the Dart
4
+ * (app) and Rust (relay) implementations of the same protocol.
5
+ *
6
+ * On the wire, JSON field names are `snake_case` (per the plan); this module
7
+ * exposes `camelCase` for ergonomic TS use. Conversion happens at the
8
+ * (de)serialization boundary in `verify.ts` / `canonical.ts`.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/mesh/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
@@ -0,0 +1,17 @@
1
+ import type { MeshEnvelope, MeshHeader } from "./types.js";
2
+ /**
3
+ * Verifies the Ed25519 signature on a mesh envelope and decodes the blob
4
+ * into a typed `MeshHeader`.
5
+ *
6
+ * The verification key is extracted *from the blob* (`owner_pk` field) —
7
+ * the caller MUST then check that `sha256(header.ownerPk)` matches the
8
+ * URL hash they queried with. Otherwise a malicious relay could serve a
9
+ * valid-but-different-owner blob at our hash slot.
10
+ *
11
+ * Throws on:
12
+ * - JSON parse failure
13
+ * - Missing or wrong-type required fields
14
+ * - `owner_pk` not 32 bytes
15
+ * - Signature mismatch
16
+ */
17
+ export declare function verifyEnvelope(env: MeshEnvelope): Promise<MeshHeader>;
@@ -0,0 +1,77 @@
1
+ import { ed25519Verify } from "../pairing/crypto.js";
2
+ /**
3
+ * Verifies the Ed25519 signature on a mesh envelope and decodes the blob
4
+ * into a typed `MeshHeader`.
5
+ *
6
+ * The verification key is extracted *from the blob* (`owner_pk` field) —
7
+ * the caller MUST then check that `sha256(header.ownerPk)` matches the
8
+ * URL hash they queried with. Otherwise a malicious relay could serve a
9
+ * valid-but-different-owner blob at our hash slot.
10
+ *
11
+ * Throws on:
12
+ * - JSON parse failure
13
+ * - Missing or wrong-type required fields
14
+ * - `owner_pk` not 32 bytes
15
+ * - Signature mismatch
16
+ */
17
+ export async function verifyEnvelope(env) {
18
+ let parsed;
19
+ try {
20
+ parsed = JSON.parse(new TextDecoder().decode(env.blob));
21
+ }
22
+ catch (e) {
23
+ throw new Error(`mesh: blob is not valid JSON: ${e.message}`);
24
+ }
25
+ if (!parsed || typeof parsed !== "object") {
26
+ throw new Error("mesh: blob is not a JSON object");
27
+ }
28
+ const o = parsed;
29
+ if (typeof o["owner_pk"] !== "string") {
30
+ throw new Error("mesh: owner_pk missing or not a string");
31
+ }
32
+ if (typeof o["version"] !== "number" || !Number.isInteger(o["version"])) {
33
+ throw new Error("mesh: version missing or not an integer");
34
+ }
35
+ if (typeof o["issued_at"] !== "number" || !Number.isInteger(o["issued_at"])) {
36
+ throw new Error("mesh: issued_at missing or not an integer");
37
+ }
38
+ if (!Array.isArray(o["members"])) {
39
+ throw new Error("mesh: members missing or not an array");
40
+ }
41
+ const ownerPk = Uint8Array.from(Buffer.from(o["owner_pk"], "base64"));
42
+ if (ownerPk.length !== 32) {
43
+ throw new Error(`mesh: owner_pk wrong length (${ownerPk.length}, expected 32)`);
44
+ }
45
+ if (!ed25519Verify(ownerPk, env.blob, env.sig)) {
46
+ throw new Error("mesh: signature verification failed");
47
+ }
48
+ const members = o["members"].map((raw, i) => {
49
+ if (!raw || typeof raw !== "object") {
50
+ throw new Error(`mesh: members[${i}] is not an object`);
51
+ }
52
+ const m = raw;
53
+ if (typeof m["remote_epk"] !== "string") {
54
+ throw new Error(`mesh: members[${i}].remote_epk invalid`);
55
+ }
56
+ if (typeof m["relay_url"] !== "string") {
57
+ throw new Error(`mesh: members[${i}].relay_url invalid`);
58
+ }
59
+ if (typeof m["paired_at"] !== "string") {
60
+ throw new Error(`mesh: members[${i}].paired_at invalid`);
61
+ }
62
+ const nickname = m["nickname"];
63
+ return {
64
+ remoteEpk: m["remote_epk"],
65
+ relayUrl: m["relay_url"],
66
+ pairedAt: m["paired_at"],
67
+ ...(typeof nickname === "string" ? { nickname } : {}),
68
+ };
69
+ });
70
+ return {
71
+ version: o["version"],
72
+ issuedAt: o["issued_at"],
73
+ ownerPk,
74
+ members,
75
+ };
76
+ }
77
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../../src/mesh/verify.ts"],"names":[],"mappings":"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"}
@@ -26,11 +26,22 @@ sessionName: string,
26
26
  */
27
27
  roomId?: string): string;
28
28
  /**
29
- * Renders the QR + URI in the Pi TUI's output pane via stderr. `ctx.ui.notify`
30
- * collapses multi-line content into a single toast, so for ASCII art we need
31
- * the raw stderr capture that the Pi TUI exposes as scrollable log. Post
32
- * plano 14 the QR carries only `t/epk/n`, fits comfortably in the panel
33
- * without needing a separate Terminal window.
29
+ * Returns the QR ASCII as a string (pure Unicode block characters
30
+ * `█ ▄` and space, NO ANSI escapes qrcode-terminal v0.12 small mode
31
+ * is escape-free, see lib/main.js:48-53).
32
+ *
33
+ * The caller can either write the string to stderr (legacy path, breaks
34
+ * the Pi TUI layout) or inject it via `pi.sendMessage` (renders inside
35
+ * the chat panel as proper content).
36
+ */
37
+ export declare function renderQRAscii(uri: string): string;
38
+ /**
39
+ * Legacy stderr writer — kept for the standalone CLI mode
40
+ * (`pi-extension/src/index.ts` bottom block, which runs outside a Pi TUI).
41
+ * Inside the Pi TUI extension flow, use `renderQRAscii` + `pi.sendMessage`
42
+ * instead — direct stderr writes from inside an extension break the TUI's
43
+ * scrollable output widget (the QR overflows the panel and other writes
44
+ * collide with the prompt area).
34
45
  */
35
46
  export declare function displayQR(uri: string): void;
36
47
  /**