Skip to content
Draft
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
94 changes: 94 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,66 @@ 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;
if (path.startsWith("/s/")) return true;
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;
Expand Down Expand Up @@ -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<string, Session | null>();
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({
Expand Down
7 changes: 7 additions & 0 deletions server/sqlStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions server/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
2 changes: 2 additions & 0 deletions server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ export interface Store {
setSetting(key: string, value: string): Promise<void>;

listPosts(sessionId?: string): Promise<Post[]>;
/** The N most-recently-updated posts across all sessions (newest first). */
listRecentPosts(limit: number): Promise<Post[]>;
getPost(id: string): Promise<Post | null>;
createPost(input: CreatePostInput): Promise<Post | null>;
updatePost(id: string, patch: UpdatePostInput): Promise<Post | null>;
Expand Down
32 changes: 32 additions & 0 deletions test/storeContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>1</p>")] });
await sleep(10);
const s2 = await store.createPost({ sessionId: two.id, surfaces: [htmlSurface("<p>2</p>")] });
await sleep(10);
const s3 = await store.createPost({ sessionId: one.id, surfaces: [htmlSurface("<p>3</p>")] });

// 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("<p>1b</p>")] });
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({
Expand Down
205 changes: 205 additions & 0 deletions test/surfaces-recent.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<html><head></head><body>viewer</body></html>",
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<typeof makeApp>, 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<typeof makeApp>, 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: "<p>1</p>" }],
});
await new Promise((r) => setTimeout(r, 5));
const p2 = await publish(app, {
session: b.id,
title: "second",
parts: [{ kind: "html", html: "<p>2</p>" }],
});
await new Promise((r) => setTimeout(r, 5));
const p3 = await publish(app, {
session: a.id,
title: "third",
parts: [{ kind: "html", html: "<p>3</p>" }],
});

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: `<p>${i}</p>` }] });
}

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: "<p>x</p>" }] }),
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: "<p>x</p>" }] }),
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);
});
Loading