Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
38 changes: 35 additions & 3 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string | null>(null);
const [updateAvailable, setUpdateAvailable] = useState(false);
const versionRef = useRef(props.version);
const saveInlineEditRef = useRef<(changes: TextChange[]) => void>(() => {});
const statusTimer = useRef<number | null>(null);
Expand All @@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -937,7 +950,22 @@ export default function CommentsShell(props: Props) {
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 700 }}>
{title}
</span>
<span style={{ flexShrink: 0, paddingLeft: "1.25rem", display: "flex", gap: "1.25rem", alignItems: "center", color: "var(--jh-bar-muted, #666)" }}>
<span
className={`jh-bar-actions${updateAvailable ? " jh-has-update" : ""}`}
style={{ flexShrink: 0, paddingLeft: "1.25rem", display: "flex", gap: "1.25rem", alignItems: "center", color: "var(--jh-bar-muted, #666)" }}
>
{updateAvailable ? (
<span role="status">
<button
type="button"
aria-label="Updated. Refresh document"
onClick={() => window.location.reload()}
style={{ ...commentBtnStyle(true), fontWeight: 700 }}
Comment thread
ehfeng marked this conversation as resolved.
>
<span className="jh-update-prefix">updated · </span>refresh
</button>
</span>
) : null}
{readMinutes != null && readMinutes > 0 ? (
<span className="jh-readtime" data-level={readTimeLevel(readMinutes)} title={readTimeTitle(readMinutes)}>
{readMinutes} min read
Expand Down Expand Up @@ -1015,8 +1043,8 @@ export default function CommentsShell(props: Props) {
>
💬 {commentCount}
</button>
<a href={`/d/${encodeURIComponent(slug)}/history${tokenQuery}`} style={{ color: "var(--jh-bar-muted, #666)" }}>history</a>
<span>made with <a href="/" style={{ color: "var(--jh-bar-muted, #666)" }}>justhtml.sh</a></span>
<a className="jh-history" href={`/d/${encodeURIComponent(slug)}/history${tokenQuery}`} style={{ color: "var(--jh-bar-muted, #666)" }}>history</a>
<span className="jh-brand">made with <a href="/" style={{ color: "var(--jh-bar-muted, #666)" }}>justhtml.sh</a></span>
</span>
</div>

Expand Down Expand Up @@ -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; }
}
`;

Expand Down
14 changes: 0 additions & 14 deletions app/d/[slug]/preview/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -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 () => {
Expand Down
19 changes: 0 additions & 19 deletions app/d/[slug]/preview/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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);
Expand Down
22 changes: 0 additions & 22 deletions app/d/[slug]/raw/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -39,20 +31,6 @@ function deny(status: number, msg: string): Response {
}

export async function GET(req: Request, ctx: Ctx): Promise<Response> {
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");
Expand Down
83 changes: 83 additions & 0 deletions app/d/[slug]/version/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/docs/access")>()),
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("");
});
});
30 changes: 30 additions & 0 deletions app/d/[slug]/version/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
const { slug } = await ctx.params;
const doc = await findVersionBySlug(slug);
if (!doc) return notFound();
Comment thread
ehfeng marked this conversation as resolved.

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",
},
});
}
48 changes: 48 additions & 0 deletions lib/auth/session.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading