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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,23 @@ 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 = {
bundle,
openPath: target.openPath,
project,
isPublic,
commentsEnabled,
};
const response = yield* postPublish(server, body, authToken).pipe(
Effect.catchIf((error) => error instanceof PublishAuthRequired, () =>
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"),
Expand Down
18 changes: 16 additions & 2 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions cli/src/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down
4 changes: 3 additions & 1 deletion cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
67 changes: 67 additions & 0 deletions cli/test/e2e-publish.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
84 changes: 84 additions & 0 deletions e2e/src/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), "<h1>marker-v3</h1>");
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 {
Expand Down
69 changes: 67 additions & 2 deletions e2e/test/browser-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -91,15 +92,17 @@ describe("browser security [local-dev]", () => {
writeFileSync(join(privateSite.path, "data.txt"), "secret-data");
writeFileSync(join(attackerSite.path, "index.html"), "<h1>attacker-page</h1>");
writeFileSync(join(homeSite.path, "index.html"), "<h1>home-secret</h1>");
writeFileSync(join(commentsSite.path, "index.html"), "<html><body><h1 id=\"headline\">comments-page</h1></body></html>");

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 },
);
Expand All @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions scripts/check-generated-fresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading