Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/core/identity-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ function slotPaths(slot: Readonly<IdentitySlot>): {
};
}

/** 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<IdentitySlot>): 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);
Expand Down
61 changes: 61 additions & 0 deletions src/core/room-notice-log.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer>;
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<RecordRoomSendNoticeDeps>,
options: Readonly<RecordRoomSendNoticeOptions>,
): 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<ArrayBufferLike>; appendOwnEntry needs the narrower Uint8Array<ArrayBuffer> 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<ArrayBuffer> = Uint8Array.from(
cborEncode(notice, cdeEncodeOptions),
);
return appendOwnEntry(
{ identity: deps.identity, storage: deps.storage },
encoded,
);
}
12 changes: 12 additions & 0 deletions src/test/identity-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
140 changes: 140 additions & 0 deletions src/test/room-notice-log.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof toIdentityPort>>,
bearer: Awaited<ReturnType<typeof toIdentityPort>>,
roomPath: string,
): Promise<CapabilityToken> {
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<RoomNotice>(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);
});
});