From 5ae9932b29522da9af2b43b59ce495ecff945984 Mon Sep 17 00:00:00 2001 From: ehfeng <279398+ehfeng@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:17:27 +0000 Subject: [PATCH 1/7] Notify viewers when documents update --- app/d/[slug]/CommentsShell.tsx | 40 +++++++++++++++++++++ app/d/[slug]/version/route.test.ts | 58 ++++++++++++++++++++++++++++++ app/d/[slug]/version/route.ts | 28 +++++++++++++++ lib/docs/access.ts | 8 ++--- lib/docs/store.ts | 13 +++++++ 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 app/d/[slug]/version/route.test.ts create mode 100644 app/d/[slug]/version/route.ts diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 1511d2a..03ff9f2 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -266,6 +266,7 @@ export default function CommentsShell(props: Props) { // the new version on each save. const [editing, setEditing] = useState(false); const [editStatus, setEditStatus] = useState(null); + const [updateAvailable, setUpdateAvailable] = useState(false); const versionRef = useRef(props.version); const saveInlineEditRef = useRef<(changes: TextChange[]) => void>(() => {}); const statusTimer = useRef(null); @@ -282,6 +283,32 @@ export default function CommentsShell(props: Props) { const apiBase = `/api/v1/docs/${encodeURIComponent(slug)}`; const tokenQuery = viewtoken ? `?viewtoken=${encodeURIComponent(viewtoken)}` : ""; + useEffect(() => { + if (updateAvailable) return; + let checking = false; + + const checkVersion = async () => { + if (checking || document.hidden) return; + checking = true; + try { + const r = await fetch(`/d/${encodeURIComponent(slug)}/version${tokenQuery}`, { + cache: "no-store", + credentials: "same-origin", + }); + if (!r.ok) return; + const version = Number(await r.text()); + if (Number.isInteger(version) && version > versionRef.current) setUpdateAvailable(true); + } catch { + return; + } finally { + checking = false; + } + }; + + const interval = window.setInterval(() => void checkVersion(), 30_000); + return () => window.clearInterval(interval); + }, [slug, tokenQuery, updateAvailable]); + // The anchors we ask the overlay to paint (anchored, non-orphaned roots that // are visible under the resolved toggle). const paintAnchors = useMemo( @@ -732,10 +759,12 @@ export default function CommentsShell(props: Props) { // the rendered document never disagrees with the stored bytes. postToOverlay({ type: "jh:editResult", ok: r.ok }); if (!r.ok) { + if (r.status === 409) setUpdateAvailable(true); showEditStatus(editErrorMessage(r.status, body), 6000); return; } if (typeof body?.version === "number") versionRef.current = body.version; + setUpdateAvailable(false); showEditStatus("saved", 2000); // The write re-anchored comments in the same transaction; pull the result. await reload(); @@ -938,6 +967,17 @@ export default function CommentsShell(props: Props) { {title} + {updateAvailable ? ( + + + + ) : null} {readMinutes != null && readMinutes > 0 ? ( {readMinutes} min read diff --git a/app/d/[slug]/version/route.test.ts b/app/d/[slug]/version/route.test.ts new file mode 100644 index 0000000..cbb0710 --- /dev/null +++ b/app/d/[slug]/version/route.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findVersionBySlug: vi.fn(), + canViewSession: vi.fn(), + getSession: vi.fn(), +})); + +vi.mock("@/lib/docs/store", () => ({ findVersionBySlug: mocks.findVersionBySlug })); +vi.mock("@/lib/docs/access", () => ({ canViewSession: mocks.canViewSession })); +vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession })); + +import { GET } from "@/app/d/[slug]/version/route"; + +const doc = { + id: 1, + owner_id: 2, + is_public: false, + view_token: "secret-token", + version: 7, +}; + +function request(viewtoken?: string) { + const query = viewtoken ? `?viewtoken=${viewtoken}` : ""; + return new Request(`https://justhtml.sh/d/quiet-moon-12345/version${query}`); +} + +const ctx = { params: Promise.resolve({ slug: "quiet-moon-12345" }) }; + +describe("document version", () => { + beforeEach(() => { + mocks.findVersionBySlug.mockResolvedValue(doc); + mocks.getSession.mockResolvedValue(null); + mocks.canViewSession.mockResolvedValue(true); + }); + + it("returns the current version without caching", async () => { + const res = await GET(request("secret-token"), ctx); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("7"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + expect(mocks.canViewSession).toHaveBeenCalledWith(doc, null, "secret-token"); + }); + + it("does not distinguish missing and unauthorized documents", async () => { + mocks.findVersionBySlug.mockResolvedValueOnce(null); + const missing = await GET(request(), ctx); + + mocks.canViewSession.mockResolvedValueOnce(false); + const unauthorized = await GET(request(), ctx); + + expect(missing.status).toBe(404); + expect(unauthorized.status).toBe(404); + expect(await missing.text()).toBe(""); + expect(await unauthorized.text()).toBe(""); + }); +}); diff --git a/app/d/[slug]/version/route.ts b/app/d/[slug]/version/route.ts new file mode 100644 index 0000000..4a58159 --- /dev/null +++ b/app/d/[slug]/version/route.ts @@ -0,0 +1,28 @@ +import { getSession } from "@/lib/auth/session"; +import { canViewSession } from "@/lib/docs/access"; +import { findVersionBySlug } from "@/lib/docs/store"; + +export const dynamic = "force-dynamic"; + +type Ctx = { params: Promise<{ slug: string }> }; + +function notFound(): Response { + return new Response(null, { status: 404 }); +} + +export async function GET(req: Request, ctx: Ctx): Promise { + const { slug } = await ctx.params; + const doc = await findVersionBySlug(slug); + if (!doc) return notFound(); + + const viewtoken = new URL(req.url).searchParams.get("viewtoken"); + const session = await getSession(req); + if (!(await canViewSession(doc, session, viewtoken))) return notFound(); + + return new Response(String(doc.version), { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "private, no-store", + }, + }); +} diff --git a/lib/docs/access.ts b/lib/docs/access.ts index 4f9c547..77fefdc 100644 --- a/lib/docs/access.ts +++ b/lib/docs/access.ts @@ -1,5 +1,5 @@ import { query } from "@/lib/db"; -import type { DocRow } from "@/lib/docs/store"; +import type { ViewableDoc } from "@/lib/docs/store"; import type { Session } from "@/lib/auth/session"; import { emailDomain, grantFor } from "@/lib/docs/grants"; import { safeEqualStr } from "@/lib/auth/tokens"; @@ -22,7 +22,7 @@ import { safeEqualStr } from "@/lib/auth/tokens"; * timing-safe string compare in lib/auth/tokens.ts). Used where no session * context is in play; the session-aware path is canViewSession below. */ -export function canView(doc: DocRow, viewtoken: string | null): boolean { +export function canView(doc: ViewableDoc, viewtoken: string | null): boolean { if (doc.is_public) return true; if (!viewtoken) return false; return safeEqualStr(viewtoken, doc.view_token); @@ -45,7 +45,7 @@ async function sessionHasGrant(docId: number, email: string): Promise { } /** True if `email` is the registered owner of `doc` (one indexed lookup). */ -async function emailOwnsDoc(doc: DocRow, email: string): Promise { +async function emailOwnsDoc(doc: ViewableDoc, email: string): Promise { const { rows } = await query<{ n: number }>( `SELECT count(*) AS n FROM users WHERE id = $1 AND email = $2`, @@ -62,7 +62,7 @@ async function emailOwnsDoc(doc: DocRow, email: string): Promise { * email-keyed session — that's the whole point of the share-notification flow). */ export async function canViewSession( - doc: DocRow, + doc: ViewableDoc, session: Session | null, viewtoken: string | null ): Promise { diff --git a/lib/docs/store.ts b/lib/docs/store.ts index 72ba7fa..28d5838 100644 --- a/lib/docs/store.ts +++ b/lib/docs/store.ts @@ -36,6 +36,9 @@ export type DocRow = { deleted_at: string | null; }; +export type ViewableDoc = Pick; +export type DocVersion = ViewableDoc & Pick; + export type EditKind = "create" | "patch" | "rewrite"; export function docUrl(slug: string): string { @@ -561,6 +564,16 @@ export async function findBySlug(slug: string): Promise { return rows[0] ?? null; } +/** Fetch only the fields needed to authorize a viewer and compare versions. */ +export async function findVersionBySlug(slug: string): Promise { + const { rows } = await query( + `SELECT id, owner_id, is_public, view_token, version + FROM documents WHERE slug = $1 AND deleted_at IS NULL`, + [slug] + ); + return rows[0] ?? null; +} + /** * A doc row carrying a live comment count — for the listing surfaces (the /docs * dashboard rows and GET /api/v1/docs items, birthday.md B11). comment_count is From 6058cf1269d4cbe5013ccd67879bb2777f5d07e4 Mon Sep 17 00:00:00 2001 From: ehfeng <279398+ehfeng@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:26:38 +0000 Subject: [PATCH 2/7] Test document update polling --- app/d/[slug]/CommentsShell.test.tsx | 143 ++++++++++++++++++++++++++++ package-lock.json | 33 +++++++ package.json | 2 + 3 files changed, 178 insertions(+) create mode 100644 app/d/[slug]/CommentsShell.test.tsx diff --git a/app/d/[slug]/CommentsShell.test.tsx b/app/d/[slug]/CommentsShell.test.tsx new file mode 100644 index 0000000..1a00a4d --- /dev/null +++ b/app/d/[slug]/CommentsShell.test.tsx @@ -0,0 +1,143 @@ +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CommentsShell from "./CommentsShell"; + +const props = { + slug: "test doc", + title: "Test doc", + rawSrc: "/d/test-doc/raw", + viewtoken: null, + canComment: false, + canReact: false, + canEdit: false, + signedIn: false, + docId: 1, + bookmarked: false, + me: null, + initialThreads: [], + initialDocReactions: [], + initialAnchoredReactions: [], + initialSections: [], + version: 1, + initialTheme: null, +}; + +let hidden = false; +let reload: ReturnType; +let renderer: ReactTestRenderer | null; + +function response(version: string) { + return { ok: true, text: async () => version } as Response; +} + +async function renderShell() { + await act(async () => { + renderer = create(); + }); +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + hidden = false; + reload = vi.fn(); + renderer = null; + + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { + get hidden() { + return hidden; + }, + }); + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }); + vi.stubGlobal("window", { + setInterval, + clearInterval, + setTimeout, + clearTimeout, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + matchMedia: vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + location: { origin: "https://justhtml.test", hash: "", reload }, + }); +}); + +afterEach(async () => { + if (renderer) { + await act(async () => renderer?.unmount()); + } + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("document update polling", () => { + it("does not request a version while the document is hidden", async () => { + const fetch = vi.fn().mockResolvedValue(response("1")); + vi.stubGlobal("fetch", fetch); + hidden = true; + await renderShell(); + + await advance(60_000); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("requests the version once every 30 seconds while visible", async () => { + const fetch = vi.fn().mockResolvedValue(response("1")); + vi.stubGlobal("fetch", fetch); + await renderShell(); + + await advance(29_999); + expect(fetch).not.toHaveBeenCalled(); + await advance(1); + expect(fetch).toHaveBeenCalledTimes(1); + await advance(30_000); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("does not overlap version requests", async () => { + let resolveFetch: ((value: Response) => void) | undefined; + const fetch = vi.fn(() => new Promise((resolve) => (resolveFetch = resolve))); + vi.stubGlobal("fetch", fetch); + await renderShell(); + + await advance(60_000); + expect(fetch).toHaveBeenCalledTimes(1); + + await act(async () => resolveFetch?.(response("1"))); + await advance(30_000); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("shows the refresh control and stops polling after detecting a newer version", async () => { + const fetch = vi.fn().mockResolvedValue(response("2")); + vi.stubGlobal("fetch", fetch); + await renderShell(); + + await advance(30_000); + + const button = renderer!.root + .findAllByType("button") + .find((candidate) => candidate.children.includes("updated · refresh")); + expect(button).toBeDefined(); + await advance(60_000); + expect(fetch).toHaveBeenCalledTimes(1); + + act(() => button!.props.onClick()); + expect(reload).toHaveBeenCalledOnce(); + expect(reload).toHaveBeenCalledWith(); + }); +}); diff --git a/package-lock.json b/package-lock.json index ab44357..b47427e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,9 +27,11 @@ "@types/pg": "^8.11.10", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/react-test-renderer": "^19.1.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "js-yaml": "^4.2.0", + "react-test-renderer": "^19.1.0", "tsx": "^4.22.4", "typescript": "^5.7.2", "vitest": "^4.1.8" @@ -1776,6 +1778,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4148,6 +4160,13 @@ "react": "^19.1.0" } }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -4184,6 +4203,20 @@ "fast-deep-equal": "^2.0.1" } }, + "node_modules/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^19.1.0", + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", diff --git a/package.json b/package.json index 69993ea..5d9ca30 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,11 @@ "@types/pg": "^8.11.10", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/react-test-renderer": "^19.1.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "js-yaml": "^4.2.0", + "react-test-renderer": "^19.1.0", "tsx": "^4.22.4", "typescript": "^5.7.2", "vitest": "^4.1.8" From d129d59bfcb73fcdfdf54d0b2af78f2c6784fd4a Mon Sep 17 00:00:00 2001 From: ehfeng <279398+ehfeng@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:38:23 +0000 Subject: [PATCH 3/7] Harden document version polling --- app/d/[slug]/CommentsShell.test.tsx | 38 +++++++++++++++++++++++++++ app/d/[slug]/CommentsShell.tsx | 11 +++++++- app/d/[slug]/version/route.test.ts | 37 +++++++++++++++++++++----- app/d/[slug]/version/route.ts | 10 +++++--- lib/auth/session.test.ts | 40 +++++++++++++++++++++++++++++ lib/auth/session.ts | 13 ++++++++-- 6 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 lib/auth/session.test.ts diff --git a/app/d/[slug]/CommentsShell.test.tsx b/app/d/[slug]/CommentsShell.test.tsx index 1a00a4d..a2e695a 100644 --- a/app/d/[slug]/CommentsShell.test.tsx +++ b/app/d/[slug]/CommentsShell.test.tsx @@ -122,6 +122,44 @@ describe("document update polling", () => { expect(fetch).toHaveBeenCalledTimes(2); }); + it("aborts a stalled request and retries on the next interval", async () => { + const signals: AbortSignal[] = []; + const fetch = vi.fn((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init.signal as AbortSignal; + signals.push(signal); + signal.addEventListener("abort", () => reject(new Error("aborted"))); + }) + ); + vi.stubGlobal("fetch", fetch); + await renderShell(); + + await advance(30_000); + expect(fetch).toHaveBeenCalledTimes(1); + await advance(15_000); + expect(signals[0].aborted).toBe(true); + await advance(15_000); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("aborts an active request when polling stops", async () => { + const fetch = vi.fn((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init.signal as AbortSignal; + signal.addEventListener("abort", () => reject(new Error("aborted"))); + }) + ); + vi.stubGlobal("fetch", fetch); + await renderShell(); + await advance(30_000); + const signal = fetch.mock.calls[0][1].signal as AbortSignal; + + await act(async () => renderer?.unmount()); + renderer = null; + + expect(signal.aborted).toBe(true); + }); + it("shows the refresh control and stops polling after detecting a newer version", async () => { const fetch = vi.fn().mockResolvedValue(response("2")); vi.stubGlobal("fetch", fetch); diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 03ff9f2..e2dbcfc 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -286,14 +286,18 @@ export default function CommentsShell(props: Props) { useEffect(() => { if (updateAvailable) return; let checking = false; + let controller: AbortController | null = null; const checkVersion = async () => { if (checking || document.hidden) return; checking = true; + controller = new AbortController(); + const timeout = window.setTimeout(() => controller?.abort(), 15_000); try { const r = await fetch(`/d/${encodeURIComponent(slug)}/version${tokenQuery}`, { cache: "no-store", credentials: "same-origin", + signal: controller.signal, }); if (!r.ok) return; const version = Number(await r.text()); @@ -301,12 +305,17 @@ export default function CommentsShell(props: Props) { } catch { return; } finally { + window.clearTimeout(timeout); + controller = null; checking = false; } }; const interval = window.setInterval(() => void checkVersion(), 30_000); - return () => window.clearInterval(interval); + return () => { + window.clearInterval(interval); + controller?.abort(); + }; }, [slug, tokenQuery, updateAvailable]); // The anchors we ask the overlay to paint (anchored, non-orphaned roots that diff --git a/app/d/[slug]/version/route.test.ts b/app/d/[slug]/version/route.test.ts index cbb0710..020514b 100644 --- a/app/d/[slug]/version/route.test.ts +++ b/app/d/[slug]/version/route.test.ts @@ -3,12 +3,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ findVersionBySlug: vi.fn(), canViewSession: vi.fn(), - getSession: vi.fn(), + getSessionReadOnly: vi.fn(), })); vi.mock("@/lib/docs/store", () => ({ findVersionBySlug: mocks.findVersionBySlug })); -vi.mock("@/lib/docs/access", () => ({ canViewSession: mocks.canViewSession })); -vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession })); +vi.mock("@/lib/docs/access", async (importOriginal) => ({ + ...(await importOriginal()), + canViewSession: mocks.canViewSession, +})); +vi.mock("@/lib/auth/session", () => ({ getSessionReadOnly: mocks.getSessionReadOnly })); import { GET } from "@/app/d/[slug]/version/route"; @@ -20,6 +23,8 @@ const doc = { version: 7, }; +const session = { id: 3, email: "viewer@example.com", user_id: 4 }; + function request(viewtoken?: string) { const query = viewtoken ? `?viewtoken=${viewtoken}` : ""; return new Request(`https://justhtml.sh/d/quiet-moon-12345/version${query}`); @@ -29,18 +34,38 @@ const ctx = { params: Promise.resolve({ slug: "quiet-moon-12345" }) }; describe("document version", () => { beforeEach(() => { + vi.clearAllMocks(); mocks.findVersionBySlug.mockResolvedValue(doc); - mocks.getSession.mockResolvedValue(null); + mocks.getSessionReadOnly.mockResolvedValue(session); mocks.canViewSession.mockResolvedValue(true); }); it("returns the current version without caching", async () => { - const res = await GET(request("secret-token"), ctx); + const res = await GET(request(), ctx); expect(res.status).toBe(200); expect(await res.text()).toBe("7"); expect(res.headers.get("Cache-Control")).toBe("private, no-store"); - expect(mocks.canViewSession).toHaveBeenCalledWith(doc, null, "secret-token"); + expect(mocks.getSessionReadOnly).toHaveBeenCalledOnce(); + expect(mocks.canViewSession).toHaveBeenCalledWith(doc, session, null); + }); + + it("skips session resolution for a valid view token", async () => { + const res = await GET(request("secret-token"), ctx); + + expect(res.status).toBe(200); + expect(mocks.getSessionReadOnly).not.toHaveBeenCalled(); + expect(mocks.canViewSession).not.toHaveBeenCalled(); + }); + + it("skips session resolution for a public document", async () => { + mocks.findVersionBySlug.mockResolvedValueOnce({ ...doc, is_public: true }); + + const res = await GET(request(), ctx); + + expect(res.status).toBe(200); + expect(mocks.getSessionReadOnly).not.toHaveBeenCalled(); + expect(mocks.canViewSession).not.toHaveBeenCalled(); }); it("does not distinguish missing and unauthorized documents", async () => { diff --git a/app/d/[slug]/version/route.ts b/app/d/[slug]/version/route.ts index 4a58159..b54d528 100644 --- a/app/d/[slug]/version/route.ts +++ b/app/d/[slug]/version/route.ts @@ -1,5 +1,5 @@ -import { getSession } from "@/lib/auth/session"; -import { canViewSession } from "@/lib/docs/access"; +import { getSessionReadOnly } from "@/lib/auth/session"; +import { canView, canViewSession } from "@/lib/docs/access"; import { findVersionBySlug } from "@/lib/docs/store"; export const dynamic = "force-dynamic"; @@ -16,8 +16,10 @@ export async function GET(req: Request, ctx: Ctx): Promise { if (!doc) return notFound(); const viewtoken = new URL(req.url).searchParams.get("viewtoken"); - const session = await getSession(req); - if (!(await canViewSession(doc, session, viewtoken))) return notFound(); + if (!canView(doc, viewtoken)) { + const session = await getSessionReadOnly(req); + if (!(await canViewSession(doc, session, null))) return notFound(); + } return new Response(String(doc.version), { headers: { diff --git a/lib/auth/session.test.ts b/lib/auth/session.test.ts new file mode 100644 index 0000000..3a0811a --- /dev/null +++ b/lib/auth/session.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ query: vi.fn() })); + +vi.mock("@/lib/db", () => ({ query: mocks.query })); + +import { getSessionReadOnly } from "@/lib/auth/session"; + +function request() { + return new Request("https://justhtml.sh/d/test/version", { + headers: { cookie: "jh_sess=sess_test-token" }, + }); +} + +describe("getSessionReadOnly", () => { + beforeEach(() => { + mocks.query.mockResolvedValue({ + rows: [ + { + id: 1, + email: "viewer@example.com", + user_id: 2, + last_seen_at: "2000-01-01T00:00:00.000Z", + }, + ], + }); + }); + + it("validates expiry and revocation without renewing the session", async () => { + await expect(getSessionReadOnly(request())).resolves.toEqual({ + id: 1, + email: "viewer@example.com", + user_id: 2, + }); + + expect(mocks.query).toHaveBeenCalledOnce(); + expect(mocks.query.mock.calls[0][0]).toContain("revoked_at IS NULL AND expires_at > now()"); + expect(mocks.query.mock.calls[0][0]).not.toContain("UPDATE sessions"); + }); +}); diff --git a/lib/auth/session.ts b/lib/auth/session.ts index d6ac72a..08f2f9c 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -36,7 +36,12 @@ export function readSessionCookie(req: Request): string | null { * session. */ export async function getSession(req: Request): Promise { - return getSessionFromToken(readSessionCookie(req)); + return resolveSession(readSessionCookie(req), true); +} + +/** Resolve a session without extending its expiry. */ +export async function getSessionReadOnly(req: Request): Promise { + return resolveSession(readSessionCookie(req), false); } /** @@ -44,6 +49,10 @@ export async function getSession(req: Request): Promise { * e.g. React server components reading the cookie via next/headers. */ export async function getSessionFromToken(raw: string | null): Promise { + return resolveSession(raw, true); +} + +async function resolveSession(raw: string | null, slide: boolean): Promise { if (!raw || !raw.startsWith("sess_")) return null; const hash = sha256Hex(raw); const { rows } = await query<{ @@ -61,7 +70,7 @@ export async function getSessionFromToken(raw: string | null): Promise SESSION_SLIDE_FLOOR_S * 1000) { + if (slide && Date.now() - lastSeen > SESSION_SLIDE_FLOOR_S * 1000) { // Slide forward; throttled by the floor check above to avoid a write/request. query( `UPDATE sessions From 62676ddff733df33a6ca01e57eae87de5a121e88 Mon Sep 17 00:00:00 2001 From: ehfeng <279398+ehfeng@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:47:06 +0000 Subject: [PATCH 4/7] Remove deprecated test renderer --- app/d/[slug]/CommentsShell.test.tsx | 181 ---------------------------- package-lock.json | 33 ----- package.json | 2 - 3 files changed, 216 deletions(-) delete mode 100644 app/d/[slug]/CommentsShell.test.tsx diff --git a/app/d/[slug]/CommentsShell.test.tsx b/app/d/[slug]/CommentsShell.test.tsx deleted file mode 100644 index a2e695a..0000000 --- a/app/d/[slug]/CommentsShell.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { act, create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import CommentsShell from "./CommentsShell"; - -const props = { - slug: "test doc", - title: "Test doc", - rawSrc: "/d/test-doc/raw", - viewtoken: null, - canComment: false, - canReact: false, - canEdit: false, - signedIn: false, - docId: 1, - bookmarked: false, - me: null, - initialThreads: [], - initialDocReactions: [], - initialAnchoredReactions: [], - initialSections: [], - version: 1, - initialTheme: null, -}; - -let hidden = false; -let reload: ReturnType; -let renderer: ReactTestRenderer | null; - -function response(version: string) { - return { ok: true, text: async () => version } as Response; -} - -async function renderShell() { - await act(async () => { - renderer = create(); - }); -} - -async function advance(ms: number) { - await act(async () => { - await vi.advanceTimersByTimeAsync(ms); - }); -} - -beforeEach(() => { - vi.useFakeTimers(); - hidden = false; - reload = vi.fn(); - renderer = null; - - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("document", { - get hidden() { - return hidden; - }, - }); - vi.stubGlobal("localStorage", { - getItem: vi.fn(() => null), - setItem: vi.fn(), - }); - vi.stubGlobal("window", { - setInterval, - clearInterval, - setTimeout, - clearTimeout, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - matchMedia: vi.fn(() => ({ - matches: false, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })), - location: { origin: "https://justhtml.test", hash: "", reload }, - }); -}); - -afterEach(async () => { - if (renderer) { - await act(async () => renderer?.unmount()); - } - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe("document update polling", () => { - it("does not request a version while the document is hidden", async () => { - const fetch = vi.fn().mockResolvedValue(response("1")); - vi.stubGlobal("fetch", fetch); - hidden = true; - await renderShell(); - - await advance(60_000); - - expect(fetch).not.toHaveBeenCalled(); - }); - - it("requests the version once every 30 seconds while visible", async () => { - const fetch = vi.fn().mockResolvedValue(response("1")); - vi.stubGlobal("fetch", fetch); - await renderShell(); - - await advance(29_999); - expect(fetch).not.toHaveBeenCalled(); - await advance(1); - expect(fetch).toHaveBeenCalledTimes(1); - await advance(30_000); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("does not overlap version requests", async () => { - let resolveFetch: ((value: Response) => void) | undefined; - const fetch = vi.fn(() => new Promise((resolve) => (resolveFetch = resolve))); - vi.stubGlobal("fetch", fetch); - await renderShell(); - - await advance(60_000); - expect(fetch).toHaveBeenCalledTimes(1); - - await act(async () => resolveFetch?.(response("1"))); - await advance(30_000); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("aborts a stalled request and retries on the next interval", async () => { - const signals: AbortSignal[] = []; - const fetch = vi.fn((_url: string, init: RequestInit) => - new Promise((_resolve, reject) => { - const signal = init.signal as AbortSignal; - signals.push(signal); - signal.addEventListener("abort", () => reject(new Error("aborted"))); - }) - ); - vi.stubGlobal("fetch", fetch); - await renderShell(); - - await advance(30_000); - expect(fetch).toHaveBeenCalledTimes(1); - await advance(15_000); - expect(signals[0].aborted).toBe(true); - await advance(15_000); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("aborts an active request when polling stops", async () => { - const fetch = vi.fn((_url: string, init: RequestInit) => - new Promise((_resolve, reject) => { - const signal = init.signal as AbortSignal; - signal.addEventListener("abort", () => reject(new Error("aborted"))); - }) - ); - vi.stubGlobal("fetch", fetch); - await renderShell(); - await advance(30_000); - const signal = fetch.mock.calls[0][1].signal as AbortSignal; - - await act(async () => renderer?.unmount()); - renderer = null; - - expect(signal.aborted).toBe(true); - }); - - it("shows the refresh control and stops polling after detecting a newer version", async () => { - const fetch = vi.fn().mockResolvedValue(response("2")); - vi.stubGlobal("fetch", fetch); - await renderShell(); - - await advance(30_000); - - const button = renderer!.root - .findAllByType("button") - .find((candidate) => candidate.children.includes("updated · refresh")); - expect(button).toBeDefined(); - await advance(60_000); - expect(fetch).toHaveBeenCalledTimes(1); - - act(() => button!.props.onClick()); - expect(reload).toHaveBeenCalledOnce(); - expect(reload).toHaveBeenCalledWith(); - }); -}); diff --git a/package-lock.json b/package-lock.json index b47427e..ab44357 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,11 +27,9 @@ "@types/pg": "^8.11.10", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", - "@types/react-test-renderer": "^19.1.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "js-yaml": "^4.2.0", - "react-test-renderer": "^19.1.0", "tsx": "^4.22.4", "typescript": "^5.7.2", "vitest": "^4.1.8" @@ -1778,16 +1776,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/react-test-renderer": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", - "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4160,13 +4148,6 @@ "react": "^19.1.0" } }, - "node_modules/react-is": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", - "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", - "dev": true, - "license": "MIT" - }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -4203,20 +4184,6 @@ "fast-deep-equal": "^2.0.1" } }, - "node_modules/react-test-renderer": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz", - "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-is": "^19.1.0", - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", diff --git a/package.json b/package.json index 5d9ca30..69993ea 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,9 @@ "@types/pg": "^8.11.10", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", - "@types/react-test-renderer": "^19.1.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "js-yaml": "^4.2.0", - "react-test-renderer": "^19.1.0", "tsx": "^4.22.4", "typescript": "^5.7.2", "vitest": "^4.1.8" From b2e1311de883f4209c215de4674f61a4376c106f Mon Sep 17 00:00:00 2001 From: ehfeng <279398+ehfeng@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:31:09 +0000 Subject: [PATCH 5/7] Cache document version checks --- DEVELOPMENT.md | 7 +++++ app/d/[slug]/preview/route.test.ts | 14 --------- app/d/[slug]/preview/route.tsx | 19 ------------ app/d/[slug]/raw/route.ts | 22 -------------- app/d/[slug]/version/route.test.ts | 4 +-- app/d/[slug]/version/route.ts | 2 +- lib/docs/config.ts | 4 --- lib/docs/store.ts | 24 +++++++--------- lib/docs/version-cache.test.ts | 46 ++++++++++++++++++++++++++++++ lib/docs/version-cache.ts | 33 +++++++++++++++++++++ 10 files changed, 100 insertions(+), 75 deletions(-) create mode 100644 lib/docs/version-cache.test.ts create mode 100644 lib/docs/version-cache.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 51f0b63..d864ddd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -163,6 +163,13 @@ The `justhtml.sh` apex is attached + verified on the kernel-team Vercel project and serves production. Verify after deploy: `GET https://justhtml.sh/api/health` returns `{"ok":true,"db":true}`. +### Viewer rate limit + +The Vercel project has a `Rate limit document viewer` firewall rule for paths +starting with `/d/`: a fixed 60-second window, 300 requests per IP, then 429. +Keeping this limit at the edge prevents abusive requests from reaching a function +or adding Postgres counter writes before the document lookup. + ## Surfaces Agent / discovery (plain text or JSON, zero JS): diff --git a/app/d/[slug]/preview/route.test.ts b/app/d/[slug]/preview/route.test.ts index 7322198..511f5c9 100644 --- a/app/d/[slug]/preview/route.test.ts +++ b/app/d/[slug]/preview/route.test.ts @@ -3,14 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ findBySlug: vi.fn(), canView: vi.fn(), - clientIp: vi.fn(), - checkLimits: vi.fn(), })); vi.mock("@/lib/docs/store", () => ({ findBySlug: mocks.findBySlug })); vi.mock("@/lib/docs/access", () => ({ canView: mocks.canView })); -vi.mock("@/lib/auth/request", () => ({ clientIp: mocks.clientIp })); -vi.mock("@/lib/auth/ratelimit", () => ({ checkLimits: mocks.checkLimits })); vi.mock("next/og", () => ({ ImageResponse: class extends Response { constructor(_element: unknown, options: { headers?: HeadersInit } = {}) { @@ -46,16 +42,6 @@ describe("GET /d/:slug/preview", () => { vi.clearAllMocks(); mocks.findBySlug.mockResolvedValue(doc); mocks.canView.mockReturnValue(false); - mocks.clientIp.mockReturnValue("192.0.2.10"); - mocks.checkLimits.mockResolvedValue(null); - }); - - it("rate limits image rendering by viewer IP", async () => { - mocks.checkLimits.mockResolvedValue({ retryAfter: 37 }); - const res = await request("?viewtoken=secret-token"); - expect(res.status).toBe(429); - expect(res.headers.get("Retry-After")).toBe("37"); - expect(mocks.findBySlug).not.toHaveBeenCalled(); }); it("does not expose a private document preview without a valid view token", async () => { diff --git a/app/d/[slug]/preview/route.tsx b/app/d/[slug]/preview/route.tsx index 3935bec..cc753a2 100644 --- a/app/d/[slug]/preview/route.tsx +++ b/app/d/[slug]/preview/route.tsx @@ -2,32 +2,13 @@ import { ImageResponse } from "next/og"; import { findBySlug } from "@/lib/docs/store"; import { canView } from "@/lib/docs/access"; import { documentPreview } from "@/lib/docs/preview"; -import { clientIp } from "@/lib/auth/request"; -import { checkLimits } from "@/lib/auth/ratelimit"; -import { RL_VIEWER_PER_MIN } from "@/lib/docs/config"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; type Ctx = { params: Promise<{ slug: string }> }; -const VIEWER_PER_HOUR = RL_VIEWER_PER_MIN * 60; - export async function GET(req: Request, ctx: Ctx): Promise { - const ip = clientIp(req); - const tripped = await checkLimits([ - ip ? { key: `viewer:ip:${ip}`, limit: VIEWER_PER_HOUR, window: "hour" } : null, - ]); - if (tripped) { - return new Response("Too many requests.", { - status: 429, - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Retry-After": String(tripped.retryAfter), - }, - }); - } - const { slug } = await ctx.params; const viewtoken = new URL(req.url).searchParams.get("viewtoken"); const doc = await findBySlug(slug); diff --git a/app/d/[slug]/raw/route.ts b/app/d/[slug]/raw/route.ts index ace92f2..4f732da 100644 --- a/app/d/[slug]/raw/route.ts +++ b/app/d/[slug]/raw/route.ts @@ -2,9 +2,6 @@ import { findBySlug } from "@/lib/docs/store"; import { canViewSession } from "@/lib/docs/access"; import { verifyViewCap } from "@/lib/docs/viewcap"; import { getSession } from "@/lib/auth/session"; -import { clientIp } from "@/lib/auth/request"; -import { checkLimits } from "@/lib/auth/ratelimit"; -import { RL_VIEWER_PER_MIN } from "@/lib/docs/config"; import { OVERLAY_SCRIPT } from "@/lib/docs/overlay"; export const dynamic = "force-dynamic"; @@ -26,11 +23,6 @@ type Ctx = { params: Promise<{ slug: string }> }; // // Directly linkable for zero-chrome viewing; same token rules as /d/:slug. // -// Viewer rate limit: per-IP (the sandbox + token model is the real protection; -// this just caps scraping). The per-minute cap is mapped onto the hourly counter -// bucket (×60) since the rate_limits table buckets hourly — see lib/docs/api.ts. -const VIEWER_PER_HOUR = RL_VIEWER_PER_MIN * 60; - function deny(status: number, msg: string): Response { return new Response(msg, { status, @@ -39,20 +31,6 @@ function deny(status: number, msg: string): Response { } export async function GET(req: Request, ctx: Ctx): Promise { - const ip = clientIp(req); - const tripped = await checkLimits([ - ip ? { key: `viewer:ip:${ip}`, limit: VIEWER_PER_HOUR, window: "hour" } : null, - ]); - if (tripped) { - return new Response("Too many requests.", { - status: 429, - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Retry-After": String(tripped.retryAfter), - }, - }); - } - const { slug } = await ctx.params; const url = new URL(req.url); const viewtoken = url.searchParams.get("viewtoken"); diff --git a/app/d/[slug]/version/route.test.ts b/app/d/[slug]/version/route.test.ts index 020514b..cb07d07 100644 --- a/app/d/[slug]/version/route.test.ts +++ b/app/d/[slug]/version/route.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ getSessionReadOnly: vi.fn(), })); -vi.mock("@/lib/docs/store", () => ({ findVersionBySlug: mocks.findVersionBySlug })); +vi.mock("@/lib/docs/version-cache", () => ({ findVersionBySlug: mocks.findVersionBySlug })); vi.mock("@/lib/docs/access", async (importOriginal) => ({ ...(await importOriginal()), canViewSession: mocks.canViewSession, @@ -40,7 +40,7 @@ describe("document version", () => { mocks.canViewSession.mockResolvedValue(true); }); - it("returns the current version without caching", async () => { + it("returns the current version without browser caching", async () => { const res = await GET(request(), ctx); expect(res.status).toBe(200); diff --git a/app/d/[slug]/version/route.ts b/app/d/[slug]/version/route.ts index b54d528..c7ea239 100644 --- a/app/d/[slug]/version/route.ts +++ b/app/d/[slug]/version/route.ts @@ -1,6 +1,6 @@ import { getSessionReadOnly } from "@/lib/auth/session"; import { canView, canViewSession } from "@/lib/docs/access"; -import { findVersionBySlug } from "@/lib/docs/store"; +import { findVersionBySlug } from "@/lib/docs/version-cache"; export const dynamic = "force-dynamic"; diff --git a/lib/docs/config.ts b/lib/docs/config.ts index 1d1a088..8ccf166 100644 --- a/lib/docs/config.ts +++ b/lib/docs/config.ts @@ -13,10 +13,6 @@ export const RL_CREATES_PER_HOUR = 60; // doc creates export const RL_WRITES_PER_MIN = 60; // PATCH, /edits, grants, rotate-token export const RL_READS_PER_MIN = 300; // GET -// Unauthenticated viewer routes (per IP). The sandbox + token model is the real -// protection; this just caps scraping. -export const RL_VIEWER_PER_MIN = 300; - export const ORIGIN = "https://justhtml.sh"; // Title cap — generous, keeps the metadata column sane. Not in the plan's table diff --git a/lib/docs/store.ts b/lib/docs/store.ts index 28d5838..67ebe34 100644 --- a/lib/docs/store.ts +++ b/lib/docs/store.ts @@ -2,6 +2,7 @@ import { getPool, query } from "@/lib/db"; import { generateSlug, generateViewToken } from "@/lib/docs/slug"; import { applyEdits, type Edit } from "@/lib/docs/edit-diff"; import { reanchorComments } from "@/lib/docs/reanchor"; +import { invalidateDocVersion } from "@/lib/docs/version-cache"; import { MAX_DOCS_PER_USER, MAX_HTML_BYTES, @@ -318,6 +319,7 @@ export async function rewriteDoc(opts: { /* re-anchoring is best-effort; never block a doc write on it */ } await client.query("COMMIT"); + invalidateDocVersion(current.slug); return { doc: updRows[0] as DocRow }; } catch (e) { await client.query("ROLLBACK").catch(() => {}); @@ -423,6 +425,7 @@ export async function applyPatch(opts: { /* re-anchoring is best-effort; never block a doc write on it */ } await client.query("COMMIT"); + invalidateDocVersion(current.slug); return { doc: updRows[0] as DocRow }; } catch (e) { await client.query("ROLLBACK").catch(() => {}); @@ -535,6 +538,7 @@ export async function updateMeta(opts: { `UPDATE documents SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params ); + invalidateDocVersion(rows[0].slug); return rows[0]; } @@ -545,14 +549,18 @@ export async function rotateViewToken(docId: number): Promise { WHERE id = $1 RETURNING *`, [docId, generateViewToken()] ); + invalidateDocVersion(rows[0].slug); return rows[0]; } /** Soft-delete (sets deleted_at). Idempotent. */ export async function softDelete(docId: number): Promise { - await query(`UPDATE documents SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, [ - docId, - ]); + const { rows } = await query<{ slug: string }>( + `UPDATE documents SET deleted_at = now() + WHERE id = $1 AND deleted_at IS NULL RETURNING slug`, + [docId] + ); + if (rows[0]) invalidateDocVersion(rows[0].slug); } /** Fetch a live (non-deleted) doc by slug. */ @@ -564,16 +572,6 @@ export async function findBySlug(slug: string): Promise { return rows[0] ?? null; } -/** Fetch only the fields needed to authorize a viewer and compare versions. */ -export async function findVersionBySlug(slug: string): Promise { - const { rows } = await query( - `SELECT id, owner_id, is_public, view_token, version - FROM documents WHERE slug = $1 AND deleted_at IS NULL`, - [slug] - ); - return rows[0] ?? null; -} - /** * A doc row carrying a live comment count — for the listing surfaces (the /docs * dashboard rows and GET /api/v1/docs items, birthday.md B11). comment_count is diff --git a/lib/docs/version-cache.test.ts b/lib/docs/version-cache.test.ts new file mode 100644 index 0000000..c3ea327 --- /dev/null +++ b/lib/docs/version-cache.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + revalidateTag: vi.fn(), + unstableCache: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ query: mocks.query })); +vi.mock("next/cache", () => ({ + revalidateTag: mocks.revalidateTag, + unstable_cache: mocks.unstableCache, +})); + +import { findVersionBySlug, invalidateDocVersion } from "@/lib/docs/version-cache"; + +const doc = { id: 1, owner_id: 2, is_public: false, view_token: "token", version: 7 }; + +describe("document version cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.unstableCache.mockImplementation((fn) => fn); + mocks.query.mockResolvedValue({ rows: [doc] }); + }); + + it("caches the minimal document row by slug", async () => { + await expect(findVersionBySlug("quiet-moon-12345")).resolves.toEqual(doc); + + expect(mocks.unstableCache).toHaveBeenCalledWith(expect.any(Function), [ + "doc-version:quiet-moon-12345", + ], { + revalidate: 30, + tags: ["doc-version:quiet-moon-12345"], + }); + expect(mocks.query.mock.calls[0][0]).toContain( + "SELECT id, owner_id, is_public, view_token, version" + ); + expect(mocks.query.mock.calls[0][1]).toEqual(["quiet-moon-12345"]); + }); + + it("invalidates the document tag after writes", () => { + invalidateDocVersion("quiet-moon-12345"); + + expect(mocks.revalidateTag).toHaveBeenCalledWith("doc-version:quiet-moon-12345"); + }); +}); diff --git a/lib/docs/version-cache.ts b/lib/docs/version-cache.ts new file mode 100644 index 0000000..249b16a --- /dev/null +++ b/lib/docs/version-cache.ts @@ -0,0 +1,33 @@ +import { revalidateTag, unstable_cache } from "next/cache"; +import { query } from "@/lib/db"; +import type { DocVersion } from "@/lib/docs/store"; + +const REVALIDATE_SECONDS = 30; + +function versionTag(slug: string): string { + return `doc-version:${slug}`; +} + +export function findVersionBySlug(slug: string): Promise { + const tag = versionTag(slug); + return unstable_cache( + async () => { + const { rows } = await query( + `SELECT id, owner_id, is_public, view_token, version + FROM documents WHERE slug = $1 AND deleted_at IS NULL`, + [slug] + ); + return rows[0] ?? null; + }, + [tag], + { revalidate: REVALIDATE_SECONDS, tags: [tag] } + )(); +} + +export function invalidateDocVersion(slug: string): void { + try { + revalidateTag(versionTag(slug)); + } catch { + return; + } +} From ba361d97c593649fca8a02287897450d8984c8e8 Mon Sep 17 00:00:00 2001 From: ehfeng <279398+ehfeng@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:08:52 +0000 Subject: [PATCH 6/7] Compact update prompt on mobile --- app/d/[slug]/CommentsShell.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index e2dbcfc..628f237 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -975,15 +975,19 @@ export default function CommentsShell(props: Props) { {title} - + {updateAvailable ? ( ) : null} @@ -1064,8 +1068,8 @@ export default function CommentsShell(props: Props) { > 💬 {commentCount} - history - made with justhtml.sh + history + made with justhtml.sh @@ -1802,6 +1806,10 @@ const RAIL_CSS = ` .jh-scrim { display: block; } /* The bar is already tight at this width; the read time is the first thing to go. */ .jh-readtime { display: none; } + .jh-bar-actions { gap: 12px !important; padding-left: 12px !important; } + .jh-bar-actions.jh-has-update .jh-history, + .jh-bar-actions.jh-has-update .jh-brand, + .jh-bar-actions.jh-has-update .jh-update-prefix { display: none; } } `; From a403259ee8c61cef4ef5b6dc691e93d25506fa87 Mon Sep 17 00:00:00 2001 From: ehfeng <279398+ehfeng@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:07:06 +0000 Subject: [PATCH 7/7] Harden document update checks --- DEVELOPMENT.md | 5 ++ app/d/[slug]/CommentsShell.tsx | 37 ++------- lib/auth/session.test.ts | 14 +++- lib/docs/store.test.ts | 112 +++++++++++++++++++++++++++ lib/docs/version-cache.test.ts | 21 ++++- lib/docs/version-cache.ts | 4 +- lib/docs/version-polling.test.ts | 83 ++++++++++++++++++++ lib/docs/version-polling.ts | 43 ++++++++++ package.json | 1 + scripts/configure-viewer-firewall.sh | 53 +++++++++++++ 10 files changed, 336 insertions(+), 37 deletions(-) create mode 100644 lib/docs/store.test.ts create mode 100644 lib/docs/version-polling.test.ts create mode 100644 lib/docs/version-polling.ts create mode 100755 scripts/configure-viewer-firewall.sh diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d864ddd..04b760f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -170,6 +170,11 @@ starting with `/d/`: a fixed 60-second window, 300 requests per IP, then 429. Keeping this limit at the edge prevents abusive requests from reaching a function or adding Postgres counter writes before the document lookup. +Run `npm run firewall:configure` with an authenticated Vercel CLI or +`VERCEL_TOKEN` to create the rule, repair configuration drift, and publish it. +`VERCEL_PROJECT` and `VERCEL_SCOPE` override the `justhtml` and `onkernel` +defaults. + ## Surfaces Agent / discovery (plain text or JSON, zero JS): diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 628f237..8943b12 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -7,6 +7,7 @@ import type { Section } from "@/lib/docs/sections"; import { fragmentFor, parseHash } from "@/lib/docs/deeplink"; import { readMinutesFor, readTimeLevel, readTimeTitle } from "@/lib/docs/reading-time"; import { buildInlineEdits, type TextChange } from "@/lib/docs/inline-edit"; +import { startVersionPolling } from "@/lib/docs/version-polling"; // CommentsShell — the THIRD React surface (birthday.md "Production // architecture", "CHOSEN: variant B"). The google-docs-style comment rail. The @@ -285,37 +286,11 @@ export default function CommentsShell(props: Props) { useEffect(() => { if (updateAvailable) return; - let checking = false; - let controller: AbortController | null = null; - - const checkVersion = async () => { - if (checking || document.hidden) return; - checking = true; - controller = new AbortController(); - const timeout = window.setTimeout(() => controller?.abort(), 15_000); - try { - const r = await fetch(`/d/${encodeURIComponent(slug)}/version${tokenQuery}`, { - cache: "no-store", - credentials: "same-origin", - signal: controller.signal, - }); - if (!r.ok) return; - const version = Number(await r.text()); - if (Number.isInteger(version) && version > versionRef.current) setUpdateAvailable(true); - } catch { - return; - } finally { - window.clearTimeout(timeout); - controller = null; - checking = false; - } - }; - - const interval = window.setInterval(() => void checkVersion(), 30_000); - return () => { - window.clearInterval(interval); - controller?.abort(); - }; + return startVersionPolling({ + url: `/d/${encodeURIComponent(slug)}/version${tokenQuery}`, + currentVersion: () => versionRef.current, + onUpdate: () => setUpdateAvailable(true), + }); }, [slug, tokenQuery, updateAvailable]); // The anchors we ask the overlay to paint (anchored, non-orphaned roots that diff --git a/lib/auth/session.test.ts b/lib/auth/session.test.ts index 3a0811a..c2cc6b5 100644 --- a/lib/auth/session.test.ts +++ b/lib/auth/session.test.ts @@ -4,7 +4,7 @@ const mocks = vi.hoisted(() => ({ query: vi.fn() })); vi.mock("@/lib/db", () => ({ query: mocks.query })); -import { getSessionReadOnly } from "@/lib/auth/session"; +import { getSession, getSessionReadOnly } from "@/lib/auth/session"; function request() { return new Request("https://justhtml.sh/d/test/version", { @@ -12,8 +12,9 @@ function request() { }); } -describe("getSessionReadOnly", () => { +describe("session lookup", () => { beforeEach(() => { + vi.clearAllMocks(); mocks.query.mockResolvedValue({ rows: [ { @@ -26,7 +27,7 @@ describe("getSessionReadOnly", () => { }); }); - it("validates expiry and revocation without renewing the session", async () => { + it("validates expiry and revocation without renewing a read-only session", async () => { await expect(getSessionReadOnly(request())).resolves.toEqual({ id: 1, email: "viewer@example.com", @@ -37,4 +38,11 @@ describe("getSessionReadOnly", () => { expect(mocks.query.mock.calls[0][0]).toContain("revoked_at IS NULL AND expires_at > now()"); expect(mocks.query.mock.calls[0][0]).not.toContain("UPDATE sessions"); }); + + it("preserves sliding expiry for normal session lookup", async () => { + await getSession(request()); + + expect(mocks.query).toHaveBeenCalledTimes(2); + expect(mocks.query.mock.calls[1][0]).toContain("UPDATE sessions"); + }); }); diff --git a/lib/docs/store.test.ts b/lib/docs/store.test.ts new file mode 100644 index 0000000..33deb2d --- /dev/null +++ b/lib/docs/store.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + connect: vi.fn(), + clientQuery: vi.fn(), + invalidateDocVersion: vi.fn(), + query: vi.fn(), + reanchorComments: vi.fn(), + release: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + getPool: () => ({ connect: mocks.connect }), + query: mocks.query, +})); +vi.mock("@/lib/docs/reanchor", () => ({ reanchorComments: mocks.reanchorComments })); +vi.mock("@/lib/docs/version-cache", () => ({ + invalidateDocVersion: mocks.invalidateDocVersion, +})); + +import { + applyPatch, + rewriteDoc, + rotateViewToken, + softDelete, + updateMeta, + type DocRow, +} from "@/lib/docs/store"; + +const doc: DocRow = { + id: 1, + slug: "quiet-moon-12345", + owner_id: 2, + title: "Test", + html: "

old

", + version: 1, + is_public: false, + view_token: "token", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + deleted_at: null, +}; + +function setupTransaction() { + mocks.clientQuery.mockImplementation(async (text: string) => { + if (text.includes("SELECT * FROM documents")) return { rows: [doc] }; + if (text.includes("AS other_bytes")) { + return { rows: [{ other_bytes: 0, this_versions_bytes: 0 }] }; + } + if (text.includes("UPDATE documents SET html")) { + return { rows: [{ ...doc, html: "

new

", version: 2 }] }; + } + return { rows: [] }; + }); +} + +describe("document version cache invalidation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.connect.mockResolvedValue({ + query: mocks.clientQuery, + release: mocks.release, + }); + setupTransaction(); + }); + + it.each([ + [ + "full rewrites", + () => rewriteDoc({ doc, html: "

new

", authorUserId: 2 }), + ], + [ + "patches", + () => + applyPatch({ + doc, + edits: [{ oldText: "old", newText: "new" }], + baseVersion: 1, + authorUserId: 2, + }), + ], + ])("invalidates after %s", async (_name, mutate) => { + await mutate(); + + const commitCall = mocks.clientQuery.mock.calls.findIndex(([text]) => text === "COMMIT"); + expect(commitCall).toBeGreaterThanOrEqual(0); + expect(mocks.invalidateDocVersion).toHaveBeenCalledWith(doc.slug); + expect(mocks.invalidateDocVersion.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.clientQuery.mock.invocationCallOrder[commitCall] + ); + }); + + it.each([ + ["metadata updates", () => updateMeta({ docId: doc.id, isPublic: true })], + ["token rotations", () => rotateViewToken(doc.id)], + ["deletion", () => softDelete(doc.id)], + ])("invalidates after %s", async (_name, mutate) => { + mocks.query.mockResolvedValue({ rows: [doc] }); + + await mutate(); + + expect(mocks.invalidateDocVersion).toHaveBeenCalledWith(doc.slug); + }); + + it("does not invalidate an already-deleted document", async () => { + mocks.query.mockResolvedValue({ rows: [] }); + + await softDelete(doc.id); + + expect(mocks.invalidateDocVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/docs/version-cache.test.ts b/lib/docs/version-cache.test.ts index c3ea327..3ca7017 100644 --- a/lib/docs/version-cache.test.ts +++ b/lib/docs/version-cache.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ query: vi.fn(), @@ -23,6 +23,10 @@ describe("document version cache", () => { mocks.query.mockResolvedValue({ rows: [doc] }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("caches the minimal document row by slug", async () => { await expect(findVersionBySlug("quiet-moon-12345")).resolves.toEqual(doc); @@ -43,4 +47,19 @@ describe("document version cache", () => { expect(mocks.revalidateTag).toHaveBeenCalledWith("doc-version:quiet-moon-12345"); }); + + it("reports invalidation failures", () => { + const error = new Error("cache unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.revalidateTag.mockImplementationOnce(() => { + throw error; + }); + + invalidateDocVersion("quiet-moon-12345"); + + expect(consoleError).toHaveBeenCalledWith("Failed to invalidate document version cache", { + slug: "quiet-moon-12345", + error, + }); + }); }); diff --git a/lib/docs/version-cache.ts b/lib/docs/version-cache.ts index 249b16a..fe5cc2c 100644 --- a/lib/docs/version-cache.ts +++ b/lib/docs/version-cache.ts @@ -27,7 +27,7 @@ export function findVersionBySlug(slug: string): Promise { export function invalidateDocVersion(slug: string): void { try { revalidateTag(versionTag(slug)); - } catch { - return; + } catch (error) { + console.error("Failed to invalidate document version cache", { slug, error }); } } diff --git a/lib/docs/version-polling.test.ts b/lib/docs/version-polling.test.ts new file mode 100644 index 0000000..eadba9d --- /dev/null +++ b/lib/docs/version-polling.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startVersionPolling } from "@/lib/docs/version-polling"; + +const fetchMock = vi.fn(); +let hidden = false; + +async function advance(ms: number) { + await vi.advanceTimersByTimeAsync(ms); +} + +function start(onUpdate = vi.fn()) { + const stop = startVersionPolling({ + url: "/d/test/version?viewtoken=token", + currentVersion: () => 1, + onUpdate, + }); + return { onUpdate, stop }; +} + +describe("version polling", () => { + beforeEach(() => { + vi.useFakeTimers(); + hidden = false; + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response("1")); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("document", { + get hidden() { + return hidden; + }, + }); + vi.stubGlobal("window", { + setInterval, + clearInterval, + setTimeout, + clearTimeout, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("does not request versions while the document is hidden", async () => { + hidden = true; + const { stop } = start(); + + await advance(30_000); + expect(fetchMock).not.toHaveBeenCalled(); + + hidden = false; + await advance(30_000); + expect(fetchMock).toHaveBeenCalledOnce(); + stop(); + }); + + it("reports a newer version", async () => { + fetchMock.mockResolvedValueOnce(new Response("2")); + const { onUpdate, stop } = start(); + + await advance(30_000); + + expect(onUpdate).toHaveBeenCalledOnce(); + stop(); + }); + + it("clears the interval and aborts an in-flight request", async () => { + let signal: AbortSignal | undefined; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + signal = init.signal as AbortSignal; + return new Promise(() => {}); + }); + const { stop } = start(); + + await advance(30_000); + stop(); + + expect(signal?.aborted).toBe(true); + await advance(30_000); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/lib/docs/version-polling.ts b/lib/docs/version-polling.ts new file mode 100644 index 0000000..5c2979a --- /dev/null +++ b/lib/docs/version-polling.ts @@ -0,0 +1,43 @@ +type VersionPollingOptions = { + url: string; + currentVersion: () => number; + onUpdate: () => void; +}; + +export function startVersionPolling({ + url, + currentVersion, + onUpdate, +}: VersionPollingOptions): () => void { + let checking = false; + let controller: AbortController | null = null; + + const checkVersion = async () => { + if (checking || document.hidden) return; + checking = true; + controller = new AbortController(); + const timeout = window.setTimeout(() => controller?.abort(), 15_000); + try { + const response = await fetch(url, { + cache: "no-store", + credentials: "same-origin", + signal: controller.signal, + }); + if (!response.ok) return; + const version = Number(await response.text()); + if (Number.isInteger(version) && version > currentVersion()) onUpdate(); + } catch { + return; + } finally { + window.clearTimeout(timeout); + controller = null; + checking = false; + } + }; + + const interval = window.setInterval(() => void checkVersion(), 30_000); + return () => { + window.clearInterval(interval); + controller?.abort(); + }; +} diff --git a/package.json b/package.json index 69993ea..8350b51 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint", "migrate": "node --env-file=.env scripts/migrate.mjs", "migrate:status": "node --env-file=.env scripts/migrate.mjs status", + "firewall:configure": "scripts/configure-viewer-firewall.sh", "gen:skill": "tsx scripts/gen-skill.ts", "gen:spec": "tsx scripts/gen-spec.ts", "spec:check": "tsx scripts/spec-check.ts", diff --git a/scripts/configure-viewer-firewall.sh b/scripts/configure-viewer-firewall.sh new file mode 100755 index 0000000..f6239d9 --- /dev/null +++ b/scripts/configure-viewer-firewall.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +RULE_NAME="Rate limit document viewer" +PROJECT="${VERCEL_PROJECT:-justhtml}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${TMPDIR:-/tmp}/justhtml-vercel-cache}" +SCOPE="${VERCEL_SCOPE:-onkernel}" +VERCEL=(npx --yes vercel@59.9.1) +AUTH=() +if [[ -n "${VERCEL_TOKEN:-}" ]]; then + AUTH=(--token "$VERCEL_TOKEN") +fi +COMMON=(--project "$PROJECT" --scope "$SCOPE" "${AUTH[@]}") + +rules="$("${VERCEL[@]}" firewall rules list --json "${COMMON[@]}")" +if jq -e --arg name "$RULE_NAME" ' + .rules[]? + | select(.name == $name) + | .active == true + and .action.mitigate.action == "rate_limit" + and .action.mitigate.rateLimit.limit == 300 + and .action.mitigate.rateLimit.window == 60 + and .action.mitigate.rateLimit.keys == ["ip"] + and .action.mitigate.rateLimit.algo == "fixed_window" + and .conditionGroup == [{"conditions":[{"type":"path","value":"/d/","op":"pre"}]}] +' <<<"$rules" >/dev/null; then + echo "Viewer firewall rule is already configured." + exit 0 +fi + +if jq -e --arg name "$RULE_NAME" '.rules[]? | select(.name == $name)' <<<"$rules" >/dev/null; then + "${VERCEL[@]}" firewall rules edit "$RULE_NAME" \ + --condition '{"type":"path","op":"pre","value":"/d/"}' \ + --action rate_limit \ + --rate-limit-algo fixed_window \ + --rate-limit-keys ip \ + --rate-limit-requests 300 \ + --rate-limit-window 60 \ + --rate-limit-action rate_limit \ + --enabled --yes "${COMMON[@]}" +else + "${VERCEL[@]}" firewall rules add "$RULE_NAME" \ + --condition '{"type":"path","op":"pre","value":"/d/"}' \ + --action rate_limit \ + --rate-limit-algo fixed_window \ + --rate-limit-keys ip \ + --rate-limit-requests 300 \ + --rate-limit-window 60 \ + --rate-limit-action rate_limit \ + --yes "${COMMON[@]}" +fi + +"${VERCEL[@]}" firewall publish --yes "${COMMON[@]}"