diff --git a/src/core/identity-store.ts b/src/core/identity-store.ts index b85eff9f..24997d2a 100644 --- a/src/core/identity-store.ts +++ b/src/core/identity-store.ts @@ -156,6 +156,13 @@ function slotPaths(slot: Readonly): { }; } +/** Directory this slot's own room-notice oplog is stored under (createNodeFsStorage's own dir option), mirroring the identity file's per-(harness, cwd) naming convention -- a sibling directory rather than a sibling file, since an oplog needs its own directory tree (one entry per sequence number) rather than a single JSON blob. */ +export function oplogDirFor(slot: Readonly): string { + const { dir } = slotPaths(slot); + const base = `oplog-${slot.harness}--${slugifyCwd(slot.cwd)}`; + return path.join(dir, base); +} + function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); diff --git a/src/core/room-notice-log.ts b/src/core/room-notice-log.ts new file mode 100644 index 00000000..40be5d75 --- /dev/null +++ b/src/core/room-notice-log.ts @@ -0,0 +1,61 @@ +/** + * Records a room.send as a durable, catch-up-able room-notice in the sender's own oplog -- P5's mint-and-append half (agent-comms#50), riding wire-mesh-core's createRoomNotice + appendOwnEntry directly. Deliberately opt-in per call, not wired into sendRoomMessage's own default path: a caller decides per-message whether durability is worth the extra local write, matching the plan's own "delivery and durable catch-up are the same artifact, opt-in per message" design. Wiring this into a bridge's own send path, and the actual catch-up policy (when to send data-have, which peers' logs to track), stay their own separate, still-open piece. + */ + +import { encode as cborEncode, cdeEncodeOptions } from "cbor2"; +import { createRoomNotice } from "wire-mesh-core/domain/room"; +import { appendOwnEntry } from "wire-mesh-core/domain/data-sync"; +import type { + CapabilityToken, + DataHaveFrame, + MessageRef, + RoomPath, +} from "wire-mesh-core/generated/protocol"; +import type { IdentityPort } from "wire-mesh-core/ports/identity"; +import type { Clock } from "wire-mesh-core/ports/clock"; +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; +import { randomId } from "./random-id.js"; + +export interface RecordRoomSendNoticeDeps { + identity: IdentityPort; + clock: Clock; + storage: KeyValueStorage; +} + +export interface RecordRoomSendNoticeOptions { + room: RoomPath; + /** The sender's own room:member token for `room`, embedded in full in the minted notice -- see createRoomNotice's own token field for why. */ + token: CapabilityToken; + contentType: string; + content: Uint8Array; + refs?: readonly MessageRef[]; + validUntil?: number; +} + +/** Mints a self-certifying room-notice for this send, CBOR-encodes it (cbor2's own deterministic-encoding preset, matching handshake.ts's established convention for every other wire-mesh value this codebase serialises), and appends it to the sender's own oplog. Returns the new sequence number and the data-have frame announcing it -- sending that frame to any peer is the caller's own policy, not this function's. */ +export async function recordRoomSendNotice( + deps: Readonly, + options: Readonly, +): Promise<{ seq: number; haveFrame: DataHaveFrame }> { + const notice = await createRoomNotice({ + identity: deps.identity, + clock: deps.clock, + room: options.room, + token: options.token, + noticeId: randomId(), + contentType: options.contentType, + content: options.content, + ...(options.refs !== undefined ? { refs: options.refs } : {}), + ...(options.validUntil !== undefined + ? { validUntil: options.validUntil } + : {}), + }); + // cbor2's encode() returns Uint8Array; appendOwnEntry needs the narrower Uint8Array every other wire-mesh-core byte-string field already uses. Uint8Array.from copies into a fresh, plain ArrayBuffer-backed array rather than asserting the existing buffer's type. + const encoded: Uint8Array = Uint8Array.from( + cborEncode(notice, cdeEncodeOptions), + ); + return appendOwnEntry( + { identity: deps.identity, storage: deps.storage }, + encoded, + ); +} diff --git a/src/test/identity-store.test.ts b/src/test/identity-store.test.ts index b9128253..42ce9764 100644 --- a/src/test/identity-store.test.ts +++ b/src/test/identity-store.test.ts @@ -9,6 +9,7 @@ import { spawn } from "node:child_process"; import { test, expect } from "vitest"; import { loadOrCreateIdentity, + oplogDirFor, releaseIdentityLock, type IdentitySlot, } from "../core/identity-store.js"; @@ -156,3 +157,14 @@ test("a corrupt identity file is regenerated", () => { expect(regenerated.fingerprint).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2})+$/); releaseIdentityLock(slot); }); + +test("oplogDirFor is a sibling directory of the identity file, distinct per (harness, cwd)", () => { + const { slot, dir } = tempSlot("pi"); + const oplogDir = oplogDirFor(slot); + expect(path.dirname(oplogDir)).toBe(dir); + expect(oplogDir).not.toBe(dir); + expect(oplogDirFor({ ...slot, cwd: "/tmp/other-project" })).not.toBe( + oplogDir, + ); + expect(oplogDirFor({ ...slot, harness: "claude-code" })).not.toBe(oplogDir); +}); diff --git a/src/test/room-notice-log.test.ts b/src/test/room-notice-log.test.ts new file mode 100644 index 00000000..9065e09f --- /dev/null +++ b/src/test/room-notice-log.test.ts @@ -0,0 +1,140 @@ +/** + * recordRoomSendNotice: mints a room-notice for a real room-send and appends it to the sender's own oplog. The critical correctness property is round-trip fidelity -- what gets CBOR-encoded and appended must decode back into exactly the same verifiable RoomNotice createRoomNotice produced, since appendOwnEntry only ever stores opaque bytes and has no idea a RoomNotice is inside them. + */ + +import { describe, expect, it } from "vitest"; +import { decode as cborDecode, cdeDecodeOptions } from "cbor2"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { ownerNamedRoomPath } from "wire-mesh-core/domain/room-path"; +import { verifyRoomNotice } from "wire-mesh-core/domain/room"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; +import { readEntries, headSeqFor } from "wire-mesh-core/domain/data-sync"; +import type { + CapabilityToken, + RoomNotice, +} from "wire-mesh-core/generated/protocol"; +import { generateIdentity } from "../core/identity.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { recordRoomSendNotice } from "../core/room-notice-log.js"; +import { randomId } from "../core/random-id.js"; + +const NO_DELEGATIONS_REMAINING = 0; +const MINUTES_PER_HOUR = 60; +const SECONDS_PER_MINUTE = 60; +const MS_PER_SECOND = 1000; +const ONE_HOUR_MS = MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND; + +async function mintRoomMemberToken( + issuer: Awaited>, + bearer: Awaited>, + roomPath: string, +): Promise { + const verdict = await mintCapabilityToken({ + identity: issuer, + clock: createSystemClock(), + tokenId: randomId(), + bearer: bearer.deviceId, + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: Date.now() + ONE_HOUR_MS, + delegationsRemaining: NO_DELEGATIONS_REMAINING, + }); + if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; +} + +describe("recordRoomSendNotice", () => { + it("appends a notice that decodes back into exactly what verifyRoomNotice accepts", async () => { + const ownerIdentity = await toIdentityPort(generateIdentity()); + const posterIdentity = await toIdentityPort(generateIdentity()); + const roomPath = ownerNamedRoomPath( + deviceIdToHex(ownerIdentity.deviceId), + "general", + ); + const token = await mintRoomMemberToken( + ownerIdentity, + posterIdentity, + roomPath, + ); + const storage = createMemoryStorage(); + const clock = createSystemClock(); + + const { seq, haveFrame } = await recordRoomSendNotice( + { identity: posterIdentity, clock, storage }, + { + room: roomPath, + token, + contentType: "text/plain", + content: new TextEncoder().encode("hello room"), + }, + ); + + expect(seq).toBe(1); + expect(haveFrame).toEqual({ + type: "data-have", + peer: posterIdentity.deviceId, + "head-seq": 1, + }); + expect(await headSeqFor(storage, posterIdentity.deviceId)).toBe(1); + + const [storedBytes] = await readEntries( + storage, + posterIdentity.deviceId, + 0, + ); + expect(storedBytes).toBeDefined(); + if (storedBytes === undefined) return; + const decoded = cborDecode(storedBytes, cdeDecodeOptions); + + const verdict = await verifyRoomNotice(decoded, { + identity: posterIdentity, + clock, + revocation: createRevocationView(), + }); + + expect( + verdict.ok, + `expected the round-tripped notice to verify, got ${JSON.stringify(verdict)}`, + ).toBeTruthy(); + if (!verdict.ok) return; + expect(verdict.claims.room).toBe(roomPath); + expect(new TextDecoder().decode(verdict.claims.content)).toBe("hello room"); + }); + + it("increments the sequence across successive sends in the same room", async () => { + const ownerIdentity = await toIdentityPort(generateIdentity()); + const posterIdentity = await toIdentityPort(generateIdentity()); + const roomPath = ownerNamedRoomPath( + deviceIdToHex(ownerIdentity.deviceId), + "general", + ); + const token = await mintRoomMemberToken( + ownerIdentity, + posterIdentity, + roomPath, + ); + const storage = createMemoryStorage(); + const clock = createSystemClock(); + const deps = { identity: posterIdentity, clock, storage }; + const options = { + room: roomPath, + token, + contentType: "text/plain", + }; + + const first = await recordRoomSendNotice(deps, { + ...options, + content: new TextEncoder().encode("first"), + }); + const second = await recordRoomSendNotice(deps, { + ...options, + content: new TextEncoder().encode("second"), + }); + + expect(first.seq).toBe(1); + expect(second.seq).toBe(2); + }); +});