From 4f5e6ab4b416f3a2a80ddcbbc61fc1fef1b20b54 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 27 Jun 2026 09:53:38 -0400 Subject: [PATCH] feat(app): add GET /api/surfaces/recent (post-grained recent surfaces) Post-grained source for cross-session feeds (Org Home + per-workspace Home), distinct from the session-grained /api/sessions. Caps oversized inline text parts (truncated:true) while images stay by-reference (assetId); same auth as /api/sessions (not broadened on a session-scoped publicRead board). Co-Authored-By: Claude Opus 4.8 --- server/app.ts | 94 ++++++++++++++++ server/sqlStore.ts | 7 ++ server/storage.ts | 8 ++ server/types.ts | 2 + test/storeContract.ts | 32 ++++++ test/surfaces-recent.test.ts | 205 +++++++++++++++++++++++++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 test/surfaces-recent.test.ts diff --git a/server/app.ts b/server/app.ts index 59ae46a..80825e7 100644 --- a/server/app.ts +++ b/server/app.ts @@ -221,6 +221,55 @@ const surfaceMeta = (s: Post) => ({ parts: stripParts(s.surfaces), }); +// Cap inline text parts so /api/surfaces/recent stays cheap while still carrying a +// real (clipped) preview. Unlike stripParts (which empties html for the card list), +// this TRUNCATES the large text-bearing kinds so a feed card can render an honest +// preview. Images/assets are already by-reference (assetId) and json/trace are +// small, so we leave those untouched. When a field is clipped we set `truncated:true` +// on that part so a client can offer a "view full post" affordance honestly. +// A response is bounded by limit × (#parts × PART_TEXT_CAP). +const PART_TEXT_CAP = 8_000; // chars; ~a screenful, enough for a clipped preview +type CappedSurface = Surface & { truncated?: true }; +function capText(text: string): { value: string; truncated: boolean } { + return text.length > PART_TEXT_CAP + ? { value: text.slice(0, PART_TEXT_CAP), truncated: true } + : { value: text, truncated: false }; +} +function capParts(parts: Surface[]): CappedSurface[] { + return parts.map((p): CappedSurface => { + switch (p.kind) { + case "html": { + const { value, truncated } = capText(p.html); + return truncated ? { ...p, html: value, truncated: true } : p; + } + case "markdown": { + const { value, truncated } = capText(p.markdown); + return truncated ? { ...p, markdown: value, truncated: true } : p; + } + case "mermaid": { + const { value, truncated } = capText(p.mermaid); + return truncated ? { ...p, mermaid: value, truncated: true } : p; + } + case "code": { + const { value, truncated } = capText(p.code); + return truncated ? { ...p, code: value, truncated: true } : p; + } + case "terminal": { + const { value, truncated } = capText(p.text); + return truncated ? { ...p, text: value, truncated: true } : p; + } + case "diff": { + if (p.patch === undefined) return p; + const { value, truncated } = capText(p.patch); + return truncated ? { ...p, patch: value, truncated: true } : p; + } + // image (assetId ref), trace (small/by-ref), json (small) travel as-is. + default: + return p; + } + }); +} + function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { if (mode === "full") return true; if (path.startsWith("/session/")) return true; @@ -228,6 +277,10 @@ function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { if (path.startsWith("/p/")) return true; if (path.startsWith("/a/")) return true; if (path.startsWith("/api/sessions/")) return true; + // /api/surfaces/recent is the cross-session feed source — gate it like + // /api/sessions (NOT public on a session-scoped board), not like the + // per-surface /api/surfaces/:id reads below. + if (path === "/api/surfaces/recent") return false; if (path.startsWith("/api/surfaces/")) return true; if (path.startsWith("/api/posts/")) return true; if (path.startsWith("/api/snippets/")) return true; @@ -946,6 +999,47 @@ export function createApp({ return c.json(sessions.map((s) => ({ ...s, surfaceCount: counts.get(s.id) ?? 0 }))); }); + // --- recent surfaces (post-grained feed source) --- + // + // The N most-recently-updated posts across ALL sessions, newest first — one + // row per post (post-grained), distinct from the session-grained GET + // /api/sessions. This is the source a cross-session "latest posts" feed needs + // (Org Home, a per-workspace Home): each item carries its session id/title + + // agent for the feed card, the post's part kinds, and capped part previews. + // + // Previews are bounded by capParts (large inline text clipped to PART_TEXT_CAP + // with truncated:true); images travel as plain assetId refs (served at /a/:id), + // so the response stays cheap. Same auth as /api/sessions — see + // isPublicReadAllowed, which intentionally does NOT expose this path on a + // session-scoped publicRead board. + app.get("/api/surfaces/recent", async (c) => { + const limit = Math.min(Math.max(Number(c.req.query("limit") ?? "20") || 20, 1), 100); + const posts = await store.listRecentPosts(limit); + // Resolve each post's session once (agent + session title for the feed card). + const sessions = new Map(); + for (const p of posts) { + if (!sessions.has(p.sessionId)) + sessions.set(p.sessionId, await store.getSession(p.sessionId)); + } + return c.json( + posts.map((p) => { + const s = sessions.get(p.sessionId); + return { + id: p.id, + sessionId: p.sessionId, + sessionTitle: s?.title ?? null, + agent: s?.agent ?? null, + title: p.title, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + version: p.version, + partKinds: p.surfaces.map((x) => x.kind), + parts: capParts(p.surfaces), + }; + }), + ); + }); + app.post("/api/sessions", async (c) => { const body = await c.req.json().catch(() => ({})); const session = await store.createSession({ diff --git a/server/sqlStore.ts b/server/sqlStore.ts index dad15e4..4b49c61 100644 --- a/server/sqlStore.ts +++ b/server/sqlStore.ts @@ -364,6 +364,13 @@ export class SqlStore implements Store { return rows.map((r) => this.rowToPost(r)); } + async listRecentPosts(limit: number) { + const rows = this.sql + .exec("SELECT * FROM posts ORDER BY updatedAt DESC LIMIT ?", limit) + .toArray(); + return rows.map((r) => this.rowToPost(r)); + } + async getPost(id: string) { const rows = this.sql.exec("SELECT * FROM posts WHERE id = ?", id).toArray(); return rows.length > 0 ? this.rowToPost(rows[0]) : null; diff --git a/server/storage.ts b/server/storage.ts index c2b565a..a19f8eb 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -348,6 +348,14 @@ export class JsonFileStore implements Store { return all.map(clone).sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } + async listRecentPosts(limit: number) { + await this.load(); + return [...this.surfaces.values()] + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .slice(0, limit) + .map(clone); + } + async getPost(id: string) { await this.load(); return cloneOrNull(this.surfaces.get(id)); diff --git a/server/types.ts b/server/types.ts index 9391bc9..a0036ec 100644 --- a/server/types.ts +++ b/server/types.ts @@ -277,6 +277,8 @@ export interface Store { setSetting(key: string, value: string): Promise; listPosts(sessionId?: string): Promise; + /** The N most-recently-updated posts across all sessions (newest first). */ + listRecentPosts(limit: number): Promise; getPost(id: string): Promise; createPost(input: CreatePostInput): Promise; updatePost(id: string, patch: UpdatePostInput): Promise; diff --git a/test/storeContract.ts b/test/storeContract.ts index fd881d9..ef4f4eb 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -301,6 +301,38 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.deepEqual(await store.listPosts("missing"), []); }); + contract( + "listRecentPosts returns newest-updated first across sessions, clamped to limit", + async (store) => { + const one = await store.createSession({ agent: "a" }); + const two = await store.createSession({ agent: "b" }); + const s1 = await store.createPost({ sessionId: one.id, surfaces: [htmlSurface("

1

")] }); + await sleep(10); + const s2 = await store.createPost({ sessionId: two.id, surfaces: [htmlSurface("

2

")] }); + await sleep(10); + const s3 = await store.createPost({ sessionId: one.id, surfaces: [htmlSurface("

3

")] }); + + // Newest updatedAt first — the reverse of listPosts' oldest-first order. + assert.deepEqual( + (await store.listRecentPosts(10)).map((s) => s.id), + [s3?.id, s2?.id, s1?.id], + ); + // limit slices to the N most recent. + assert.deepEqual( + (await store.listRecentPosts(2)).map((s) => s.id), + [s3?.id, s2?.id], + ); + + // Updating an older post bumps it to the front (updatedAt, not createdAt). + await sleep(10); + await store.updatePost(s1!.id, { surfaces: [htmlSurface("

1b

")] }); + assert.deepEqual( + (await store.listRecentPosts(10)).map((s) => s.id), + [s1?.id, s3?.id, s2?.id], + ); + }, + ); + contract("updates bump the version and archive the previous one", async (store) => { const session = await store.createSession({ agent: "pi" }); const surface = await store.createPost({ diff --git a/test/surfaces-recent.test.ts b/test/surfaces-recent.test.ts new file mode 100644 index 0000000..08c11aa --- /dev/null +++ b/test/surfaces-recent.test.ts @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { createApp } from "../server/app.ts"; +import { JsonFileStore } from "../server/storage.ts"; + +function makeApp(authToken?: string, opts?: { publicRead?: "session" | "full" }) { + const dir = mkdtempSync(join(tmpdir(), "sideshow-recent-test-")); + const store = new JsonFileStore(join(dir, "data.json")); + return createApp({ + store, + viewerHtml: "viewer", + guideMarkdown: "# guide", + setupText: "# setup", + agentHowtoText: "# agent how-to", + authToken, + ...opts, + }); +} + +const json = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +// Publish a surface; returns the lean write response ({ id, sessionId, ... }). +async function publish(app: ReturnType, body: unknown) { + const res = await app.request("/api/surfaces", json(body)); + if (res.status !== 201) assert.fail(`publish failed (${res.status}): ${await res.text()}`); + return (await res.json()) as any; +} + +// Create a session up front so we can publish into it with a known agent/title. +async function createSession(app: ReturnType, agent: string, title?: string) { + const res = await app.request("/api/sessions", json({ agent, title })); + assert.equal(res.status, 201); + return (await res.json()) as any; +} + +test("GET /api/surfaces/recent returns posts newest-first across sessions", async () => { + const app = makeApp(); + const a = await createSession(app, "amp", "Session A"); + const b = await createSession(app, "pi", "Session B"); + + // Publish in an interleaved order; updatedAt (touched on create) drives recency. + const p1 = await publish(app, { + session: a.id, + title: "first", + parts: [{ kind: "html", html: "

1

" }], + }); + await new Promise((r) => setTimeout(r, 5)); + const p2 = await publish(app, { + session: b.id, + title: "second", + parts: [{ kind: "html", html: "

2

" }], + }); + await new Promise((r) => setTimeout(r, 5)); + const p3 = await publish(app, { + session: a.id, + title: "third", + parts: [{ kind: "html", html: "

3

" }], + }); + + const feed = (await (await app.request("/api/surfaces/recent")).json()) as any[]; + assert.equal(feed.length, 3); + assert.deepEqual( + feed.map((x) => x.id), + [p3.id, p2.id, p1.id], + ); + + // Each item carries session context for the feed card. + const top = feed[0]; + assert.equal(top.sessionId, a.id); + assert.equal(top.sessionTitle, "Session A"); + assert.equal(top.agent, "amp"); + assert.equal(top.title, "third"); + assert.deepEqual(top.partKinds, ["html"]); + assert.ok(Array.isArray(top.parts)); + + const middle = feed[1]; + assert.equal(middle.sessionId, b.id); + assert.equal(middle.agent, "pi"); +}); + +test("GET /api/surfaces/recent respects and clamps limit", async () => { + const app = makeApp(); + const s = await createSession(app, "amp"); + for (let i = 0; i < 5; i++) { + await publish(app, { session: s.id, parts: [{ kind: "html", html: `

${i}

` }] }); + } + + const two = (await (await app.request("/api/surfaces/recent?limit=2")).json()) as any[]; + assert.equal(two.length, 2); + + // a negative limit clamps up to 1. + const clampedLow = (await (await app.request("/api/surfaces/recent?limit=-5")).json()) as any[]; + assert.equal(clampedLow.length, 1); + + // garbage / 0 falls back to default (20) → all 5 returned. + const fallback = (await (await app.request("/api/surfaces/recent?limit=abc")).json()) as any[]; + assert.equal(fallback.length, 5); + const zero = (await (await app.request("/api/surfaces/recent?limit=0")).json()) as any[]; + assert.equal(zero.length, 5); + + // limit above 100 clamps to 100 (we only have 5, so this just confirms no error). + const high = (await (await app.request("/api/surfaces/recent?limit=9999")).json()) as any[]; + assert.equal(high.length, 5); +}); + +test("GET /api/surfaces/recent caps oversized text parts and flags truncation", async () => { + const app = makeApp(); + const s = await createSession(app, "amp"); + const bigHtml = "x".repeat(20_000); + const bigMarkdown = "# ".repeat(10_000); // 20k chars + const smallCode = "const a = 1;"; + await publish(app, { + session: s.id, + parts: [ + { kind: "html", html: bigHtml }, + { kind: "markdown", markdown: bigMarkdown }, + { kind: "code", code: smallCode, language: "ts" }, + ], + }); + + const feed = (await (await app.request("/api/surfaces/recent")).json()) as any[]; + const parts = feed[0].parts; + + const html = parts.find((p: any) => p.kind === "html"); + assert.equal(html.html.length, 8_000); // PART_TEXT_CAP + assert.equal(html.truncated, true); + + const md = parts.find((p: any) => p.kind === "markdown"); + assert.equal(md.markdown.length, 8_000); + assert.equal(md.truncated, true); + + // a small part is left whole, with no truncated flag. + const code = parts.find((p: any) => p.kind === "code"); + assert.equal(code.code, smallCode); + assert.equal(code.truncated, undefined); +}); + +test("GET /api/surfaces/recent leaves image parts as plain assetId refs", async () => { + const app = makeApp(); + const s = await createSession(app, "amp"); + + // Upload an asset via the JSON envelope to get a real assetId. + const upload = (await ( + await app.request("/api/assets", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + data: Buffer.from("\x89PNG\r\n\x1a\n px").toString("base64"), + contentType: "image/png", + filename: "shot.png", + kind: "image", + session: s.id, + }), + }) + ).json()) as any; + assert.ok(upload.id); + + await publish(app, { + session: s.id, + parts: [{ kind: "image", assetId: upload.id, alt: "a shot" }], + }); + + const feed = (await (await app.request("/api/surfaces/recent")).json()) as any[]; + const img = feed[0].parts.find((p: any) => p.kind === "image"); + assert.equal(img.assetId, upload.id); + assert.equal(img.alt, "a shot"); + assert.equal(img.truncated, undefined); +}); + +test("GET /api/surfaces/recent is auth-gated exactly like /api/sessions", async () => { + // With an auth token configured, both routes require it. + const guarded = makeApp("secret"); + await guarded.request("/api/surfaces", { + ...json({ parts: [{ kind: "html", html: "

x

" }] }), + headers: { "content-type": "application/json", authorization: "Bearer secret" }, + }); + assert.equal((await guarded.request("/api/sessions")).status, 401); + assert.equal((await guarded.request("/api/surfaces/recent")).status, 401); + const ok = await guarded.request("/api/surfaces/recent", { + headers: { authorization: "Bearer secret" }, + }); + assert.equal(ok.status, 200); + + // On a session-scoped publicRead board, /api/sessions is NOT public — and + // neither is /api/surfaces/recent (it must not broaden access). + const board = makeApp("secret", { publicRead: "session" }); + assert.equal((await board.request("/api/sessions")).status, 401); + assert.equal((await board.request("/api/surfaces/recent")).status, 401); + // the per-surface read IS public on a session board — recent must NOT be. + const made = (await ( + await board.request("/api/surfaces", { + ...json({ parts: [{ kind: "html", html: "

x

" }] }), + headers: { "content-type": "application/json", authorization: "Bearer secret" }, + }) + ).json()) as any; + assert.equal((await board.request(`/api/surfaces/${made.id}`)).status, 200); + assert.equal((await board.request("/api/surfaces/recent")).status, 401); +});