Skip to content

Commit a29b3f0

Browse files
feat: add browser owner collab transport
1 parent a1d1b2f commit a29b3f0

10 files changed

Lines changed: 1336 additions & 71 deletions

File tree

.beads/export-state.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"last_dolt_commit":"hj3nlh9ehb039rqg4lcm5vc86eb7h40j","timestamp":"2026-07-11T00:22:01.847764-06:00","issues":297,"memories":13}
1+
{"last_dolt_commit":"j4dh0qlaf710t1mespf1qt2nes42e5ag","timestamp":"2026-07-11T00:42:18.264734-06:00","issues":297,"memories":13}

.beads/interactions.jsonl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,3 +298,5 @@
298298
{"id":"int-d9dbd71a","kind":"field_change","created_at":"2026-07-11T05:50:42.403172Z","actor":"James Lal","issue_id":"attn-7xl.4.4","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
299299
{"id":"int-dfb1fb6a","kind":"field_change","created_at":"2026-07-11T05:57:58.620744Z","actor":"James Lal","issue_id":"attn-7xl.4.4.1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
300300
{"id":"int-39fe275f","kind":"field_change","created_at":"2026-07-11T06:21:54.401105Z","actor":"James Lal","issue_id":"attn-7xl.4.4.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and security-audited epoch-bound browser collab core: atomic prepare/persist/commit/broadcast, authenticated-owner broadcast gating, strict legacy-aware wire parsing, stale catch-up/resync, sealed checkpoint restore, workspace-key AAD, snapshot-base binding, epoch-keyed/version-CASed/lease-fenced persistence, reload-to-controller integration, and visible pause seam. Validation: 61 web test files/0 failures, svelte-check 0/0, production browser build and route bundle gate pass, final adversarial re-audit found no blockers."}}
301+
{"id":"int-96547502","kind":"field_change","created_at":"2026-07-11T06:22:25.7727Z","actor":"James Lal","issue_id":"attn-7xl.4.4.2","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
302+
{"id":"int-ff450871","kind":"field_change","created_at":"2026-07-11T06:42:10.428821Z","actor":"James Lal","issue_id":"attn-7xl.4.4.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed recovered browser-owner GET/WS-only startup, fully validated sealed credentials and authenticated policy/directory, honest owner presence/live-authority state, target-null collab direct+relay exact-envelope delivery with retry-safe dedup, and aggregate same-device relay presence including peer-cap race hardening. Full web/relay tests, static checks, production browser build, route bundle gate, and adversarial re-audit all pass."}}

.beads/issues.jsonl

Lines changed: 12 additions & 12 deletions
Large diffs are not rendered by default.

relay/src/room-do.ts

Lines changed: 76 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3087,21 +3087,24 @@ export class RoomDO extends DurableObject<Env> {
30873087
}
30883088
const participantId = deviceRecord.participantId;
30893089

3090-
// Peer cap: count distinct deviceIds currently connected.
3090+
// Peer cap: count distinct deviceIds currently represented by an
3091+
// announced socket. Multiple tabs/connections for one registered device
3092+
// are one presence peer and consume one peer-cap slot.
30913093
const policy = await this.ctx.storage.get<RoomPolicy>(META.policy);
30923094
if (policy === undefined) {
30933095
return errorResponse(500, "ATTN_ROOM_CORRUPT", `room ${roomId} missing policy`);
30943096
}
30953097
const connectedDevices = new Set<string>();
30963098
for (const existing of this.ctx.getWebSockets()) {
30973099
const attached = readAttachment(existing);
3098-
if (attached !== undefined) connectedDevices.add(attached.deviceId);
3100+
if (attached?.presenceAnnounced === true) connectedDevices.add(attached.deviceId);
30993101
}
3100-
// If THIS deviceId is already connected we let it in (the prior socket
3101-
// will be replaced — the spec doesn't speak to multi-tab per device, so we
3102-
// treat it as the same peer). New deviceId beyond cap → close 4004.
3102+
// If THIS deviceId is already connected we let another socket in while
3103+
// continuing to represent it as one online peer. New deviceId beyond cap
3104+
// → close 4004.
3105+
const deviceAlreadyOnline = connectedDevices.has(deviceId);
31033106
const wouldBeOver =
3104-
!connectedDevices.has(deviceId) && connectedDevices.size >= policy.maxPeers;
3107+
!deviceAlreadyOnline && connectedDevices.size >= policy.maxPeers;
31053108

31063109
// Build the upgrade response.
31073110
const pair = new WebSocketPair();
@@ -3119,6 +3122,7 @@ export class RoomDO extends DurableObject<Env> {
31193122
participantId,
31203123
subscribed: false,
31213124
lastPongTs: 0,
3125+
presenceAnnounced: !wouldBeOver,
31223126
});
31233127

31243128
if (wouldBeOver) {
@@ -3135,8 +3139,11 @@ export class RoomDO extends DurableObject<Env> {
31353139
});
31363140
}
31373141

3138-
// Broadcast presence:join to every OTHER connected peer.
3139-
this.broadcastPresence({ event: "join", deviceId, participantId }, server);
3142+
// Presence is per registered device, not per socket. A second tab/socket
3143+
// for an already-online device must not produce another join.
3144+
if (!deviceAlreadyOnline) {
3145+
this.broadcastPresence({ event: "join", deviceId, participantId }, server);
3146+
}
31403147

31413148
return new Response(null, {
31423149
status: 101,
@@ -3151,6 +3158,27 @@ export class RoomDO extends DurableObject<Env> {
31513158
* eviction between frames.
31523159
*/
31533160
override async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer): Promise<void> {
3161+
// A peer-cap rejection uses an accepted-then-closed upgrade so the client
3162+
// can observe close code 4004. Frames can race that close. Gate on the
3163+
// attachment before decoding or replying so the rejected socket cannot
3164+
// subscribe, receive hello/replay/ping, or elicit validation errors.
3165+
const att = readAttachment(ws);
3166+
if (att === undefined) {
3167+
try {
3168+
ws.close(CLOSE_NORMAL, "missing attachment");
3169+
} catch {
3170+
// ignore
3171+
}
3172+
return;
3173+
}
3174+
if (!att.presenceAnnounced) {
3175+
try {
3176+
ws.close(CLOSE_PEER_CAP, "peer cap reached");
3177+
} catch {
3178+
// close is already in progress
3179+
}
3180+
return;
3181+
}
31543182
if (typeof msg !== "string") {
31553183
sendError(ws, "ATTN_FRAME_INVALID", "binary frames are reserved");
31563184
return;
@@ -3167,16 +3195,6 @@ export class RoomDO extends DurableObject<Env> {
31673195
sendError(ws, "ATTN_FRAME_INVALID", formatZodError(frame.error));
31683196
return;
31693197
}
3170-
const att = readAttachment(ws);
3171-
if (att === undefined) {
3172-
// Attachment shouldn't be missing — defensively close.
3173-
try {
3174-
ws.close(CLOSE_NORMAL, "missing attachment");
3175-
} catch {
3176-
// ignore
3177-
}
3178-
return;
3179-
}
31803198
const body = frame.data;
31813199
if (body.type === "subscribe") {
31823200
await this.handleSubscribe(ws, att, body.after);
@@ -3188,19 +3206,20 @@ export class RoomDO extends DurableObject<Env> {
31883206
}
31893207
}
31903208

3191-
/** Hibernation entry-point on close. Broadcast presence:leave. */
3209+
/** Hibernation entry-point on close. Leave only after the final device socket. */
31923210
override async webSocketClose(
31933211
ws: WebSocket,
31943212
_code: number,
31953213
_reason: string,
31963214
_wasClean: boolean,
31973215
): Promise<void> {
31983216
const att = readAttachment(ws);
3199-
if (att !== undefined) {
3200-
this.broadcastPresence(
3201-
{ event: "leave", deviceId: att.deviceId, participantId: att.participantId },
3202-
ws,
3203-
);
3217+
if (att?.presenceAnnounced === true && !this.hasOtherDeviceSocket(ws, att.deviceId)) {
3218+
this.broadcastPresence({
3219+
event: "leave",
3220+
deviceId: att.deviceId,
3221+
participantId: att.participantId,
3222+
}, ws);
32043223
}
32053224
// No need to call ws.close — the runtime already did. We just clean up
32063225
// any state we owned (the attachment lives on the socket itself, which
@@ -3210,14 +3229,29 @@ export class RoomDO extends DurableObject<Env> {
32103229
/** Hibernation entry-point on socket-level error. Same handling as close. */
32113230
override async webSocketError(ws: WebSocket, _err: unknown): Promise<void> {
32123231
const att = readAttachment(ws);
3213-
if (att !== undefined) {
3214-
this.broadcastPresence(
3215-
{ event: "leave", deviceId: att.deviceId, participantId: att.participantId },
3216-
ws,
3217-
);
3232+
if (att?.presenceAnnounced === true) {
3233+
// Error may be followed by webSocketClose. Mark this socket first so
3234+
// that the later callback cannot emit a duplicate leave.
3235+
writeAttachment(ws, { ...att, presenceAnnounced: false });
3236+
if (!this.hasOtherDeviceSocket(ws, att.deviceId)) {
3237+
this.broadcastPresence({
3238+
event: "leave",
3239+
deviceId: att.deviceId,
3240+
participantId: att.participantId,
3241+
}, ws);
3242+
}
32183243
}
32193244
}
32203245

3246+
/** True when another announced socket still represents `deviceId` online. */
3247+
private hasOtherDeviceSocket(originator: WebSocket, deviceId: string): boolean {
3248+
return this.ctx.getWebSockets().some((socket) => {
3249+
if (socket === originator) return false;
3250+
const attachment = readAttachment(socket);
3251+
return attachment?.presenceAnnounced === true && attachment.deviceId === deviceId;
3252+
});
3253+
}
3254+
32213255
/**
32223256
* Handle the client's `subscribe { after }` frame. Sends `hello` plus a
32233257
* replay of every envelope with serverSeq > after, in order. If `after` is
@@ -3276,7 +3310,12 @@ export class RoomDO extends DurableObject<Env> {
32763310
}
32773311
const onlineDeviceIds = [...new Set(
32783312
this.ctx.getWebSockets()
3279-
.map((socket) => readAttachment(socket)?.deviceId)
3313+
.map((socket) => {
3314+
const attachment = readAttachment(socket);
3315+
return attachment?.presenceAnnounced === true
3316+
? attachment.deviceId
3317+
: undefined;
3318+
})
32803319
.filter((deviceId): deviceId is string => deviceId !== undefined),
32813320
)].sort();
32823321
const hello: ServerFrame = {
@@ -3336,7 +3375,7 @@ export class RoomDO extends DurableObject<Env> {
33363375
const json = JSON.stringify(frame);
33373376
for (const sock of sockets) {
33383377
const att = readAttachment(sock);
3339-
if (att === undefined) continue;
3378+
if (att?.presenceAnnounced !== true) continue;
33403379
if (!att.subscribed) continue;
33413380
if (!deliverableTo(record, att.deviceId)) continue;
33423381
sendRaw(sock, json);
@@ -3362,7 +3401,7 @@ export class RoomDO extends DurableObject<Env> {
33623401
for (const sock of this.ctx.getWebSockets()) {
33633402
if (sock === originator) continue;
33643403
const att = readAttachment(sock);
3365-
if (att === undefined) continue;
3404+
if (att?.presenceAnnounced !== true) continue;
33663405
sendRaw(sock, json);
33673406
}
33683407
}
@@ -4458,6 +4497,8 @@ export const WS_CLOSE_CODES = {
44584497
interface WSAttachment {
44594498
deviceId: string;
44604499
participantId: string;
4500+
/** This socket participates in the device's aggregate online presence. */
4501+
presenceAnnounced: boolean;
44614502
/** True once the socket has received a successful `subscribe` reply. */
44624503
subscribed: boolean;
44634504
/** Last seen pong timestamp (ms). 0 until the first pong. */
@@ -4587,6 +4628,9 @@ function readAttachment(ws: WebSocket): WSAttachment | undefined {
45874628
return {
45884629
deviceId: r.deviceId,
45894630
participantId: r.participantId,
4631+
// Attachments written before aggregate same-device presence shipped did
4632+
// announce presence, so missing legacy state must restore as true.
4633+
presenceAnnounced: r.presenceAnnounced !== false,
45904634
subscribed: r.subscribed === true,
45914635
lastPongTs: typeof r.lastPongTs === "number" ? r.lastPongTs : 0,
45924636
};

relay/test/integration/websocket.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,65 @@ describe("WS /socket — live broadcast", () => {
897897
});
898898

899899
describe("WS /socket — presence", () => {
900+
it("tracks same-device presence across 0→1 and 1→0 socket transitions", async () => {
901+
const roomId = uniqueRoomId("ws-presence-same-device");
902+
const owner = await generateEd25519Keypair();
903+
const admissionKey = await createRoom({ roomId, ownerKp: owner });
904+
await registerDevice({ roomId, admissionKey, deviceId: "observer", participantId: "observer-p" });
905+
await registerDevice({ roomId, admissionKey, deviceId: "shared-device", participantId: "shared-p" });
906+
await registerDevice({ roomId, admissionKey, deviceId: "isolated-device", participantId: "isolated-p" });
907+
908+
const observer = await openSocket({ roomId, deviceId: "observer", admissionKey });
909+
const queue = new FrameQueue(observer.ws);
910+
observer.ws.send(JSON.stringify({ type: "subscribe", after: 0 }));
911+
expect(isHello(await queue.next())).toBe(true);
912+
expect(isPing(await queue.next())).toBe(true);
913+
914+
const first = await openSocket({ roomId, deviceId: "shared-device", admissionKey });
915+
const firstJoin = await queue.next();
916+
expect(firstJoin).toEqual({
917+
type: "presence",
918+
event: "join",
919+
deviceId: "shared-device",
920+
participantId: "shared-p",
921+
});
922+
923+
const second = await openSocket({ roomId, deviceId: "shared-device", admissionKey });
924+
const isolated = await openSocket({ roomId, deviceId: "isolated-device", admissionKey });
925+
// This must be the isolated device's join. If the second shared-device
926+
// socket emitted a duplicate join, it would be the next queued frame.
927+
expect(await queue.next()).toEqual({
928+
type: "presence",
929+
event: "join",
930+
deviceId: "isolated-device",
931+
participantId: "isolated-p",
932+
});
933+
934+
first.ws.close(1000, "first tab closed");
935+
isolated.ws.close(1000, "other device closed");
936+
// This must be isolated-device's leave. A false leave from the first
937+
// shared-device socket would arrive first. Presence is isolated by
938+
// deviceId: the remaining shared socket cannot keep this device online.
939+
expect(await queue.next()).toEqual({
940+
type: "presence",
941+
event: "leave",
942+
deviceId: "isolated-device",
943+
participantId: "isolated-p",
944+
});
945+
946+
second.ws.close(1000, "final tab closed");
947+
expect(await queue.next()).toEqual({
948+
type: "presence",
949+
event: "leave",
950+
deviceId: "shared-device",
951+
participantId: "shared-p",
952+
});
953+
// The 1→0 transition emits exactly one leave.
954+
expect(await queue.next(250)).toBeUndefined();
955+
956+
observer.ws.close(1000);
957+
});
958+
900959
it("broadcasts presence:join to existing peers when a new socket opens", async () => {
901960
const roomId = uniqueRoomId("ws-presence-join");
902961
const owner = await generateEd25519Keypair();
@@ -987,6 +1046,66 @@ describe("WS /socket — ping/pong", () => {
9871046
});
9881047

9891048
describe("WS /socket — peer cap", () => {
1049+
it("drops a subscribe racing close on a cap-rejected attachment", async () => {
1050+
const roomId = uniqueRoomId("ws-peercap-subscribe-race");
1051+
const owner = await generateEd25519Keypair();
1052+
const admissionKey = await createRoom({ roomId, ownerKp: owner });
1053+
await registerDevice({
1054+
roomId,
1055+
admissionKey,
1056+
deviceId: "dev-rejected",
1057+
participantId: "rejected-p",
1058+
});
1059+
// Ensure a successful subscribe would have both hello and replay data to
1060+
// send, making silence a meaningful assertion rather than an empty room.
1061+
const envelope = buildEnvelope({
1062+
envelopeId: "rejected-race-event",
1063+
authorId: "rejected-p",
1064+
deviceId: "dev-rejected",
1065+
});
1066+
expect(
1067+
(await postEnvelopes({ roomId, admissionKey, envelopes: [envelope] })).status,
1068+
).toBe(201);
1069+
1070+
const stub = env.RELAY_ROOMS.get(env.RELAY_ROOMS.idFromName(roomId));
1071+
const sent: string[] = [];
1072+
const closes: Array<{ code?: number; reason?: string }> = [];
1073+
await runInDurableObject(stub, async (instance) => {
1074+
let attachment: unknown = {
1075+
deviceId: "dev-rejected",
1076+
participantId: "rejected-p",
1077+
presenceAnnounced: false,
1078+
subscribed: false,
1079+
lastPongTs: 0,
1080+
};
1081+
// Drive the hibernation callback directly with a rejected attachment.
1082+
// This fixes the race ordering: subscribe is delivered while the
1083+
// accepted-then-closed socket is still capable of receiving callbacks.
1084+
const rejectedSocket = {
1085+
deserializeAttachment: () => attachment,
1086+
serializeAttachment: (next: unknown) => {
1087+
attachment = next;
1088+
},
1089+
send: (payload: string) => {
1090+
sent.push(payload);
1091+
},
1092+
close: (code?: number, reason?: string) => {
1093+
closes.push({ code, reason });
1094+
},
1095+
} as unknown as WebSocket;
1096+
const handler = instance as unknown as {
1097+
webSocketMessage(socket: WebSocket, message: string): Promise<void>;
1098+
};
1099+
await handler.webSocketMessage(
1100+
rejectedSocket,
1101+
JSON.stringify({ type: "subscribe", after: 0 }),
1102+
);
1103+
});
1104+
1105+
expect(closes).toEqual([{ code: 4004, reason: "peer cap reached" }]);
1106+
expect(sent).toEqual([]);
1107+
});
1108+
9901109
it("closes the (maxPeers+1)-th distinct device with 4004", async () => {
9911110
const roomId = uniqueRoomId("ws-peercap");
9921111
const owner = await generateEd25519Keypair();
@@ -1004,18 +1123,33 @@ describe("WS /socket — peer cap", () => {
10041123
const qa = new FrameQueue(a.ws);
10051124
a.ws.send(JSON.stringify({ type: "subscribe", after: 0 }));
10061125
expect(isHello(await qa.next())).toBe(true);
1126+
expect(isPing(await qa.next())).toBe(true);
10071127

10081128
const b = await openSocket({ roomId, deviceId: "dev-c2", admissionKey });
1129+
expect(await qa.next()).toEqual({
1130+
type: "presence",
1131+
event: "join",
1132+
deviceId: "dev-c2",
1133+
participantId: "c2",
1134+
});
10091135
const qb = new FrameQueue(b.ws);
10101136
b.ws.send(JSON.stringify({ type: "subscribe", after: 0 }));
1011-
expect(isHello(await qb.next())).toBe(true);
1137+
const helloB = await qb.next();
1138+
expect(isHello(helloB)).toBe(true);
1139+
if (isHello(helloB)) {
1140+
expect(helloB.onlineDeviceIds).toEqual(["dev-c1", "dev-c2"]);
1141+
}
1142+
expect(isPing(await qb.next())).toBe(true);
10121143

10131144
// Third connect should be rejected with close 4004.
10141145
const c = await openSocket({ roomId, deviceId: "dev-c3", admissionKey });
10151146
const qc = new FrameQueue(c.ws);
10161147
await qc.waitClosed(2000);
10171148
expect(qc.closed).toBe(true);
10181149
expect(qc.closeCode).toBe(4004);
1150+
// A cap-rejected socket never joins presence and its close cannot emit a
1151+
// leave during the brief accepted-then-closed transition.
1152+
expect(await qa.next(250)).toBeUndefined();
10191153

10201154
a.ws.close(1000);
10211155
b.ws.close(1000);

web/src/BrowserReviewApp.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@
8080
// ---------------------------------------------------------------------------
8181
8282
let sessionState = $state<BrowserSessionState>({
83+
principal: 'reviewer',
84+
ownerOnline: false,
85+
liveEditingAvailable: false,
8386
status: 'idle',
8487
connection: 'offline',
8588
directError: null,

0 commit comments

Comments
 (0)