Skip to content

Commit eaf9428

Browse files
committed
fix(store): derive comment sessionId from its surface
createComment stored the caller's sessionId verbatim instead of deriving it from the surface the comment attaches to. A comment could land in a session that doesn't own its surface, breaking listComments joins and the unread/aggregation logic. The HTTP/MCP flow happened to pass surface.sessionId, so it was safe today; any future caller of the Store interface could split them. Both JsonFileStore and SqlStore now resolve the surface first and derive sessionId from it, falling back to input.sessionId only when no surface (or an unknown one) is provided.
1 parent 7685b4b commit eaf9428

4 files changed

Lines changed: 46 additions & 7 deletions

File tree

.changeset/cold-cars-arrive.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"sideshow": patch
3+
---
4+
5+
Fix a comment/session mismatch in both stores. `createComment` stored the caller's `sessionId` verbatim instead of deriving it from the surface the comment attaches to. A comment could land in a session that doesn't own its surface, breaking `listComments` joins and the unread/aggregation logic. The HTTP/MCP flow happened to pass `surface.sessionId`, so it was safe today; any future caller of the `Store` interface could split them. Both `JsonFileStore` and `SqlStore` now derive `sessionId` from the surface when one is provided.

server/storage.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,20 +347,23 @@ export class JsonFileStore implements Store {
347347

348348
async createComment(input: CreateCommentInput) {
349349
await this.load();
350-
if (!this.sessions.has(input.sessionId)) return null;
351350
const surface = input.surfaceId ? this.surfaces.get(input.surfaceId) : null;
351+
// Derive the session from the surface so a comment can never land in a
352+
// session that doesn't own its surface.
353+
const sessionId = surface ? surface.sessionId : input.sessionId;
354+
if (!this.sessions.has(sessionId)) return null;
352355
const comment: Comment = {
353356
id: newId(),
354357
seq: ++this.lastSeq,
355-
sessionId: input.sessionId,
358+
sessionId,
356359
surfaceId: surface?.id ?? null,
357360
surfaceTitle: surface?.title ?? null,
358361
author: input.author.trim() || "user",
359362
text: input.text,
360363
createdAt: new Date().toISOString(),
361364
};
362365
this.comments.push(comment);
363-
this.touch(input.sessionId);
366+
this.touch(sessionId);
364367
await this.persist();
365368
return clone(comment);
366369
}

test/storeContract.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,34 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise<
392392
assert.equal(ghost?.surfaceId, null);
393393
});
394394

395+
contract("a comment's session follows its surface, not the caller's sessionId", async (store) => {
396+
// Two sessions; a surface in session A. A comment that passes B's
397+
// sessionId but the surface's id must still be filed under A — the
398+
// comment's session should always match the surface's session.
399+
const a = await store.createSession({ agent: "a" });
400+
const b = await store.createSession({ agent: "b" });
401+
const surface = await store.createSurface({
402+
sessionId: a.id,
403+
parts: [htmlPart("<p>x</p>")],
404+
});
405+
assert.ok(surface);
406+
const comment = await store.createComment({
407+
sessionId: b.id,
408+
surfaceId: surface.id,
409+
author: "user",
410+
text: "mismatch",
411+
});
412+
assert.ok(comment);
413+
assert.equal(comment.sessionId, a.id);
414+
assert.equal(comment.surfaceId, surface.id);
415+
// listed under A, not B
416+
assert.deepEqual(
417+
(await store.listComments({ sessionId: a.id })).map((c) => c.text),
418+
["mismatch"],
419+
);
420+
assert.deepEqual(await store.listComments({ sessionId: b.id }), []);
421+
});
422+
395423
contract("comment seq is strictly monotonic, even across deletes", async (store) => {
396424
const first = await store.createSession({ agent: "a" });
397425
const c1 = await store.createComment({ sessionId: first.id, author: "user", text: "1" });

workers/sqlStore.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,27 +368,30 @@ export class SqlStore implements Store {
368368
}
369369

370370
async createComment(input: CreateCommentInput) {
371-
if (!(await this.getSession(input.sessionId))) return null;
372371
const surface = input.surfaceId ? await this.getSurface(input.surfaceId) : null;
372+
// Derive the session from the surface so a comment can never land in a
373+
// session that doesn't own its surface.
374+
const sessionId = surface ? surface.sessionId : input.sessionId;
375+
if (!(await this.getSession(sessionId))) return null;
373376
const id = newId();
374377
const createdAt = new Date().toISOString();
375378
const author = input.author.trim() || "user";
376379
this.sql.exec(
377380
"INSERT INTO comments (id, sessionId, surfaceId, surfaceTitle, author, text, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)",
378381
id,
379-
input.sessionId,
382+
sessionId,
380383
surface?.id ?? null,
381384
surface?.title ?? null,
382385
author,
383386
input.text,
384387
createdAt,
385388
);
386389
const seq = this.sql.exec("SELECT last_insert_rowid() AS seq").one().seq as number;
387-
this.touch(input.sessionId);
390+
this.touch(sessionId);
388391
return {
389392
id,
390393
seq,
391-
sessionId: input.sessionId,
394+
sessionId,
392395
surfaceId: surface?.id ?? null,
393396
surfaceTitle: surface?.title ?? null,
394397
author,

0 commit comments

Comments
 (0)