From d979427a7b64db72b0eedf38eecde12f3a3306c4 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 31 Jul 2026 14:15:31 -0400 Subject: [PATCH] fix(read-state): structured decode for NIP-RS override layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat-map decode path with a parseContexts() function that returns {frontiers, overrides} — separate namespaces keyed by raw context ID. This fixes three correctness defects in the previous implementation: - Frontier keys were stored with their esc: prefix intact, so isOverrideActive evaluated against the wrong frontier for any raw context ID starting with ov_ or esc: (e.g. raw ov_s:evil had its frontier stored as esc:ov_s:evil while the override register was keyed by ov_s:evil). - The 256-byte UTF-8 key-length check was applied only to frontier entries; ov_* sibling keys were written back unchecked, allowing wire keys up to 5 + suffix bytes through. - extractOverrideRegisters accepted an arbitrary flat map and zero-filled missing components, reconstructing partial groups that group-first validation had already rejected. Changes: - readStateFormat.ts: add ParsedContexts interface and parseContexts(); make partitionOverrideGroups internal; add 256-byte enforcement to every wire key (frontier and override siblings alike); fix the sanitizeContexts doc comment. - readStateSnapshot.ts: add contexts: ParsedContexts to ParsedReadStateEvent; parseReadStateEvent populates both blob.contexts (flat, for readStateManager backward compat) and contexts (structured); mergeReadStateEvents iterates parsed.contexts.frontiers so callers receive unescaped raw ctx IDs; remove extractOverrideRegisters; update mergeOverrideRegisterMaps to accept ReadonlyMap inputs. - readStateOverride.test.mjs: rewrite around parseContexts(); add the full partial-group rejection matrix (S-only, B-only, S+B, C+B, S+C), invalid sibling at each position, uint32 boundary values, 256/257-byte key-length cases for live and floor shapes, multibyte UTF-8 boundary, and Thufir's exact collision witness (raw ctx ov_s:evil alongside ctx evil with its own ov_s:evil counter). 3968/3968 pass. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../channels/readState/readStateFormat.ts | 253 ++++++-- .../readState/readStateOverride.test.mjs | 586 +++++++++++++----- .../channels/readState/readStateSnapshot.ts | 79 +-- 3 files changed, 660 insertions(+), 258 deletions(-) diff --git a/desktop/src/features/channels/readState/readStateFormat.ts b/desktop/src/features/channels/readState/readStateFormat.ts index 3699e28982..3e848c7144 100644 --- a/desktop/src/features/channels/readState/readStateFormat.ts +++ b/desktop/src/features/channels/readState/readStateFormat.ts @@ -97,8 +97,8 @@ export function unescapeFrontierKey(wireKey: string): string { * * Slice 2 (manager) and slice 3 (UI) use this type as the wire-in/wire-out * contract. Slices MUST NOT construct registers directly — use - * `encodeOverrideGroup` for encoding and rely on `extractOverrideRegisters` - * (snapshot.ts) for decoding. + * `encodeOverrideGroup` for encoding and read `ParsedReadStateEvent.contexts.overrides` + * (via `parseContexts`) for decoding. */ export interface OverrideRegister { /** Set counter S — incremented on each mark-unread. */ @@ -193,90 +193,224 @@ export function encodeOverrideGroup( } // --------------------------------------------------------------------------- -// Override group validation — used inside sanitizeContexts -// -// The spec requires that override entries be validated as a complete logical -// group BEFORE any per-entry processing. This function partitions the raw -// contexts map into (a) validated override groups and (b) everything else, -// so that sanitizeContexts can apply group-first rules without mixing the two -// validation paths. +// Structured parse result — separates decoded frontier IDs from override keys // --------------------------------------------------------------------------- +/** + * Structured result of parsing a `contexts` map. + * + * `frontiers` — Map from **raw** context ID (unescaped) to merged frontier + * value. Keys are never `ov_*` or `esc:` prefixed: all escaping is stripped + * at decode time so the same raw ID is used in both maps. + * + * `overrides` — Map from **raw** context ID to validated `OverrideRegister`. + * Keyed by the same raw IDs as `frontiers`, so `isOverrideActive` can safely + * evaluate `overrides.get(rawCtx)` against `frontiers.get(rawCtx)`. + */ +export interface ParsedContexts { + readonly frontiers: ReadonlyMap; + readonly overrides: ReadonlyMap; +} + /** * A validated override group ready for merge. * `kind === "live"` → three-key group with all counters present. * `kind === "floor"` → tombstone, only `c` is meaningful (s=0, b=0). + * + * Used internally by `parseContexts` and `sanitizeContexts`. */ export type ValidatedOverrideGroup = | { kind: "live"; reg: OverrideRegister } | { kind: "floor"; c: number }; +/** + * Parse and validate a raw `contexts` object from a decoded blob into + * separate frontier and override namespaces. + * + * This is the single structured decode path for the override layer. It + * applies group-first validation (spec `:114`) before any per-entry + * processing: + * + * 1. Collect all `ov_*` entries by suffix into candidate groups. + * 2. Validate each group as a whole: complete live (S+C+B) or tombstone + * floor (C-only). Any other shape (partial group, invalid value in any + * sibling) silently drops the whole group; the frontier entry for `` + * is retained. + * 3. Apply the 256-byte UTF-8 key-length check to EVERY wire key — both + * frontier keys and override sibling keys. An invalid sibling rejects + * its entire group; an invalid frontier key discards only that entry. + * 4. Unescape frontier wire keys (`esc:…` → raw ctx) so both maps share + * the same key domain. + * + * The result contains no `ov_*` or `esc:*` keys in `frontiers`. + * Slices 2–3 use this type; existing callers use `sanitizeContexts` which + * wraps this function. + */ +export function parseContexts(raw: Record): ParsedContexts { + const enc = new TextEncoder(); + const isUint32 = (v: unknown): v is number => + typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295; + + // ── Pass 1: bucket override candidates by suffix ───────────────────────── + const sWire = new Map(); // suffix → wire key (for byte-length check) + const cWire = new Map(); + const bWire = new Map(); + const sVal = new Map(); + const cVal = new Map(); + const bVal = new Map(); + const frontierEntries: Array<[string, unknown]> = []; + + for (const [key, value] of Object.entries(raw)) { + if (key.startsWith(OV_S_PREFIX)) { + const suffix = key.slice(OV_S_PREFIX.length); + sWire.set(suffix, key); + sVal.set(suffix, value); + } else if (key.startsWith(OV_C_PREFIX)) { + const suffix = key.slice(OV_C_PREFIX.length); + cWire.set(suffix, key); + cVal.set(suffix, value); + } else if (key.startsWith(OV_B_PREFIX)) { + const suffix = key.slice(OV_B_PREFIX.length); + bWire.set(suffix, key); + bVal.set(suffix, value); + } else { + frontierEntries.push([key, value]); + } + } + + // ── Pass 2: validate each suffix as a group ─────────────────────────────── + const overrides = new Map(); + const suffixes = new Set([...sWire.keys(), ...cWire.keys(), ...bWire.keys()]); + + for (const suffix of suffixes) { + const hasS = sWire.has(suffix); + const hasC = cWire.has(suffix); + const hasB = bWire.has(suffix); + const sv = sVal.get(suffix); + const cv = cVal.get(suffix); + const bv = bVal.get(suffix); + + if (!hasS && hasC && !hasB) { + // Tombstone floor: only ov_c: present. + const wireKey = cWire.get(suffix) ?? ""; + if (enc.encode(wireKey).length > 256) continue; // key too long → drop group + if (!isUint32(cv)) continue; + overrides.set(suffix, { s: 0, c: cv, b: 0 }); + } else if (hasS && hasC && hasB) { + // Complete live group: all three sibling wire keys must be ≤ 256 bytes. + const swk = sWire.get(suffix) ?? ""; + const cwk = cWire.get(suffix) ?? ""; + const bwk = bWire.get(suffix) ?? ""; + if ( + enc.encode(swk).length > 256 || + enc.encode(cwk).length > 256 || + enc.encode(bwk).length > 256 + ) + continue; // any sibling key too long → drop group + if (!isUint32(sv) || !isUint32(cv) || !isUint32(bv)) continue; + overrides.set(suffix, { s: sv, c: cv, b: bv }); + } + // Any other shape (B-only, S-only, S+B, C+B, S+C): silently dropped. + // The frontier entry for `suffix` may still appear in frontierEntries. + } + + // ── Pass 3: validate and unescape frontier entries ──────────────────────── + const frontiers = new Map(); + for (const [wireKey, value] of frontierEntries) { + if (enc.encode(wireKey).length > 256) continue; + if (!isUint32(value)) continue; + // Strip exactly one leading `esc:` to recover the raw context ID. + const rawCtx = unescapeFrontierKey(wireKey); + // max-merge in case a malformed blob has both escaped and unescaped forms. + const existing = frontiers.get(rawCtx); + if (existing === undefined || value > existing) { + frontiers.set(rawCtx, value); + } + } + + return { frontiers, overrides }; +} + /** * Partition the raw contexts map into validated override groups and * non-override entries. * - * Override entries that fail group validation are silently dropped (per spec: - * "the entire override group is rejected"); the corresponding frontier entry - * for `` is retained in `nonOverride`. + * This internal helper is used by `sanitizeContexts` to produce the flat + * `Record` stored in `ReadStateBlob.contexts` for backward + * compatibility with `readStateManager`. New callers (slices 2–3) should + * use `parseContexts` directly for the structured result. + * + * Override key-length enforcement mirrors `parseContexts`: an invalid sibling + * wire key rejects the entire group while retaining the frontier entry. * * @returns - * `overrides` — Map from raw context ID to validated group. - * `nonOverride` — Raw contexts map with all `ov_*` keys removed (frontier - * entries and escaped keys remain for standard processing). + * `overrides` — Map from raw context suffix to `ValidatedOverrideGroup`. + * `nonOverride` — Raw contexts map with all `ov_*` keys removed. */ -export function partitionOverrideGroups(contexts: Record): { +function partitionOverrideGroups(contexts: Record): { overrides: Map; nonOverride: Record; } { - // First pass: collect all ov_* entries by context suffix. - const sMap = new Map(); - const cMap = new Map(); - const bMap = new Map(); + const enc = new TextEncoder(); + const isUint32 = (v: unknown): v is number => + typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295; + + const sWire = new Map(); + const cWire = new Map(); + const bWire = new Map(); + const sVal = new Map(); + const cVal = new Map(); + const bVal = new Map(); const nonOverride: Record = {}; for (const [key, value] of Object.entries(contexts)) { if (key.startsWith(OV_S_PREFIX)) { - sMap.set(key.slice(OV_S_PREFIX.length), value); + const suffix = key.slice(OV_S_PREFIX.length); + sWire.set(suffix, key); + sVal.set(suffix, value); } else if (key.startsWith(OV_C_PREFIX)) { - cMap.set(key.slice(OV_C_PREFIX.length), value); + const suffix = key.slice(OV_C_PREFIX.length); + cWire.set(suffix, key); + cVal.set(suffix, value); } else if (key.startsWith(OV_B_PREFIX)) { - bMap.set(key.slice(OV_B_PREFIX.length), value); + const suffix = key.slice(OV_B_PREFIX.length); + bWire.set(suffix, key); + bVal.set(suffix, value); } else { nonOverride[key] = value; } } - // Second pass: validate each unique ctx suffix as a group. - const ctxSuffixes = new Set([...sMap.keys(), ...cMap.keys(), ...bMap.keys()]); + const suffixes = new Set([...sWire.keys(), ...cWire.keys(), ...bWire.keys()]); const overrides = new Map(); - for (const ctx of ctxSuffixes) { - const hasS = sMap.has(ctx); - const hasC = cMap.has(ctx); - const hasB = bMap.has(ctx); - - const sVal = sMap.get(ctx); - const cVal = cMap.get(ctx); - const bVal = bMap.get(ctx); - - const isUint32 = (v: unknown): v is number => - typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295; + for (const suffix of suffixes) { + const hasS = sWire.has(suffix); + const hasC = cWire.has(suffix); + const hasB = bWire.has(suffix); + const sv = sVal.get(suffix); + const cv = cVal.get(suffix); + const bv = bVal.get(suffix); if (!hasS && hasC && !hasB) { - // Tombstone floor: only ov_c: present — must be a valid uint32. - if (!isUint32(cVal)) continue; // invalid — drop group silently - overrides.set(ctx, { kind: "floor", c: cVal }); + const wireKey = cWire.get(suffix) ?? ""; + if (enc.encode(wireKey).length > 256) continue; + if (!isUint32(cv)) continue; + overrides.set(suffix, { kind: "floor", c: cv }); } else if (hasS && hasC && hasB) { - // Complete live group: all three keys must have valid uint32 values. - if (!isUint32(sVal) || !isUint32(cVal) || !isUint32(bVal)) continue; - overrides.set(ctx, { - kind: "live", - reg: { s: sVal, c: cVal, b: bVal }, - }); - } else { - // Partial group or any other shape — reject the whole group (spec). - // The frontier entry for this ctx remains in nonOverride; do nothing here. + const swk = sWire.get(suffix) ?? ""; + const cwk = cWire.get(suffix) ?? ""; + const bwk = bWire.get(suffix) ?? ""; + if ( + enc.encode(swk).length > 256 || + enc.encode(cwk).length > 256 || + enc.encode(bwk).length > 256 + ) + continue; + if (!isUint32(sv) || !isUint32(cv) || !isUint32(bv)) continue; + overrides.set(suffix, { kind: "live", reg: { s: sv, c: cv, b: bv } }); } + // Any other shape: silently dropped; frontier entry retained in nonOverride. } return { overrides, nonOverride }; @@ -346,28 +480,29 @@ export function isValidBlob(obj: unknown): obj is ReadStateBlob { } /** - * Sanitize and validate a raw contexts object from a decoded blob. + * Sanitize and validate a raw contexts object from a decoded blob into the + * flat `Record` stored in `ReadStateBlob.contexts`. * - * Override entries (`ov_*` keys) are validated as complete logical groups - * BEFORE any per-entry processing, per the spec's Content Validation section. - * Partial groups are rejected (dropped) while their corresponding frontier - * entry is retained. Validated override groups are re-encoded into the result - * as raw entries so that the rest of the pipeline (merge, liveness) operates - * on a flat integer map — the same type as frontier entries. - * - * Escaped frontier keys (`esc:…`) pass through unchanged; unescaping happens - * at read time in the snapshot layer. + * This is the backward-compatible path for `readStateManager`, which reads + * the flat blob directly. Override entries (`ov_*` keys) are validated as + * complete logical groups BEFORE any per-entry processing; partial groups are + * dropped while their frontier entry is retained. The 256-byte UTF-8 + * key-length check is applied to every wire key — including override siblings. + * Escaped frontier keys (`esc:…`) are stored as-is in the flat map (the + * structured path via `parseContexts` applies unescaping; this flat path + * preserves the wire form for the manager). */ export function sanitizeContexts( contexts: Record, ): Record { + const enc = new TextEncoder(); // Step 1: partition override groups from the rest (group-first validation). const { overrides, nonOverride } = partitionOverrideGroups(contexts); // Step 2: sanitize non-override entries with the standard per-entry rules. const result: Record = {}; for (const [key, value] of Object.entries(nonOverride)) { - if (new TextEncoder().encode(key).length > 256) continue; + if (enc.encode(key).length > 256) continue; if (typeof value !== "number" || !Number.isInteger(value)) continue; if (value < 0 || value > 4294967295) continue; result[key] = value; diff --git a/desktop/src/features/channels/readState/readStateOverride.test.mjs b/desktop/src/features/channels/readState/readStateOverride.test.mjs index bb47eef10c..1ba8bf9b80 100644 --- a/desktop/src/features/channels/readState/readStateOverride.test.mjs +++ b/desktop/src/features/channels/readState/readStateOverride.test.mjs @@ -10,15 +10,12 @@ import { OV_B_PREFIX, OV_C_PREFIX, OV_S_PREFIX, - partitionOverrideGroups, + parseContexts, sanitizeContexts, unescapeFrontierKey, } from "./readStateFormat.ts"; -import { - extractOverrideRegisters, - mergeOverrideRegisterMaps, -} from "./readStateSnapshot.ts"; +import { mergeOverrideRegisterMaps } from "./readStateSnapshot.ts"; // --------------------------------------------------------------------------- // escapeFrontierKey / unescapeFrontierKey @@ -130,6 +127,24 @@ test("isOverrideActive_tombstoneFloor_returnsFalse", () => { assert.equal(isOverrideActive({ s: 0, c: 5, b: 0 }, 0), false); }); +test("isOverrideActive_uint32Max_liveOverride_returnsTrue", () => { + // Boundary: S at uint32 max, C one less, B at max, F at zero + const UINT32_MAX = 4294967295; + assert.equal( + isOverrideActive({ s: UINT32_MAX, c: UINT32_MAX - 1, b: UINT32_MAX }, 0), + true, + ); +}); + +test("isOverrideActive_uint32Max_zeroFrontier_tile_returnsFalse", () => { + // S == C at max → clear-wins + const UINT32_MAX = 4294967295; + assert.equal( + isOverrideActive({ s: UINT32_MAX, c: UINT32_MAX, b: 1 }, 0), + false, + ); +}); + // --------------------------------------------------------------------------- // mergeOverrideRegisters — componentwise max() // --------------------------------------------------------------------------- @@ -161,6 +176,29 @@ test("mergeOverrideRegisters_associative", () => { ); }); +test("mergeOverrideRegisters_zeroBoundary_idempotent", () => { + const zero = { s: 0, c: 0, b: 0 }; + assert.deepEqual(mergeOverrideRegisters(zero, zero), zero); +}); + +test("mergeOverrideRegisters_uint32Max_idempotent", () => { + const UINT32_MAX = 4294967295; + const max = { s: UINT32_MAX, c: UINT32_MAX, b: UINT32_MAX }; + assert.deepEqual(mergeOverrideRegisters(max, max), max); +}); + +test("mergeOverrideRegisters_uint32Max_commutative", () => { + const UINT32_MAX = 4294967295; + const a = { s: UINT32_MAX, c: 0, b: UINT32_MAX }; + const b = { s: 0, c: UINT32_MAX, b: 0 }; + assert.deepEqual(mergeOverrideRegisters(a, b), mergeOverrideRegisters(b, a)); + assert.deepEqual(mergeOverrideRegisters(a, b), { + s: UINT32_MAX, + c: UINT32_MAX, + b: UINT32_MAX, + }); +}); + // --------------------------------------------------------------------------- // encodeOverrideGroup — canonical wire encoding // --------------------------------------------------------------------------- @@ -204,116 +242,373 @@ test("encodeOverrideGroup_tombstoneFloorShape_returnsOnlyOvC", () => { }); // --------------------------------------------------------------------------- -// partitionOverrideGroups — group-first validation +// parseContexts — structured decode (group-first validation, unescape, 256-byte) // --------------------------------------------------------------------------- -test("partitionOverrideGroups_liveGroup_validates", () => { +// ── accepted shapes ───────────────────────────────────────────────────────── + +test("parseContexts_liveGroup_populatesBothMaps", () => { const raw = { "channel:x": 1000, "ov_s:channel:x": 1, "ov_c:channel:x": 0, "ov_b:channel:x": 900, }; - const { overrides, nonOverride } = partitionOverrideGroups(raw); + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("channel:x"), 1000); + assert.deepEqual(overrides.get("channel:x"), { s: 1, c: 0, b: 900 }); assert.equal(overrides.size, 1); - const group = overrides.get("channel:x"); - assert.equal(group?.kind, "live"); - assert.deepEqual(group?.reg, { s: 1, c: 0, b: 900 }); - assert.deepEqual(nonOverride, { "channel:x": 1000 }); + // No ov_* keys in frontiers + assert.equal(frontiers.has("ov_s:channel:x"), false); }); -test("partitionOverrideGroups_tombstoneFloor_validates", () => { +test("parseContexts_tombstoneFloor_populatesBothMaps", () => { const raw = { "channel:x": 1000, "ov_c:channel:x": 5, }; - const { overrides, nonOverride } = partitionOverrideGroups(raw); + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("channel:x"), 1000); + assert.deepEqual(overrides.get("channel:x"), { s: 0, c: 5, b: 0 }); assert.equal(overrides.size, 1); - const group = overrides.get("channel:x"); - assert.equal(group?.kind, "floor"); - assert.equal(group?.c, 5); - assert.deepEqual(nonOverride, { "channel:x": 1000 }); }); -test("partitionOverrideGroups_partialGroup_droppedFrontierRetained", () => { - // Only ov_s without ov_c and ov_b → partial group → rejected +test("parseContexts_normalFrontierOnly_noOverrides", () => { const raw = { - "channel:x": 1000, - "ov_s:channel:x": 1, + "ch:normal": 5000, + "msg:abc": 3000, + "thread:def": 2000, }; - const { overrides, nonOverride } = partitionOverrideGroups(raw); - assert.equal(overrides.size, 0); // group rejected - assert.deepEqual(nonOverride, { "channel:x": 1000 }); // frontier retained -}); - -test("partitionOverrideGroups_sConly_droppedFrontierRetained", () => { - // ov_s + ov_c but no ov_b → partial group - const raw = { - "channel:x": 1000, - "ov_s:channel:x": 1, - "ov_c:channel:x": 0, - }; - const { overrides, nonOverride } = partitionOverrideGroups(raw); + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:normal"), 5000); + assert.equal(frontiers.get("msg:abc"), 3000); + assert.equal(frontiers.get("thread:def"), 2000); assert.equal(overrides.size, 0); - assert.deepEqual(nonOverride, { "channel:x": 1000 }); }); -test("partitionOverrideGroups_invalidValue_groupDropped", () => { - // ov_s has non-integer value → invalid → whole group dropped - const raw = { - "channel:x": 1000, - "ov_s:channel:x": "not-a-number", - "ov_c:channel:x": 0, - "ov_b:channel:x": 900, - }; - const { overrides, nonOverride } = partitionOverrideGroups(raw); +test("parseContexts_escapedFrontierKey_unescapedInFrontiers", () => { + // Wire key esc:ov_s:evil → raw ctx ov_s:evil + const raw = { "esc:ov_s:evil": 777 }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ov_s:evil"), 777); + assert.equal(frontiers.has("esc:ov_s:evil"), false); assert.equal(overrides.size, 0); - assert.deepEqual(nonOverride, { "channel:x": 1000 }); }); -test("partitionOverrideGroups_negativeValue_groupDropped", () => { +test("parseContexts_doubleEscapedFrontierKey_stripsOneEsc", () => { + // Wire key esc:esc:foo → raw ctx esc:foo + const raw = { "esc:esc:foo": 888 }; + const { frontiers } = parseContexts(raw); + assert.equal(frontiers.get("esc:foo"), 888); + assert.equal(frontiers.has("esc:esc:foo"), false); +}); + +// ── partial-group rejection matrix (every illegal subset) ─────────────────── + +test("parseContexts_sOnly_droppedFrontierRetained", () => { + const raw = { "ch:x": 100, "ov_s:ch:x": 1 }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); +}); + +test("parseContexts_bOnly_droppedFrontierRetained", () => { + const raw = { "ch:x": 100, "ov_b:ch:x": 50 }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); +}); + +test("parseContexts_sConly_droppedFrontierRetained", () => { + const raw = { "ch:x": 100, "ov_s:ch:x": 1, "ov_c:ch:x": 0 }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); +}); + +test("parseContexts_sBonly_droppedFrontierRetained", () => { + const raw = { "ch:x": 100, "ov_s:ch:x": 1, "ov_b:ch:x": 50 }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); +}); + +test("parseContexts_cBonly_droppedFrontierRetained", () => { + const raw = { "ch:x": 100, "ov_c:ch:x": 2, "ov_b:ch:x": 50 }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); +}); + +// ── invalid sibling at each position ──────────────────────────────────────── + +test("parseContexts_invalidS_liveGroupDropped", () => { const raw = { - "channel:x": 1000, - "ov_s:channel:x": -1, - "ov_c:channel:x": 0, - "ov_b:channel:x": 900, + "ch:x": 100, + "ov_s:ch:x": "not-a-number", + "ov_c:ch:x": 0, + "ov_b:ch:x": 50, }; - const { overrides, nonOverride } = partitionOverrideGroups(raw); - assert.equal(overrides.size, 0); - assert.deepEqual(nonOverride, { "channel:x": 1000 }); + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); }); -test("partitionOverrideGroups_ovCInvalidValue_tombstoneDropped", () => { - // Tombstone floor with non-integer c +test("parseContexts_invalidC_liveGroupDropped", () => { const raw = { - "channel:x": 1000, - "ov_c:channel:x": 1.5, + "ch:x": 100, + "ov_s:ch:x": 1, + "ov_c:ch:x": 1.5, + "ov_b:ch:x": 50, }; - const { overrides, nonOverride } = partitionOverrideGroups(raw); - assert.equal(overrides.size, 0); - assert.deepEqual(nonOverride, { "channel:x": 1000 }); + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); }); -test("partitionOverrideGroups_multipleContexts_allValidated", () => { +test("parseContexts_invalidB_liveGroupDropped", () => { + const raw = { "ch:x": 100, "ov_s:ch:x": 1, "ov_c:ch:x": 0, "ov_b:ch:x": -1 }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); +}); + +test("parseContexts_uint32Overflow_liveGroupDropped", () => { + const raw = { + "ch:x": 100, + "ov_s:ch:x": 4294967296, + "ov_c:ch:x": 0, + "ov_b:ch:x": 50, + }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); +}); + +test("parseContexts_invalidC_tombstoneDropped", () => { + const raw = { "ch:x": 100, "ov_c:ch:x": -5 }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), 100); + assert.equal(overrides.has("ch:x"), false); +}); + +// ── uint32 boundary values accepted ───────────────────────────────────────── + +test("parseContexts_uint32Zero_acceptedInLiveGroup", () => { + const raw = { "ch:x": 0, "ov_s:ch:x": 0, "ov_c:ch:x": 0, "ov_b:ch:x": 0 }; + const { overrides } = parseContexts(raw); + assert.deepEqual(overrides.get("ch:x"), { s: 0, c: 0, b: 0 }); +}); + +test("parseContexts_uint32Max_acceptedInLiveGroup", () => { + const UINT32_MAX = 4294967295; + const raw = { + "ch:x": UINT32_MAX, + "ov_s:ch:x": UINT32_MAX, + "ov_c:ch:x": UINT32_MAX, + "ov_b:ch:x": UINT32_MAX, + }; + const { frontiers, overrides } = parseContexts(raw); + assert.equal(frontiers.get("ch:x"), UINT32_MAX); + assert.deepEqual(overrides.get("ch:x"), { + s: UINT32_MAX, + c: UINT32_MAX, + b: UINT32_MAX, + }); +}); + +test("parseContexts_uint32Max_acceptedInTombstone", () => { + const UINT32_MAX = 4294967295; + const raw = { "ch:x": 0, "ov_c:ch:x": UINT32_MAX }; + const { overrides } = parseContexts(raw); + assert.deepEqual(overrides.get("ch:x"), { s: 0, c: UINT32_MAX, b: 0 }); +}); + +// ── 256-byte key-length enforcement ───────────────────────────────────────── + +// Build a string of exactly N UTF-8 bytes (ASCII, so length == byte count). +function asciiOfBytes(n) { + return "a".repeat(n); +} + +test("parseContexts_frontierKey256Bytes_accepted", () => { + // ov_s: prefix is 5 bytes; a 251-byte suffix yields a 256-byte wire key. + const suffix251 = asciiOfBytes(251); + const raw = { [suffix251]: 42 }; + const { frontiers } = parseContexts(raw); + assert.equal(frontiers.get(suffix251), 42); +}); + +test("parseContexts_frontierKey257Bytes_dropped", () => { + const suffix257 = asciiOfBytes(257); + const raw = { [suffix257]: 42 }; // 257 bytes — exceeds 256 + const { frontiers } = parseContexts(raw); + assert.equal(frontiers.has(suffix257), false); +}); + +test("parseContexts_liveGroupSiblingKey256Bytes_accepted", () => { + // ov_s: is 5 bytes; suffix of 251 bytes → wire key exactly 256 bytes. + const suffix251 = asciiOfBytes(251); + const raw = { + [suffix251]: 10, + [`${OV_S_PREFIX}${suffix251}`]: 1, + [`${OV_C_PREFIX}${suffix251}`]: 0, + [`${OV_B_PREFIX}${suffix251}`]: 9, + }; + const { overrides } = parseContexts(raw); + assert.deepEqual(overrides.get(suffix251), { s: 1, c: 0, b: 9 }); +}); + +test("parseContexts_liveGroupSiblingKey257Bytes_groupDropped", () => { + // ov_s: is 5 bytes; suffix of 252 bytes → wire key 257 bytes — drops group. + const suffix252 = asciiOfBytes(252); + const raw = { + [suffix252]: 10, // frontier key also 252 bytes — also dropped + [`${OV_S_PREFIX}${suffix252}`]: 1, + [`${OV_C_PREFIX}${suffix252}`]: 0, + [`${OV_B_PREFIX}${suffix252}`]: 9, + }; + const { overrides } = parseContexts(raw); + assert.equal(overrides.has(suffix252), false); +}); + +test("parseContexts_tombstoneFloorKey256Bytes_accepted", () => { + // ov_c: is 5 bytes; suffix of 251 bytes → wire key exactly 256 bytes. + const suffix251 = asciiOfBytes(251); + const raw = { + [suffix251]: 0, + [`${OV_C_PREFIX}${suffix251}`]: 3, + }; + const { overrides } = parseContexts(raw); + assert.deepEqual(overrides.get(suffix251), { s: 0, c: 3, b: 0 }); +}); + +test("parseContexts_tombstoneFloorKey257Bytes_groupDropped", () => { + // ov_c: is 5 bytes; suffix of 252 bytes → wire key 257 bytes — drops group. + const suffix252 = asciiOfBytes(252); + const raw = { + [suffix252]: 0, + [`${OV_C_PREFIX}${suffix252}`]: 3, + }; + const { overrides } = parseContexts(raw); + assert.equal(overrides.has(suffix252), false); +}); + +test("parseContexts_multibyteSuffix_keyLengthCheckedInBytes", () => { + // A 2-byte UTF-8 char (e.g. 'é') counts as 2 bytes toward the 256 limit. + // suffix of 127 × 'é' = 254 bytes; ov_c: (5) + 254 = 259 → over limit. + const multiSuffix = "\u00e9".repeat(127); // 127 × 2 bytes = 254 bytes + const raw = { + [multiSuffix]: 0, + [`${OV_C_PREFIX}${multiSuffix}`]: 7, + }; + const { overrides } = parseContexts(raw); + assert.equal(overrides.has(multiSuffix), false); +}); + +test("parseContexts_multibyteSuffix_justUnder256_accepted", () => { + // 'é' × 125 = 250 bytes; ov_c: (5) + 250 = 255 → under limit. + const multiSuffix = "\u00e9".repeat(125); // 125 × 2 = 250 bytes + const raw = { + [multiSuffix]: 0, + [`${OV_C_PREFIX}${multiSuffix}`]: 7, + }; + const { overrides } = parseContexts(raw); + assert.deepEqual(overrides.get(multiSuffix), { s: 0, c: 7, b: 0 }); +}); + +// ── reserved-ID collision witness (Thufir finding 1) ──────────────────────── + +test("parseContexts_reservedIdCollision_frontierAndOverrideUseDistinctNamespaces", () => { + // Raw context `ov_s:evil` has its frontier published under `esc:ov_s:evil` + // (as a publisher-aware client would do). It also has a live register for + // raw context `ov_s:evil` (suffix = `evil` for the ov_* keys). + // Raw context `evil` independently has its own frontier and override. + const raw = { + "esc:ov_s:evil": 7, // frontier for raw ctx `ov_s:evil` + "ov_s:evil": 3, // set counter for raw ctx `evil` + "ov_c:evil": 1, + "ov_b:evil": 6, + evil: 100, // frontier for raw ctx `evil` + "ov_s:regular": 2, // set counter for raw ctx `regular` + "ov_c:regular": 0, + "ov_b:regular": 50, + regular: 40, + }; + const { frontiers, overrides } = parseContexts(raw); + + // frontier for raw ctx `ov_s:evil` must be 7 (unescaped from `esc:ov_s:evil`) + assert.equal( + frontiers.get("ov_s:evil"), + 7, + "frontier for ctx ov_s:evil must be 7", + ); + // frontier for raw ctx `evil` must be 100 + assert.equal(frontiers.get("evil"), 100, "frontier for ctx evil must be 100"); + // override for raw ctx `evil` comes from `ov_s:evil`/`ov_c:evil`/`ov_b:evil` (suffix=`evil`) + assert.deepEqual( + overrides.get("evil"), + { s: 3, c: 1, b: 6 }, + "override for ctx evil", + ); + // override for raw ctx `regular` + assert.deepEqual( + overrides.get("regular"), + { s: 2, c: 0, b: 50 }, + "override for ctx regular", + ); + // No `ov_s:evil` in frontiers as an override key — but `ov_s:evil` IS a raw ctx ID with frontier 7 + assert.equal( + frontiers.get("ov_s:evil"), + 7, + "ov_s:evil raw ctx has frontier 7", + ); + // No `esc:ov_s:evil` in frontiers (was unescaped) + assert.equal( + frontiers.has("esc:ov_s:evil"), + false, + "escaped wire key must not appear in frontiers", + ); + // liveness evaluation uses consistent namespaces + const evilReg = overrides.get("evil"); + const evilFrontier = frontiers.get("evil") ?? 0; + // s=3, c=1, b=6, f=100 → F(100) > B(6) → inactive + assert.equal( + isOverrideActive(evilReg, evilFrontier), + false, + "evil override inactive because frontier(100) > baseline(6)", + ); + + const regularReg = overrides.get("regular"); + const regularFrontier = frontiers.get("regular") ?? 0; + // s=2, c=0, b=50, f=40 → S>0 ∧ F(40)<=B(50) ∧ S(2)>C(0) → active + assert.equal( + isOverrideActive(regularReg, regularFrontier), + true, + "regular override active: F(40) <= B(50), S(2) > C(0)", + ); +}); + +// ── multiple contexts in one blob ──────────────────────────────────────────── + +test("parseContexts_multipleContexts_allProcessedCorrectly", () => { const raw = { "ch:a": 100, "ov_s:ch:a": 1, "ov_c:ch:a": 0, "ov_b:ch:a": 90, "ch:b": 200, - "ov_c:ch:b": 3, // tombstone floor for ch:b + "ov_c:ch:b": 3, // tombstone floor "ch:c": 300, "ov_s:ch:c": 1, // partial — only s, no c/b → rejected }; - const { overrides, nonOverride } = partitionOverrideGroups(raw); - assert.equal(overrides.size, 2); // ch:a and ch:b, ch:c rejected - assert.equal(overrides.get("ch:a")?.kind, "live"); - assert.equal(overrides.get("ch:b")?.kind, "floor"); + const { frontiers, overrides } = parseContexts(raw); + assert.equal(overrides.get("ch:a")?.s, 1); + assert.deepEqual(overrides.get("ch:b"), { s: 0, c: 3, b: 0 }); assert.equal(overrides.has("ch:c"), false); - // Frontier entries all retained - assert.equal(nonOverride["ch:a"], 100); - assert.equal(nonOverride["ch:b"], 200); - assert.equal(nonOverride["ch:c"], 300); + assert.equal(frontiers.get("ch:a"), 100); + assert.equal(frontiers.get("ch:b"), 200); + assert.equal(frontiers.get("ch:c"), 300); }); // --------------------------------------------------------------------------- @@ -357,6 +652,27 @@ test("sanitizeContexts_partialGroup_droppedFrontierRetained", () => { assert.equal(result[`${OV_S_PREFIX}channel:x`], undefined); }); +test("sanitizeContexts_liveGroupSiblingKeyTooLong_groupDropped", () => { + // ov_s: (5 bytes) + suffix of 252 bytes = 257 bytes wire key → drop group + const suffix252 = asciiOfBytes(252); + const raw = { + [suffix252]: 10, + [`${OV_S_PREFIX}${suffix252}`]: 1, + [`${OV_C_PREFIX}${suffix252}`]: 0, + [`${OV_B_PREFIX}${suffix252}`]: 9, + }; + const result = sanitizeContexts(raw); + assert.equal(result[`${OV_S_PREFIX}${suffix252}`], undefined); + assert.equal(result[`${OV_C_PREFIX}${suffix252}`], undefined); +}); + +test("sanitizeContexts_tombstoneKeyTooLong_groupDropped", () => { + const suffix252 = asciiOfBytes(252); + const raw = { [suffix252]: 0, [`${OV_C_PREFIX}${suffix252}`]: 3 }; + const result = sanitizeContexts(raw); + assert.equal(result[`${OV_C_PREFIX}${suffix252}`], undefined); +}); + test("sanitizeContexts_normalEntriesUnaffected", () => { const raw = { "ch:normal": 5000, @@ -380,56 +696,12 @@ test("sanitizeContexts_invalidFrontierEntry_dropped", () => { }); test("sanitizeContexts_escapedFrontierKey_passesThrough", () => { - // esc: prefixed frontier keys must pass through unchanged + // esc: prefixed frontier keys must pass through unchanged in the flat map const raw = { "esc:ov_s:suspicious-ctx": 1000 }; const result = sanitizeContexts(raw); assert.equal(result["esc:ov_s:suspicious-ctx"], 1000); }); -// --------------------------------------------------------------------------- -// extractOverrideRegisters — from sanitized flat map -// --------------------------------------------------------------------------- - -test("extractOverrideRegisters_liveGroup_reconstructsRegister", () => { - const contexts = new Map([ - ["channel:x", 1000], - [`${OV_S_PREFIX}channel:x`, 2], - [`${OV_C_PREFIX}channel:x`, 1], - [`${OV_B_PREFIX}channel:x`, 900], - ]); - const regs = extractOverrideRegisters(contexts); - assert.equal(regs.size, 1); - assert.deepEqual(regs.get("channel:x"), { s: 2, c: 1, b: 900 }); -}); - -test("extractOverrideRegisters_tombstoneFloor_reconstructsRegister", () => { - const contexts = new Map([ - ["channel:x", 1000], - [`${OV_C_PREFIX}channel:x`, 5], - ]); - const regs = extractOverrideRegisters(contexts); - assert.equal(regs.size, 1); - // Floor shape: s=0, c=5, b=0 - assert.deepEqual(regs.get("channel:x"), { s: 0, c: 5, b: 0 }); -}); - -test("extractOverrideRegisters_noOverrideKeys_returnsEmptyMap", () => { - const contexts = new Map([["channel:x", 1000]]); - const regs = extractOverrideRegisters(contexts); - assert.equal(regs.size, 0); -}); - -test("extractOverrideRegisters_acceptsPlainRecord", () => { - const contexts = { - "channel:x": 1000, - [`${OV_S_PREFIX}channel:x`]: 1, - [`${OV_C_PREFIX}channel:x`]: 0, - [`${OV_B_PREFIX}channel:x`]: 900, - }; - const regs = extractOverrideRegisters(contexts); - assert.deepEqual(regs.get("channel:x"), { s: 1, c: 0, b: 900 }); -}); - // --------------------------------------------------------------------------- // mergeOverrideRegisterMaps — across multiple blobs // --------------------------------------------------------------------------- @@ -469,51 +741,87 @@ test("mergeOverrideRegisterMaps_threeWayMerge_takesGlobalMax", () => { assert.deepEqual(merged.get("ctx"), { s: 5, c: 3, b: 30 }); }); +test("mergeOverrideRegisterMaps_uint32Max_commutative", () => { + const UINT32_MAX = 4294967295; + const a = new Map([["ctx", { s: UINT32_MAX, c: 0, b: UINT32_MAX }]]); + const b = new Map([["ctx", { s: 0, c: UINT32_MAX, b: 0 }]]); + const ab = mergeOverrideRegisterMaps(a, b); + const ba = mergeOverrideRegisterMaps(b, a); + assert.deepEqual(ab.get("ctx"), ba.get("ctx")); + assert.deepEqual(ab.get("ctx"), { + s: UINT32_MAX, + c: UINT32_MAX, + b: UINT32_MAX, + }); +}); + // --------------------------------------------------------------------------- -// Round-trip: encode → sanitize → extract produces original register +// Round-trip: encodeOverrideGroup → parseContexts produces original register // --------------------------------------------------------------------------- -test("roundTrip_liveRegister_survivesEncodeAndExtract", () => { +test("roundTrip_liveRegister_survivesEncodeAndParse", () => { const reg = { s: 2, c: 1, b: 100 }; const frontier = 50; const rawCtx = "channel-uuid-1234"; - // Encode (as a publisher would) const patch = encodeOverrideGroup(rawCtx, reg, frontier); - // Simulate inclusion in a blob's contexts map (add frontier entry too) const rawContexts = { [rawCtx]: frontier, ...patch }; - // Sanitize (as a receiver would) - const sanitized = sanitizeContexts(rawContexts); - // Extract registers - const regs = extractOverrideRegisters(sanitized); + const { frontiers, overrides } = parseContexts(rawContexts); - assert.deepEqual(regs.get(rawCtx), reg); + assert.equal(frontiers.get(rawCtx), frontier); + assert.deepEqual(overrides.get(rawCtx), reg); }); -test("roundTrip_tombstoneFloor_survivesEncodeAndExtract", () => { - // A dead register compacts to tombstone floor on encode - const reg = { s: 0, c: 5, b: 0 }; // already a floor shape +test("roundTrip_tombstoneFloor_survivesEncodeAndParse", () => { + const reg = { s: 0, c: 5, b: 0 }; const frontier = 0; const rawCtx = "channel-uuid-5678"; const patch = encodeOverrideGroup(rawCtx, reg, frontier); const rawContexts = { [rawCtx]: frontier, ...patch }; - const sanitized = sanitizeContexts(rawContexts); - const regs = extractOverrideRegisters(sanitized); + const { frontiers, overrides } = parseContexts(rawContexts); - // Floor is s=0, c=5, b=0 - assert.deepEqual(regs.get(rawCtx), { s: 0, c: 5, b: 0 }); + assert.equal(frontiers.get(rawCtx), frontier); + assert.deepEqual(overrides.get(rawCtx), { s: 0, c: 5, b: 0 }); }); test("roundTrip_virginRegister_omittedFromWire", () => { const reg = { s: 0, c: 0, b: 0 }; - const frontier = 0; const rawCtx = "channel-uuid-virgin"; - const patch = encodeOverrideGroup(rawCtx, reg, frontier); - assert.deepEqual(patch, {}); // nothing emitted - const rawContexts = { [rawCtx]: frontier }; - const sanitized = sanitizeContexts(rawContexts); - const regs = extractOverrideRegisters(sanitized); - assert.equal(regs.has(rawCtx), false); + const patch = encodeOverrideGroup(rawCtx, reg, 0); + assert.deepEqual(patch, {}); + const rawContexts = { [rawCtx]: 0 }; + const { frontiers, overrides } = parseContexts(rawContexts); + assert.equal(frontiers.get(rawCtx), 0); + assert.equal(overrides.has(rawCtx), false); +}); + +test("roundTrip_escapedRawCtx_survivesEncodeAndParse", () => { + // A raw context ID that starts with ov_: publisher escapes frontier key. + // encodeOverrideGroup does NOT escape the raw ctx suffix in ov_* keys — + // the spec says ov_* keys use the raw suffix. The frontier key is + // published escaped (esc:ov_s:raw) by the publisher — simulate that here. + const rawCtx = "ov_s:tricky"; + const reg = { s: 1, c: 0, b: 10 }; + const frontier = 8; + + // Publisher encodes: override keys use raw suffix, frontier key is escaped. + const patch = encodeOverrideGroup(rawCtx, reg, frontier); + // patch has ov_s:ov_s:tricky, ov_c:ov_s:tricky, ov_b:ov_s:tricky + const rawContexts = { + [`esc:${rawCtx}`]: frontier, // frontier published escaped + ...patch, + }; + const { frontiers, overrides } = parseContexts(rawContexts); + + // After unescape: frontier keyed by raw ctx `ov_s:tricky` + assert.equal(frontiers.get(rawCtx), frontier); + // Override keyed by suffix after `ov_s:` → `ov_s:tricky` + assert.deepEqual(overrides.get(rawCtx), reg); + // liveness evaluation uses the same raw key for both + const ov = overrides.get(rawCtx); + const f = frontiers.get(rawCtx) ?? 0; + // s=1, c=0, b=10, f=8 → S>0 ∧ F(8)<=B(10) ∧ S(1)>C(0) → active + assert.equal(isOverrideActive(ov, f), true); }); diff --git a/desktop/src/features/channels/readState/readStateSnapshot.ts b/desktop/src/features/channels/readState/readStateSnapshot.ts index 3934925e38..1d6f84721d 100644 --- a/desktop/src/features/channels/readState/readStateSnapshot.ts +++ b/desktop/src/features/channels/readState/readStateSnapshot.ts @@ -4,11 +4,10 @@ import { isValidBlob, isValidReadStateDTag, mergeOverrideRegisters, - OV_B_PREFIX, - OV_C_PREFIX, - OV_S_PREFIX, + parseContexts, sanitizeContexts, type OverrideRegister, + type ParsedContexts, type ReadStateBlob, } from "@/features/channels/readState/readStateFormat"; @@ -17,6 +16,8 @@ export type ReadStateDecrypt = (ciphertext: string) => Promise; export type ParsedReadStateEvent = { dTag: string; blob: ReadStateBlob; + /** Structured decode: frontiers keyed by raw ctx ID, overrides by raw ctx ID. */ + contexts: ParsedContexts; createdAt: number; }; @@ -41,13 +42,15 @@ export async function parseReadStateEvent( const plaintext = await decrypt(event.content); const parsed = JSON.parse(plaintext); if (!isValidBlob(parsed)) return null; + const sanitized = sanitizeContexts(parsed.contexts); return { dTag, blob: { v: 1, client_id: parsed.client_id, - contexts: sanitizeContexts(parsed.contexts), + contexts: sanitized, }, + contexts: parseContexts(parsed.contexts), createdAt: event.created_at, }; } catch (error) { @@ -60,61 +63,15 @@ export async function parseReadStateEvent( } /** - * Extract all override registers from a sanitized contexts map. - * - * Returns a Map from raw context ID to `OverrideRegister`. A tombstone floor - * (`ov_c:` only) is returned as `{ s: 0, c: , b: 0 }` — the same - * shape as a dead register, which is correct: the floor's sole purpose is to - * block counter reuse, and the liveness predicate evaluates to false for it. - * - * This is the read-side counterpart of `encodeOverrideGroup`: it reconstructs - * the register from the flat integer map that `sanitizeContexts` produced. - * The input MUST already have been through `sanitizeContexts` (so partial - * groups are absent — they were rejected at validation time). - */ -export function extractOverrideRegisters( - contexts: ReadonlyMap | Record, -): Map { - // Collect s/c/b values per context suffix. - const sVals = new Map(); - const cVals = new Map(); - const bVals = new Map(); - - const iterate = - contexts instanceof Map - ? [...contexts.entries()] - : Object.entries(contexts); - - for (const [key, value] of iterate) { - if (key.startsWith(OV_S_PREFIX)) - sVals.set(key.slice(OV_S_PREFIX.length), value); - else if (key.startsWith(OV_C_PREFIX)) - cVals.set(key.slice(OV_C_PREFIX.length), value); - else if (key.startsWith(OV_B_PREFIX)) - bVals.set(key.slice(OV_B_PREFIX.length), value); - } - - const result = new Map(); - const ctxSet = new Set([...sVals.keys(), ...cVals.keys(), ...bVals.keys()]); - for (const ctx of ctxSet) { - result.set(ctx, { - s: sVals.get(ctx) ?? 0, - c: cVals.get(ctx) ?? 0, - b: bVals.get(ctx) ?? 0, - }); - } - return result; -} - -/** - * Merge override registers from multiple sanitized contexts maps. + * Merge override registers from multiple register maps. * * Applies componentwise `max()` across all sources for each context suffix, * returning a single Map from raw context ID to merged `OverrideRegister`. - * This is the register-level analogue of the frontier `max()` merge. + * Slices 2–3 call this after collecting `ParsedReadStateEvent.contexts.overrides` + * maps from each parsed event. */ export function mergeOverrideRegisterMaps( - ...maps: Array> + ...maps: Array> ): Map { const result = new Map(); for (const source of maps) { @@ -131,21 +88,23 @@ export async function mergeReadStateEvents( pubkey: string, decrypt?: ReadStateDecrypt, ): Promise> { - const contexts = new Map(); + const frontiers = new Map(); for (const event of events) { const parsed = await parseReadStateEvent(event, pubkey, decrypt); if (!parsed) continue; - for (const [contextId, timestamp] of Object.entries(parsed.blob.contexts)) { - const current = contexts.get(contextId) ?? 0; - if (timestamp > current) { - contexts.set(contextId, timestamp); + // Use the structured frontiers (keyed by raw ctx ID, unescaped) rather + // than the flat blob.contexts, which retains wire keys for readStateManager. + for (const [rawCtx, ts] of parsed.contexts.frontiers) { + const current = frontiers.get(rawCtx) ?? 0; + if (ts > current) { + frontiers.set(rawCtx, ts); } } } - return contexts; + return frontiers; } export function getSnapshotReadTimestamp(