diff --git a/README.md b/README.md
index f2de94f..4ee725c 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,28 @@ scratchwork share --role write bob@example.com
scratchwork revoke alice@example.com
```
+### Comments
+
+Private projects can let their viewers leave comments on any page:
+
+```sh
+scratchwork publish --comments # turn comments on (persisted in .scratchwork.json)
+scratchwork publish --no-comments # turn them off again
+```
+
+Every page of a comments-enabled project gets a small "Comments" button in the
+lower-right corner. Anyone with read access can add a comment (click "Add
+comment", then click anywhere on the page to pin it), resolve or reopen one,
+and edit or delete their own; the project's writers and admins can edit or
+delete anyone's. Comments are anchored to the element that was clicked and fall
+back to coarser positions if a republish changes the page, and they persist
+across republishes.
+
+Comments require a private project — `--comments` together with `--public`
+fails the publish, because a public page would collect comments from the whole
+internet. Turning the toggle on or off requires admin access, like the
+public/private flip.
+
Deployments live as projects under `deploy/`, one per domain, each deployable with one command:
```sh
diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts
index 6bbc83d..929c89c 100644
--- a/cli/src/commands/publish.ts
+++ b/cli/src/commands/publish.ts
@@ -61,6 +61,15 @@ export function runPublish(
// An omitted isPublic preserves an existing project's setting (new projects are
// created private); an omitted project lets a random-naming server mint one.
const isPublic = config.isPublic ?? projectConfig?.isPublic;
+ const commentsEnabled = config.commentsEnabled ?? projectConfig?.commentsEnabled;
+ // The server enforces this too (it knows the project's current settings);
+ // failing here just catches the contradiction before the upload.
+ if (commentsEnabled === true && isPublic === true) {
+ return yield* Effect.fail(new CliError({
+ code: 1,
+ message: "scratchwork publish: comments require a private project (drop --public, or pass --no-comments)",
+ }));
+ }
const bundle = yield* createBundle(target.root);
const body: PublishRequestBody = {
@@ -68,6 +77,7 @@ export function runPublish(
openPath: target.openPath,
project,
isPublic,
+ commentsEnabled,
};
const response = yield* postPublish(server, body, authToken).pipe(
Effect.catchIf((error) => error instanceof PublishAuthRequired, () =>
@@ -205,6 +215,7 @@ function writeMetadata(
server,
project: response.project,
isPublic: response.isPublic,
+ ...(response.commentsEnabled != null ? { commentsEnabled: response.commentsEnabled } : {}),
url: response.url,
updatedAt: new Date().toISOString(),
}).pipe(
@@ -235,6 +246,7 @@ function printResult(
? [` note server assigned project name "${response.project}"`]
: []),
` is publicly visible? ${response.isPublic ? "yes" : "no"}`,
+ ...(response.commentsEnabled === true ? [" comments enabled"] : []),
` files ${bundle.files.length} (${formatBytes(bytes)})`,
...(saved ? [` saved ${PROJECT_CONFIG_FILE}\n`] : [""]),
].join("\n"),
diff --git a/cli/src/index.ts b/cli/src/index.ts
index f5c081d..3b32597 100755
--- a/cli/src/index.ts
+++ b/cli/src/index.ts
@@ -86,11 +86,25 @@ const publishCommand = Command.make(
isPrivateFlag: Options.boolean("private").pipe(
Options.withDescription("Make the project readable only by its owner and share grants."),
),
+ commentsFlag: Options.boolean("comments").pipe(
+ Options.withDescription("Let authenticated viewers leave comments on the project's pages. Requires a private project. Default: saved config or the project's current setting (off for a new project)."),
+ ),
+ noCommentsFlag: Options.boolean("no-comments").pipe(
+ Options.withDescription("Turn viewer comments off."),
+ ),
},
- ({ path, server, project, isPublicFlag, isPrivateFlag }) =>
+ ({ path, server, project, isPublicFlag, isPrivateFlag, commentsFlag, noCommentsFlag }) =>
isPublicFlag && isPrivateFlag
? Effect.fail(new CliError({ code: 1, message: "scratchwork publish: pass at most one of --public and --private" }))
- : runPublish({ path, server, project, isPublic: isPublicFlag ? true : isPrivateFlag ? false : undefined }),
+ : commentsFlag && noCommentsFlag
+ ? Effect.fail(new CliError({ code: 1, message: "scratchwork publish: pass at most one of --comments and --no-comments" }))
+ : runPublish({
+ path,
+ server,
+ project,
+ isPublic: isPublicFlag ? true : isPrivateFlag ? false : undefined,
+ commentsEnabled: commentsFlag ? true : noCommentsFlag ? false : undefined,
+ }),
).pipe(Command.withDescription("Publish a static Scratchwork project to a server"));
const projectRefOptions = {
diff --git a/cli/src/project-config.ts b/cli/src/project-config.ts
index b5f120e..a3a4757 100644
--- a/cli/src/project-config.ts
+++ b/cli/src/project-config.ts
@@ -30,6 +30,7 @@ const ProjectConfigFileSchema = Schema.Struct({
server: Schema.optional(Schema.String),
project: Schema.optional(Schema.String),
isPublic: Schema.optional(Schema.Boolean),
+ commentsEnabled: Schema.optional(Schema.Boolean),
url: Schema.optional(Schema.String),
updatedAt: Schema.optional(Schema.String),
});
diff --git a/cli/src/types.ts b/cli/src/types.ts
index 5f0f67f..5dcbcdd 100644
--- a/cli/src/types.ts
+++ b/cli/src/types.ts
@@ -22,12 +22,14 @@ export interface TemplateConfig {
}
/** `scratchwork publish` options. `isPublic` stays undefined when neither --public nor
- * --private is passed, letting saved config or the server decide. */
+ * --private is passed, letting saved config or the server decide; `commentsEnabled`
+ * behaves the same for --comments/--no-comments. */
export interface PublishConfig {
readonly path: string;
readonly server?: string;
readonly project?: string;
readonly isPublic?: boolean;
+ readonly commentsEnabled?: boolean;
}
/** `scratchwork login` options. */
diff --git a/cli/test/e2e-publish.test.js b/cli/test/e2e-publish.test.js
index 11822f3..03c6350 100644
--- a/cli/test/e2e-publish.test.js
+++ b/cli/test/e2e-publish.test.js
@@ -93,6 +93,73 @@ describe("scratchwork publish", () => {
}
});
+ test("sends commentsEnabled, saves it, and enforces the comments flag rules", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "scratchwork-publish-"));
+ const configDir = mkdtempSync(join(tmpdir(), "scratchwork-config-"));
+ const port = nextPort();
+ const serverUrl = `http://localhost:${port}`;
+ let publishBody;
+
+ const server = Bun.serve({
+ port,
+ async fetch(request) {
+ publishBody = await request.json();
+ return Response.json({
+ project: publishBody.project,
+ isPublic: publishBody.isPublic ?? false,
+ commentsEnabled: publishBody.commentsEnabled ?? false,
+ openPath: publishBody.openPath,
+ url: `${serverUrl}/${publishBody.project}/`,
+ });
+ },
+ });
+
+ try {
+ writeFileSync(join(dir, "index.html"), staticPage("comments"));
+
+ const conflictingFlags = await runCli(
+ ["publish", ".", "--server", serverUrl, "--comments", "--no-comments"],
+ dir,
+ { env: { SCRATCHWORK_HOME: configDir } },
+ );
+ expect(conflictingFlags.code).toBe(1);
+ expect(conflictingFlags.stderr).toContain("at most one of --comments and --no-comments");
+
+ // The comments+public contradiction fails before anything is uploaded.
+ const publicComments = await runCli(
+ ["publish", ".", "--server", serverUrl, "--public", "--comments"],
+ dir,
+ { env: { SCRATCHWORK_HOME: configDir } },
+ );
+ expect(publicComments.code).toBe(1);
+ expect(publicComments.stderr).toContain("comments require a private project");
+ expect(publishBody).toBeUndefined();
+
+ const { code, stdout, stderr } = await runCli(
+ ["publish", ".", "--server", serverUrl, "--private", "--comments", "--project", "site"],
+ dir,
+ { env: { SCRATCHWORK_HOME: configDir } },
+ );
+ expect(code).toBe(0);
+ expect(stderr).toBe("");
+ expect(publishBody.commentsEnabled).toBe(true);
+ expect(publishBody.isPublic).toBe(false);
+ expect(stdout).toContain("comments enabled");
+ const saved = JSON.parse(readFileSync(join(dir, ".scratchwork.json"), "utf8"));
+ expect(saved.commentsEnabled).toBe(true);
+
+ // A later publish without the flags reuses the saved setting.
+ publishBody = undefined;
+ const again = await runCli(["publish", "."], dir, { env: { SCRATCHWORK_HOME: configDir } });
+ expect(again.code).toBe(0);
+ expect(publishBody.commentsEnabled).toBe(true);
+ } finally {
+ server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+ rmSync(configDir, { recursive: true, force: true });
+ }
+ });
+
test("publishing a directory without an index sends the first page file's route as openPath", async () => {
const dir = mkdtempSync(join(tmpdir(), "scratchwork-publish-"));
const configDir = mkdtempSync(join(tmpdir(), "scratchwork-config-"));
diff --git a/e2e/src/suite.ts b/e2e/src/suite.ts
index 89f535a..d74cc0f 100644
--- a/e2e/src/suite.ts
+++ b/e2e/src/suite.ts
@@ -215,6 +215,90 @@ export function publishLoopSuite(
expect(deniedBody).not.toContain("marker-v2");
}, 60_000);
+ test("comments: viewers comment through the content-origin API; the widget is injected", async () => {
+ // Turn comments on for the private project (admin toggle via republish).
+ const enabled = await runCli(["publish", ".", "--comments"], site.path, { SCRATCHWORK_HOME: ownerHome.path });
+ expect(enabled.stderr).toBe("");
+ expect(enabled.code).toBe(0);
+ expect(enabled.stdout).toContain("comments enabled");
+
+ // Making the project public now must fail: comments require privacy.
+ // (The saved config carries commentsEnabled, so the CLI catches this
+ // before uploading; the server enforces the same rule.)
+ const flippedPublic = await runCli(["publish", ".", "--public"], site.path, { SCRATCHWORK_HOME: ownerHome.path });
+ expect(flippedPublic.code).toBe(1);
+
+ // The served page carries the widget script, and the script serves.
+ const pageUrl = `${context.contentUrl}/${project}/`;
+ const apiBase = `${context.contentUrl}/${project}/__scratchwork/comments`;
+ const page = await ownerBrowser.get(pageUrl);
+ expect(page.status).toBe(200);
+ expect(await page.response.text()).toContain("/__scratchwork/comments/widget.js");
+ const widget = await ownerBrowser.request(`${apiBase}/widget.js`, {
+ headers: { referer: pageUrl, "sec-fetch-dest": "script" },
+ });
+ expect(widget.status).toBe(200);
+ expect(widget.headers.get("content-type")).toContain("text/javascript");
+
+ // The owner comments, exactly as the widget would (same-origin fetch,
+ // path-scoped access cookie).
+ const created = await ownerBrowser.request(apiBase, {
+ method: "POST",
+ headers: { "content-type": "application/json", origin: context.contentUrl },
+ body: JSON.stringify({ page: "/", body: "owner comment", anchors: [{ selector: "body", x: 10, y: 20 }] }),
+ });
+ expect(created.status).toBe(200);
+ const ownerComment = (await created.json() as { comment: { id: string } }).comment;
+
+ // A shared viewer sees it, resolves it, and adds their own — but cannot
+ // delete the owner's.
+ const shared = await runCli(
+ ["share", VIEWER.email, "--server", context.appUrl, "--project", project],
+ site.path,
+ { SCRATCHWORK_HOME: ownerHome.path },
+ );
+ expect(shared.code).toBe(0);
+ provider.user = VIEWER;
+ const viewerBrowser = new Browser();
+ await loginBrowser(context, viewerBrowser);
+ expect((await viewerBrowser.get(pageUrl)).status).toBe(200);
+
+ const listed = await viewerBrowser.request(`${apiBase}?page=/`);
+ expect(listed.status).toBe(200);
+ const listing = await listed.json() as {
+ viewer: { email: string; canModerate: boolean };
+ comments: Array<{ id: string; body: string }>;
+ };
+ expect(listing.viewer).toEqual({ email: VIEWER.email, canModerate: false });
+ expect(listing.comments.map((comment) => comment.body)).toEqual(["owner comment"]);
+
+ const resolved = await viewerBrowser.request(`${apiBase}/${ownerComment.id}`, {
+ method: "PATCH",
+ headers: { "content-type": "application/json", origin: context.contentUrl },
+ body: JSON.stringify({ page: "/", resolved: true }),
+ });
+ expect(resolved.status).toBe(200);
+ expect((await resolved.json() as { comment: { resolvedBy: string } }).comment.resolvedBy).toBe(VIEWER.email);
+
+ const denied = await viewerBrowser.request(`${apiBase}/${ownerComment.id}?page=/`, {
+ method: "DELETE",
+ headers: { origin: context.contentUrl },
+ });
+ expect(denied.status).toBe(403);
+
+ // Anonymous callers get nothing.
+ const anonymous = await rawFetch(`${apiBase}?page=/`);
+ expect(anonymous.status).toBe(401);
+
+ // Comments survive a republish (they live beside the content, not in it).
+ writeFileSync(join(site.path, "index.html"), "
marker-v3
");
+ const republished = await runCli(["publish", "."], site.path, { SCRATCHWORK_HOME: ownerHome.path });
+ expect(republished.code).toBe(0);
+ const after = await ownerBrowser.request(`${apiBase}?page=/`);
+ expect(after.status).toBe(200);
+ expect((await after.json() as { comments: unknown[] }).comments).toHaveLength(1);
+ }, 60_000);
+
test("a public project serves without any credentials", async () => {
const publicSite = tempDir(`scratchwork-e2e-${lane}-public-`);
try {
diff --git a/e2e/test/browser-security.test.ts b/e2e/test/browser-security.test.ts
index 315e43c..1147cc1 100644
--- a/e2e/test/browser-security.test.ts
+++ b/e2e/test/browser-security.test.ts
@@ -62,6 +62,7 @@ describe("browser security [local-dev]", () => {
const privateSite = tempDir("scratchwork-browser-private-");
const attackerSite = tempDir("scratchwork-browser-attacker-");
const homeSite = tempDir("scratchwork-browser-homepage-");
+ const commentsSite = tempDir("scratchwork-browser-comments-");
beforeAll(async () => {
const port = nextPort();
@@ -91,15 +92,17 @@ describe("browser security [local-dev]", () => {
writeFileSync(join(privateSite.path, "data.txt"), "secret-data");
writeFileSync(join(attackerSite.path, "index.html"), "attacker-page
");
writeFileSync(join(homeSite.path, "index.html"), "home-secret
");
+ writeFileSync(join(commentsSite.path, "index.html"), "comments-page
");
await loginCli(context, ownerHome.path, privateSite.path);
- for (const [dir, name, visibility] of [
+ for (const [dir, name, ...flags] of [
[privateSite.path, "sec-private", "--private"],
[attackerSite.path, "sec-attacker", "--public"],
[homeSite.path, "sec-home", "--private"],
+ [commentsSite.path, "sec-comments", "--private", "--comments"],
] as const) {
const published = await runCli(
- ["publish", ".", "--server", appUrl, "--project", name, visibility],
+ ["publish", ".", "--server", appUrl, "--project", name, ...flags],
dir,
{ SCRATCHWORK_HOME: ownerHome.path },
);
@@ -118,6 +121,7 @@ describe("browser security [local-dev]", () => {
privateSite.remove();
attackerSite.remove();
homeSite.remove();
+ commentsSite.remove();
});
/** Completes the browser login redirect dance through the hermetic provider. */
@@ -281,6 +285,67 @@ describe("browser security [local-dev]", () => {
}
}, 60_000);
+ test("the comments widget runs for viewers in-project; foreign pages cannot reach the comments API", async () => {
+ const { ctx, page } = await signedInContext();
+ try {
+ // The injected widget boots inside the real page and renders its UI in
+ // a shadow root once the same-origin list fetch (cookie-authenticated)
+ // succeeds.
+ await page.goto(`${contentUrl}/sec-comments/`, { waitUntil: "domcontentloaded" });
+ expect(await page.textContent("#headline")).toBe("comments-page");
+ await page.waitForFunction(() => {
+ const host = document.querySelector("[data-scratchwork-comments]");
+ return host?.shadowRoot?.querySelector(".fab") != null;
+ });
+
+ // Creating a comment exactly as the widget does works from in-project JS.
+ const created = await page.evaluate(async () => {
+ const response = await fetch("/sec-comments/__scratchwork/comments", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ page: "/", body: "from a real browser", anchors: [{ selector: "#headline", x: 5, y: 5 }, { selector: "body", x: 5, y: 5 }] }),
+ });
+ return response.status;
+ });
+ expect(created).toBe(200);
+
+ // A reload shows the persisted comment as a pin.
+ await page.goto(`${contentUrl}/sec-comments/`, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(() => {
+ const host = document.querySelector("[data-scratchwork-comments]");
+ return host?.shadowRoot?.querySelector(".pin") != null;
+ });
+
+ // Another project's page on the same origin: the Referer proves the
+ // requesting page is foreign, so the request dies at the cross-project
+ // guard (and the path-scoped cookie wouldn't attach anyway).
+ await page.goto(`${contentUrl}/sec-attacker/`, { waitUntil: "domcontentloaded" });
+ const attackerRead = await page.evaluate(async () =>
+ (await fetch("/sec-comments/__scratchwork/comments?page=/")).status);
+ expect(attackerRead).toBe(403);
+ // The public attacker page gets no widget injected.
+ expect(await page.evaluate(() => document.querySelector("[data-scratchwork-comments]") == null)).toBe(true);
+
+ // A cross-origin top-level form POST (real Origin header, no preflight)
+ // from the app origin to the content-host comments API is rejected by
+ // the origin gate before auth even runs (403, not a 401 cookie demand).
+ await page.goto(`${appUrl}/`, { waitUntil: "domcontentloaded" });
+ const [response] = await Promise.all([
+ page.waitForNavigation({ waitUntil: "domcontentloaded" }),
+ page.evaluate((target) => {
+ const form = document.createElement("form");
+ form.method = "POST";
+ form.action = `${target}/sec-comments/__scratchwork/comments`;
+ document.body.append(form);
+ form.submit();
+ }, contentUrl),
+ ]);
+ expect(response?.status()).toBe(403);
+ } finally {
+ await ctx.close();
+ }
+ }, 60_000);
+
test("auth redirects never leave their intended origins", async () => {
const { ctx, page } = await signedInContext();
try {
diff --git a/scripts/check-generated-fresh.ts b/scripts/check-generated-fresh.ts
index 167e514..3450fe2 100644
--- a/scripts/check-generated-fresh.ts
+++ b/scripts/check-generated-fresh.ts
@@ -16,6 +16,7 @@ const root = dirname(dirname(fileURLToPath(import.meta.url)));
const artifacts = [
"shared/src/site/default-renderer.generated.js",
"shared/src/assets/figure-svg.generated.ts",
+ "server/core/src/comments-widget.generated.ts",
];
const status = Bun.spawnSync(
diff --git a/scripts/generate-assets.ts b/scripts/generate-assets.ts
index dff2f23..28e1307 100644
--- a/scripts/generate-assets.ts
+++ b/scripts/generate-assets.ts
@@ -1,26 +1,35 @@
#!/usr/bin/env bun
/*
- * Regenerates shared/src/assets/figure-svg.generated.ts from
- * shared/assets/figure.svg. The module form (instead of importing the .svg
- * with a Bun `with { type: "text" }` attribute) keeps the publishable
- * packages free of loader-specific syntax so their tsc-built dist runs under
- * plain Node. Runs in the root `bun run ci`; freshness is enforced by
+ * Regenerates the string-module assets: shared/assets/figure.svg into
+ * shared/src/assets/figure-svg.generated.ts, and the comments widget
+ * server/core/assets/comments-widget.js into
+ * server/core/src/comments-widget.generated.ts. The module form (instead of
+ * importing the sources with a Bun `with { type: "text" }` attribute) keeps
+ * the publishable packages free of loader-specific syntax so their tsc-built
+ * dist runs under plain Node; for the widget it also keeps a plain browser
+ * script out of the Effect-boundary lint's scope, exactly like the embedded
+ * renderer. Runs in the root `bun run ci`; freshness is enforced by
* scripts/check-generated-fresh.ts.
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { repoRoot } from "./workspaces";
-const svg = readFileSync(join(repoRoot, "shared", "assets", "figure.svg"), "utf8");
-const out = join(repoRoot, "shared", "src", "assets", "figure-svg.generated.ts");
-mkdirSync(dirname(out), { recursive: true });
-writeFileSync(
- out,
- [
- "// Generated by scripts/generate-assets.ts from shared/assets/figure.svg — do not edit.",
- "// Regenerate with: bun scripts/generate-assets.ts",
- `export const FIGURE_SVG: string = ${JSON.stringify(svg)};`,
- "",
- ].join("\n"),
-);
-console.log(`generated ${out.slice(repoRoot.length + 1)} (${svg.length} chars of svg)`);
+function generateStringModule(sourcePath: string, outPath: string, exportName: string): void {
+ const source = readFileSync(join(repoRoot, ...sourcePath.split("/")), "utf8");
+ const out = join(repoRoot, ...outPath.split("/"));
+ mkdirSync(dirname(out), { recursive: true });
+ writeFileSync(
+ out,
+ [
+ `// Generated by scripts/generate-assets.ts from ${sourcePath} — do not edit.`,
+ "// Regenerate with: bun scripts/generate-assets.ts",
+ `export const ${exportName}: string = ${JSON.stringify(source)};`,
+ "",
+ ].join("\n"),
+ );
+ console.log(`generated ${outPath} (${source.length} chars)`);
+}
+
+generateStringModule("shared/assets/figure.svg", "shared/src/assets/figure-svg.generated.ts", "FIGURE_SVG");
+generateStringModule("server/core/assets/comments-widget.js", "server/core/src/comments-widget.generated.ts", "COMMENTS_WIDGET_JS");
diff --git a/server/core/assets/comments-widget.js b/server/core/assets/comments-widget.js
new file mode 100644
index 0000000..100c621
--- /dev/null
+++ b/server/core/assets/comments-widget.js
@@ -0,0 +1,642 @@
+/*
+ * The Scratchwork comments widget: a self-contained browser script injected
+ * into every HTML page of a private, comments-enabled project. Plain browser
+ * JS by design (it runs inside published pages, like the renderer) — the
+ * server embeds it via the generated module comments-widget.generated.ts.
+ *
+ * It talks to the content-origin comments API at
+ * /{project}/__scratchwork/comments; the path-scoped project-access cookie
+ * that gated the page itself authenticates every call.
+ *
+ * Anchoring: each comment stores a list of {selector, x, y} candidates — the
+ * clicked element first, then its ancestors, ending with a body-relative
+ * fallback. A pin renders at the first selector that still matches, so a
+ * republished or dynamic page degrades toward the coarser anchors instead of
+ * losing the comment.
+ */
+(function () {
+ "use strict";
+ if (window.__scratchworkComments) return;
+ window.__scratchworkComments = true;
+
+ var segments = location.pathname.split("/").filter(Boolean);
+ if (segments.length === 0) return;
+ var project = decodeURIComponent(segments[0]);
+ var API_BASE = "/" + encodeURIComponent(project) + "/__scratchwork/comments";
+ var PAGE = normalizePage();
+
+ var state = {
+ viewer: null,
+ comments: [],
+ open: false,
+ adding: false,
+ showResolved: false,
+ active: null, // highlighted comment id
+ pending: null, // draft anchor {anchors, pageX, pageY}
+ editing: null, // comment id with an open edit box
+ confirmingDelete: null, // comment id whose delete needs confirming
+ };
+
+ // ------------------------------------------------------------------
+ // Page identity: the decoded path under the project. The server owns
+ // canonicalization (normalizeCommentPage) and applies it to every page
+ // value this script sends, so no normalization logic is duplicated here.
+ // ------------------------------------------------------------------
+ function normalizePage() {
+ var rest = "/" + segments.slice(1).map(function (s) {
+ try { return decodeURIComponent(s); } catch (e) { return s; }
+ }).join("/");
+ return rest.replace(/\/+/g, "/") || "/";
+ }
+
+ // ------------------------------------------------------------------
+ // API
+ // ------------------------------------------------------------------
+ function api(method, path, body) {
+ var options = { method: method, headers: {} };
+ if (body) {
+ options.headers["content-type"] = "application/json";
+ options.body = JSON.stringify(body);
+ }
+ return fetch(API_BASE + path, options).then(function (res) {
+ if (!res.ok) {
+ return res.json().catch(function () { return {}; }).then(function (data) {
+ throw new Error(data.error || "Request failed (" + res.status + ")");
+ });
+ }
+ return res.json();
+ });
+ }
+
+ function pageQuery() {
+ return "?page=" + encodeURIComponent(PAGE);
+ }
+
+ // ------------------------------------------------------------------
+ // Anchor capture and resolution
+ // ------------------------------------------------------------------
+ function cssEscape(value) {
+ return window.CSS && CSS.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
+ }
+
+ function uniqueMatch(selector, el) {
+ try {
+ var found = document.querySelectorAll(selector);
+ return found.length === 1 && found[0] === el ? selector : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function idSelector(el) {
+ if (!el.id) return null;
+ return uniqueMatch("#" + cssEscape(el.id), el);
+ }
+
+ function nthOfType(el) {
+ var index = 1;
+ var node = el.previousElementSibling;
+ while (node) {
+ if (node.tagName === el.tagName) index++;
+ node = node.previousElementSibling;
+ }
+ return index;
+ }
+
+ /** A selector for el: its unique #id, or a short nth-of-type path from the
+ * nearest uniquely-id'd ancestor (or body). Null when nothing stable fits. */
+ function selectorFor(el) {
+ var direct = idSelector(el);
+ if (direct) return direct;
+ var parts = [];
+ var node = el;
+ var depth = 0;
+ while (node && node !== document.body && node.nodeType === 1 && depth < 6) {
+ var anchor = idSelector(node);
+ if (anchor && node !== el) {
+ var withId = anchor + " > " + parts.join(" > ");
+ return withId.length <= 400 ? uniqueMatch(withId, el) : null;
+ }
+ parts.unshift(node.tagName.toLowerCase() + ":nth-of-type(" + nthOfType(node) + ")");
+ node = node.parentElement;
+ depth++;
+ }
+ if (node !== document.body) return null;
+ var selector = "body > " + parts.join(" > ");
+ return selector.length <= 400 ? uniqueMatch(selector, el) : null;
+ }
+
+ function round2(value) {
+ return Math.round(value * 100) / 100;
+ }
+
+ /** Anchor candidates for a click: target, then ancestors, then body. */
+ function computeAnchors(target, pageX, pageY) {
+ var anchors = [];
+ var node = target;
+ var depth = 0;
+ while (node && node !== document.body && node.nodeType === 1 && depth < 10 && anchors.length < 7) {
+ var selector = selectorFor(node);
+ if (selector) {
+ var rect = node.getBoundingClientRect();
+ anchors.push({
+ selector: selector,
+ x: round2(pageX - (rect.left + window.scrollX)),
+ y: round2(pageY - (rect.top + window.scrollY)),
+ });
+ }
+ node = node.parentElement;
+ depth++;
+ }
+ anchors.push({ selector: "body", x: round2(pageX), y: round2(pageY) });
+ return anchors;
+ }
+
+ /** Page position of a comment: the first anchor whose selector still matches. */
+ function anchorPosition(anchors) {
+ for (var i = 0; i < anchors.length; i++) {
+ var el;
+ try { el = document.querySelector(anchors[i].selector); } catch (e) { el = null; }
+ if (el) {
+ var rect = el.getBoundingClientRect();
+ return {
+ x: rect.left + window.scrollX + anchors[i].x,
+ y: rect.top + window.scrollY + anchors[i].y,
+ };
+ }
+ }
+ return null;
+ }
+
+ // ------------------------------------------------------------------
+ // DOM scaffolding (shadow root keeps page CSS and widget CSS apart)
+ // ------------------------------------------------------------------
+ var host = document.createElement("div");
+ host.setAttribute("data-scratchwork-comments", "");
+ host.style.cssText = "position:absolute;left:0;top:0;width:0;height:0;overflow:visible;z-index:2147483000;";
+ var root = host.attachShadow({ mode: "open" });
+
+ var globalStyle = document.createElement("style");
+ globalStyle.textContent = "html.--sw-commenting, html.--sw-commenting * { cursor: crosshair !important; }";
+
+ var style = document.createElement("style");
+ style.textContent = "" +
+ ":host { all: initial; }" +
+ "* { box-sizing: border-box; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; }" +
+ ".fab { position: fixed; right: 20px; bottom: 20px; z-index: 3; display: flex; align-items: center; gap: 7px;" +
+ " border: 1px solid #d4d4d8; background: #ffffff; color: #27272a; border-radius: 999px; padding: 9px 15px;" +
+ " font-size: 13px; font-weight: 600; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,.12); }" +
+ ".fab:hover { background: #fafafa; }" +
+ ".fab .count { background: #4f46e5; color: #fff; border-radius: 999px; min-width: 18px; height: 18px;" +
+ " display: inline-flex; align-items: center; justify-content: center; font-size: 11px; padding: 0 5px; }" +
+ ".fab .count.zero { background: #a1a1aa; }" +
+ ".panel { position: fixed; top: 0; right: 0; bottom: 0; width: 320px; max-width: 92vw; background: #fff;" +
+ " border-left: 1px solid #e4e4e7; box-shadow: -4px 0 18px rgba(0,0,0,.08); z-index: 2; display: flex;" +
+ " flex-direction: column; }" +
+ ".panel-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid #e4e4e7; }" +
+ ".panel-head .title { font-size: 14px; font-weight: 700; color: #18181b; margin-right: auto; }" +
+ ".icon-btn { border: none; background: none; color: #71717a; font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px; }" +
+ ".icon-btn:hover { background: #f4f4f5; color: #27272a; }" +
+ ".btn { border: 1px solid #d4d4d8; background: #fff; color: #27272a; border-radius: 7px; padding: 5px 10px;" +
+ " font-size: 12px; font-weight: 600; cursor: pointer; }" +
+ ".btn:hover { background: #fafafa; }" +
+ ".btn.primary { background: #4f46e5; border-color: #4f46e5; color: #fff; }" +
+ ".btn.primary:hover { background: #4338ca; }" +
+ ".btn.danger { color: #b91c1c; border-color: #fca5a5; }" +
+ ".btn.danger:hover { background: #fef2f2; }" +
+ ".panel-tools { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid #f4f4f5; }" +
+ ".toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: #52525b; cursor: pointer; user-select: none; }" +
+ ".list { overflow-y: auto; flex: 1; padding: 8px 10px 16px; }" +
+ ".empty { color: #71717a; font-size: 13px; text-align: center; padding: 26px 14px; line-height: 1.5; }" +
+ ".card { border: 1px solid #e4e4e7; border-radius: 10px; padding: 10px 12px; margin: 8px 2px; background: #fff; }" +
+ ".card.active { border-color: #4f46e5; box-shadow: 0 0 0 2px rgba(79,70,229,.15); }" +
+ ".card.resolved { background: #fafafa; }" +
+ ".card.resolved .body { color: #71717a; }" +
+ ".card-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }" +
+ ".author { font-size: 12px; font-weight: 700; color: #18181b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 170px; }" +
+ ".time { font-size: 11px; color: #a1a1aa; margin-left: auto; flex-shrink: 0; }" +
+ ".resolved-tag { font-size: 10px; font-weight: 700; color: #15803d; background: #f0fdf4; border-radius: 5px; padding: 1px 6px; }" +
+ ".body { font-size: 13px; color: #27272a; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }" +
+ ".actions { display: flex; gap: 6px; margin-top: 9px; flex-wrap: wrap; }" +
+ ".actions .btn { padding: 3px 8px; font-size: 11px; }" +
+ "textarea { width: 100%; min-height: 64px; border: 1px solid #d4d4d8; border-radius: 8px; padding: 8px 10px;" +
+ " font-size: 13px; color: #18181b; resize: vertical; outline: none; background: #fff; }" +
+ "textarea:focus { border-color: #4f46e5; }" +
+ ".pin { position: absolute; width: 26px; height: 26px; margin: -13px 0 0 -13px; border-radius: 50% 50% 50% 4px;" +
+ " background: #4f46e5; color: #fff; border: 2px solid #fff; box-shadow: 0 1px 6px rgba(0,0,0,.3); cursor: pointer;" +
+ " display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; z-index: 1; }" +
+ ".pin.resolved { background: #a1a1aa; }" +
+ ".pin.active { transform: scale(1.2); }" +
+ ".composer { position: absolute; width: 280px; background: #fff; border: 1px solid #d4d4d8; border-radius: 12px;" +
+ " box-shadow: 0 6px 24px rgba(0,0,0,.16); padding: 10px; z-index: 4; }" +
+ ".composer .actions { justify-content: flex-end; }" +
+ ".hint { position: fixed; top: 14px; left: 50%; transform: translateX(-50%); background: #18181b; color: #fff;" +
+ " font-size: 12px; font-weight: 600; border-radius: 999px; padding: 7px 14px; z-index: 5; box-shadow: 0 2px 10px rgba(0,0,0,.25); }";
+
+ var pinsLayer = document.createElement("div");
+ var ui = document.createElement("div");
+ root.appendChild(style);
+ root.appendChild(pinsLayer);
+ root.appendChild(ui);
+
+ // ------------------------------------------------------------------
+ // Rendering
+ // ------------------------------------------------------------------
+ function esc(text) {
+ var div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ function relativeTime(iso) {
+ var then = new Date(iso).getTime();
+ if (isNaN(then)) return "";
+ var minutes = Math.round((Date.now() - then) / 60000);
+ if (minutes < 1) return "just now";
+ if (minutes < 60) return minutes + "m ago";
+ if (minutes < 60 * 24) return Math.round(minutes / 60) + "h ago";
+ if (minutes < 60 * 24 * 14) return Math.round(minutes / (60 * 24)) + "d ago";
+ return new Date(iso).toLocaleDateString();
+ }
+
+ function visibleComments() {
+ return state.comments.filter(function (comment) {
+ return state.showResolved || !comment.resolved;
+ });
+ }
+
+ function canEdit(comment) {
+ if (!state.viewer) return false;
+ return state.viewer.canModerate ||
+ comment.author.email.toLowerCase() === state.viewer.email.toLowerCase();
+ }
+
+ function render() {
+ renderPins();
+ renderUi();
+ }
+
+ function renderPins() {
+ pinsLayer.textContent = "";
+ var visible = visibleComments();
+ for (var i = 0; i < visible.length; i++) {
+ (function (comment, index) {
+ var position = anchorPosition(comment.anchors);
+ if (!position) return;
+ var pin = document.createElement("div");
+ pin.className = "pin" + (comment.resolved ? " resolved" : "") + (state.active === comment.id ? " active" : "");
+ pin.style.left = position.x + "px";
+ pin.style.top = position.y + "px";
+ pin.textContent = String(index + 1);
+ pin.title = comment.author.email;
+ pin.addEventListener("click", function (event) {
+ event.stopPropagation();
+ state.open = true;
+ state.active = comment.id;
+ render();
+ var card = ui.querySelector('[data-comment="' + comment.id + '"]');
+ if (card) card.scrollIntoView({ block: "nearest" });
+ });
+ pinsLayer.appendChild(pin);
+ })(visible[i], i);
+ }
+ }
+
+ function renderUi() {
+ ui.textContent = "";
+ ui.appendChild(renderFab());
+ if (state.adding) ui.appendChild(renderHint());
+ if (state.open) ui.appendChild(renderPanel());
+ if (state.pending) ui.appendChild(renderComposer());
+ }
+
+ function renderFab() {
+ var openCount = state.comments.filter(function (comment) { return !comment.resolved; }).length;
+ var fab = document.createElement("button");
+ fab.className = "fab";
+ fab.type = "button";
+ fab.innerHTML = "💬 Comments " + openCount + "";
+ fab.addEventListener("click", function () {
+ state.open = !state.open;
+ if (!state.open) state.active = null;
+ render();
+ });
+ return fab;
+ }
+
+ function renderHint() {
+ var hint = document.createElement("div");
+ hint.className = "hint";
+ hint.textContent = "Click anywhere to leave a comment - Esc to cancel";
+ return hint;
+ }
+
+ function renderPanel() {
+ var panel = document.createElement("div");
+ panel.className = "panel";
+
+ var head = document.createElement("div");
+ head.className = "panel-head";
+ head.innerHTML = "Comments";
+ var close = document.createElement("button");
+ close.className = "icon-btn";
+ close.type = "button";
+ close.innerHTML = "✕";
+ close.title = "Close";
+ close.addEventListener("click", function () {
+ state.open = false;
+ state.active = null;
+ render();
+ });
+ head.appendChild(close);
+ panel.appendChild(head);
+
+ var tools = document.createElement("div");
+ tools.className = "panel-tools";
+ var add = document.createElement("button");
+ add.className = "btn primary";
+ add.type = "button";
+ add.textContent = "+ Add comment";
+ add.addEventListener("click", startAdding);
+ tools.appendChild(add);
+ var toggle = document.createElement("label");
+ toggle.className = "toggle";
+ var checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.checked = state.showResolved;
+ checkbox.addEventListener("change", function () {
+ state.showResolved = checkbox.checked;
+ render();
+ });
+ toggle.appendChild(checkbox);
+ toggle.appendChild(document.createTextNode("Show resolved"));
+ tools.appendChild(toggle);
+ panel.appendChild(tools);
+
+ var list = document.createElement("div");
+ list.className = "list";
+ var visible = visibleComments();
+ if (visible.length === 0) {
+ var empty = document.createElement("div");
+ empty.className = "empty";
+ empty.textContent = state.comments.length === 0
+ ? "No comments on this page yet. Click “+ Add comment”, then click anywhere on the page."
+ : "All comments on this page are resolved.";
+ list.appendChild(empty);
+ }
+ for (var i = 0; i < visible.length; i++) {
+ list.appendChild(renderCard(visible[i], i));
+ }
+ panel.appendChild(list);
+ return panel;
+ }
+
+ function renderCard(comment, index) {
+ var card = document.createElement("div");
+ card.className = "card" + (comment.resolved ? " resolved" : "") + (state.active === comment.id ? " active" : "");
+ card.setAttribute("data-comment", comment.id);
+
+ var head = document.createElement("div");
+ head.className = "card-head";
+ head.innerHTML =
+ "" + (index + 1) + ". " + esc(comment.author.email) + "" +
+ (comment.resolved ? "resolved" : "") +
+ "" + esc(relativeTime(comment.createdAt)) + "";
+ card.appendChild(head);
+
+ if (state.editing === comment.id) {
+ var textarea = document.createElement("textarea");
+ textarea.value = comment.body;
+ card.appendChild(textarea);
+ card.appendChild(actionRow([
+ button("Save", "btn primary", function () {
+ var body = textarea.value.trim();
+ if (!body) return;
+ state.editing = null;
+ update(comment, { body: body });
+ }),
+ button("Cancel", "btn", function () {
+ state.editing = null;
+ render();
+ }),
+ ]));
+ return card;
+ }
+
+ var body = document.createElement("div");
+ body.className = "body";
+ body.textContent = comment.body;
+ card.appendChild(body);
+
+ var actions = [];
+ actions.push(button(comment.resolved ? "Reopen" : "Resolve", "btn", function () {
+ update(comment, { resolved: !comment.resolved });
+ }));
+ if (canEdit(comment)) {
+ actions.push(button("Edit", "btn", function () {
+ state.editing = comment.id;
+ render();
+ }));
+ if (state.confirmingDelete === comment.id) {
+ actions.push(button("Really delete?", "btn danger", function () {
+ state.confirmingDelete = null;
+ remove(comment);
+ }));
+ } else {
+ actions.push(button("Delete", "btn danger", function () {
+ state.confirmingDelete = comment.id;
+ render();
+ }));
+ }
+ }
+ card.appendChild(actionRow(actions));
+
+ card.addEventListener("click", function () {
+ if (state.active === comment.id) return;
+ state.active = comment.id;
+ render();
+ var position = anchorPosition(comment.anchors);
+ if (position) {
+ window.scrollTo({
+ top: Math.max(position.y - window.innerHeight / 2, 0),
+ behavior: "smooth",
+ });
+ }
+ });
+ return card;
+ }
+
+ function button(label, className, onClick) {
+ var el = document.createElement("button");
+ el.className = className;
+ el.type = "button";
+ el.textContent = label;
+ el.addEventListener("click", function (event) {
+ event.stopPropagation();
+ onClick();
+ });
+ return el;
+ }
+
+ function actionRow(buttons) {
+ var row = document.createElement("div");
+ row.className = "actions";
+ for (var i = 0; i < buttons.length; i++) row.appendChild(buttons[i]);
+ return row;
+ }
+
+ function renderComposer() {
+ var composer = document.createElement("div");
+ composer.className = "composer";
+ var left = Math.min(state.pending.pageX + 12, window.scrollX + window.innerWidth - 300);
+ composer.style.left = Math.max(left, window.scrollX + 8) + "px";
+ composer.style.top = (state.pending.pageY + 12) + "px";
+
+ var textarea = document.createElement("textarea");
+ textarea.placeholder = "Leave a comment…";
+ composer.appendChild(textarea);
+ composer.appendChild(actionRow([
+ button("Comment", "btn primary", function () {
+ var body = textarea.value.trim();
+ if (!body) return;
+ create(body);
+ }),
+ button("Cancel", "btn", function () {
+ state.pending = null;
+ render();
+ }),
+ ]));
+ setTimeout(function () { textarea.focus(); }, 0);
+ return composer;
+ }
+
+ // ------------------------------------------------------------------
+ // Add-comment mode
+ // ------------------------------------------------------------------
+ function startAdding() {
+ if (state.adding) return;
+ state.adding = true;
+ state.pending = null;
+ document.documentElement.classList.add("--sw-commenting");
+ document.addEventListener("click", captureClick, true);
+ document.addEventListener("keydown", captureEscape, true);
+ render();
+ }
+
+ function stopAdding() {
+ state.adding = false;
+ document.documentElement.classList.remove("--sw-commenting");
+ document.removeEventListener("click", captureClick, true);
+ document.removeEventListener("keydown", captureEscape, true);
+ }
+
+ function captureClick(event) {
+ if (event.composedPath().indexOf(host) !== -1) return; // widget clicks pass through
+ event.preventDefault();
+ event.stopPropagation();
+ stopAdding();
+ var target = event.target && event.target.nodeType === 1 ? event.target : document.body;
+ state.pending = {
+ anchors: computeAnchors(target, event.pageX, event.pageY),
+ pageX: event.pageX,
+ pageY: event.pageY,
+ };
+ render();
+ }
+
+ function captureEscape(event) {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ stopAdding();
+ render();
+ }
+
+ // ------------------------------------------------------------------
+ // Mutations
+ // ------------------------------------------------------------------
+ function fail(error) {
+ console.error("[scratchwork comments]", error);
+ var hint = document.createElement("div");
+ hint.className = "hint";
+ hint.style.background = "#b91c1c";
+ hint.textContent = String(error && error.message || error);
+ ui.appendChild(hint);
+ setTimeout(function () { hint.remove(); }, 4000);
+ }
+
+ function create(body) {
+ var pending = state.pending;
+ if (!pending) return;
+ api("POST", "", { page: PAGE, body: body, anchors: pending.anchors }).then(function (data) {
+ state.pending = null;
+ state.viewer = data.viewer;
+ state.comments.push(data.comment);
+ state.open = true;
+ state.active = data.comment.id;
+ render();
+ }).catch(fail);
+ }
+
+ function update(comment, patch) {
+ var body = { page: PAGE };
+ if (patch.body != null) body.body = patch.body;
+ if (patch.resolved != null) body.resolved = patch.resolved;
+ api("PATCH", "/" + comment.id, body).then(function (data) {
+ state.viewer = data.viewer;
+ state.comments = state.comments.map(function (existing) {
+ return existing.id === comment.id ? data.comment : existing;
+ });
+ render();
+ }).catch(fail);
+ }
+
+ function remove(comment) {
+ api("DELETE", "/" + comment.id + pageQuery()).then(function () {
+ state.comments = state.comments.filter(function (existing) { return existing.id !== comment.id; });
+ if (state.active === comment.id) state.active = null;
+ render();
+ }).catch(fail);
+ }
+
+ // ------------------------------------------------------------------
+ // Boot
+ // ------------------------------------------------------------------
+ var repositionTimer = null;
+ function scheduleReposition() {
+ if (repositionTimer != null) return;
+ repositionTimer = setTimeout(function () {
+ repositionTimer = null;
+ renderPins();
+ }, 200);
+ }
+
+ function boot() {
+ document.body.appendChild(host);
+ document.head.appendChild(globalStyle);
+ api("GET", pageQuery()).then(function (data) {
+ state.viewer = data.viewer;
+ state.comments = data.comments.slice();
+ render();
+ window.addEventListener("resize", scheduleReposition);
+ // Rendered-Markdown pages build their DOM after load; watch for layout
+ // changes so pins track their anchors instead of going stale.
+ var observer = new MutationObserver(function (mutations) {
+ for (var i = 0; i < mutations.length; i++) {
+ if (!host.contains(mutations[i].target)) {
+ scheduleReposition();
+ return;
+ }
+ }
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ }).catch(function (error) {
+ console.warn("[scratchwork comments] disabled:", error && error.message || error);
+ });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", boot);
+ } else {
+ boot();
+ }
+})();
diff --git a/server/core/src/api-routes.ts b/server/core/src/api-routes.ts
index 2cf5f53..56c7019 100644
--- a/server/core/src/api-routes.ts
+++ b/server/core/src/api-routes.ts
@@ -476,8 +476,10 @@ function runRoute(
/** Reads, size-limits, parses, and strictly decodes one request body through
* the contract payload schema. Decoding is deliberately strict — every
* problem is reported and unknown fields are errors — so protocol drift
- * surfaces as a clear 400 instead of being silently dropped. */
-function readEndpointPayload(
+ * surfaces as a clear 400 instead of being silently dropped. Also used by the
+ * content-origin comments routes, so every JSON body the server accepts is
+ * read through the same gate. */
+export function readEndpointPayload(
request: HttpServerRequest.HttpServerRequest,
schema: Schema.Schema.Any,
limit: PayloadLimit,
@@ -556,6 +558,7 @@ export function projectSummary(record: SiteRecord, contentBase: string, callerRo
return {
project: record.project,
isPublic: record.isPublic,
+ commentsEnabled: record.commentsEnabled,
...permissions,
url: projectUrl(record.project, contentBase, config),
owner: record.owner,
diff --git a/server/core/src/app.ts b/server/core/src/app.ts
index 6bd2774..40b1d2a 100644
--- a/server/core/src/app.ts
+++ b/server/core/src/app.ts
@@ -21,7 +21,10 @@ import { dispatchApiRoute, requireReadableSite } from "./api-routes.ts";
import { Auth, AuthError, type AuthShape, type AuthUser } from "./auth.ts";
import { ServerConfig, type ServerConfigShape } from "./config.ts";
import { PrimitiveDb } from "./db.ts";
-import { projectAccessCookie, projectAccessCookieValues } from "./cookies.ts";
+import type { CommentStoreError } from "./comment-store.ts";
+import { dispatchCommentsRoute, injectCommentsWidget } from "./comments-routes.ts";
+import { projectAccessCookie } from "./cookies.ts";
+import { blockedCrossProjectSubresource, isSubresourceRequest, projectAccessUser } from "./project-access.ts";
import { acceptsHtmlPage, errorPageResponse, errorResponse } from "./error-pages.ts";
import {
appBaseUrl,
@@ -29,6 +32,7 @@ import {
homepageBaseUrl,
HttpError,
requestBaseUrl,
+ requestHomepageOrigin,
sameOrigin,
securityHeaders,
} from "./http.ts";
@@ -48,7 +52,7 @@ const HANDOFF_PARAM = "_scratchwork_handoff";
/** Return type shared by every request handler in this file. */
type AppEffect = Effect.Effect<
HttpServerResponse.HttpServerResponse,
- HttpError | AuthError | SiteStoreError | StorageError,
+ HttpError | AuthError | SiteStoreError | StorageError | CommentStoreError,
ServerConfig | SiteStore | Auth | PrimitiveDb
>;
@@ -61,6 +65,7 @@ export const app: HttpApp.Default Effect.succeed(errorResponse(request, error.status, error.message)),
AuthError: (error) => Effect.succeed(errorResponse(request, error.status, error.message)),
SiteStoreError: (error) => Effect.succeed(errorResponse(request, error.status, error.message)),
+ CommentStoreError: (error) => Effect.succeed(errorResponse(request, error.status, error.message)),
StorageError: (error) => Effect.succeed(errorResponse(request, 500, error.message)),
}),
);
@@ -100,6 +105,10 @@ function handleRequest(request: HttpServerRequest.HttpServerRequest): AppEffect
const apiResponse = yield* dispatchApiRoute(request, url);
if (apiResponse != null) return apiResponse;
+ // Comments routes accept mutations, so they dispatch before the GET/HEAD gate.
+ const commentsResponse = yield* dispatchCommentsRoute(request, url);
+ if (commentsResponse != null) return commentsResponse;
+
if (request.method !== "GET" && request.method !== "HEAD") {
return yield* Effect.fail(new HttpError({ status: 405, message: "Method not allowed" }));
}
@@ -268,26 +277,6 @@ function redeemHandoffToken(
});
}
-/** Verifies the request's project-access cookies and current read access, if any. */
-function projectAccessUser(
- request: HttpServerRequest.HttpServerRequest,
- auth: AuthShape,
- site: LoadedSite,
- config: ServerConfigShape,
-): Effect.Effect {
- return Effect.gen(function* () {
- for (const value of projectAccessCookieValues(request, site.record.project)) {
- const user = yield* auth
- .verifyProjectAccessToken(value, site.record.project, "cookie")
- .pipe(Effect.orElseSucceed(() => null));
- // Re-check read access on every request so revocation applies immediately even
- // though the cookie itself is long-lived.
- if (user != null && canReadProject(site.record, user, config)) return user;
- }
- return null;
- });
-}
-
/** Serves the resolved project's file for the request path, or 308-redirects to the
* trailing-slash canonical root when the path has no remainder under the route. */
function serveProjectContent(
@@ -302,7 +291,10 @@ function serveProjectContent(
return serveSiteFiles(site, rest, url.search, `/${site.record.project}`, isPublic);
}
-/** Serves one file from a loaded site under the given canonical path prefix. */
+/** Serves one file from a loaded site under the given canonical path prefix.
+ * Private comments-enabled projects get the comments widget injected into
+ * every HTML response (homepage serving passes an empty prefix and never
+ * injects — comments are a content-origin feature). */
function serveSiteFiles(
site: LoadedSite,
rest: string,
@@ -310,10 +302,12 @@ function serveSiteFiles(
pathPrefix: string,
isPublic: boolean,
): Effect.Effect {
+ const withComments = !isPublic && site.record.commentsEnabled && pathPrefix !== "";
return servePath(rest, search, {
cacheControl: () => NO_STORE,
defaultFaviconSvg: FIGURE_SVG,
headers: () => publishedSiteHeaders(isPublic),
+ htmlTransforms: withComments ? [injectCommentsWidget(pathPrefix)] : [],
pathPrefix,
rendererFallback: Effect.succeed(defaultRendererHtml),
}).pipe(
@@ -348,32 +342,6 @@ function canonicalContentPath(url: URL, project: string): string {
return `/${project}${rest}${search === "" ? "" : `?${search}`}`;
}
-/** Detects browser subresource loads (fetch/img/script/frame/...) via Sec-Fetch-Dest.
- * Requests without the header (non-browsers, old browsers) count as navigations. */
-function isSubresourceRequest(request: HttpServerRequest.HttpServerRequest): boolean {
- const dest = request.headers["sec-fetch-dest"]?.toLowerCase();
- return dest != null && dest !== "document";
-}
-
-/** Returns true for a private-content subresource request whose initiating page is outside
- * this project. The Referer is script-unforgeable, and content responses set
- * `Referrer-Policy: same-origin`, so in-project pages always send a usable full path while
- * another project's page sends its own path (or nothing, if it strips the referrer) and is
- * refused. Top-level navigations are never blocked. */
-function blockedCrossProjectSubresource(
- request: HttpServerRequest.HttpServerRequest,
- project: string,
-): boolean {
- if (!isSubresourceRequest(request)) return false;
- const referer = request.headers.referer;
- if (referer == null) return true;
- try {
- return routeRest(new URL(referer).pathname, project) == null;
- } catch {
- return true;
- }
-}
-
/** Redirects an unauthenticated private-content viewer to the app host's handoff route. */
function projectAccessRedirect(
request: HttpServerRequest.HttpServerRequest,
@@ -441,18 +409,6 @@ function publishedSiteHeaders(isPublic: boolean): Record {
// on every host and the matching homepage files are unreachable.
// ---------------------------------------------------------------------------
-/** Matches the request's origin against the configured homepage origins; null when the
- * server has no homepage or the request is for another host. */
-function requestHomepageOrigin(
- request: HttpServerRequest.HttpServerRequest,
- config: ServerConfigShape,
-): string | null {
- if (config.homepageProject == null || config.homepageUrls.length === 0) return null;
- const requestBase = requestBaseUrl(request);
- if (requestBase == null) return null;
- return config.homepageUrls.find((url) => sameOrigin(url, requestBase)) ?? null;
-}
-
/** Serves the homepage project on a home origin. Non-canonical home origins 308 to the
* canonical one; an unpublished homepage answers with setup instructions instead. */
function serveHomepage(
diff --git a/server/core/src/comment-records.ts b/server/core/src/comment-records.ts
new file mode 100644
index 0000000..feb5ee8
--- /dev/null
+++ b/server/core/src/comment-records.ts
@@ -0,0 +1,116 @@
+/**
+ * The persisted data model for viewer comments on published pages. Comments are
+ * mutable, listable, per-page state, so they live in one PrimitiveDb namespace
+ * keyed `{project}/{encodedPage}/{commentId}`: a prefix listing yields one
+ * page's comments, and comment ids are creation-time prefixed so key order is
+ * creation order. There is no object-storage component — bodies are small and
+ * capped. Comments exist only on private projects (publish rejects the
+ * comments+public combination), so every commenter identity comes from the
+ * project-access cookie flow.
+ */
+import * as Schema from "effect/Schema";
+import { isSafeProjectIdentifier } from "./access.ts";
+
+/** DB namespace of comment records, keyed `{project}/{encodedPage}/{commentId}`. */
+export const COMMENTS_NAMESPACE = "comments";
+
+/** Maximum characters in one comment body. */
+export const MAX_COMMENT_BODY_CHARS = 5_000;
+/** Maximum anchor candidates one comment may carry. */
+export const MAX_COMMENT_ANCHORS = 8;
+/** Maximum characters in one anchor selector. */
+export const MAX_COMMENT_SELECTOR_CHARS = 400;
+/** Maximum characters in a normalized page path. */
+export const MAX_COMMENT_PAGE_CHARS = 256;
+
+/**
+ * One anchor candidate: a CSS selector plus an offset (CSS pixels) from the
+ * matched element's top-left corner. Candidates are ordered most specific
+ * first — the clicked element, then its ancestors — and always end with a
+ * body-relative fallback, so the widget renders at the first selector that
+ * still matches and a republished page degrades gracefully instead of losing
+ * the comment.
+ */
+const CommentAnchorSchema = Schema.Struct({
+ selector: Schema.String.pipe(
+ Schema.minLength(1),
+ Schema.maxLength(MAX_COMMENT_SELECTOR_CHARS),
+ ),
+ x: Schema.Number.pipe(Schema.finite()),
+ y: Schema.Number.pipe(Schema.finite()),
+});
+
+/** Validates one stored (or incoming) comment. The `version` literal gates
+ * format migrations, like the site-record schemas. */
+const CommentRecordSchema = Schema.Struct({
+ version: Schema.Literal(1),
+ id: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(64)),
+ project: Schema.String.pipe(Schema.filter((value) => isSafeProjectIdentifier(value) || "Invalid project")),
+ page: Schema.String.pipe(Schema.filter((value) => isSafeCommentPage(value) || "Invalid page path")),
+ author: Schema.Struct({ email: Schema.String }),
+ body: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(MAX_COMMENT_BODY_CHARS)),
+ anchors: Schema.Array(CommentAnchorSchema).pipe(Schema.minItems(1), Schema.maxItems(MAX_COMMENT_ANCHORS)),
+ resolved: Schema.Boolean,
+ resolvedBy: Schema.optional(Schema.String),
+ createdAt: Schema.String,
+ updatedAt: Schema.String,
+});
+
+export { CommentAnchorSchema, CommentRecordSchema };
+
+/** One anchor candidate as stored and sent over the wire. */
+export type CommentAnchor = typeof CommentAnchorSchema.Type;
+/** One stored comment. */
+export type CommentRecord = typeof CommentRecordSchema.Type;
+
+/** Builds the DB key of one comment. */
+export function commentKey(project: string, page: string, id: string): string {
+ return `${pageCommentsPrefix(project, page)}${id}`;
+}
+
+/** Builds the key prefix that lists every comment on one page. */
+export function pageCommentsPrefix(project: string, page: string): string {
+ return `${project}/${encodeKeySegment(page)}/`;
+}
+
+/** Builds the key prefix that lists every comment of one project. */
+export function projectCommentsPrefix(project: string): string {
+ return `${project}/`;
+}
+
+/**
+ * Normalizes a viewer-supplied page path into the canonical comment page key:
+ * percent-decoded, single slashes, no trailing "index.html", no trailing slash
+ * (except the root "/"). Returns null for anything unsafe. The widget applies
+ * the same normalization client-side so pins land on the page they were left on
+ * regardless of how the URL was spelled.
+ */
+export function normalizeCommentPage(value: string): string | null {
+ if (typeof value !== "string" || value === "" || value.length > MAX_COMMENT_PAGE_CHARS) return null;
+ if (!value.startsWith("/") || value.includes("\0") || value.includes("\\") || value.includes("?") || value.includes("#")) return null;
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(value);
+ } catch {
+ return null;
+ }
+ if (decoded.includes("\0") || decoded.includes("\\") || decoded.includes("?") || decoded.includes("#")) return null;
+ let normalized = decoded.replace(/\/+/g, "/");
+ if (normalized.split("/").some((segment) => segment === "." || segment === "..")) return null;
+ if (normalized.endsWith("/index.html")) normalized = normalized.slice(0, -"index.html".length);
+ if (normalized.length > 1 && normalized.endsWith("/")) normalized = normalized.slice(0, -1);
+ if (normalized === "") normalized = "/";
+ return normalized.length > MAX_COMMENT_PAGE_CHARS ? null : normalized;
+}
+
+/** Returns true when a string is already a normalized comment page path. */
+export function isSafeCommentPage(value: string): boolean {
+ return normalizeCommentPage(value) === value;
+}
+
+/** Percent-encodes a page path into one key segment, additionally escaping "."
+ * (which encodeURIComponent leaves bare) so a page can never form a "." or
+ * ".." key segment. Mirrors the owner-index encoding in site-records.ts. */
+function encodeKeySegment(value: string): string {
+ return encodeURIComponent(value).replace(/\./g, "%2E");
+}
diff --git a/server/core/src/comment-store.ts b/server/core/src/comment-store.ts
new file mode 100644
index 0000000..4596064
--- /dev/null
+++ b/server/core/src/comment-store.ts
@@ -0,0 +1,174 @@
+/**
+ * Storage operations for viewer comments, as plain functions over PrimitiveDb
+ * (no service tag: every caller — the comments routes and the site store's
+ * delete purge — already holds the PrimitiveDb service). Pure storage only;
+ * who may create, edit, resolve, or delete a comment is decided by the
+ * comments routes, which hold the viewer's project role. See
+ * comment-records.ts for the persisted data model.
+ */
+import * as Data from "effect/Data";
+import * as Effect from "effect/Effect";
+import * as ParseResult from "effect/ParseResult";
+import * as Schema from "effect/Schema";
+import {
+ COMMENTS_NAMESPACE,
+ commentKey,
+ CommentRecordSchema,
+ pageCommentsPrefix,
+ projectCommentsPrefix,
+ type CommentRecord,
+} from "./comment-records.ts";
+import {
+ PrimitiveDbConflict,
+ type JsonValue,
+ type PrimitiveDbError,
+ type PrimitiveDbRecord,
+ type PrimitiveDbShape,
+} from "./db.ts";
+
+/** How large a comment key may grow; PrimitiveDb rejects longer keys with a 500,
+ * so the store fails 400 first. */
+const MAX_COMMENT_KEY_CHARS = 1024;
+
+/** Comment-store failure; `status` becomes the HTTP response status. */
+export class CommentStoreError extends Data.TaggedError("CommentStoreError")<{
+ readonly status: number;
+ readonly message: string;
+ readonly cause?: unknown;
+}> {}
+
+/** A decoded comment plus the version metadata needed for conditional writes. */
+export interface LoadedComment {
+ readonly value: CommentRecord;
+ readonly version: number;
+}
+
+/** Lists every comment on one page, oldest first (comment ids are
+ * creation-time prefixed, and listing is UTF-8 key order). */
+export function listPageComments(
+ db: PrimitiveDbShape,
+ project: string,
+ page: string,
+): Effect.Effect, CommentStoreError> {
+ return listCommentRecords(db, pageCommentsPrefix(project, page)).pipe(
+ Effect.flatMap((records) => Effect.forEach(records, (record) => decodeComment(record))),
+ Effect.map((loaded) => loaded.map((comment) => comment.value)),
+ );
+}
+
+/** Writes a new comment; the create-only precondition makes id collisions
+ * (vanishingly unlikely) an explicit conflict instead of an overwrite. */
+export function createComment(
+ db: PrimitiveDbShape,
+ record: CommentRecord,
+): Effect.Effect {
+ return Effect.gen(function* () {
+ const key = commentKey(record.project, record.page, record.id);
+ if (key.length > MAX_COMMENT_KEY_CHARS) {
+ return yield* Effect.fail(new CommentStoreError({ status: 400, message: "Page path is too long for comments" }));
+ }
+ const written = yield* db.put(COMMENTS_NAMESPACE, key, record, { ifNoneMatch: "*" }).pipe(
+ Effect.mapError(dbError),
+ Effect.flatMap(decodeComment),
+ );
+ return written.value;
+ });
+}
+
+/** Loads one comment with its write-precondition version, or null when absent. */
+export function loadComment(
+ db: PrimitiveDbShape,
+ project: string,
+ page: string,
+ id: string,
+): Effect.Effect {
+ return db.get(COMMENTS_NAMESPACE, commentKey(project, page, id)).pipe(
+ Effect.mapError(dbError),
+ Effect.flatMap((record) => record == null ? Effect.succeed(null) : decodeComment(record)),
+ );
+}
+
+/** Rewrites one comment under its loaded version; a lost race is a 409. */
+export function putComment(
+ db: PrimitiveDbShape,
+ record: CommentRecord,
+ ifMatch: number,
+): Effect.Effect {
+ return db.put(COMMENTS_NAMESPACE, commentKey(record.project, record.page, record.id), record, { ifMatch }).pipe(
+ Effect.mapError(dbError),
+ Effect.flatMap(decodeComment),
+ Effect.map((loaded) => loaded.value),
+ );
+}
+
+/** Deletes one comment; deleting an already-gone comment is a no-op. */
+export function deleteComment(
+ db: PrimitiveDbShape,
+ project: string,
+ page: string,
+ id: string,
+): Effect.Effect {
+ return db.delete(COMMENTS_NAMESPACE, commentKey(project, page, id)).pipe(Effect.mapError(dbError));
+}
+
+/** Deletes every comment of one project. Runs before the project pointer is
+ * released so a name reclaimed by a different owner can never inherit the
+ * previous project's comments; a failure here fails (and thereby retries)
+ * the whole project deletion. */
+export function purgeProjectComments(
+ db: PrimitiveDbShape,
+ project: string,
+): Effect.Effect {
+ return Effect.gen(function* () {
+ const prefix = projectCommentsPrefix(project);
+ while (true) {
+ const page = yield* db.list(COMMENTS_NAMESPACE, { prefix, limit: 1000 }).pipe(Effect.mapError(dbError));
+ for (const record of page.records) {
+ yield* db.delete(COMMENTS_NAMESPACE, record.key).pipe(Effect.mapError(dbError));
+ }
+ // Re-list from the start rather than paging: every listed key was just
+ // deleted, so a cursor would skip past keys written concurrently.
+ if (page.records.length === 0) return;
+ }
+ });
+}
+
+/** Drains every list page under a key prefix. */
+function listCommentRecords(
+ db: PrimitiveDbShape,
+ prefix: string,
+): Effect.Effect>, CommentStoreError> {
+ return Effect.gen(function* () {
+ const records: Array> = [];
+ let startAfter: string | undefined;
+ do {
+ const page = yield* db.list(COMMENTS_NAMESPACE, { prefix, startAfter, limit: 1000 }).pipe(
+ Effect.mapError(dbError),
+ );
+ records.push(...page.records);
+ startAfter = page.cursor;
+ } while (startAfter != null);
+ return records;
+ });
+}
+
+/** Decodes one stored comment, keeping the write-precondition version. */
+function decodeComment(record: PrimitiveDbRecord): Effect.Effect {
+ return Schema.decodeUnknown(CommentRecordSchema)(record.value, { errors: "all" }).pipe(
+ Effect.mapError((error) =>
+ new CommentStoreError({
+ status: 500,
+ message: `Invalid stored comment ${record.namespace}/${record.key}: ${ParseResult.TreeFormatter.formatErrorSync(error)}`,
+ }),
+ ),
+ Effect.map((value) => ({ value, version: record.version })),
+ );
+}
+
+/** Maps primitive-DB failures onto comment-store errors (409 for conflicts, 500 otherwise). */
+function dbError(error: PrimitiveDbError | PrimitiveDbConflict): CommentStoreError {
+ if (error instanceof PrimitiveDbConflict) {
+ return new CommentStoreError({ status: 409, message: error.message, cause: error });
+ }
+ return new CommentStoreError({ status: 500, message: error.message, cause: error });
+}
diff --git a/server/core/src/comments-routes.ts b/server/core/src/comments-routes.ts
new file mode 100644
index 0000000..5ef73a5
--- /dev/null
+++ b/server/core/src/comments-routes.ts
@@ -0,0 +1,479 @@
+/**
+ * The content-origin comments API: the per-project routes the injected comments
+ * widget calls, served under `/:project/__scratchwork/comments` on the content
+ * host. These routes are deliberately NOT part of the shared CLI contract —
+ * the CLI never calls them — but they follow the same route-policy discipline
+ * as the app-host JSON API (AGENTS.md, invariant 4): every route is registered
+ * once in COMMENTS_ROUTES with its method, minimum project role, and mutation
+ * flag; dispatch walks only that registry; and the policy matrix test
+ * enumerates it. The gates run in fixed order for every route:
+ *
+ * - the whole `__scratchwork/` prefix under a project is server-owned
+ * (publishing files into it is rejected), and everything under it that is
+ * not a registered comments route is 404;
+ * - a missing project, a public project, and a comments-disabled project all
+ * read as the same 404, so these routes reveal nothing servePublishedSite
+ * would not;
+ * - every request rejects cross-origin browser calls against the content
+ * origin, and subresource requests must prove (via the script-unforgeable
+ * Referer, exactly like private-content serving) that the initiating page
+ * lives inside this project;
+ * - the caller authenticates with the project's path-scoped access cookie —
+ * the same credential that gated the page itself — which names the viewer
+ * and is re-checked against current read access on every request;
+ * - request bodies are size-capped and decoded strictly through the wire
+ * schemas below; responses are encoded through their declared schemas.
+ *
+ * Comments exist only on private projects (publish rejects comments+public),
+ * so every commenter is an authenticated, read-authorized viewer. Fine-grained
+ * rules on top of the "read" floor: anyone with read access may comment and
+ * resolve/unresolve; only the comment's author or a project writer+ may edit
+ * or delete it.
+ *
+ * Trust model note: these routes are same-origin with the project's own pages,
+ * so a project's published JavaScript can act on its viewers' behalf — but
+ * only inside that project's comment space (the cookie and the guards scope
+ * everything to the one project the viewer is already reading, whose authors
+ * they already trust). No other project's comments, content, or identity is
+ * reachable.
+ */
+import * as HttpServerRequest from "@effect/platform/HttpServerRequest";
+import * as HttpServerResponse from "@effect/platform/HttpServerResponse";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import type { HtmlTransform } from "@scratchwork/shared/site/html";
+import { readEndpointPayload } from "./api-routes.ts";
+import { Auth, type AuthUser } from "./auth.ts";
+import {
+ CommentAnchorSchema,
+ CommentRecordSchema,
+ MAX_COMMENT_BODY_CHARS,
+ normalizeCommentPage,
+ type CommentRecord,
+} from "./comment-records.ts";
+import {
+ createComment,
+ deleteComment,
+ listPageComments,
+ loadComment,
+ putComment,
+ type CommentStoreError,
+} from "./comment-store.ts";
+import { COMMENTS_WIDGET_JS } from "./comments-widget.generated.ts";
+import { ServerConfig, type ServerConfigShape } from "./config.ts";
+import { PrimitiveDb, type PrimitiveDbShape } from "./db.ts";
+import {
+ contentBaseUrl,
+ HttpError,
+ jsonResponse,
+ rejectCrossOriginApiRequest,
+ requestHomepageOrigin,
+ securityHeaders,
+} from "./http.ts";
+import { blockedCrossProjectSubresource, projectAccessUser } from "./project-access.ts";
+import { RESERVED_SITE_PREFIX } from "./publish-request.ts";
+import { projectForRequest, routeRest } from "./routes.ts";
+import { projectRole, roleAtLeast, SiteStore, type LoadedSite, type ProjectRole } from "./site-store.ts";
+import { randomCommentId } from "./tokens.ts";
+import type { AuthError } from "./auth.ts";
+import type { SiteStoreError } from "./site-store.ts";
+import type { StorageError } from "./storage.ts";
+
+/** The path under a project where every comments route lives. */
+const COMMENTS_REST_PREFIX = `/${RESERVED_SITE_PREFIX}/comments`;
+/** Generous ceiling for comment request bodies: one capped body plus anchors. */
+const MAX_COMMENTS_BODY_BYTES = 64 * 1024;
+
+// ---------------------------------------------------------------------------
+// Wire schemas
+// ---------------------------------------------------------------------------
+
+/** One comment as the widget sees it: the stored record minus its internal
+ * `version`/`project` fields. */
+const CommentWireSchema = Schema.Struct({
+ id: Schema.String,
+ page: Schema.String,
+ author: Schema.Struct({ email: Schema.String }),
+ body: Schema.String,
+ anchors: Schema.Array(CommentAnchorSchema),
+ resolved: Schema.Boolean,
+ resolvedBy: Schema.optional(Schema.String),
+ createdAt: Schema.String,
+ updatedAt: Schema.String,
+});
+
+/** What the list endpoint tells the widget about the viewer themselves:
+ * identity for authorship display, and whether they hold write access (may
+ * edit/delete everyone's comments). The role never appears in the page DOM. */
+const CommentsViewerSchema = Schema.Struct({
+ email: Schema.String,
+ canModerate: Schema.Boolean,
+});
+
+const CommentsListResponseSchema = Schema.Struct({
+ viewer: CommentsViewerSchema,
+ comments: Schema.Array(CommentWireSchema),
+});
+
+const CommentResponseSchema = Schema.Struct({
+ viewer: CommentsViewerSchema,
+ comment: CommentWireSchema,
+});
+
+const CommentDeleteResponseSchema = Schema.Struct({ ok: Schema.Boolean });
+
+/** The JSON body of a comment create: which page, the text, and the anchor
+ * candidates (most specific first, body-relative fallback last). */
+const CommentCreateRequestSchema = Schema.Struct({
+ page: Schema.String,
+ body: Schema.String.pipe(
+ Schema.minLength(1, { message: () => "Comment body must not be empty" }),
+ Schema.maxLength(MAX_COMMENT_BODY_CHARS, { message: () => "Comment body is too long" }),
+ ),
+ anchors: Schema.Array(CommentAnchorSchema).pipe(Schema.minItems(1), Schema.maxItems(8)),
+});
+
+/** The JSON body of a comment update: a body edit, a resolved flip, or both. */
+const CommentUpdateRequestSchema = Schema.Struct({
+ page: Schema.String,
+ body: Schema.optional(Schema.String.pipe(
+ Schema.minLength(1, { message: () => "Comment body must not be empty" }),
+ Schema.maxLength(MAX_COMMENT_BODY_CHARS, { message: () => "Comment body is too long" }),
+ )),
+ resolved: Schema.optional(Schema.Boolean),
+});
+
+// ---------------------------------------------------------------------------
+// The registry
+// ---------------------------------------------------------------------------
+
+/** Failures any comments handler may raise. */
+type CommentsRouteError = HttpError | AuthError | CommentStoreError | SiteStoreError | StorageError;
+
+/** What the policy gates hand every comments handler: the request, the loaded
+ * (private, comments-enabled) site, the cookie-authenticated viewer with their
+ * resolved role, the `:id` path segment for item routes, and the DB. */
+interface CommentsContext {
+ readonly request: HttpServerRequest.HttpServerRequest;
+ readonly url: URL;
+ readonly site: LoadedSite;
+ readonly user: AuthUser;
+ readonly role: ProjectRole;
+ readonly id: string | null;
+ readonly db: PrimitiveDbShape;
+}
+
+/** One registered comments route: subject shape, method, and policy. All
+ * routes require "read" (the cookie gate); rules above read are enforced by
+ * the handler and declared via `ownerOrWriter` so the matrix can assert them. */
+interface CommentsRoute {
+ readonly name: string;
+ readonly method: "GET" | "POST" | "PATCH" | "DELETE";
+ /** "collection" matches .../comments, "item" matches .../comments/{id},
+ * "widget" matches .../comments/widget.js. */
+ readonly subject: "collection" | "item" | "widget";
+ readonly mutation: boolean;
+ /** True when the route additionally requires being the comment's author or
+ * holding write access. */
+ readonly ownerOrWriter: boolean;
+ readonly handler: (
+ context: CommentsContext,
+ ) => Effect.Effect;
+}
+
+/** Every comments route, with its complete policy. The dispatcher walks only
+ * this list, and the comments policy matrix test enumerates it. */
+export const COMMENTS_ROUTES: ReadonlyArray = [
+ { name: "comments-widget", method: "GET", subject: "widget", mutation: false, ownerOrWriter: false, handler: serveWidget },
+ { name: "comments-list", method: "GET", subject: "collection", mutation: false, ownerOrWriter: false, handler: listComments },
+ { name: "comments-create", method: "POST", subject: "collection", mutation: true, ownerOrWriter: false, handler: postComment },
+ { name: "comments-update", method: "PATCH", subject: "item", mutation: true, ownerOrWriter: true, handler: patchComment },
+ { name: "comments-delete", method: "DELETE", subject: "item", mutation: true, ownerOrWriter: true, handler: removeComment },
+];
+
+// ---------------------------------------------------------------------------
+// Dispatch
+// ---------------------------------------------------------------------------
+
+/**
+ * Routes one request through the comments registry, or returns null when the
+ * path is not a `/:project/__scratchwork/...` content-host path. Runs before
+ * the app's GET/HEAD method gate so mutations reach it. Anything under the
+ * reserved prefix that matches no registered route is 404 — the prefix is
+ * server-owned in its entirety.
+ */
+export function dispatchCommentsRoute(
+ request: HttpServerRequest.HttpServerRequest,
+ url: URL,
+): Effect.Effect<
+ HttpServerResponse.HttpServerResponse | null,
+ CommentsRouteError,
+ ServerConfig | SiteStore | Auth | PrimitiveDb
+> {
+ return Effect.gen(function* () {
+ const config = yield* ServerConfig;
+ // On a home origin the whole path space belongs to the homepage project;
+ // comments are a content-origin feature.
+ if (requestHomepageOrigin(request, config) != null) return null;
+ const project = projectForRequest(url.pathname);
+ if (project == null) return null;
+ const rest = routeRest(url.pathname, project);
+ if (rest == null) return null;
+ if (rest !== `/${RESERVED_SITE_PREFIX}` && !rest.startsWith(`/${RESERVED_SITE_PREFIX}/`)) return null;
+
+ const subject = matchSubject(rest);
+ if (subject == null) {
+ return yield* Effect.fail(new HttpError({ status: 404, message: "Not found" }));
+ }
+
+ const siteStore = yield* SiteStore;
+ const site = yield* siteStore.loadProject(project);
+ // Missing, public, and comments-disabled all answer identically so these
+ // routes never confirm which projects exist or how they are configured.
+ if (site == null || site.record.isPublic || !site.record.commentsEnabled) {
+ return yield* Effect.fail(new HttpError({ status: 404, message: "Not found" }));
+ }
+
+ // HEAD is answered by the matching GET route, per HTTP semantics.
+ const method = request.method === "HEAD" ? "GET" : request.method;
+ const candidates = COMMENTS_ROUTES.filter((route) => route.subject === subject.kind);
+ const route = candidates.find((candidate) => candidate.method === method);
+ if (route == null) {
+ return yield* Effect.fail(new HttpError({
+ status: candidates.length === 0 ? 404 : 405,
+ message: candidates.length === 0 ? "Not found" : "Method not allowed",
+ }));
+ }
+
+ return yield* runCommentsRoute(route, subject.id, request, url, site, config);
+ });
+}
+
+/** Applies the shared policy gates in fixed order, then runs the handler. */
+function runCommentsRoute(
+ route: CommentsRoute,
+ id: string | null,
+ request: HttpServerRequest.HttpServerRequest,
+ url: URL,
+ site: LoadedSite,
+ config: ServerConfigShape,
+): Effect.Effect {
+ return Effect.gen(function* () {
+ yield* rejectCrossOriginApiRequest(request, contentBaseUrl(request, config));
+ if (blockedCrossProjectSubresource(request, site.record.project)) {
+ return yield* Effect.fail(new HttpError({ status: 403, message: "Cross-project request rejected" }));
+ }
+
+ const auth = yield* Auth;
+ const user = yield* projectAccessUser(request, auth, site, config);
+ if (user == null) {
+ return yield* Effect.fail(new HttpError({ status: 401, message: "Authentication required" }));
+ }
+ const role = projectRole(site.record, user, config);
+
+ const db = yield* PrimitiveDb;
+ return yield* route.handler({ request, url, site, user, role, id, db });
+ });
+}
+
+/** Matches the path remainder under the project against the three route
+ * subjects. Item ids are comment ids (safe id alphabet); anything else under
+ * the reserved prefix is no subject at all, which dispatch answers with 404. */
+function matchSubject(rest: string): { readonly kind: CommentsRoute["subject"]; readonly id: string | null } | null {
+ if (rest === COMMENTS_REST_PREFIX) return { kind: "collection", id: null };
+ if (rest === `${COMMENTS_REST_PREFIX}/widget.js`) return { kind: "widget", id: null };
+ const item = new RegExp(`^${COMMENTS_REST_PREFIX}/([0-9]{14}-[a-z2-9]{8})$`).exec(rest);
+ if (item != null) return { kind: "item", id: item[1] };
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Handlers
+// ---------------------------------------------------------------------------
+
+/** Handles `GET .../comments/widget.js`: the self-contained comments UI script
+ * injected into every HTML page of a comments-enabled project. */
+function serveWidget(_context: CommentsContext): Effect.Effect {
+ return Effect.succeed(HttpServerResponse.text(COMMENTS_WIDGET_JS, {
+ contentType: "text/javascript; charset=utf-8",
+ headers: securityHeaders(),
+ }));
+}
+
+/** Handles `GET .../comments?page=`: every comment on one page, oldest first,
+ * plus the viewer's own identity and moderation standing. */
+function listComments({ url, site, user, role, db }: CommentsContext) {
+ return Effect.gen(function* () {
+ const page = yield* requirePage(url.searchParams.get("page"));
+ const comments = yield* listPageComments(db, site.record.project, page);
+ return yield* encodeResponse(CommentsListResponseSchema, {
+ viewer: viewerInfo(user, role),
+ comments: comments.map(wireComment),
+ });
+ });
+}
+
+/** Handles `POST .../comments`: creates a comment authored by the viewer. */
+function postComment({ request, site, user, role, db }: CommentsContext) {
+ return Effect.gen(function* () {
+ const payload = yield* readEndpointPayload(request, CommentCreateRequestSchema, {
+ maxBytes: MAX_COMMENTS_BODY_BYTES,
+ message: "Comment is too large",
+ }).pipe(Effect.map((value) => value as typeof CommentCreateRequestSchema.Type));
+ const page = yield* requirePage(payload.page);
+ const now = new Date().toISOString();
+ const record: CommentRecord = {
+ version: 1,
+ id: randomCommentId(),
+ project: site.record.project,
+ page,
+ author: { email: user.email },
+ body: payload.body,
+ anchors: payload.anchors,
+ resolved: false,
+ createdAt: now,
+ updatedAt: now,
+ };
+ const written = yield* createComment(db, yield* validateRecord(record));
+ return yield* encodeResponse(CommentResponseSchema, {
+ viewer: viewerInfo(user, role),
+ comment: wireComment(written),
+ });
+ });
+}
+
+/** Handles `PATCH .../comments/:id`: edits the body (author or writer+ only)
+ * and/or flips resolved (any reader, matching the Google Docs convention —
+ * resolving is triage, not authorship). */
+function patchComment({ request, site, user, role, id, db }: CommentsContext) {
+ return Effect.gen(function* () {
+ const payload = yield* readEndpointPayload(request, CommentUpdateRequestSchema, {
+ maxBytes: MAX_COMMENTS_BODY_BYTES,
+ message: "Comment is too large",
+ }).pipe(Effect.map((value) => value as typeof CommentUpdateRequestSchema.Type));
+ if (payload.body == null && payload.resolved == null) {
+ return yield* Effect.fail(new HttpError({ status: 400, message: "Nothing to update" }));
+ }
+ const page = yield* requirePage(payload.page);
+ const loaded = yield* requireComment(db, site.record.project, page, id!);
+ if (payload.body != null && !mayModify(loaded.value, user, role)) {
+ return yield* Effect.fail(new HttpError({ status: 403, message: "Only the comment author or a project writer can edit a comment" }));
+ }
+
+ const resolvedChanged = payload.resolved != null && payload.resolved !== loaded.value.resolved;
+ const next: CommentRecord = {
+ ...loaded.value,
+ body: payload.body ?? loaded.value.body,
+ resolved: payload.resolved ?? loaded.value.resolved,
+ ...(resolvedChanged
+ ? payload.resolved
+ ? { resolvedBy: user.email }
+ : { resolvedBy: undefined }
+ : {}),
+ updatedAt: new Date().toISOString(),
+ };
+ const written = yield* putComment(db, yield* validateRecord(next), loaded.version);
+ return yield* encodeResponse(CommentResponseSchema, {
+ viewer: viewerInfo(user, role),
+ comment: wireComment(written),
+ });
+ });
+}
+
+/** Handles `DELETE .../comments/:id?page=`: author or writer+ only. */
+function removeComment({ url, site, user, role, id, db }: CommentsContext) {
+ return Effect.gen(function* () {
+ const page = yield* requirePage(url.searchParams.get("page"));
+ const loaded = yield* requireComment(db, site.record.project, page, id!);
+ if (!mayModify(loaded.value, user, role)) {
+ return yield* Effect.fail(new HttpError({ status: 403, message: "Only the comment author or a project writer can delete a comment" }));
+ }
+ yield* deleteComment(db, site.record.project, page, id!);
+ return yield* encodeResponse(CommentDeleteResponseSchema, { ok: true });
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Shared handler helpers
+// ---------------------------------------------------------------------------
+
+/** True when the viewer may edit or delete this comment: its author, or
+ * anyone holding write access to the project. */
+function mayModify(comment: CommentRecord, user: AuthUser, role: ProjectRole): boolean {
+ return comment.author.email.toLowerCase() === user.email.toLowerCase() || roleAtLeast(role, "write");
+}
+
+/** The viewer block included in every non-delete response. */
+function viewerInfo(user: AuthUser, role: ProjectRole): typeof CommentsViewerSchema.Type {
+ return { email: user.email, canModerate: roleAtLeast(role, "write") };
+}
+
+/** Normalizes a viewer-supplied page path or fails 400. */
+function requirePage(value: string | null): Effect.Effect {
+ const page = value == null ? null : normalizeCommentPage(value);
+ return page == null
+ ? Effect.fail(new HttpError({ status: 400, message: "Invalid page path" }))
+ : Effect.succeed(page);
+}
+
+/** Loads one comment or fails 404. */
+function requireComment(db: PrimitiveDbShape, project: string, page: string, id: string) {
+ return loadComment(db, project, page, id).pipe(
+ Effect.flatMap((loaded) =>
+ loaded == null
+ ? Effect.fail(new HttpError({ status: 404, message: "Comment not found" }))
+ : Effect.succeed(loaded),
+ ),
+ );
+}
+
+/** Validates a record the handler assembled — the runtime counterpart of the
+ * schema the store trusts, so caps hold even for handler-constructed values. */
+function validateRecord(record: CommentRecord): Effect.Effect {
+ return Schema.decodeUnknown(CommentRecordSchema)(record, { errors: "all" }).pipe(
+ Effect.mapError(() => new HttpError({ status: 400, message: "Invalid comment" })),
+ );
+}
+
+/** Strips a stored comment down to its wire shape. */
+function wireComment(record: CommentRecord): typeof CommentWireSchema.Type {
+ return {
+ id: record.id,
+ page: record.page,
+ author: record.author,
+ body: record.body,
+ anchors: record.anchors,
+ resolved: record.resolved,
+ ...(record.resolvedBy != null ? { resolvedBy: record.resolvedBy } : {}),
+ createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
+ };
+}
+
+/** Encodes a handler result through its declared response schema — a response
+ * that drifts from the schema is a 500, exactly like the app-host API. */
+function encodeResponse(
+ schema: Schema.Schema,
+ value: A,
+): Effect.Effect {
+ return Schema.encode(schema)(value).pipe(
+ Effect.mapError((cause) => new HttpError({ status: 500, message: "Could not encode the response", cause })),
+ Effect.map((body) => jsonResponse(body, 200)),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Widget injection
+// ---------------------------------------------------------------------------
+
+/** Returns the HTML transform that injects the comments widget script into a
+ * comments-enabled project's pages, mirroring the dev server's live-reload
+ * injector: before