@fairfox/polly 0.70.0 → 0.71.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.
@@ -45,6 +45,41 @@ import { type SigningKeyPair } from "./signing";
45
45
  * encryption mode. See the file-level comment for the per-document key
46
46
  * follow-up. */
47
47
  export declare const DEFAULT_MESH_KEY_ID = "polly-mesh-default";
48
+ /**
49
+ * Control-message type tags. Every mesh message carries a single tag
50
+ * byte at the front of its plaintext payload, after decryption and
51
+ * signature verification. The tag discriminates the inner contents so
52
+ * future control flows (RFC-043 revocation propagation, future RFCs)
53
+ * can share the same encrypted-signed envelope without re-versioning.
54
+ *
55
+ * 0x00 is the default Automerge sync-message channel that polly has
56
+ * carried since the first cut. 0x01 and 0x02 are reserved for the
57
+ * revocation flow designed in RFC-043; the adapter recognises them at
58
+ * receive time but does not yet act on the payload — that lands in
59
+ * the second RFC-043 PR. Tags 0x03 and above are unassigned; a
60
+ * receiver that sees one emits `drop:unknown-control-type` and the
61
+ * message is dropped.
62
+ *
63
+ * Wire format break: polly 0.70 and earlier do not prepend the tag
64
+ * and do not strip it on receive. Mixing 0.70 and 0.71-plus peers on
65
+ * the same mesh produces garbage on both sides. The break is
66
+ * acknowledged in RFC-043 and required for the protocol-level
67
+ * revocation feature; the alternative (a separate WebRTC data
68
+ * channel) doubles transport complexity.
69
+ */
70
+ export declare const MESH_CONTROL_TYPE: {
71
+ /** Automerge sync message — the default channel. */
72
+ readonly Sync: 0;
73
+ /** A signed RevocationRecord (RFC-043). Receive-side dispatch is
74
+ * wired in this PR; the apply-and-persist behaviour lands in the
75
+ * next RFC-043 PR. */
76
+ readonly Revocation: 1;
77
+ /** A revocation-set summary exchanged on every new peer connection
78
+ * to gossip revocations to peers that were offline at issue-time
79
+ * (RFC-043). Same staging as Revocation. */
80
+ readonly RevocationSummary: 2;
81
+ };
82
+ export type MeshControlType = (typeof MESH_CONTROL_TYPE)[keyof typeof MESH_CONTROL_TYPE];
48
83
  /**
49
84
  * A mesh keyring holds the local peer's signing identity, the public keys
50
85
  * of every peer the local node will accept messages from, the symmetric
@@ -106,6 +141,22 @@ export interface MeshNetworkAdapterOptions {
106
141
  * messages. Defaults to true (encrypt + sign, the full $meshState
107
142
  * posture). */
108
143
  encryptionEnabled?: boolean;
144
+ /**
145
+ * Optional handler for non-Sync control messages (RFC-043). Called
146
+ * after signature verification and decryption with the type tag,
147
+ * the body bytes that followed the tag, and the verified senderId.
148
+ * The handler owns the apply-and-persist behaviour for whichever
149
+ * control flow the tag belongs to; the adapter routes only.
150
+ *
151
+ * The handler is called *after* the corresponding `ctrl:*-received`
152
+ * diagnostic fires, so subscribers observing the low-level signal
153
+ * see the event before the handler mutates any application state.
154
+ * Handler exceptions are swallowed by the adapter — a buggy handler
155
+ * cannot tear the network path — but a diagnostic
156
+ * `drop:control-handler-threw` is emitted so the failure is
157
+ * observable.
158
+ */
159
+ onControlMessage?: (tag: number, body: Uint8Array, senderId: string) => void;
109
160
  }
110
161
  /**
111
162
  * NetworkAdapter that wraps another adapter with Polly's mesh-transport
@@ -121,6 +172,7 @@ export declare class MeshNetworkAdapter extends NetworkAdapter {
121
172
  readonly base: NetworkAdapter;
122
173
  readonly keyringSource: () => MeshKeyring;
123
174
  readonly encryptionEnabled: boolean;
175
+ readonly onControlMessage: ((tag: number, body: Uint8Array, senderId: string) => void) | undefined;
124
176
  /** Read-only view of the current keyring. Each access calls
125
177
  * {@link MeshNetworkAdapterOptions.keyringSource}, so the value
126
178
  * reflects whatever mutations or swaps the caller has applied since
@@ -137,7 +189,46 @@ export declare class MeshNetworkAdapter extends NetworkAdapter {
137
189
  * Wrap an outgoing Automerge message in an encrypt-then-sign envelope.
138
190
  * The wrapped payload is returned as a Message with the original sender
139
191
  * and target ids and the crypto blob in the `data` field.
192
+ *
193
+ * The serialised Automerge message is prefixed with a one-byte
194
+ * control-type tag (RFC-043). For outgoing Automerge sync this is
195
+ * always {@link MESH_CONTROL_TYPE.Sync} (0x00); the tag exists so
196
+ * future control flows (revocation propagation, revocation-set
197
+ * summaries) can share the same encrypted-signed envelope without
198
+ * re-versioning the wire format.
140
199
  */
141
200
  private wrap;
142
201
  private tryUnwrap;
202
+ /**
203
+ * Dispatch a verified-and-decrypted plaintext payload based on its
204
+ * one-byte control-type tag (RFC-043). For {@link MESH_CONTROL_TYPE.Sync}
205
+ * the remainder is the serialised Automerge message and is returned
206
+ * to the caller. For revocation tags the dispatch emits a
207
+ * `ctrl:*-received` diagnostic and drops the payload at this layer
208
+ * pending the next RFC-043 PR. Unknown tags emit
209
+ * `drop:unknown-control-type` and drop.
210
+ */
211
+ private dispatchTaggedPayload;
212
+ private invokeControlHandler;
213
+ /**
214
+ * Send a control message (RFC-043) to a list of connected peers.
215
+ * The body is wrapped in the same encrypt-sign envelope Automerge
216
+ * sync uses; the type tag goes in front so receivers route it to
217
+ * {@link MeshNetworkAdapterOptions.onControlMessage}.
218
+ *
219
+ * The base adapter does not surface its connected-peer set on the
220
+ * NetworkAdapter interface, so the caller passes the targets
221
+ * explicitly. The MeshClient layer maintains that list from the
222
+ * `peer-candidate` / `peer-disconnected` events.
223
+ */
224
+ sendControlMessage(tag: MeshControlType, body: Uint8Array, targetPeerIds: ReadonlyArray<PeerId>): void;
143
225
  }
226
+ /**
227
+ * Prepend a one-byte control-type tag to a payload. The tag goes
228
+ * inside the encrypted-signed envelope (or directly inside the signed
229
+ * envelope in sign-only mode), so it is both confidential (encrypted
230
+ * when encryption is enabled) and authenticated (covered by the
231
+ * signature). Exposed so future RFC-043 issue helpers can build
232
+ * tagged payloads without rebuilding this primitive.
233
+ */
234
+ export declare function prependControlTag(tag: MeshControlType, body: Uint8Array): Uint8Array;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * revocation-summary — RFC-043 wire format for the revocation-set
3
+ * summary exchanged on every new peer connection.
4
+ *
5
+ * On a fresh data channel both peers send the other a summary of
6
+ * every revocation they currently hold. After exchange each side
7
+ * computes which entries in its own set are missing from the
8
+ * remote's summary, and pushes those entries as full encoded
9
+ * revocation blobs (tag 0x01). The summary itself is informative,
10
+ * not authoritative — only the signed blob can mutate a keyring.
11
+ *
12
+ * Wire format: UTF-8 JSON. A summary is a sorted JSON array of
13
+ * entries, each carrying the fields needed to detect set-difference
14
+ * without leaking the blob's signature. Sorting is canonical so two
15
+ * peers comparing summaries agree on order even when their stores
16
+ * were populated in different sequences.
17
+ *
18
+ * [
19
+ * { "r": "<revokedPeerId>", "i": "<issuerPeerId>", "t": <issuedAt> },
20
+ * ...
21
+ * ]
22
+ *
23
+ * Short field names keep the wire compact for keyrings with many
24
+ * revocations. Long-form parsing isn't a goal — the format is
25
+ * internal to the mesh protocol.
26
+ */
27
+ /** A single entry in a revocation-set summary. */
28
+ export interface RevocationSummaryEntry {
29
+ /** Peer id the revocation targets. */
30
+ revokedPeerId: string;
31
+ /** Peer id that issued the revocation. */
32
+ issuerPeerId: string;
33
+ /** Unix milliseconds at issue time, from the underlying RevocationRecord. */
34
+ issuedAt: number;
35
+ }
36
+ /**
37
+ * Encode a list of revocation entries to the canonical wire
38
+ * representation. Entries are sorted by `revokedPeerId` then
39
+ * `issuerPeerId` then `issuedAt` to make the comparison
40
+ * order-independent.
41
+ */
42
+ export declare function encodeRevocationSummary(entries: ReadonlyArray<RevocationSummaryEntry>): Uint8Array;
43
+ /**
44
+ * Inverse of {@link encodeRevocationSummary}. Throws on malformed
45
+ * input — the caller is expected to drop the summary and emit a
46
+ * diagnostic when this happens, not retry.
47
+ */
48
+ export declare function decodeRevocationSummary(bytes: Uint8Array): RevocationSummaryEntry[];
49
+ /**
50
+ * Stable key for a summary entry. Two entries with the same key
51
+ * represent the same logical revocation. Used by the
52
+ * peer-candidate gossip path to compute set differences.
53
+ */
54
+ export declare function summaryEntryKey(entry: RevocationSummaryEntry): string;
@@ -20,7 +20,7 @@
20
20
  */
21
21
  export { type ConsolePattern, isAllowedConsoleLine, MESH_CONSOLE_ALLOWLIST, } from "./console-allowlist";
22
22
  export { knownPeersFor, type PrebakedKeyringPair, type PrebakedKeyringSet, type PrebakedPeer, prebakeKeyringPair, prebakeKeyringSet, } from "./keys";
23
- export { type CapturedConsoleLine, type LaunchedPeer, type LaunchPeerOptions, launchPeer, } from "./launch-peer";
23
+ export { type CapturedConsoleLine, type LaunchedPeer, type LaunchPeerOptions, type LaunchSecondTabOptions, launchPeer, launchSecondTab, } from "./launch-peer";
24
24
  export { type DiagnosticRecorder, MeshAssertionError, type MeshAssertionFailure, startDiagnosticRecorder, } from "./mesh-assertions";
25
25
  export { type ServeConsumerOptions, type ServeConsumerResult, serveConsumer, } from "./serve-consumer";
26
26
  export { type ConvergencePredicate, type PeerSnapshot, type WaitForConvergenceOptions, waitForConvergence, waitForMeshConnected, } from "./wait-for-convergence";
@@ -806,6 +806,7 @@ ${summary}
806
806
  return {
807
807
  peerId,
808
808
  page,
809
+ browser,
809
810
  console: consoleLines,
810
811
  pageErrors,
811
812
  assertNoUnexpectedConsole,
@@ -827,6 +828,98 @@ ${summary}
827
828
  }
828
829
  };
829
830
  }
831
+ async function launchSecondTab(parent, options) {
832
+ const {
833
+ consumerUrl,
834
+ consoleAllowlist = MESH_CONSOLE_ALLOWLIST,
835
+ readyTimeoutMs = 15000
836
+ } = options;
837
+ const page = await parent.browser.newPage();
838
+ const consoleLines = [];
839
+ const pageErrors = [];
840
+ page.on("console", (msg) => {
841
+ const level = msg.type();
842
+ const text = msg.text();
843
+ const allowed = isAllowedConsoleLine({ level, text }, consoleAllowlist);
844
+ consoleLines.push({ level, text, allowed });
845
+ });
846
+ page.on("pageerror", (err) => {
847
+ pageErrors.push(err instanceof Error ? err.message : String(err));
848
+ });
849
+ await page.goto(consumerUrl, { waitUntil: "domcontentloaded" });
850
+ const deadline = Date.now() + readyTimeoutMs;
851
+ let ready = false;
852
+ let lastStatus = "";
853
+ while (Date.now() < deadline) {
854
+ lastStatus = await page.evaluate(() => document.querySelector("[data-e2e='status']")?.textContent ?? "");
855
+ if (lastStatus === "ready") {
856
+ ready = true;
857
+ break;
858
+ }
859
+ if (lastStatus.startsWith("error") || lastStatus.startsWith("bootstrap-failed")) {
860
+ await page.close();
861
+ throw new Error(`launchSecondTab(${parent.peerId}): consumer reported "${lastStatus}"`);
862
+ }
863
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
864
+ }
865
+ if (!ready) {
866
+ await page.close();
867
+ throw new Error(`launchSecondTab(${parent.peerId}): consumer did not reach "ready" within ${readyTimeoutMs}ms (last status: "${lastStatus}")`);
868
+ }
869
+ function assertNoUnexpectedConsole() {
870
+ const bad = consoleLines.filter((line) => !line.allowed && (line.level === "error" || line.level === "warn" || line.level === "warning"));
871
+ if (bad.length > 0) {
872
+ const summary = bad.map((l) => ` [${l.level}] ${l.text}`).join(`
873
+ `);
874
+ throw new Error(`launchSecondTab(${parent.peerId}): unexpected console output:
875
+ ${summary}`);
876
+ }
877
+ if (pageErrors.length > 0) {
878
+ throw new Error(`launchSecondTab(${parent.peerId}): page errors:
879
+ ${pageErrors.map((e) => ` ${e}`).join(`
880
+ `)}`);
881
+ }
882
+ }
883
+ async function collectDiagnostics() {
884
+ return page.evaluate(() => {
885
+ const w = window;
886
+ return w.__pollyE2eDiagnostics ? [...w.__pollyE2eDiagnostics] : [];
887
+ });
888
+ }
889
+ async function assertNoSilentDrops(allow = []) {
890
+ const allowed = new Set(allow);
891
+ const events = await collectDiagnostics();
892
+ const unexpected = events.filter((event) => event.kind.startsWith("drop:") && !allowed.has(event.kind));
893
+ if (unexpected.length === 0)
894
+ return;
895
+ const summary = unexpected.map((event) => {
896
+ const { kind, timestamp: _ts, ...rest } = event;
897
+ return ` ${kind} ${JSON.stringify(rest)}`;
898
+ }).join(`
899
+ `);
900
+ throw new MeshAssertionError(`launchSecondTab(${parent.peerId}): unexpected silent-drop diagnostics fired during the e2e run.
901
+ ${summary}`, unexpected);
902
+ }
903
+ let closed = false;
904
+ return {
905
+ peerId: parent.peerId,
906
+ page,
907
+ browser: parent.browser,
908
+ console: consoleLines,
909
+ pageErrors,
910
+ assertNoUnexpectedConsole,
911
+ collectDiagnostics,
912
+ assertNoSilentDrops,
913
+ close: async () => {
914
+ if (closed)
915
+ return;
916
+ closed = true;
917
+ try {
918
+ await page.close();
919
+ } catch {}
920
+ }
921
+ };
922
+ }
830
923
  // tools/test/src/e2e-mesh/serve-consumer.ts
831
924
  var {readFileSync} = (() => ({}));
832
925
  var __dirname = "/Users/AJT/projects/polly/packages/polly/tools/test/src/e2e-mesh";
@@ -1079,6 +1172,7 @@ export {
1079
1172
  serveConsumer,
1080
1173
  prebakeKeyringSet,
1081
1174
  prebakeKeyringPair,
1175
+ launchSecondTab,
1082
1176
  launchPeer,
1083
1177
  knownPeersFor,
1084
1178
  isAllowedConsoleLine,
@@ -1086,4 +1180,4 @@ export {
1086
1180
  MESH_CONSOLE_ALLOWLIST
1087
1181
  };
1088
1182
 
1089
- //# debugId=7F448F738B995B3064756E2164756E21
1183
+ //# debugId=2D797840A9246AEE64756E2164756E21