From 789d4384c20af5760f8259056c74c4b3ca2d0bc2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 04:56:37 +0100 Subject: [PATCH] feat(core): retire deliverToRoom's mesh-wide broadcast for a real directed room.notify deliverToRoom (member_status/member_joined/name_changed and every other informational DeliveryEvent it fans out) no longer rides deliverLocallyAndBroadcast's mesh-wide broadcastPatch for each room member. It now queues locally for every member as before, fires local delivery directly for this store's own agent, and sends a real, wire-authenticated room.notify to every other member -- matching room.send/room.read's own established directed-fan-out shape (P3.5), closing one more piece of P3.8's own directed-delivery retirement (agent-comms#48). A member this store holds no current room:member token for is silently skipped, the same best-effort choice markRead's own directed room.read already makes for an unreachable read receipt. room.notify is agent-comms' own verb (not a wire-mesh-generated schema): it carries no content beyond the already-validated DeliveryEvent to deliver, riding manage-command-params' open socket the same way the legacy opaque frame carriage always did. The receiving side (handleRoomNotify) verifies the token exactly as handleRoomSend does, then refuses a malformed event or one whose own room field doesn't match the token's verified scope, before queueing and firing it locally. deliverLocallyAndBroadcast itself is untouched -- it still backs every other caller (federation-bridge.ts, room-lifecycle.ts's own invite/join notifications, emitDeliveryStatus). Converting those is its own separate, still-open piece of the same broader retirement, not bundled in here. --- src/core/delivery-engine.ts | 17 +- src/core/room-protocol.ts | 52 +++- src/test/delivery-engine-delivery.test.ts | 34 --- .../delivery-engine-directed-notify.test.ts | 213 ++++++++++++++ src/test/room-protocol-notify.test.ts | 259 ++++++++++++++++++ 5 files changed, 539 insertions(+), 36 deletions(-) create mode 100644 src/test/delivery-engine-directed-notify.test.ts create mode 100644 src/test/room-protocol-notify.test.ts diff --git a/src/core/delivery-engine.ts b/src/core/delivery-engine.ts index 44c3103e..16172cc8 100644 --- a/src/core/delivery-engine.ts +++ b/src/core/delivery-engine.ts @@ -428,6 +428,9 @@ export class DeliveryEngine { if (!this.deps.isShutDown()) this.deps.pendingMarkReadTimers.push(timer); } + /** + * Delivers an informational event (member_status, member_joined, name_changed, and the like -- never room_message/dm, which already ride handleRoomSend's own directed path) to every current member of a room. Queues locally for every member (matching every other queueDelivery caller's own "hold it for whoever reads it next" contract), then either fires local delivery directly for this store's own agent or sends a real, wire-authenticated room.notify to everyone else -- replacing the legacy mesh-wide broadcastPatch this used to ride via deliverLocallyAndBroadcast, per P3.8's own directed-delivery retirement (agent-comms#48). Silently skips a member this store holds no current room:member token for, the same best-effort-by-design choice markRead's own directed room.read already makes for an unreachable read receipt. + */ async deliverToRoom( roomId: string, event: DeliveryEvent, @@ -435,9 +438,21 @@ export class DeliveryEngine { ): Promise { const room = this.deps.rooms.get(roomId); if (!room) return; + const peerId = this.deps.getPeerId(); for (const memberId of room.members) { if (memberId === excludeAgent) continue; - await this.deliverLocallyAndBroadcast(memberId, event); + this.queueDelivery(memberId, event); + if (memberId === peerId) { + this.fireLocalDelivery(memberId, event); + continue; + } + const { slot } = this.deps.requireIdentity(); + const token = loadRoomTokens(slot)[roomId]; + if (token === undefined) continue; + await this.deps.sendRoomRequestToMember(memberId, roomId, token, { + verb: "room.notify", + event, + }); } } diff --git a/src/core/room-protocol.ts b/src/core/room-protocol.ts index 31653ad5..e5eb9972 100644 --- a/src/core/room-protocol.ts +++ b/src/core/room-protocol.ts @@ -46,7 +46,8 @@ import { import type { DeliveryEngine } from "./delivery-engine.js"; import type { RoomVerbHandler } from "./room-router.js"; import type { ConnectionHandle, MeshTransport } from "./transport.js"; -import { StreamingBehavior } from "./types.js"; +import { z } from "zod"; +import { DeliveryEventSchema, StreamingBehavior } from "./types.js"; import type { AgentIdentity, DeliveryEvent, @@ -55,6 +56,9 @@ import type { RoomMessage, } from "./types.js"; +/** room.notify's own params shape: the already-validated DeliveryEvent to deliver, and nothing else -- room.notify carries no content of its own beyond the event, unlike room.send's own message/dm fields. Not a wire-mesh-generated schema (room.notify is agent-comms' own verb, riding manage-command-params' open socket the same way the legacy opaque frame carriage always did, never a real wire-mesh CDDL type other clients need to interoperate with). */ +const RoomNotifyParamsSchema = z.object({ event: DeliveryEventSchema }); + /** The state and collaborators RoomProtocol needs from MeshStore. rooms/messages/dms/agents/dmRequestsInitiatedByMe are direct references into MeshStore's own fields; deliveryEngine is the already-constructed instance, narrowed to what a room-verb handler ever needs; revokeMemberGrant is deferred (RoomLifecycle, which owns it, doesn't exist yet when RoomProtocol is constructed -- construction order: ... -\> roomProtocol -\> roomMessaging -\> roomLifecycle -\> ...), wired the same lazy-`this`-capture way DeliveryEngine's own sendRoomRequestToMember closure is. */ export interface RoomProtocolDeps { rooms: Map; @@ -112,6 +116,8 @@ export class RoomProtocol { "room.invite": async (request) => this.handleRoomInvite(request), "room.leave": async (request, handle) => this.handleRoomLeave(request, handle), + "room.notify": async (request, handle) => + this.handleRoomNotify(request, handle), }; } @@ -209,6 +215,50 @@ export class RoomProtocol { return { result: "ok" }; } + /** + * Receiving side of a directed room.notify (P3.8): the same token verification handleRoomSend does, then queues and fires the already-validated DeliveryEvent locally exactly as if it had arrived any other way -- room.notify carries no content of its own beyond the event, so there is nothing to construct or persist here, unlike room.send's own message/dm branches. Replaces the legacy mesh-wide broadcastPatch deliverToRoom used to ride for informational events (member_status, member_joined, name_changed, and the like) with a real directed request to each room member, matching room.send/room.read's own established shape. A malformed event, or one whose own room field doesn't match the token's verified scope, is refused rather than silently accepted -- unlike a gossiped advert's own self-asserted facts, this is an authenticated peer actively claiming something happened, so it gets the same strict validation room.send's params already get. + */ + private async handleRoomNotify( + request: IncomingManageRequest, + handle: Readonly, + ): Promise { + const roomPath = request.scope.path; + if (roomPath === undefined) { + return { result: "error", code: "missing_scope_path" }; + } + if (request.token === undefined) { + return { result: "error", code: "unauthorized" }; + } + const { identity, clock, revocation } = this.deps.requireIdentity(); + const verdict = await verifyRoomToken(request.token, { + identity, + clock, + revocation, + expectedBearer: deviceIdFromHex(handle.id), + roomPath, + }); + if (!verdict.ok) { + return { result: "error", code: "unauthorized" }; + } + + const parsedParams = RoomNotifyParamsSchema.safeParse( + request.command.params, + ); + if (!parsedParams.success) { + return { result: "error", code: "malformed_params" }; + } + const event = parsedParams.data.event; + if ("room" in event && event.room !== roomPath) { + return { result: "error", code: "malformed_params" }; + } + + const peerId = this.deps.getPeerId(); + this.deps.deliveryEngine.queueDelivery(peerId, event); + this.deps.deliveryEngine.fireLocalDelivery(peerId, event); + + return { result: "ok" }; + } + /** * Receiving side of a directed room.read (P3.5): verifies the presented token the same way handleRoomSend does, then for each read message-id in the batch, updates this store's own local copy of that message's readBy (this store holds one because it's the message's own author -- the reason it's the one being notified) and fires a delivery_status event locally, replacing what markRead used to broadcast via the legacy message_read patch. Read receipts stay voluntary and best-effort by design: a message-id this store doesn't recognise (already expired from history, or simply never this store's own) is silently skipped rather than treated as an error. */ diff --git a/src/test/delivery-engine-delivery.test.ts b/src/test/delivery-engine-delivery.test.ts index 8b60a1cd..9ef9067a 100644 --- a/src/test/delivery-engine-delivery.test.ts +++ b/src/test/delivery-engine-delivery.test.ts @@ -599,40 +599,6 @@ describe("DeliveryEngine — fireLocalDelivery", () => { // deliverToRoom / notifyRoomsOfStatus / notifyRoomsOfNameChange // --------------------------------------------------------------------------- -describe("DeliveryEngine — deliverToRoom", () => { - it("does nothing for an unknown room", async () => { - const h = makeHarness(); - await expect( - h.engine.deliverToRoom("no-such-room", { - type: "member_left", - room: "no-such-room", - agent: OTHER_ID, - }), - ).resolves.toBeUndefined(); - }); - - it("delivers to every member except the excluded one", async () => { - const h = makeHarness(); - h.deps.rooms.set( - "room-1", - room({ members: [PEER_ID, OTHER_ID, THIRD_ID] }), - ); - await h.engine.deliverToRoom( - "room-1", - { - type: "member_status", - room: "room-1", - agent: PEER_ID, - status: "idle", - }, - OTHER_ID, - ); - expect(h.deps.deliveryQueues.get(PEER_ID)).toHaveLength(1); - expect(h.deps.deliveryQueues.get(THIRD_ID)).toHaveLength(1); - expect(h.deps.deliveryQueues.get(OTHER_ID)).toBeUndefined(); - }); -}); - describe("DeliveryEngine — notifyRoomsOfStatus", () => { it("does nothing for an agent with no known record", async () => { const h = makeHarness(); diff --git a/src/test/delivery-engine-directed-notify.test.ts b/src/test/delivery-engine-directed-notify.test.ts new file mode 100644 index 00000000..cdcfdc73 --- /dev/null +++ b/src/test/delivery-engine-directed-notify.test.ts @@ -0,0 +1,213 @@ +/** + * Direct, DI-based unit tests for DeliveryEngine.deliverToRoom's directed room.notify replacement (P3.8, agent-comms#48) -- moved into its own file to stay under the repo's max-lines cap once delivery-engine.test.ts and delivery-engine-delivery.test.ts were already at capacity. Shares the same fake-harness convention those two files established: a narrow, injectable DeliveryEngineDeps surface with every collaborator boundary (transport, sendRoomRequestToMember, onDelivery/onPatch callbacks) a vi.fn() this file controls per test. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadRoomTokens } from "../core/identity-store.js"; +import { + DeliveryEngine, + type DeliveryEngineDeps, +} from "../core/delivery-engine.js"; +import type { MeshTransport } from "../core/transport.js"; +import type { MeshStatePatch } from "../core/wire-protocol.js"; +import type { AgentIdentity, DeliveryEvent, Room } from "../core/types.js"; +import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; + +vi.mock("../core/identity-store.js", () => ({ + loadRoomTokens: vi.fn(), +})); + +// Opaque placeholder: deliverToRoom never inspects a token's own COSE_Sign1 structure, only forwards whatever loadRoomTokens returns to sendRoomRequestToMember. +const FAKE_TOKEN = "fake-token" as unknown as CapabilityToken; + +/** A device-id is a 64-character lowercase hex SHA-256 digest; room-path.ts's assertDeviceIdHex rejects anything shorter. */ +const DEVICE_ID_HEX_LENGTH = 64; +const PEER_ID = "a".repeat(DEVICE_ID_HEX_LENGTH); +const THIRD_ID = "c".repeat(DEVICE_ID_HEX_LENGTH); +const NOW_MS = 1_700_000_000_000; + +function room(overrides: Partial = {}): Room { + return { + id: "room-1", + version: 1, + name: "room-name", + type: "public", + owner: PEER_ID, + createdAt: "2026-01-01T00:00:00.000Z", + description: "", + members: [PEER_ID], + invited: [], + memberJoins: {}, + memberLeaves: {}, + invitedJoins: {}, + invitedLeaves: {}, + ...overrides, + }; +} + +function fakeTransport(): MeshTransport { + return { + dataPort: 4000, + isCoordinator: false, + hasCoordinatorConnection: false, + startDataServer: vi.fn().mockResolvedValue(undefined), + connectToCoordinator: vi.fn().mockResolvedValue(undefined), + becomeCoordinator: vi.fn().mockResolvedValue(undefined), + connectToPeer: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockResolvedValue(undefined), + acceptConnection: vi.fn().mockResolvedValue(undefined), + rejectConnection: vi.fn().mockResolvedValue(undefined), + connectToRemote: vi.fn().mockResolvedValue(undefined), + broadcast: vi.fn().mockResolvedValue(undefined), + broadcastRevocation: vi.fn().mockResolvedValue(undefined), + sendRoomRequest: vi + .fn() + .mockResolvedValue({ result: "error", code: "not_connected" }), + addListener: vi.fn().mockResolvedValue("listener-1"), + removeListener: vi.fn().mockResolvedValue(undefined), + listListeners: vi.fn().mockReturnValue([]), + shutdown: vi.fn().mockResolvedValue(undefined), + unref: vi.fn<() => void>(), + }; +} + +function makeHarness() { + let onDelivery: + | ((agentId: string, event: DeliveryEvent) => void | Promise) + | undefined; + let onPatch: ((patch: MeshStatePatch) => void | Promise) | undefined; + const shutDown = false; + const transport = fakeTransport(); + const sendRoomRequestToMember = vi.fn().mockResolvedValue(undefined); + const revocationRecord = vi.fn().mockResolvedValue(undefined); + const deps: DeliveryEngineDeps = { + agents: new Map(), + rooms: new Map(), + messages: new Map(), + dms: new Map(), + deliveryQueues: new Map(), + localDeliveryKeys: new Set(), + pendingMarkReadTimers: [], + getPeerId: () => PEER_ID, + requireIdentity: () => ({ + slot: { harness: "pi", cwd: "/tmp" }, + clock: { now: () => NOW_MS }, + identity: {} as never, + revocation: { record: revocationRecord } as never, + dataStorage: {} as never, + }), + requireTransport: () => transport, + getOnDelivery: () => onDelivery, + getOnPatch: () => onPatch, + isShutDown: () => shutDown, + sendRoomRequestToMember, + }; + return { + deps, + engine: new DeliveryEngine(deps), + transport, + sendRoomRequestToMember, + setOnDelivery(fn: typeof onDelivery) { + onDelivery = fn; + }, + setOnPatch(fn: typeof onPatch) { + onPatch = fn; + }, + }; +} + +beforeEach(() => { + vi.mocked(loadRoomTokens).mockReset(); + vi.mocked(loadRoomTokens).mockReturnValue({ "room-1": FAKE_TOKEN }); +}); + +describe("DeliveryEngine — deliverToRoom directed room.notify", () => { + it("does nothing for an unknown room", async () => { + const h = makeHarness(); + await expect( + h.engine.deliverToRoom("no-such-room", { + type: "member_left", + room: "no-such-room", + agent: PEER_ID, + }), + ).resolves.toBeUndefined(); + }); + + it("sends a directed room.notify to every non-self member, never broadcasting mesh-wide", async () => { + const h = makeHarness(); + h.deps.rooms.set("room-1", room({ members: [PEER_ID, THIRD_ID] })); + const event: DeliveryEvent = { + type: "member_status", + room: "room-1", + agent: PEER_ID, + status: "idle", + }; + + await h.engine.deliverToRoom("room-1", event); + + expect(h.sendRoomRequestToMember).toHaveBeenCalledWith( + THIRD_ID, + "room-1", + FAKE_TOKEN, + { verb: "room.notify", event }, + ); + expect(h.sendRoomRequestToMember).not.toHaveBeenCalledWith( + PEER_ID, + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(h.transport.broadcast).not.toHaveBeenCalled(); + }); + + it("fires local delivery for this store's own agent without a directed send", async () => { + const h = makeHarness(); + h.deps.rooms.set("room-1", room({ members: [PEER_ID] })); + const onDelivery = vi.fn(); + h.setOnDelivery(onDelivery); + const event: DeliveryEvent = { + type: "member_status", + room: "room-1", + agent: PEER_ID, + status: "busy", + }; + + await h.engine.deliverToRoom("room-1", event); + + // fireLocalDelivery removes the event from deliveryQueues once fired (it's no longer pending for this process) -- onDelivery having been called is the real signal local delivery happened, not the queue's own post-fire contents. + expect(onDelivery).toHaveBeenCalledWith(PEER_ID, event); + expect(h.sendRoomRequestToMember).not.toHaveBeenCalled(); + }); + + it("silently skips a member this store holds no room:member token for", async () => { + const h = makeHarness(); + vi.mocked(loadRoomTokens).mockReturnValue({}); + h.deps.rooms.set("room-1", room({ members: [PEER_ID, THIRD_ID] })); + + await expect( + h.engine.deliverToRoom("room-1", { + type: "member_status", + room: "room-1", + agent: PEER_ID, + status: "idle", + }), + ).resolves.toBeUndefined(); + expect(h.sendRoomRequestToMember).not.toHaveBeenCalled(); + }); + + it("delivers to every member except the excluded one", async () => { + const h = makeHarness(); + h.deps.rooms.set("room-1", room({ members: [PEER_ID, THIRD_ID] })); + await h.engine.deliverToRoom( + "room-1", + { + type: "member_status", + room: "room-1", + agent: PEER_ID, + status: "idle", + }, + THIRD_ID, + ); + expect(h.deps.deliveryQueues.get(PEER_ID)).toHaveLength(1); + expect(h.deps.deliveryQueues.get(THIRD_ID)).toBeUndefined(); + }); +}); diff --git a/src/test/room-protocol-notify.test.ts b/src/test/room-protocol-notify.test.ts new file mode 100644 index 00000000..c560b44e --- /dev/null +++ b/src/test/room-protocol-notify.test.ts @@ -0,0 +1,259 @@ +/** + * Direct, DI-based unit tests for RoomProtocol's handleRoomNotify (P3.8's directed-delivery replacement for informational events, agent-comms#48) -- moved into its own file to stay under the repo's max-lines cap once room-protocol.test.ts and room-protocol-admission.test.ts were already at capacity. Real identities and real minted tokens throughout, matching those two files' own established convention (handleRoomNotify genuinely verifies tokens cryptographically, so an opaque placeholder string fails the mint/verify outright). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { tmpdir } from "node:os"; +import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; +import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; +import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; +import type { + IncomingManageRequest, + ManageOutcome, +} from "wire-mesh-core/domain/mesh-session"; +import { generateIdentity } from "../core/identity.js"; +import { loadOrCreateIdentity } from "../core/identity-store.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { ownerNamedRoomPath } from "../core/room-path.js"; +import { RoomProtocol, type RoomProtocolDeps } from "../core/room-protocol.js"; +import { randomId } from "../core/random-id.js"; +import type { DeliveryEvent, Room } from "../core/types.js"; + +const TOKEN_TTL_MS = 60_000; + +function room(overrides: Partial = {}): Room { + return { + id: "room-1", + version: 1, + name: "room-name", + type: "public", + owner: "", + createdAt: "2026-01-01T00:00:00.000Z", + description: "a room", + members: [], + invited: [], + memberJoins: {}, + memberLeaves: {}, + invitedJoins: {}, + invitedLeaves: {}, + ...overrides, + }; +} + +interface Identities { + ownerId: string; + ownerPort: Awaited>; + memberId: string; +} + +async function makeIdentities(): Promise { + const owner = generateIdentity(); + const member = generateIdentity(); + return { + ownerId: bytesToHex(Uint8Array.from(owner.deviceId)), + ownerPort: await toIdentityPort(owner), + memberId: bytesToHex(Uint8Array.from(member.deviceId)), + }; +} + +async function mintRoomToken( + issuerPort: Awaited>, + bearerHex: string, + roomPath: string, +): Promise { + const clock = createSystemClock(); + const verdict = await mintCapabilityToken({ + identity: issuerPort, + clock, + tokenId: randomId(), + bearer: deviceIdFromHex(bearerHex), + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) + throw new Error("expected the fixture token to mint successfully"); + return verdict.token; +} + +interface Harness { + deps: RoomProtocolDeps; + protocol: RoomProtocol; + ids: Identities; + queueDelivery: ReturnType; + fireLocalDelivery: ReturnType; +} + +async function makeHarness(): Promise { + const ids = await makeIdentities(); + const slotDir = fs.mkdtempSync( + path.join(tmpdir(), "room-protocol-notify-test-"), + ); + const slot = { harness: "test", cwd: "room-protocol-notify", dir: slotDir }; + loadOrCreateIdentity(slot); + const queueDelivery = + vi.fn(); + const fireLocalDelivery = + vi.fn(); + + const deps: RoomProtocolDeps = { + rooms: new Map(), + messages: new Map(), + dms: new Map(), + agents: new Map(), + dmRequestsInitiatedByMe: new Set(), + getPeerId: () => ids.ownerId, + requireIdentity: () => ({ + identity: ids.ownerPort, + clock: createSystemClock(), + slot, + revocation: createRevocationView(), + dataStorage: {} as never, + }), + requireTransport: () => + ({}) as unknown as ReturnType, + deliveryEngine: { + queueDelivery, + fireLocalDelivery, + bump: vi.fn(), + recordMemberOp: + vi.fn(), + refreshMembership: + vi.fn(), + broadcastPatch: vi.fn().mockResolvedValue(undefined), + deliverToRoom: vi.fn().mockResolvedValue(undefined), + }, + revokeMemberGrant: vi.fn().mockResolvedValue(undefined), + }; + + return { + deps, + protocol: new RoomProtocol(deps), + ids, + queueDelivery, + fireLocalDelivery, + }; +} + +function handle(deviceHex: string): { id: string } { + return { id: deviceHex }; +} + +function manageRequest( + overrides: { + scope?: { kind: string; path?: string }; + token?: CapabilityToken | undefined; + params?: Record; + } = {}, +): IncomingManageRequest { + return { + requestId: 1, + command: { verb: "room:member", params: overrides.params ?? {} }, + scope: overrides.scope ?? { kind: "room" }, + token: overrides.token, + respond: vi.fn().mockResolvedValue(undefined), + } as unknown as IncomingManageRequest; +} + +describe("RoomProtocol — handleRoomNotify", () => { + let h: Harness; + let ownerNamedRoom: string; + + beforeEach(async () => { + h = await makeHarness(); + ownerNamedRoom = ownerNamedRoomPath(h.ids.ownerId, "general"); + h.deps.rooms.set( + ownerNamedRoom, + room({ + id: ownerNamedRoom, + owner: h.ids.ownerId, + members: [h.ids.ownerId, h.ids.memberId], + }), + ); + }); + + async function notify( + params: Record, + token: CapabilityToken | undefined, + ): Promise { + const handler = h.protocol.roomVerbHandlers["room.notify"]; + if (handler === undefined) throw new Error("expected room.notify handler"); + return handler( + manageRequest({ + scope: { kind: "room", path: ownerNamedRoom }, + token, + params, + }), + handle(h.ids.memberId), + ); + } + + it("returns missing_scope_path when the request carries no room path", async () => { + const handler = h.protocol.roomVerbHandlers["room.notify"]; + if (handler === undefined) throw new Error("expected room.notify handler"); + const outcome = await handler( + manageRequest({ scope: { kind: "room" } }), + handle(h.ids.memberId), + ); + expect(outcome).toEqual({ result: "error", code: "missing_scope_path" }); + }); + + it("returns unauthorized when no token is presented", async () => { + const outcome = await notify( + { verb: "room.notify", event: { type: "member_joined" } }, + undefined, + ); + expect(outcome).toEqual({ result: "error", code: "unauthorized" }); + }); + + it("returns malformed_params for an event that doesn't parse as a real DeliveryEvent", async () => { + const token = await mintRoomToken( + h.ids.ownerPort, + h.ids.memberId, + ownerNamedRoom, + ); + const outcome = await notify( + { verb: "room.notify", event: { type: "not-a-real-type" } }, + token, + ); + expect(outcome).toEqual({ result: "error", code: "malformed_params" }); + }); + + it("returns malformed_params when the event's own room field doesn't match the token's verified scope", async () => { + const token = await mintRoomToken( + h.ids.ownerPort, + h.ids.memberId, + ownerNamedRoom, + ); + const event: DeliveryEvent = { + type: "member_status", + room: "a-different-room-entirely", + agent: h.ids.memberId, + status: "idle", + }; + const outcome = await notify({ verb: "room.notify", event }, token); + expect(outcome).toEqual({ result: "error", code: "malformed_params" }); + }); + + it("queues and fires local delivery for a well-formed, correctly-scoped event", async () => { + const token = await mintRoomToken( + h.ids.ownerPort, + h.ids.memberId, + ownerNamedRoom, + ); + const event: DeliveryEvent = { + type: "member_status", + room: ownerNamedRoom, + agent: h.ids.memberId, + status: "busy", + }; + const outcome = await notify({ verb: "room.notify", event }, token); + expect(outcome).toEqual({ result: "ok" }); + expect(h.queueDelivery).toHaveBeenCalledWith(h.ids.ownerId, event); + expect(h.fireLocalDelivery).toHaveBeenCalledWith(h.ids.ownerId, event); + }); +});