diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 51f0b63..04b760f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -163,6 +163,18 @@ 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. + +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 1511d2a..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 @@ -266,6 +267,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 +284,15 @@ export default function CommentsShell(props: Props) { const apiBase = `/api/v1/docs/${encodeURIComponent(slug)}`; const tokenQuery = viewtoken ? `?viewtoken=${encodeURIComponent(viewtoken)}` : ""; + useEffect(() => { + if (updateAvailable) return; + 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 // are visible under the resolved toggle). const paintAnchors = useMemo( @@ -732,10 +743,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(); @@ -937,7 +950,22 @@ export default function CommentsShell(props: Props) { {title} - + + {updateAvailable ? ( + + + + ) : null} {readMinutes != null && readMinutes > 0 ? ( {readMinutes} min read @@ -1015,8 +1043,8 @@ export default function CommentsShell(props: Props) { > ๐Ÿ’ฌ {commentCount} - history - made with justhtml.sh + history + made with justhtml.sh @@ -1753,6 +1781,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; } } `; 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 new file mode 100644 index 0000000..cb07d07 --- /dev/null +++ b/app/d/[slug]/version/route.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findVersionBySlug: vi.fn(), + canViewSession: vi.fn(), + getSessionReadOnly: vi.fn(), +})); + +vi.mock("@/lib/docs/version-cache", () => ({ findVersionBySlug: mocks.findVersionBySlug })); +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"; + +const doc = { + id: 1, + owner_id: 2, + is_public: false, + view_token: "secret-token", + 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}`); +} + +const ctx = { params: Promise.resolve({ slug: "quiet-moon-12345" }) }; + +describe("document version", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findVersionBySlug.mockResolvedValue(doc); + mocks.getSessionReadOnly.mockResolvedValue(session); + mocks.canViewSession.mockResolvedValue(true); + }); + + it("returns the current version without browser caching", async () => { + 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.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 () => { + 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..c7ea239 --- /dev/null +++ b/app/d/[slug]/version/route.ts @@ -0,0 +1,30 @@ +import { getSessionReadOnly } from "@/lib/auth/session"; +import { canView, canViewSession } from "@/lib/docs/access"; +import { findVersionBySlug } from "@/lib/docs/version-cache"; + +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"); + if (!canView(doc, viewtoken)) { + const session = await getSessionReadOnly(req); + if (!(await canViewSession(doc, session, null))) 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/auth/session.test.ts b/lib/auth/session.test.ts new file mode 100644 index 0000000..c2cc6b5 --- /dev/null +++ b/lib/auth/session.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ query: vi.fn() })); + +vi.mock("@/lib/db", () => ({ query: mocks.query })); + +import { getSession, getSessionReadOnly } from "@/lib/auth/session"; + +function request() { + return new Request("https://justhtml.sh/d/test/version", { + headers: { cookie: "jh_sess=sess_test-token" }, + }); +} + +describe("session lookup", () => { + beforeEach(() => { + vi.clearAllMocks(); + 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 a read-only 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"); + }); + + 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/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 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/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.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/store.ts b/lib/docs/store.ts index 72ba7fa..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, @@ -36,6 +37,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 { @@ -315,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(() => {}); @@ -420,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(() => {}); @@ -532,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]; } @@ -542,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. */ diff --git a/lib/docs/version-cache.test.ts b/lib/docs/version-cache.test.ts new file mode 100644 index 0000000..3ca7017 --- /dev/null +++ b/lib/docs/version-cache.test.ts @@ -0,0 +1,65 @@ +import { afterEach, 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] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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"); + }); + + 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 new file mode 100644 index 0000000..fe5cc2c --- /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 (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[@]}"