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 when present, appended otherwise. */ +export function injectCommentsWidget(pathPrefix: string): HtmlTransform { + const tag = ``; + return (html) => { + const index = html.toLowerCase().lastIndexOf(""); + if (index === -1) return Effect.succeed(`${html}\n${tag}\n`); + return Effect.succeed(`${html.slice(0, index)}${tag}\n${html.slice(index)}`); + }; +} diff --git a/server/core/src/comments-widget.generated.ts b/server/core/src/comments-widget.generated.ts new file mode 100644 index 0000000..45b91d7 --- /dev/null +++ b/server/core/src/comments-widget.generated.ts @@ -0,0 +1,3 @@ +// Generated by scripts/generate-assets.ts from server/core/assets/comments-widget.js — do not edit. +// Regenerate with: bun scripts/generate-assets.ts +export const COMMENTS_WIDGET_JS: string = "/*\n * The Scratchwork comments widget: a self-contained browser script injected\n * into every HTML page of a private, comments-enabled project. Plain browser\n * JS by design (it runs inside published pages, like the renderer) — the\n * server embeds it via the generated module comments-widget.generated.ts.\n *\n * It talks to the content-origin comments API at\n * /{project}/__scratchwork/comments; the path-scoped project-access cookie\n * that gated the page itself authenticates every call.\n *\n * Anchoring: each comment stores a list of {selector, x, y} candidates — the\n * clicked element first, then its ancestors, ending with a body-relative\n * fallback. A pin renders at the first selector that still matches, so a\n * republished or dynamic page degrades toward the coarser anchors instead of\n * losing the comment.\n */\n(function () {\n \"use strict\";\n if (window.__scratchworkComments) return;\n window.__scratchworkComments = true;\n\n var segments = location.pathname.split(\"/\").filter(Boolean);\n if (segments.length === 0) return;\n var project = decodeURIComponent(segments[0]);\n var API_BASE = \"/\" + encodeURIComponent(project) + \"/__scratchwork/comments\";\n var PAGE = normalizePage();\n\n var state = {\n viewer: null,\n comments: [],\n open: false,\n adding: false,\n showResolved: false,\n active: null, // highlighted comment id\n pending: null, // draft anchor {anchors, pageX, pageY}\n editing: null, // comment id with an open edit box\n confirmingDelete: null, // comment id whose delete needs confirming\n };\n\n // ------------------------------------------------------------------\n // Page identity: the decoded path under the project. The server owns\n // canonicalization (normalizeCommentPage) and applies it to every page\n // value this script sends, so no normalization logic is duplicated here.\n // ------------------------------------------------------------------\n function normalizePage() {\n var rest = \"/\" + segments.slice(1).map(function (s) {\n try { return decodeURIComponent(s); } catch (e) { return s; }\n }).join(\"/\");\n return rest.replace(/\\/+/g, \"/\") || \"/\";\n }\n\n // ------------------------------------------------------------------\n // API\n // ------------------------------------------------------------------\n function api(method, path, body) {\n var options = { method: method, headers: {} };\n if (body) {\n options.headers[\"content-type\"] = \"application/json\";\n options.body = JSON.stringify(body);\n }\n return fetch(API_BASE + path, options).then(function (res) {\n if (!res.ok) {\n return res.json().catch(function () { return {}; }).then(function (data) {\n throw new Error(data.error || \"Request failed (\" + res.status + \")\");\n });\n }\n return res.json();\n });\n }\n\n function pageQuery() {\n return \"?page=\" + encodeURIComponent(PAGE);\n }\n\n // ------------------------------------------------------------------\n // Anchor capture and resolution\n // ------------------------------------------------------------------\n function cssEscape(value) {\n return window.CSS && CSS.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, \"\\\\$&\");\n }\n\n function uniqueMatch(selector, el) {\n try {\n var found = document.querySelectorAll(selector);\n return found.length === 1 && found[0] === el ? selector : null;\n } catch (e) {\n return null;\n }\n }\n\n function idSelector(el) {\n if (!el.id) return null;\n return uniqueMatch(\"#\" + cssEscape(el.id), el);\n }\n\n function nthOfType(el) {\n var index = 1;\n var node = el.previousElementSibling;\n while (node) {\n if (node.tagName === el.tagName) index++;\n node = node.previousElementSibling;\n }\n return index;\n }\n\n /** A selector for el: its unique #id, or a short nth-of-type path from the\n * nearest uniquely-id'd ancestor (or body). Null when nothing stable fits. */\n function selectorFor(el) {\n var direct = idSelector(el);\n if (direct) return direct;\n var parts = [];\n var node = el;\n var depth = 0;\n while (node && node !== document.body && node.nodeType === 1 && depth < 6) {\n var anchor = idSelector(node);\n if (anchor && node !== el) {\n var withId = anchor + \" > \" + parts.join(\" > \");\n return withId.length <= 400 ? uniqueMatch(withId, el) : null;\n }\n parts.unshift(node.tagName.toLowerCase() + \":nth-of-type(\" + nthOfType(node) + \")\");\n node = node.parentElement;\n depth++;\n }\n if (node !== document.body) return null;\n var selector = \"body > \" + parts.join(\" > \");\n return selector.length <= 400 ? uniqueMatch(selector, el) : null;\n }\n\n function round2(value) {\n return Math.round(value * 100) / 100;\n }\n\n /** Anchor candidates for a click: target, then ancestors, then body. */\n function computeAnchors(target, pageX, pageY) {\n var anchors = [];\n var node = target;\n var depth = 0;\n while (node && node !== document.body && node.nodeType === 1 && depth < 10 && anchors.length < 7) {\n var selector = selectorFor(node);\n if (selector) {\n var rect = node.getBoundingClientRect();\n anchors.push({\n selector: selector,\n x: round2(pageX - (rect.left + window.scrollX)),\n y: round2(pageY - (rect.top + window.scrollY)),\n });\n }\n node = node.parentElement;\n depth++;\n }\n anchors.push({ selector: \"body\", x: round2(pageX), y: round2(pageY) });\n return anchors;\n }\n\n /** Page position of a comment: the first anchor whose selector still matches. */\n function anchorPosition(anchors) {\n for (var i = 0; i < anchors.length; i++) {\n var el;\n try { el = document.querySelector(anchors[i].selector); } catch (e) { el = null; }\n if (el) {\n var rect = el.getBoundingClientRect();\n return {\n x: rect.left + window.scrollX + anchors[i].x,\n y: rect.top + window.scrollY + anchors[i].y,\n };\n }\n }\n return null;\n }\n\n // ------------------------------------------------------------------\n // DOM scaffolding (shadow root keeps page CSS and widget CSS apart)\n // ------------------------------------------------------------------\n var host = document.createElement(\"div\");\n host.setAttribute(\"data-scratchwork-comments\", \"\");\n host.style.cssText = \"position:absolute;left:0;top:0;width:0;height:0;overflow:visible;z-index:2147483000;\";\n var root = host.attachShadow({ mode: \"open\" });\n\n var globalStyle = document.createElement(\"style\");\n globalStyle.textContent = \"html.--sw-commenting, html.--sw-commenting * { cursor: crosshair !important; }\";\n\n var style = document.createElement(\"style\");\n style.textContent = \"\" +\n \":host { all: initial; }\" +\n \"* { box-sizing: border-box; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; }\" +\n \".fab { position: fixed; right: 20px; bottom: 20px; z-index: 3; display: flex; align-items: center; gap: 7px;\" +\n \" border: 1px solid #d4d4d8; background: #ffffff; color: #27272a; border-radius: 999px; padding: 9px 15px;\" +\n \" font-size: 13px; font-weight: 600; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,.12); }\" +\n \".fab:hover { background: #fafafa; }\" +\n \".fab .count { background: #4f46e5; color: #fff; border-radius: 999px; min-width: 18px; height: 18px;\" +\n \" display: inline-flex; align-items: center; justify-content: center; font-size: 11px; padding: 0 5px; }\" +\n \".fab .count.zero { background: #a1a1aa; }\" +\n \".panel { position: fixed; top: 0; right: 0; bottom: 0; width: 320px; max-width: 92vw; background: #fff;\" +\n \" border-left: 1px solid #e4e4e7; box-shadow: -4px 0 18px rgba(0,0,0,.08); z-index: 2; display: flex;\" +\n \" flex-direction: column; }\" +\n \".panel-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid #e4e4e7; }\" +\n \".panel-head .title { font-size: 14px; font-weight: 700; color: #18181b; margin-right: auto; }\" +\n \".icon-btn { border: none; background: none; color: #71717a; font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px; }\" +\n \".icon-btn:hover { background: #f4f4f5; color: #27272a; }\" +\n \".btn { border: 1px solid #d4d4d8; background: #fff; color: #27272a; border-radius: 7px; padding: 5px 10px;\" +\n \" font-size: 12px; font-weight: 600; cursor: pointer; }\" +\n \".btn:hover { background: #fafafa; }\" +\n \".btn.primary { background: #4f46e5; border-color: #4f46e5; color: #fff; }\" +\n \".btn.primary:hover { background: #4338ca; }\" +\n \".btn.danger { color: #b91c1c; border-color: #fca5a5; }\" +\n \".btn.danger:hover { background: #fef2f2; }\" +\n \".panel-tools { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid #f4f4f5; }\" +\n \".toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: #52525b; cursor: pointer; user-select: none; }\" +\n \".list { overflow-y: auto; flex: 1; padding: 8px 10px 16px; }\" +\n \".empty { color: #71717a; font-size: 13px; text-align: center; padding: 26px 14px; line-height: 1.5; }\" +\n \".card { border: 1px solid #e4e4e7; border-radius: 10px; padding: 10px 12px; margin: 8px 2px; background: #fff; }\" +\n \".card.active { border-color: #4f46e5; box-shadow: 0 0 0 2px rgba(79,70,229,.15); }\" +\n \".card.resolved { background: #fafafa; }\" +\n \".card.resolved .body { color: #71717a; }\" +\n \".card-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }\" +\n \".author { font-size: 12px; font-weight: 700; color: #18181b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 170px; }\" +\n \".time { font-size: 11px; color: #a1a1aa; margin-left: auto; flex-shrink: 0; }\" +\n \".resolved-tag { font-size: 10px; font-weight: 700; color: #15803d; background: #f0fdf4; border-radius: 5px; padding: 1px 6px; }\" +\n \".body { font-size: 13px; color: #27272a; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }\" +\n \".actions { display: flex; gap: 6px; margin-top: 9px; flex-wrap: wrap; }\" +\n \".actions .btn { padding: 3px 8px; font-size: 11px; }\" +\n \"textarea { width: 100%; min-height: 64px; border: 1px solid #d4d4d8; border-radius: 8px; padding: 8px 10px;\" +\n \" font-size: 13px; color: #18181b; resize: vertical; outline: none; background: #fff; }\" +\n \"textarea:focus { border-color: #4f46e5; }\" +\n \".pin { position: absolute; width: 26px; height: 26px; margin: -13px 0 0 -13px; border-radius: 50% 50% 50% 4px;\" +\n \" background: #4f46e5; color: #fff; border: 2px solid #fff; box-shadow: 0 1px 6px rgba(0,0,0,.3); cursor: pointer;\" +\n \" display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; z-index: 1; }\" +\n \".pin.resolved { background: #a1a1aa; }\" +\n \".pin.active { transform: scale(1.2); }\" +\n \".composer { position: absolute; width: 280px; background: #fff; border: 1px solid #d4d4d8; border-radius: 12px;\" +\n \" box-shadow: 0 6px 24px rgba(0,0,0,.16); padding: 10px; z-index: 4; }\" +\n \".composer .actions { justify-content: flex-end; }\" +\n \".hint { position: fixed; top: 14px; left: 50%; transform: translateX(-50%); background: #18181b; color: #fff;\" +\n \" 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); }\";\n\n var pinsLayer = document.createElement(\"div\");\n var ui = document.createElement(\"div\");\n root.appendChild(style);\n root.appendChild(pinsLayer);\n root.appendChild(ui);\n\n // ------------------------------------------------------------------\n // Rendering\n // ------------------------------------------------------------------\n function esc(text) {\n var div = document.createElement(\"div\");\n div.textContent = text;\n return div.innerHTML;\n }\n\n function relativeTime(iso) {\n var then = new Date(iso).getTime();\n if (isNaN(then)) return \"\";\n var minutes = Math.round((Date.now() - then) / 60000);\n if (minutes < 1) return \"just now\";\n if (minutes < 60) return minutes + \"m ago\";\n if (minutes < 60 * 24) return Math.round(minutes / 60) + \"h ago\";\n if (minutes < 60 * 24 * 14) return Math.round(minutes / (60 * 24)) + \"d ago\";\n return new Date(iso).toLocaleDateString();\n }\n\n function visibleComments() {\n return state.comments.filter(function (comment) {\n return state.showResolved || !comment.resolved;\n });\n }\n\n function canEdit(comment) {\n if (!state.viewer) return false;\n return state.viewer.canModerate ||\n comment.author.email.toLowerCase() === state.viewer.email.toLowerCase();\n }\n\n function render() {\n renderPins();\n renderUi();\n }\n\n function renderPins() {\n pinsLayer.textContent = \"\";\n var visible = visibleComments();\n for (var i = 0; i < visible.length; i++) {\n (function (comment, index) {\n var position = anchorPosition(comment.anchors);\n if (!position) return;\n var pin = document.createElement(\"div\");\n pin.className = \"pin\" + (comment.resolved ? \" resolved\" : \"\") + (state.active === comment.id ? \" active\" : \"\");\n pin.style.left = position.x + \"px\";\n pin.style.top = position.y + \"px\";\n pin.textContent = String(index + 1);\n pin.title = comment.author.email;\n pin.addEventListener(\"click\", function (event) {\n event.stopPropagation();\n state.open = true;\n state.active = comment.id;\n render();\n var card = ui.querySelector('[data-comment=\"' + comment.id + '\"]');\n if (card) card.scrollIntoView({ block: \"nearest\" });\n });\n pinsLayer.appendChild(pin);\n })(visible[i], i);\n }\n }\n\n function renderUi() {\n ui.textContent = \"\";\n ui.appendChild(renderFab());\n if (state.adding) ui.appendChild(renderHint());\n if (state.open) ui.appendChild(renderPanel());\n if (state.pending) ui.appendChild(renderComposer());\n }\n\n function renderFab() {\n var openCount = state.comments.filter(function (comment) { return !comment.resolved; }).length;\n var fab = document.createElement(\"button\");\n fab.className = \"fab\";\n fab.type = \"button\";\n fab.innerHTML = \"💬 Comments \" + openCount + \"\";\n fab.addEventListener(\"click\", function () {\n state.open = !state.open;\n if (!state.open) state.active = null;\n render();\n });\n return fab;\n }\n\n function renderHint() {\n var hint = document.createElement(\"div\");\n hint.className = \"hint\";\n hint.textContent = \"Click anywhere to leave a comment - Esc to cancel\";\n return hint;\n }\n\n function renderPanel() {\n var panel = document.createElement(\"div\");\n panel.className = \"panel\";\n\n var head = document.createElement(\"div\");\n head.className = \"panel-head\";\n head.innerHTML = \"Comments\";\n var close = document.createElement(\"button\");\n close.className = \"icon-btn\";\n close.type = \"button\";\n close.innerHTML = \"✕\";\n close.title = \"Close\";\n close.addEventListener(\"click\", function () {\n state.open = false;\n state.active = null;\n render();\n });\n head.appendChild(close);\n panel.appendChild(head);\n\n var tools = document.createElement(\"div\");\n tools.className = \"panel-tools\";\n var add = document.createElement(\"button\");\n add.className = \"btn primary\";\n add.type = \"button\";\n add.textContent = \"+ Add comment\";\n add.addEventListener(\"click\", startAdding);\n tools.appendChild(add);\n var toggle = document.createElement(\"label\");\n toggle.className = \"toggle\";\n var checkbox = document.createElement(\"input\");\n checkbox.type = \"checkbox\";\n checkbox.checked = state.showResolved;\n checkbox.addEventListener(\"change\", function () {\n state.showResolved = checkbox.checked;\n render();\n });\n toggle.appendChild(checkbox);\n toggle.appendChild(document.createTextNode(\"Show resolved\"));\n tools.appendChild(toggle);\n panel.appendChild(tools);\n\n var list = document.createElement(\"div\");\n list.className = \"list\";\n var visible = visibleComments();\n if (visible.length === 0) {\n var empty = document.createElement(\"div\");\n empty.className = \"empty\";\n empty.textContent = state.comments.length === 0\n ? \"No comments on this page yet. Click “+ Add comment”, then click anywhere on the page.\"\n : \"All comments on this page are resolved.\";\n list.appendChild(empty);\n }\n for (var i = 0; i < visible.length; i++) {\n list.appendChild(renderCard(visible[i], i));\n }\n panel.appendChild(list);\n return panel;\n }\n\n function renderCard(comment, index) {\n var card = document.createElement(\"div\");\n card.className = \"card\" + (comment.resolved ? \" resolved\" : \"\") + (state.active === comment.id ? \" active\" : \"\");\n card.setAttribute(\"data-comment\", comment.id);\n\n var head = document.createElement(\"div\");\n head.className = \"card-head\";\n head.innerHTML =\n \"\" + (index + 1) + \". \" + esc(comment.author.email) + \"\" +\n (comment.resolved ? \"resolved\" : \"\") +\n \"\" + esc(relativeTime(comment.createdAt)) + \"\";\n card.appendChild(head);\n\n if (state.editing === comment.id) {\n var textarea = document.createElement(\"textarea\");\n textarea.value = comment.body;\n card.appendChild(textarea);\n card.appendChild(actionRow([\n button(\"Save\", \"btn primary\", function () {\n var body = textarea.value.trim();\n if (!body) return;\n state.editing = null;\n update(comment, { body: body });\n }),\n button(\"Cancel\", \"btn\", function () {\n state.editing = null;\n render();\n }),\n ]));\n return card;\n }\n\n var body = document.createElement(\"div\");\n body.className = \"body\";\n body.textContent = comment.body;\n card.appendChild(body);\n\n var actions = [];\n actions.push(button(comment.resolved ? \"Reopen\" : \"Resolve\", \"btn\", function () {\n update(comment, { resolved: !comment.resolved });\n }));\n if (canEdit(comment)) {\n actions.push(button(\"Edit\", \"btn\", function () {\n state.editing = comment.id;\n render();\n }));\n if (state.confirmingDelete === comment.id) {\n actions.push(button(\"Really delete?\", \"btn danger\", function () {\n state.confirmingDelete = null;\n remove(comment);\n }));\n } else {\n actions.push(button(\"Delete\", \"btn danger\", function () {\n state.confirmingDelete = comment.id;\n render();\n }));\n }\n }\n card.appendChild(actionRow(actions));\n\n card.addEventListener(\"click\", function () {\n if (state.active === comment.id) return;\n state.active = comment.id;\n render();\n var position = anchorPosition(comment.anchors);\n if (position) {\n window.scrollTo({\n top: Math.max(position.y - window.innerHeight / 2, 0),\n behavior: \"smooth\",\n });\n }\n });\n return card;\n }\n\n function button(label, className, onClick) {\n var el = document.createElement(\"button\");\n el.className = className;\n el.type = \"button\";\n el.textContent = label;\n el.addEventListener(\"click\", function (event) {\n event.stopPropagation();\n onClick();\n });\n return el;\n }\n\n function actionRow(buttons) {\n var row = document.createElement(\"div\");\n row.className = \"actions\";\n for (var i = 0; i < buttons.length; i++) row.appendChild(buttons[i]);\n return row;\n }\n\n function renderComposer() {\n var composer = document.createElement(\"div\");\n composer.className = \"composer\";\n var left = Math.min(state.pending.pageX + 12, window.scrollX + window.innerWidth - 300);\n composer.style.left = Math.max(left, window.scrollX + 8) + \"px\";\n composer.style.top = (state.pending.pageY + 12) + \"px\";\n\n var textarea = document.createElement(\"textarea\");\n textarea.placeholder = \"Leave a comment…\";\n composer.appendChild(textarea);\n composer.appendChild(actionRow([\n button(\"Comment\", \"btn primary\", function () {\n var body = textarea.value.trim();\n if (!body) return;\n create(body);\n }),\n button(\"Cancel\", \"btn\", function () {\n state.pending = null;\n render();\n }),\n ]));\n setTimeout(function () { textarea.focus(); }, 0);\n return composer;\n }\n\n // ------------------------------------------------------------------\n // Add-comment mode\n // ------------------------------------------------------------------\n function startAdding() {\n if (state.adding) return;\n state.adding = true;\n state.pending = null;\n document.documentElement.classList.add(\"--sw-commenting\");\n document.addEventListener(\"click\", captureClick, true);\n document.addEventListener(\"keydown\", captureEscape, true);\n render();\n }\n\n function stopAdding() {\n state.adding = false;\n document.documentElement.classList.remove(\"--sw-commenting\");\n document.removeEventListener(\"click\", captureClick, true);\n document.removeEventListener(\"keydown\", captureEscape, true);\n }\n\n function captureClick(event) {\n if (event.composedPath().indexOf(host) !== -1) return; // widget clicks pass through\n event.preventDefault();\n event.stopPropagation();\n stopAdding();\n var target = event.target && event.target.nodeType === 1 ? event.target : document.body;\n state.pending = {\n anchors: computeAnchors(target, event.pageX, event.pageY),\n pageX: event.pageX,\n pageY: event.pageY,\n };\n render();\n }\n\n function captureEscape(event) {\n if (event.key !== \"Escape\") return;\n event.preventDefault();\n stopAdding();\n render();\n }\n\n // ------------------------------------------------------------------\n // Mutations\n // ------------------------------------------------------------------\n function fail(error) {\n console.error(\"[scratchwork comments]\", error);\n var hint = document.createElement(\"div\");\n hint.className = \"hint\";\n hint.style.background = \"#b91c1c\";\n hint.textContent = String(error && error.message || error);\n ui.appendChild(hint);\n setTimeout(function () { hint.remove(); }, 4000);\n }\n\n function create(body) {\n var pending = state.pending;\n if (!pending) return;\n api(\"POST\", \"\", { page: PAGE, body: body, anchors: pending.anchors }).then(function (data) {\n state.pending = null;\n state.viewer = data.viewer;\n state.comments.push(data.comment);\n state.open = true;\n state.active = data.comment.id;\n render();\n }).catch(fail);\n }\n\n function update(comment, patch) {\n var body = { page: PAGE };\n if (patch.body != null) body.body = patch.body;\n if (patch.resolved != null) body.resolved = patch.resolved;\n api(\"PATCH\", \"/\" + comment.id, body).then(function (data) {\n state.viewer = data.viewer;\n state.comments = state.comments.map(function (existing) {\n return existing.id === comment.id ? data.comment : existing;\n });\n render();\n }).catch(fail);\n }\n\n function remove(comment) {\n api(\"DELETE\", \"/\" + comment.id + pageQuery()).then(function () {\n state.comments = state.comments.filter(function (existing) { return existing.id !== comment.id; });\n if (state.active === comment.id) state.active = null;\n render();\n }).catch(fail);\n }\n\n // ------------------------------------------------------------------\n // Boot\n // ------------------------------------------------------------------\n var repositionTimer = null;\n function scheduleReposition() {\n if (repositionTimer != null) return;\n repositionTimer = setTimeout(function () {\n repositionTimer = null;\n renderPins();\n }, 200);\n }\n\n function boot() {\n document.body.appendChild(host);\n document.head.appendChild(globalStyle);\n api(\"GET\", pageQuery()).then(function (data) {\n state.viewer = data.viewer;\n state.comments = data.comments.slice();\n render();\n window.addEventListener(\"resize\", scheduleReposition);\n // Rendered-Markdown pages build their DOM after load; watch for layout\n // changes so pins track their anchors instead of going stale.\n var observer = new MutationObserver(function (mutations) {\n for (var i = 0; i < mutations.length; i++) {\n if (!host.contains(mutations[i].target)) {\n scheduleReposition();\n return;\n }\n }\n });\n observer.observe(document.body, { childList: true, subtree: true });\n }).catch(function (error) {\n console.warn(\"[scratchwork comments] disabled:\", error && error.message || error);\n });\n }\n\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", boot);\n } else {\n boot();\n }\n})();\n"; diff --git a/server/core/src/http.ts b/server/core/src/http.ts index 3377838..070cc6c 100644 --- a/server/core/src/http.ts +++ b/server/core/src/http.ts @@ -109,6 +109,18 @@ export function sameOrigin(left: string, right: string): boolean { } } +/** Matches the request's origin against the configured homepage origins; null when the + * server has no homepage or the request is for another host. */ +export 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; +} + /** Builds the user-facing URL returned by publish. The homepage project reports its * canonical home origin; every other project reports its content route. */ export function publishedUrl(baseUrl: string, project: string, openPath: string, config: ServerConfigShape): string { diff --git a/server/core/src/project-access.ts b/server/core/src/project-access.ts new file mode 100644 index 0000000..6381a02 --- /dev/null +++ b/server/core/src/project-access.ts @@ -0,0 +1,59 @@ +/** + * Resolves the viewer identity carried by a private project's path-scoped + * access cookie. Shared by published-content serving (app.ts) and the + * content-origin comments API (comments-routes.ts), so both gates verify the + * cookie and re-check read access identically. + */ +import type * as HttpServerRequest from "@effect/platform/HttpServerRequest"; +import * as Effect from "effect/Effect"; +import type { AuthShape, AuthUser } from "./auth.ts"; +import type { ServerConfigShape } from "./config.ts"; +import { projectAccessCookieValues } from "./cookies.ts"; +import { routeRest } from "./routes.ts"; +import { canReadProject, type LoadedSite } from "./site-store.ts"; + +/** Verifies the request's project-access cookies and current read access, if any. */ +export 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; + }); +} + +/** Detects browser subresource loads (fetch/img/script/frame/...) via Sec-Fetch-Dest. + * Requests without the header (non-browsers, old browsers) count as navigations. */ +export 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. */ +export 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; + } +} diff --git a/server/core/src/publish-request.ts b/server/core/src/publish-request.ts index 26132f0..2c0e6d1 100644 --- a/server/core/src/publish-request.ts +++ b/server/core/src/publish-request.ts @@ -18,6 +18,12 @@ export const MAX_PUBLISH_FILE_BYTES = 10 * 1024 * 1024; /** Maximum decoded size of the whole bundle. */ export const MAX_PUBLISH_TOTAL_BYTES = 25 * 1024 * 1024; +/** Site-path prefix reserved for server-provided routes under each project + * (today the comments API and widget at /:project/__scratchwork/comments). + * Publishing files under it is rejected so published content can never be + * shadowed by — or shadow — a server route. */ +export const RESERVED_SITE_PREFIX = "__scratchwork"; + /** A validated publish request: the shared wire body (see the shared api module) * plus a normalized `openPath` and the computed decoded bundle size. `project` * stays optional at the protocol level — the server mints a name when the naming @@ -41,6 +47,12 @@ export function normalizePublishRequest(raw: PublishRequestBody): Effect.Effect< // bundle schema; the size math still needs each file's decoded byte length. let totalBytes = 0; for (const file of raw.bundle.files) { + if (file.path === RESERVED_SITE_PREFIX || file.path.startsWith(`${RESERVED_SITE_PREFIX}/`)) { + return yield* Effect.fail(new HttpError({ + status: 400, + message: `Reserved site path: ${file.path} ("${RESERVED_SITE_PREFIX}/" is server-owned)`, + })); + } const bytes = decodedBase64ByteLength(file.contentBase64); if (bytes == null) { return yield* Effect.fail(new HttpError({ status: 400, message: `Invalid base64 content: ${file.path}` })); @@ -63,6 +75,7 @@ export function normalizePublishRequest(raw: PublishRequestBody): Effect.Effect< openPath, project: raw.project, isPublic: raw.isPublic, + commentsEnabled: raw.commentsEnabled, totalBytes, }; }); diff --git a/server/core/src/site-records.ts b/server/core/src/site-records.ts index 5038875..5df8657 100644 --- a/server/core/src/site-records.ts +++ b/server/core/src/site-records.ts @@ -49,10 +49,14 @@ const siteRecordCommonFields = { totalBytes: Schema.Number.pipe(Schema.filter((bytes) => Number.isInteger(bytes) && bytes >= 0 || "Invalid total bytes")), } as const; -/** Validates a stored project pointer. `isPublic` is the public/private toggle. */ +/** Validates a stored project pointer. `isPublic` is the public/private toggle; + * `commentsEnabled` turns on viewer comments (never together with `isPublic` — + * the store rejects the combination at publish time). The optionalWith default + * keeps pre-comments records readable without a version bump. */ const SiteRecordSchema = Schema.Struct({ version: Schema.Literal(5), isPublic: Schema.Boolean, + commentsEnabled: Schema.optionalWith(Schema.Boolean, { default: () => false }), ...siteRecordCommonFields, }); diff --git a/server/core/src/site-store.ts b/server/core/src/site-store.ts index 27503af..db14926 100644 --- a/server/core/src/site-store.ts +++ b/server/core/src/site-store.ts @@ -23,6 +23,7 @@ import { type AccessGroup, } from "./access.ts"; import type { AuthUser } from "./auth.ts"; +import { purgeProjectComments } from "./comment-store.ts"; import type { ServerConfigShape } from "./config.ts"; import { PrimitiveDb, @@ -67,6 +68,12 @@ export interface LoadedSite { * it is how the CLI learns the assigned name. */ export type PublishResult = Omit; +/** The publish-time boolean toggles resolved from the request and the existing record. */ +interface ProjectToggles { + readonly isPublic: boolean; + readonly commentsEnabled: boolean; +} + /** A user's effective permission level on one project, from least to greatest. Each * level implies the ones below it; `owner` is fixed at creation and cannot be granted. */ export type ProjectRole = "none" | "read" | "write" | "admin" | "owner"; @@ -181,13 +188,15 @@ function publishProject( const loaded = requested == null ? null : yield* loadSiteRecord(db, requested); const isPublic = request.isPublic ?? loaded?.value.isPublic ?? false; + const commentsEnabled = request.commentsEnabled ?? loaded?.value.commentsEnabled ?? false; yield* validateIsPublic(isPublic, config); + yield* validateCommentsEnabled(commentsEnabled, isPublic); if (loaded != null && requested != null) { if (!roleAtLeast(projectRole(loaded.value, user, config), "write")) { return yield* Effect.fail(projectNameTaken(requested)); } - return yield* updateProject(storage, db, request, user, loaded, isPublic, config); + return yield* updateProject(storage, db, request, user, loaded, { isPublic, commentsEnabled }, config); } if (config.usersCanSetProjectNames) { @@ -197,12 +206,12 @@ function publishProject( if (isReservedSlug(requested)) { return yield* Effect.fail(new SiteStoreError({ status: 400, message: `Project name is reserved: ${requested}` })); } - return yield* writeNewProject(storage, db, request, user, requested, isPublic).pipe( + return yield* writeNewProject(storage, db, request, user, requested, { isPublic, commentsEnabled }).pipe( Effect.catchTag("PrimitiveDbConflict", () => Effect.fail(projectNameTaken(requested))), ); } - return yield* createRandomProject(storage, db, request, user, isPublic); + return yield* createRandomProject(storage, db, request, user, { isPublic, commentsEnabled }); }); } @@ -213,7 +222,7 @@ function createRandomProject( db: PrimitiveDbShape, request: PublishRequest, user: AuthUser, - isPublic: boolean, + toggles: ProjectToggles, ): Effect.Effect { return Effect.gen(function* () { for (let attempt = 0; attempt < RANDOM_NAME_ATTEMPTS; attempt += 1) { @@ -221,7 +230,7 @@ function createRandomProject( // Defense in depth: today's slug alphabet cannot produce a reserved name. if (isReservedSlug(slug)) continue; if ((yield* loadSiteRecord(db, slug)) != null) continue; - const result = yield* writeNewProject(storage, db, request, user, slug, isPublic).pipe( + const result = yield* writeNewProject(storage, db, request, user, slug, toggles).pipe( Effect.catchTag("PrimitiveDbConflict", () => Effect.succeed(null)), ); if (result != null) return result; @@ -242,14 +251,14 @@ function writeNewProject( request: PublishRequest, user: AuthUser, project: string, - isPublic: boolean, + toggles: ProjectToggles, ): Effect.Effect { return Effect.gen(function* () { const now = new Date().toISOString(); const revision = yield* buildRevision(storage, project, request, now); const record = siteRecord({ project, - isPublic, + ...toggles, readers: "private", writers: "private", admins: "private", @@ -266,20 +275,22 @@ function writeNewProject( return { project: written.value.project, isPublic: written.value.isPublic, + commentsEnabled: written.value.commentsEnabled, openPath: request.openPath, }; }); } /** Writes a new immutable revision and flips an existing project pointer. Writers may - * publish content; flipping the public toggle along the way stays an admin action. */ + * publish content; flipping the public or comments toggle along the way stays an + * admin action. */ function updateProject( storage: ObjectStorageShape, db: PrimitiveDbShape, request: PublishRequest, user: AuthUser, loaded: LoadedDbRecord, - isPublic: boolean, + toggles: ProjectToggles, config: ServerConfigShape, ): Effect.Effect { return Effect.gen(function* () { @@ -287,15 +298,18 @@ function updateProject( if (!roleAtLeast(role, "write")) { return yield* Effect.fail(new SiteStoreError({ status: 403, message: "Publishing updates requires write access to this project" })); } - if (isPublic !== loaded.value.isPublic && !roleAtLeast(role, "admin")) { + if (toggles.isPublic !== loaded.value.isPublic && !roleAtLeast(role, "admin")) { return yield* Effect.fail(new SiteStoreError({ status: 403, message: "Changing a project between public and private requires admin access" })); } + if (toggles.commentsEnabled !== loaded.value.commentsEnabled && !roleAtLeast(role, "admin")) { + return yield* Effect.fail(new SiteStoreError({ status: 403, message: "Turning project comments on or off requires admin access" })); + } const now = new Date().toISOString(); const revision = yield* buildRevision(storage, loaded.value.project, request, now); const record = siteRecord({ project: loaded.value.project, - isPublic, + ...toggles, readers: loaded.value.readers, writers: loaded.value.writers, admins: loaded.value.admins, @@ -309,6 +323,7 @@ function updateProject( return { project: written.value.project, isPublic: written.value.isPublic, + commentsEnabled: written.value.commentsEnabled, openPath: request.openPath, }; }); @@ -457,7 +472,8 @@ function revokeWarnings( } /** Deletes the mutable project record and owner index, releasing the name. Immutable - * blobs are retained. */ + * blobs are retained; comments are purged first so a name reclaimed by a different + * owner can never inherit them. */ function deleteProject( db: PrimitiveDbShape, project: string, @@ -469,6 +485,9 @@ function deleteProject( if (!isProjectOwner(loaded.value, user)) { return yield* Effect.fail(new SiteStoreError({ status: 403, message: "Only the project owner can delete this project" })); } + yield* purgeProjectComments(db, project).pipe( + Effect.mapError((error) => new SiteStoreError({ status: 500, message: `Could not delete project comments: ${error.message}`, cause: error })), + ); yield* db.delete(PROJECTS_NAMESPACE, project, { ifMatch: loaded.version }).pipe(Effect.mapError(dbError)); yield* db.delete(OWNER_INDEX_NAMESPACE, ownerIndexKey(loaded.value.owner, project)).pipe(Effect.ignore); }); @@ -594,6 +613,7 @@ function projectNameTaken(project: string): SiteStoreError { function siteRecord(input: { readonly project: string; readonly isPublic: boolean; + readonly commentsEnabled: boolean; readonly readers: AccessGroup; readonly writers: AccessGroup; readonly admins: AccessGroup; @@ -606,6 +626,7 @@ function siteRecord(input: { version: 5, project: input.project, isPublic: input.isPublic, + commentsEnabled: input.commentsEnabled, readers: input.readers, writers: input.writers, admins: input.admins, @@ -627,6 +648,19 @@ function validateIsPublic(isPublic: boolean, config: ServerConfigShape): Effect. return Effect.void; } +/** Fails when a publish asks for comments on a public project: comments identify + * viewers through the private-content access flow, and an open comment box on a + * public page would accumulate unbounded anonymous-adjacent state. */ +function validateCommentsEnabled(commentsEnabled: boolean, isPublic: boolean): Effect.Effect { + if (commentsEnabled && isPublic) { + return Effect.fail(new SiteStoreError({ + status: 400, + message: "Comments require a private project. Publish with --private, or disable comments with --no-comments.", + })); + } + return Effect.void; +} + /** Fails when a modified grant group falls outside server-wide sharing policy. */ function validateShareGroup(group: AccessGroup, config: ServerConfigShape): Effect.Effect { if (!accessGroupUsesOnlyDomains(group, config.allowedShareDomains)) { diff --git a/server/core/src/tokens.ts b/server/core/src/tokens.ts index ff6ef2f..469ec6f 100644 --- a/server/core/src/tokens.ts +++ b/server/core/src/tokens.ts @@ -4,6 +4,10 @@ import * as Encoding from "effect/Encoding"; const SLUG_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; const SLUG_LENGTH = 10; const REVISION_BYTES = 16; +const COMMENT_ID_RANDOM_LENGTH = 8; +/** Fixed digit width for the comment-id timestamp prefix; 14 decimal digits of + * epoch milliseconds stay fixed-width (and therefore byte-ordered) until 5138. */ +const COMMENT_ID_TIME_DIGITS = 14; /** Generates a random project name for servers that assign names on first publish. */ export function randomSlug(): string { @@ -15,6 +19,20 @@ export function randomRevisionId(): string { return Encoding.encodeBase64Url(randomBytes(REVISION_BYTES)); } +/** The last comment-id timestamp this process minted, for same-millisecond ties. */ +let lastCommentIdTime = 0; + +/** Generates a comment identifier whose UTF-8 byte order is creation order: + * a fixed-width epoch-milliseconds prefix plus a random suffix, so prefix + * listings return a page's comments oldest-first without a separate index. + * Same-millisecond mints within one process are nudged forward a millisecond + * so their order stays creation order too. */ +export function randomCommentId(): string { + lastCommentIdTime = Math.max(Date.now(), lastCommentIdTime + 1); + const time = String(lastCommentIdTime).padStart(COMMENT_ID_TIME_DIGITS, "0"); + return `${time}-${randomAlphabetString(COMMENT_ID_RANDOM_LENGTH, SLUG_ALPHABET)}`; +} + /** Compares two strings in constant time per character; unequal lengths return false immediately. */ export function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; diff --git a/server/core/test/comments-policy.test.ts b/server/core/test/comments-policy.test.ts new file mode 100644 index 0000000..3d447f9 --- /dev/null +++ b/server/core/test/comments-policy.test.ts @@ -0,0 +1,452 @@ +/* + * The comments route policy matrix (AGENTS.md, invariant 4, applied to the + * content-origin comments API): for every route in COMMENTS_ROUTES × credential + * kind (no cookie, garbage cookie, stranger, reader, writer, admin, owner) the + * expected outcome is derived from the declared policy — cookie auth, the read + * floor, and the author-or-writer rule on edits/deletes. Also pins the + * existence masking (missing, public, and comments-disabled projects answer + * identically), the origin/referer guards, widget injection, publish-time + * validation of the comments+public combination, and the delete-time purge. + */ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import { makeAuth, createSessionToken, type AuthUser } from "../src/auth"; +import { COMMENTS_ROUTES } from "../src/comments-routes"; +import type { AuthConfig } from "../src/config"; +import { appHandler, bundle, json } from "./helpers"; + +/** Must match the appHandler defaults in helpers.ts so minted tokens verify. */ +const authConfig: AuthConfig = { + mode: "oauth", + clientId: "test-client-id", + clientSecret: "test-client-secret", + sessionSecret: "test-session-secret-test-session-secret", + allowedUsers: "public", + sessionTtlSeconds: 60, +}; + +const users = { + owner: { id: "owner-1", email: "owner@example.com" }, + admin: { id: "admin-1", email: "admin@example.com" }, + writer: { id: "writer-1", email: "writer@example.com" }, + reader: { id: "reader-1", email: "reader@example.com" }, + reader2: { id: "reader-2", email: "reader2@example.com" }, + stranger: { id: "stranger-1", email: "stranger@example.com" }, +} satisfies Record; + +const auth = makeAuth(authConfig); + +/** Mints the path-scoped access cookie a real private-content viewer holds. */ +async function accessCookie(user: AuthUser, project: string): Promise { + const token = await Effect.runPromise(auth.issueProjectAccessToken(project, user, "cookie")); + return `__Secure-scratchwork_access_${project}=${encodeURIComponent(token)}`; +} + +/** A fresh server with three projects: "site" (private, comments on, the + * standard grants), "plain" (private, comments off), "pub" (public). */ +async function fixture() { + const handler = await appHandler({}); + const ownerToken = await Effect.runPromise(createSessionToken(users.owner, authConfig)); + const post = (path: string, body: unknown) => + handler(new Request(`https://scratch.test${path}`, { + method: "POST", + headers: { authorization: `Bearer ${ownerToken}`, "content-type": "application/json" }, + body: JSON.stringify(body), + })); + for (const [project, isPublic, commentsEnabled] of [ + ["site", false, true], + ["plain", false, false], + ["pub", true, false], + ] as const) { + const published = await post("/api/publish", { + bundle: bundle({ "index.html": "hello", "notes.md": "# notes" }), + openPath: "/", + project, + isPublic, + commentsEnabled, + }); + if (published.status !== 200) throw new Error(`fixture publish failed: ${await published.text()}`); + } + for (const [role, user] of [["read", users.reader], ["read", users.reader2], ["write", users.writer], ["admin", users.admin]] as const) { + const shared = await post("/api/projects/site/share", { role, add: [user.email] }); + if (shared.status !== 200) throw new Error(`fixture share failed: ${await shared.text()}`); + } + return { handler, ownerToken }; +} + +type Fixture = Awaited>; + +/** Calls a comments route as a viewer holding the given cookie. */ +async function call( + fx: Fixture, + method: string, + path: string, + options: { cookie?: string; body?: unknown; headers?: Record } = {}, +): Promise { + const headers: Record = { ...options.headers }; + if (options.cookie != null) headers.cookie = options.cookie; + if (options.body !== undefined) headers["content-type"] = "application/json"; + return fx.handler(new Request(`https://scratch.test${path}`, { + method, + headers, + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), + })); +} + +/** Creates one comment as `author` and returns its wire form. */ +async function createComment(fx: Fixture, author: AuthUser, body = "first!"): Promise<{ id: string }> { + const response = await call(fx, "POST", "/site/__scratchwork/comments", { + cookie: await accessCookie(author, "site"), + body: { page: "/", body, anchors: [{ selector: "body", x: 10, y: 20 }] }, + }); + expect(response.status).toBe(200); + return (await json(response) as { comment: { id: string } }).comment; +} + +/** A success-shaped request per registered route (the completeness check pins this). */ +const FIXTURES: Record { method: string; path: string; body?: unknown }> = { + "comments-widget": () => ({ method: "GET", path: "/site/__scratchwork/comments/widget.js" }), + "comments-list": () => ({ method: "GET", path: "/site/__scratchwork/comments?page=/" }), + "comments-create": () => ({ + method: "POST", + path: "/site/__scratchwork/comments", + body: { page: "/", body: "hello", anchors: [{ selector: "body", x: 1, y: 2 }] }, + }), + "comments-update": (commentId) => ({ + method: "PATCH", + path: `/site/__scratchwork/comments/${commentId}`, + body: { page: "/", resolved: true }, + }), + "comments-delete": (commentId) => ({ + method: "DELETE", + path: `/site/__scratchwork/comments/${commentId}?page=/`, + }), +}; + +describe("comments route policy matrix", () => { + test("every registered route has a request fixture, and no fixture is stale", () => { + expect(Object.keys(FIXTURES).sort()).toEqual(COMMENTS_ROUTES.map((route) => route.name).sort()); + expect(new Set(COMMENTS_ROUTES.map((r) => r.name)).size).toBe(COMMENTS_ROUTES.length); + }); + + for (const route of COMMENTS_ROUTES) { + test(`${route.name} requires an authenticated read-authorized viewer`, async () => { + const fx = await fixture(); + const comment = await createComment(fx, users.reader); + const shape = FIXTURES[route.name]!(comment.id); + + // No cookie, a garbage cookie, and a cookie minted for a user without + // read access must all be 401: the route reveals nothing without a + // verified, currently-authorized viewer. + for (const cookie of [ + undefined, + "__Secure-scratchwork_access_site=garbage.token", + await accessCookie(users.stranger, "site"), + ]) { + const response = await call(fx, shape.method, shape.path, { cookie, body: shape.body }); + expect({ route: route.name, cookie: cookie ?? "none", status: response.status }) + .toEqual({ route: route.name, cookie: cookie ?? "none", status: 401 }); + } + + // A cookie scoped to a different project never transfers. + const wrongProject = await call(fx, shape.method, shape.path, { + cookie: await accessCookie(users.owner, "plain"), + body: shape.body, + }); + expect({ route: route.name, status: wrongProject.status }).toEqual({ route: route.name, status: 401 }); + + // A reader passes the gate (route-specific rules are pinned below). + const reader = await call(fx, shape.method, shape.path, { + cookie: await accessCookie(users.reader, "site"), + body: shape.body, + }); + expect({ route: route.name, status: reader.status }).toEqual({ route: route.name, status: 200 }); + }); + + test(`${route.name} rejects cross-origin browser calls even from the owner`, async () => { + const fx = await fixture(); + const comment = await createComment(fx, users.owner); + const shape = FIXTURES[route.name]!(comment.id); + const cookie = await accessCookie(users.owner, "site"); + const crossOriginHeaders: ReadonlyArray> = [ + { origin: "https://evil.example" }, + { "sec-fetch-site": "cross-site" }, + ]; + for (const headers of crossOriginHeaders) { + const response = await call(fx, shape.method, shape.path, { cookie, body: shape.body, headers }); + expect({ route: route.name, headers, status: response.status }) + .toEqual({ route: route.name, headers, status: 403 }); + } + }); + + test(`${route.name} rejects subresource requests initiated outside the project`, async () => { + const fx = await fixture(); + const comment = await createComment(fx, users.owner); + const shape = FIXTURES[route.name]!(comment.id); + const cookie = await accessCookie(users.owner, "site"); + const blocked = await call(fx, shape.method, shape.path, { + cookie, + body: shape.body, + headers: { "sec-fetch-dest": "empty", referer: "https://scratch.test/plain/" }, + }); + expect({ route: route.name, status: blocked.status }).toEqual({ route: route.name, status: 403 }); + const allowed = await call(fx, shape.method, shape.path, { + cookie, + body: shape.body, + headers: { "sec-fetch-dest": "empty", referer: "https://scratch.test/site/notes" }, + }); + expect({ route: route.name, status: allowed.status }).toEqual({ route: route.name, status: 200 }); + }); + } + + test("missing, comments-disabled, and public projects answer identically (existence masked)", async () => { + const fx = await fixture(); + const cookie = await accessCookie(users.owner, "site"); + for (const project of ["missing", "plain", "pub"]) { + const response = await call(fx, "GET", `/${project}/__scratchwork/comments?page=/`, { cookie }); + expect({ project, status: response.status }).toEqual({ project, status: 404 }); + expect(await json(response)).toEqual({ error: "Not found" }); + } + }); + + test("the whole __scratchwork prefix is server-owned: unknown paths 404, wrong methods 405", async () => { + const fx = await fixture(); + const cookie = await accessCookie(users.owner, "site"); + for (const path of [ + "/site/__scratchwork", + "/site/__scratchwork/", + "/site/__scratchwork/other", + "/site/__scratchwork/comments/not-a-comment-id", + "/site/__scratchwork/comments/widget.js/extra", + ]) { + const response = await call(fx, "GET", path, { cookie }); + expect({ path, status: response.status }).toEqual({ path, status: 404 }); + } + const put = await call(fx, "PUT", "/site/__scratchwork/comments", { cookie, body: {} }); + expect(put.status).toBe(405); + const comment = await createComment(fx, users.owner); + const postItem = await call(fx, "POST", `/site/__scratchwork/comments/${comment.id}`, { cookie, body: {} }); + expect(postItem.status).toBe(405); + }); + + test("readers may resolve and reopen anyone's comment, but not edit or delete it", async () => { + const fx = await fixture(); + const comment = await createComment(fx, users.owner, "owner's comment"); + const cookie = await accessCookie(users.reader, "site"); + + const resolve = await call(fx, "PATCH", `/site/__scratchwork/comments/${comment.id}`, { + cookie, body: { page: "/", resolved: true }, + }); + expect(resolve.status).toBe(200); + const resolved = (await json(resolve) as { comment: { resolved: boolean; resolvedBy?: string } }).comment; + expect(resolved.resolved).toBe(true); + expect(resolved.resolvedBy).toBe(users.reader.email); + + const reopen = await call(fx, "PATCH", `/site/__scratchwork/comments/${comment.id}`, { + cookie, body: { page: "/", resolved: false }, + }); + expect(((await json(reopen)) as { comment: { resolvedBy?: string } }).comment.resolvedBy).toBeUndefined(); + + const edit = await call(fx, "PATCH", `/site/__scratchwork/comments/${comment.id}`, { + cookie, body: { page: "/", body: "defaced" }, + }); + expect(edit.status).toBe(403); + const remove = await call(fx, "DELETE", `/site/__scratchwork/comments/${comment.id}?page=/`, { cookie }); + expect(remove.status).toBe(403); + }); + + test("authors edit and delete their own comments; writers moderate everyone's", async () => { + const fx = await fixture(); + const comment = await createComment(fx, users.reader, "reader's comment"); + + const own = await call(fx, "PATCH", `/site/__scratchwork/comments/${comment.id}`, { + cookie: await accessCookie(users.reader, "site"), + body: { page: "/", body: "reader's edit" }, + }); + expect(own.status).toBe(200); + + // Another reader cannot touch it; a writer can. + const other = await call(fx, "PATCH", `/site/__scratchwork/comments/${comment.id}`, { + cookie: await accessCookie(users.reader2, "site"), + body: { page: "/", body: "reader2's edit" }, + }); + expect(other.status).toBe(403); + const moderated = await call(fx, "PATCH", `/site/__scratchwork/comments/${comment.id}`, { + cookie: await accessCookie(users.writer, "site"), + body: { page: "/", body: "writer's edit" }, + }); + expect(moderated.status).toBe(200); + const removed = await call(fx, "DELETE", `/site/__scratchwork/comments/${comment.id}?page=/`, { + cookie: await accessCookie(users.writer, "site"), + }); + expect(removed.status).toBe(200); + + const list = await call(fx, "GET", "/site/__scratchwork/comments?page=/", { + cookie: await accessCookie(users.reader, "site"), + }); + expect((await json(list) as { comments: unknown[] }).comments).toEqual([]); + }); + + test("list reports the viewer's own standing and page comments oldest first", async () => { + const fx = await fixture(); + await createComment(fx, users.owner, "one"); + await createComment(fx, users.reader, "two"); + const response = await call(fx, "GET", "/site/__scratchwork/comments?page=/", { + cookie: await accessCookie(users.reader, "site"), + }); + const body = await json(response) as { + viewer: { email: string; canModerate: boolean }; + comments: Array<{ body: string; author: { email: string } }>; + }; + expect(body.viewer).toEqual({ email: users.reader.email, canModerate: false }); + expect(body.comments.map((comment) => comment.body)).toEqual(["one", "two"]); + + const asWriter = await call(fx, "GET", "/site/__scratchwork/comments?page=/", { + cookie: await accessCookie(users.writer, "site"), + }); + expect((await json(asWriter) as { viewer: { canModerate: boolean } }).viewer.canModerate).toBe(true); + }); + + test("page paths normalize, so index.html spellings share one comment set", async () => { + const fx = await fixture(); + const cookie = await accessCookie(users.owner, "site"); + const created = await call(fx, "POST", "/site/__scratchwork/comments", { + cookie, + body: { page: "/docs/index.html", body: "hello", anchors: [{ selector: "body", x: 0, y: 0 }] }, + }); + expect(created.status).toBe(200); + const list = await call(fx, "GET", "/site/__scratchwork/comments?page=/docs/", { cookie }); + expect((await json(list) as { comments: Array<{ page: string }> }).comments.map((comment) => comment.page)) + .toEqual(["/docs"]); + const bad = await call(fx, "GET", "/site/__scratchwork/comments?page=../evil", { cookie }); + expect(bad.status).toBe(400); + }); + + test("comment bodies and anchors are validated and capped", async () => { + const fx = await fixture(); + const cookie = await accessCookie(users.owner, "site"); + const cases: Array<{ label: string; body: unknown }> = [ + { label: "empty body", body: { page: "/", body: "", anchors: [{ selector: "body", x: 0, y: 0 }] } }, + { label: "oversized body", body: { page: "/", body: "x".repeat(5001), anchors: [{ selector: "body", x: 0, y: 0 }] } }, + { label: "no anchors", body: { page: "/", body: "hi", anchors: [] } }, + { label: "unknown field", body: { page: "/", body: "hi", anchors: [{ selector: "body", x: 0, y: 0 }], extra: 1 } }, + { label: "non-finite anchor", body: { page: "/", body: "hi", anchors: [{ selector: "body", x: null, y: 0 }] } }, + ]; + for (const { label, body } of cases) { + const response = await call(fx, "POST", "/site/__scratchwork/comments", { cookie, body }); + expect({ label, status: response.status }).toEqual({ label, status: 400 }); + } + }); + + test("the widget script is injected only into private comments-enabled HTML pages", async () => { + const fx = await fixture(); + const marker = "/__scratchwork/comments/widget.js"; + + const withComments = await call(fx, "GET", "/site/", { + cookie: await accessCookie(users.reader, "site"), + }); + expect(withComments.status).toBe(200); + expect(await withComments.text()).toContain(`src="/site${marker}"`); + + const rendered = await call(fx, "GET", "/site/notes", { + cookie: await accessCookie(users.reader, "site"), + }); + expect(rendered.status).toBe(200); + expect(await rendered.text()).toContain(`src="/site${marker}"`); + + const plain = await call(fx, "GET", "/plain/", { + cookie: await accessCookie(users.owner, "plain"), + }); + expect(plain.status).toBe(200); + expect(await plain.text()).not.toContain(marker); + + const pub = await call(fx, "GET", "/pub/", {}); + expect(pub.status).toBe(200); + expect(await pub.text()).not.toContain(marker); + }); + + test("the widget script serves as JavaScript to authorized viewers", async () => { + const fx = await fixture(); + const response = await call(fx, "GET", "/site/__scratchwork/comments/widget.js", { + cookie: await accessCookie(users.reader, "site"), + headers: { "sec-fetch-dest": "script", referer: "https://scratch.test/site/" }, + }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/javascript"); + expect(await response.text()).toContain("__scratchwork/comments"); + }); + + test("publish rejects comments on a public project, and flips stay admin-only", async () => { + const fx = await fixture(); + const publish = (token: string, body: Record) => + fx.handler(new Request("https://scratch.test/api/publish", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ bundle: bundle({ "index.html": "v2" }), openPath: "/", ...body }), + })); + + const ownerToken = fx.ownerToken; + const conflicting = await publish(ownerToken, { project: "with-comments", isPublic: true, commentsEnabled: true }); + expect(conflicting.status).toBe(400); + expect(((await json(conflicting)) as { error: string }).error).toContain("private"); + + // Making a comments-enabled project public must fail too (setting preserved). + const flipPublic = await publish(ownerToken, { project: "site", isPublic: true }); + expect(flipPublic.status).toBe(400); + + // A writer can republish content but cannot flip the comments toggle. + const writerToken = await Effect.runPromise(createSessionToken(users.writer, authConfig)); + const writerRepublish = await publish(writerToken, { project: "site" }); + expect(writerRepublish.status).toBe(200); + const writerFlip = await publish(writerToken, { project: "site", commentsEnabled: false }); + expect(writerFlip.status).toBe(403); + + // Files under the reserved prefix are rejected at publish time. + const reserved = await fx.handler(new Request("https://scratch.test/api/publish", { + method: "POST", + headers: { authorization: `Bearer ${ownerToken}`, "content-type": "application/json" }, + body: JSON.stringify({ + bundle: bundle({ "__scratchwork/evil.js": "x" }), + openPath: "/", + project: "reserved-path", + }), + })); + expect(reserved.status).toBe(400); + expect(((await json(reserved)) as { error: string }).error).toContain("Reserved site path"); + }); + + test("deleting a project purges its comments, so a reclaimed name starts clean", async () => { + const fx = await fixture(); + await createComment(fx, users.owner, "leftover"); + const removed = await fx.handler(new Request("https://scratch.test/api/projects/site", { + method: "DELETE", + headers: { authorization: `Bearer ${fx.ownerToken}` }, + })); + expect(removed.status).toBe(200); + + const republished = await fx.handler(new Request("https://scratch.test/api/publish", { + method: "POST", + headers: { authorization: `Bearer ${fx.ownerToken}`, "content-type": "application/json" }, + body: JSON.stringify({ + bundle: bundle({ "index.html": "reborn" }), + openPath: "/", + project: "site", + commentsEnabled: true, + }), + })); + expect(republished.status).toBe(200); + + const list = await call(fx, "GET", "/site/__scratchwork/comments?page=/", { + cookie: await accessCookie(users.owner, "site"), + }); + expect((await json(list) as { comments: unknown[] }).comments).toEqual([]); + }); + + test("project info reports commentsEnabled through the shared contract", async () => { + const fx = await fixture(); + const info = await fx.handler(new Request("https://scratch.test/api/projects/site", { + headers: { authorization: `Bearer ${fx.ownerToken}` }, + })); + expect(((await json(info)) as { project: { commentsEnabled: boolean } }).project.commentsEnabled).toBe(true); + }); +}); diff --git a/server/core/test/site-store.test.ts b/server/core/test/site-store.test.ts index 2e57181..4052f42 100644 --- a/server/core/test/site-store.test.ts +++ b/server/core/test/site-store.test.ts @@ -29,6 +29,7 @@ function record(isPublic: boolean, groups: { readers?: string; writers?: string; return { version: 5, isPublic, + commentsEnabled: false, readers: groups.readers ?? "private", writers: groups.writers ?? "private", admins: groups.admins ?? "private", diff --git a/shared/src/publish/api.ts b/shared/src/publish/api.ts index 8aeb6cb..aa22a76 100644 --- a/shared/src/publish/api.ts +++ b/shared/src/publish/api.ts @@ -31,13 +31,17 @@ export const ProjectIdentifierSchema = Schema.String.pipe( /** The JSON body of `POST /api/publish`. `project` is optional at the protocol * level — a random-naming server mints a name when none is sent; `isPublic` is * the public/private toggle (omitted preserves an existing project's setting, - * and a new project is created private). Per-account and per-domain access is a - * separate grant list managed through the share API, not a publish-time setting. */ + * and a new project is created private). `commentsEnabled` turns on viewer + * comments (omitted preserves the current setting; new projects default off; + * the server rejects comments on a public project). Per-account and per-domain + * access is a separate grant list managed through the share API, not a + * publish-time setting. */ export const PublishRequestBodySchema = Schema.Struct({ bundle: PublishBundleSchema, openPath: Schema.optional(Schema.String), project: Schema.optional(ProjectIdentifierSchema), isPublic: Schema.optional(Schema.Boolean), + commentsEnabled: Schema.optional(Schema.Boolean), }); /** The publish request body as the CLI builds it and the server decodes it. */ @@ -48,6 +52,7 @@ export type PublishRequestBody = typeof PublishRequestBodySchema.Type; export const PublishResponseSchema = Schema.Struct({ project: Schema.String, isPublic: Schema.Boolean, + commentsEnabled: Schema.optional(Schema.Boolean), openPath: Schema.String, url: Schema.String, }); @@ -109,6 +114,7 @@ export type ProjectPermissions = typeof ProjectPermissionsSchema.Type; export const ProjectInfoSchema = Schema.Struct({ project: Schema.String, isPublic: Schema.Boolean, + commentsEnabled: Schema.optional(Schema.Boolean), permissions: Schema.optional(ProjectPermissionsSchema), url: Schema.optional(Schema.String), owner: Schema.Struct({ id: Schema.String, email: Schema.String }),