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
16 changes: 16 additions & 0 deletions .changeset/calm-comments-moderate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@emdash-cms/admin": minor
"@emdash-cms/cloudflare": minor
"@emdash-cms/plugin-cli": minor
"@emdash-cms/plugin-test": minor
"@emdash-cms/plugin-types": minor
"@emdash-cms/registry-lexicons": minor
"@emdash-cms/sandbox-workerd": minor
"emdash": minor
---

Adds `comments:read` and `comments:moderate` for sandboxed plugins. `ctx.comments` can get, count, and cursor-page through non-trashed comments, and can change a comment between `approved`, `pending`, and `spam` when the caller supplies the status it previously observed.

`comments:read` exposes comment bodies, author names and email addresses, pseudonymous IP hashes, user agents, and moderation metadata. It does not expose the linked EmDash user-account ID. `comments:moderate` implies that read access, and installation or an update that requests either capability requires operator consent.

Status changes use the core moderation path. A stale expected status rejects with `COMMENT_STATUS_CONFLICT`, and an overlapping transition can reject with `COMMENT_MODERATION_IN_PROGRESS`; a successful transition runs `comment:afterModerate` once with the calling plugin's origin and preserves approval notifications. Hard deletion and bulk status replacement are not included.
35 changes: 35 additions & 0 deletions apps/release-action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,9 @@ const sbomSchema = _sbomSchema;
//#endregion
//#region ../../packages/registry-lexicons/dist/generated/types/com/emdashcms/experimental/package/releaseExtension.js
var releaseExtension_exports = /* @__PURE__ */ __exportAll({
commentsAccessSchema: () => commentsAccessSchema,
commentsModerateConstraintsSchema: () => commentsModerateConstraintsSchema,
commentsReadConstraintsSchema: () => commentsReadConstraintsSchema,
contentAccessSchema: () => contentAccessSchema,
contentReadConstraintsSchema: () => contentReadConstraintsSchema,
contentWriteConstraintsSchema: () => contentWriteConstraintsSchema,
Expand All @@ -1139,6 +1142,17 @@ var releaseExtension_exports = /* @__PURE__ */ __exportAll({
usersAccessSchema: () => usersAccessSchema,
usersReadConstraintsSchema: () => usersReadConstraintsSchema
});
const _commentsAccessSchema = /* @__PURE__ */ object$1({
$type: /* @__PURE__ */ optional$1(/* @__PURE__ */ literal$1("com.emdashcms.experimental.package.releaseExtension#commentsAccess")),
get moderate() {
return /* @__PURE__ */ optional$1(commentsModerateConstraintsSchema);
},
get read() {
return /* @__PURE__ */ optional$1(commentsReadConstraintsSchema);
}
});
const _commentsModerateConstraintsSchema = /* @__PURE__ */ object$1({ $type: /* @__PURE__ */ optional$1(/* @__PURE__ */ literal$1("com.emdashcms.experimental.package.releaseExtension#commentsModerateConstraints")) });
const _commentsReadConstraintsSchema = /* @__PURE__ */ object$1({ $type: /* @__PURE__ */ optional$1(/* @__PURE__ */ literal$1("com.emdashcms.experimental.package.releaseExtension#commentsReadConstraints")) });
const _contentAccessSchema = /* @__PURE__ */ object$1({
$type: /* @__PURE__ */ optional$1(/* @__PURE__ */ literal$1("com.emdashcms.experimental.package.releaseExtension#contentAccess")),
get read() {
Expand All @@ -1152,6 +1166,9 @@ const _contentReadConstraintsSchema = /* @__PURE__ */ object$1({ $type: /* @__PU
const _contentWriteConstraintsSchema = /* @__PURE__ */ object$1({ $type: /* @__PURE__ */ optional$1(/* @__PURE__ */ literal$1("com.emdashcms.experimental.package.releaseExtension#contentWriteConstraints")) });
const _declaredAccessSchema = /* @__PURE__ */ object$1({
$type: /* @__PURE__ */ optional$1(/* @__PURE__ */ literal$1("com.emdashcms.experimental.package.releaseExtension#declaredAccess")),
get comments() {
return /* @__PURE__ */ optional$1(commentsAccessSchema);
},
get content() {
return /* @__PURE__ */ optional$1(contentAccessSchema);
},
Expand Down Expand Up @@ -1248,6 +1265,9 @@ const _usersAccessSchema = /* @__PURE__ */ object$1({
}
});
const _usersReadConstraintsSchema = /* @__PURE__ */ object$1({ $type: /* @__PURE__ */ optional$1(/* @__PURE__ */ literal$1("com.emdashcms.experimental.package.releaseExtension#usersReadConstraints")) });
const commentsAccessSchema = _commentsAccessSchema;
const commentsModerateConstraintsSchema = _commentsModerateConstraintsSchema;
const commentsReadConstraintsSchema = _commentsReadConstraintsSchema;
const contentAccessSchema = _contentAccessSchema;
const contentReadConstraintsSchema = _contentReadConstraintsSchema;
const contentWriteConstraintsSchema = _contentWriteConstraintsSchema;
Expand Down Expand Up @@ -7786,6 +7806,8 @@ const CURRENT_PLUGIN_CAPABILITIES = [
"network:request:unrestricted",
"content:read",
"content:write",
"comments:read",
"comments:moderate",
"taxonomies:read",
"media:read",
"media:write",
Expand Down Expand Up @@ -8002,6 +8024,10 @@ const declaredAccessSchema = object({
read: accessConstraints.optional(),
write: accessConstraints.optional()
}).optional(),
comments: object({
read: accessConstraints.optional(),
moderate: accessConstraints.optional()
}).optional(),
taxonomies: object({ read: accessConstraints.optional() }).optional(),
media: object({
read: accessConstraints.optional(),
Expand Down Expand Up @@ -8113,6 +8139,10 @@ function capabilitiesToDeclaredAccess(capabilities, allowedHosts) {
out.content = { read: {} };
if (caps.has("content:write")) out.content.write = {};
}
if (caps.has("comments:read") || caps.has("comments:moderate")) {
out.comments = { read: {} };
if (caps.has("comments:moderate")) out.comments.moderate = {};
}
if (caps.has("taxonomies:read")) out.taxonomies = { read: {} };
if (caps.has("media:read") || caps.has("media:write")) {
out.media = { read: {} };
Expand Down Expand Up @@ -8142,6 +8172,11 @@ function declaredAccessToCapabilities(declaredAccess) {
caps.add("content:write");
caps.add("content:read");
}
if (declaredAccess.comments?.read) caps.add("comments:read");
if (declaredAccess.comments?.moderate) {
caps.add("comments:moderate");
caps.add("comments:read");
}
if (declaredAccess.taxonomies?.read) caps.add("taxonomies:read");
if (declaredAccess.media?.read) caps.add("media:read");
if (declaredAccess.media?.write) {
Expand Down
22 changes: 20 additions & 2 deletions docs/src/content/docs/plugins/creating-plugins/capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Declare only what the plugin actually needs. The registry shows these capabiliti
| ----------------------------------- | --------------------------------------------------------------------------------- |
| `content:read` | `ctx.content.get()`, `ctx.content.list()` |
| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` (implies `content:read`) |
| `comments:read` | `ctx.comments.get()`, `ctx.comments.list()`, `ctx.comments.count()` and comment personal data |
| `comments:moderate` | `ctx.comments.setStatus()` with expected-status concurrency control (implies `comments:read`) |
| `taxonomies:read` | `ctx.taxonomies.getAll()`, `ctx.taxonomies.getTerms()`, `ctx.taxonomies.getEntryTerms()` |
| `media:read` | `ctx.media.get()`, `ctx.media.list()` |
| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` (implies `media:read`) |
Expand All @@ -44,7 +46,7 @@ Declare only what the plugin actually needs. The registry shows these capabiliti

The following rules affect which capabilities a plugin needs:

- **Implications.** `content:write` automatically implies `content:read`; `media:write` implies `media:read`; `network:request:unrestricted` implies `network:request`. You don't need to list both.
- **Implications.** `content:write` automatically implies `content:read`; `comments:moderate` implies `comments:read`; `media:write` implies `media:read`; `network:request:unrestricted` implies `network:request`. You don't need to list both.
- **Taxonomies are a separate, read-only surface.** `taxonomies:read` grants access to taxonomy definitions, their terms, and the terms assigned to an entry via `ctx.taxonomies`. It is independent of `content:read` — declare both if the plugin reads content *and* its classification. There is no taxonomy *write* access from plugins.
- **`network:request:unrestricted` exists for user-configured URLs.** A webhook plugin where the operator types in the destination URL needs to reach hosts that aren't in the manifest. Plugins that always call known APIs should use `network:request` + `allowedHosts`.
- **`email:send` is gated by configuration, not just the capability.** A plugin can declare `email:send`, but `ctx.email` will only be populated if some other plugin has registered an `email:deliver` transport.
Expand All @@ -63,6 +65,22 @@ const post = await ctx.content.create(

Locale matching is case-insensitive and stores the casing from the site's locale configuration, so `zh-tw` becomes `zh-TW` when that is the configured form. A malformed explicit locale always throws; when i18n is configured, an explicit locale outside the configured locale list also throws. When the option is omitted, EmDash uses the site's configured default locale; sites without i18n configuration retain the `en` default.

### Reading and moderating comments

`comments:read` grants access to non-trashed comments. Results include the author name and email address, comment body, pseudonymous IP hash, user agent, moderation metadata, status, target content IDs, and timestamps. They exclude the linked EmDash user-account ID. Declare `users:read` separately when a plugin also needs to look up user accounts.

`list()` returns newest comments first. It accepts `status`, `collection`, and `contentId` filters, a cursor, and a limit from 1 to 100. The default limit is 50. `count()` accepts the same filters without pagination.

The following route approves a comment only if it is still pending:

```ts title="src/plugin.ts"
const comment = await ctx.comments!.setStatus!(commentId, "approved", {
expectedStatus: "pending",
});
```

If another moderator changed the status after the plugin read it, `setStatus()` rejects with `COMMENT_STATUS_CONFLICT`. Read the comment again and recompute the decision before retrying. A request that overlaps an earlier transition before its status is visible rejects with `COMMENT_MODERATION_IN_PROGRESS`; wait for that transition to finish, then read the current comment before retrying. A successful transition runs `comment:afterModerate` once with `origin: { source: "plugin", pluginId }`. Approval sends the same core author notification as an administrator approval. Setting a comment to its current status is a no-op and does not run the hook or send another notification.

## Network host allowlists

Plugins with `network:request` can only fetch hosts listed in `allowedHosts`. A leading `*.` matches both the named domain and its subdomains:
Expand All @@ -85,7 +103,7 @@ When a sandbox runner is active, the runtime enforces:

<Steps>

1. **Capability gating.** The PluginContext factory only populates `ctx.content`, `ctx.taxonomies`, `ctx.media`, `ctx.http`, `ctx.users`, `ctx.email` when the corresponding capability is declared. Calling a method on an undeclared capability isn't possible — there's no object there.
1. **Capability gating.** The PluginContext factory only populates `ctx.content`, `ctx.comments`, `ctx.taxonomies`, `ctx.media`, `ctx.http`, `ctx.users`, `ctx.email` when the corresponding capability is declared. Calling a method on an undeclared capability isn't possible — there's no object there.

2. **Storage and KV scoping.** Every storage and KV operation is scoped to the runtime plugin ID. A plugin can't read another plugin's KV or storage collections, and it can access only collections declared in its manifest.

Expand Down
7 changes: 5 additions & 2 deletions docs/src/content/docs/reference/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ hooks: {

## Comment hooks

Comment hooks run in order: `comment:beforeCreate`, then `comment:moderate`, then `comment:afterCreate`. The `comment:afterModerate` hook fires separately when an admin changes a comment's status.
Comment hooks run in order: `comment:beforeCreate`, then `comment:moderate`, then `comment:afterCreate`. The `comment:afterModerate` hook runs separately when an administrator or an authorized plugin changes a comment's status.

After a comment is stored, hooks receive this record shape:

Expand Down Expand Up @@ -709,7 +709,7 @@ The hook has no return value.

**Capability:** `users:read`

Fire-and-forget hook when an admin manually changes a comment's status.
Runs after an administrator or a plugin changes a comment's status. A successful transition runs the hook once. Hook errors are logged and do not undo the status change.

#### Event

Expand All @@ -719,6 +719,9 @@ interface CommentAfterModerateEvent {
previousStatus: string;
newStatus: string;
moderator: { id: string; name: string | null };
origin?:
| { source: "admin"; userId: string }
| { source: "plugin"; pluginId: string };
}
```

Expand Down
2 changes: 2 additions & 0 deletions packages/admin/src/lib/api/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ export const CAPABILITY_LABELS: Record<string, MessageDescriptor> = {
// Canonical
"content:read": msg`Read your content`,
"content:write": msg`Create, update, and delete content`,
"comments:read": msg`Read comment bodies, author email addresses, pseudonymous IP hashes, user agents, and moderation metadata`,
"comments:moderate": msg`Approve comments and mark them as pending or spam`,
"taxonomies:read": msg`Read your taxonomies and terms`,
"media:read": msg`Access your media library`,
"media:write": msg`Upload and manage media`,
Expand Down
3 changes: 3 additions & 0 deletions packages/admin/tests/lib/marketplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ describe("describeCapability", () => {
it("returns known capability label", () => {
expect(describeCapability("read:content")).toBe("Read your content");
expect(describeCapability("write:media")).toBe("Upload and manage media");
expect(describeCapability("comments:read")).toContain("author email addresses");
});

it("returns raw capability string for unknown capabilities", () => {
Expand Down Expand Up @@ -386,6 +387,8 @@ describe("CAPABILITY_LABELS", () => {
// Canonical
"content:read",
"content:write",
"comments:read",
"comments:moderate",
"taxonomies:read",
"media:read",
"media:write",
Expand Down
98 changes: 98 additions & 0 deletions packages/cloudflare/src/sandbox/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
} from "emdash";
import {
ContentRepository,
createCommentAccess,
CronAccessImpl,
createContentAccess,
createSandboxRouteError,
Expand All @@ -31,6 +32,14 @@ import {
StorageSerializationError,
resolveContentCreateLocale,
} from "emdash";
import type {
CommentCountOptions,
CommentListOptions,
PluginComment,
PluginCommentStatus,
PaginatedResult,
SandboxCommentModerateCallback,
} from "emdash";
import { Kysely } from "kysely";
import { D1Dialect } from "kysely-d1";

Expand Down Expand Up @@ -61,6 +70,11 @@ const SYSTEM_COLUMNS = new Set([

/** Regex to validate file extensions (simple alphanumeric, 1-10 chars) */
const FILE_EXT_REGEX = /^\.[a-z0-9]{1,10}$/i;
const COMMENT_STATUSES = new Set<string>(["approved", "pending", "spam"]);

function invalidCommentStatus(value: string, name: string): string | null {
return COMMENT_STATUSES.has(value) ? null : `${name} must be one of: approved, pending, spam`;
}

/**
* Module-level email send callback.
Expand All @@ -74,6 +88,7 @@ const FILE_EXT_REGEX = /^\.[a-z0-9]{1,10}$/i;
let emailSendCallback: SandboxEmailSendCallback | null = null;
let cronRescheduleCallback: (() => void) | null = null;
let cronNowCallback: (() => Date) | null = null;
let commentModerateCallback: SandboxCommentModerateCallback | null = null;

/**
* Set the email send callback for all bridge instances.
Expand All @@ -91,6 +106,10 @@ export function setCronNowCallback(callback: (() => Date) | null): void {
cronNowCallback = callback;
}

export function setCommentModerateCallback(callback: SandboxCommentModerateCallback | null): void {
commentModerateCallback = callback;
}

function serializeValue(value: unknown): unknown {
if (value === null || value === undefined) return null;
if (typeof value === "boolean") return value ? 1 : 0;
Expand Down Expand Up @@ -772,6 +791,85 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
return (result.meta?.changes ?? 0) > 0;
}

async commentGet(id: string): Promise<PluginComment | null> {
if (!this.ctx.props.capabilities.includes("comments:read")) {
throw new Error("Missing capability: comments:read");
}
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
return createCommentAccess(db).get(id);
}

async commentList(opts: CommentListOptions = {}): Promise<PaginatedResult<PluginComment>> {
if (!this.ctx.props.capabilities.includes("comments:read")) {
throw new Error("Missing capability: comments:read");
}
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
return createCommentAccess(db).list(opts);
}

async commentCount(opts: CommentCountOptions = {}): Promise<number> {
if (!this.ctx.props.capabilities.includes("comments:read")) {
throw new Error("Missing capability: comments:read");
}
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
return createCommentAccess(db).count(opts);
}

async commentSetStatus(
id: string,
status: PluginCommentStatus,
expectedStatus: PluginCommentStatus,
): Promise<
| PluginComment
| {
__emdashCommentError: {
code:
| "COMMENT_STATUS_CONFLICT"
| "COMMENT_MODERATION_IN_PROGRESS"
| "COMMENT_STATUS_INVALID";
message: string;
currentStatus?: string;
};
}
> {
if (!this.ctx.props.capabilities.includes("comments:moderate")) {
throw new Error("Missing capability: comments:moderate");
}
const invalid =
invalidCommentStatus(status, "status") ??
invalidCommentStatus(expectedStatus, "expectedStatus");
if (invalid) {
return {
__emdashCommentError: {
code: "COMMENT_STATUS_INVALID",
message: invalid,
},
};
}
if (!commentModerateCallback) throw new Error("Comment moderation is unavailable");
try {
return await commentModerateCallback(this.ctx.props.pluginId, id, status, expectedStatus);
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error) {
const code = error.code;
const currentStatus = "currentStatus" in error ? error.currentStatus : undefined;
if (
(code === "COMMENT_STATUS_CONFLICT" && typeof currentStatus === "string") ||
code === "COMMENT_MODERATION_IN_PROGRESS"
) {
return {
__emdashCommentError: {
code,
message: error instanceof Error ? error.message : "Comment moderation failed",
...(typeof currentStatus === "string" ? { currentStatus } : {}),
},
};
}
}
throw error;
}
}

// =========================================================================
// Taxonomy Operations (read-only) - gated on taxonomies:read
// =========================================================================
Expand Down
Loading
Loading