沙箱插件默认是隔离的。除了读写自己的 KV 与 storage 之外,插件若要做任何事,都必须在其 manifest 中声明一项 capability。沙箱 bridge 会根据这些声明,对主机提供的每一项 API 进行门控——未声明 content:read 的插件不会获得 ctx.content,未声明 network:request 的插件不会获得 ctx.http。
本页说明每项 capability 授予什么、沙箱如何强制执行它们,以及哪些内容不在强制范围内。
声明 capabilities
Capabilities 写在 emdash-plugin.jsonc 中,与 slug 及其余信任契约一起:
{
"slug": "plugin-hello",
// ...identity + profile...
"capabilities": ["content:read", "network:request"],
"allowedHosts": ["api.example.com"]
}
只声明插件真正需要的内容。注册表会在安装前向站点运营者展示这些 capabilities,因此每多声明一项,都会要求他们批准插件并未使用的访问权限。
Capability 参考
| Capability | 授予的访问权限 |
|---|---|
content:read | ctx.content.get()、ctx.content.list()、ctx.content.getTranslations()、ctx.content.getPublicUrl() |
content:revisions:read | ctx.content.listRevisions()、ctx.content.getRevision()(隐含 content:read) |
content:write | ctx.content.create()、ctx.content.update()、ctx.content.delete()(隐含 content:read) |
content:publish | 版本化的发布、取消发布、排期与取消排期操作(隐含 content:read) |
content:restore | 读取并恢复回收站中的内容 |
comments:read | ctx.comments.get()、ctx.comments.list()、ctx.comments.count() 以及评论的个人数据 |
comments:moderate | 带期望状态并发控制的 ctx.comments.setStatus()(隐含 comments:read) |
schema:read | ctx.schema.listCollections()、ctx.schema.getCollection() |
hooks.content-policy:register | content:beforePublish、content:beforeSchedule 与 content:beforeUnpublish 策略钩子 |
taxonomies:read | ctx.taxonomies.getAll()、ctx.taxonomies.getTerms()、ctx.taxonomies.getEntryTerms() |
taxonomies:write | ctx.taxonomies.createTerm()、ctx.taxonomies.addEntryTerms()、ctx.taxonomies.removeEntryTerms()(隐含 taxonomies:read) |
redirects:read | ctx.redirects.list()、ctx.redirects.get() |
redirects:write | ctx.redirects.create()、ctx.redirects.update()、ctx.redirects.delete()(隐含 redirects:read) |
media:read | ctx.media.get()、ctx.media.list() |
media:bytes:read | 对就绪媒体的 ctx.media.readBytes(),带缓冲上限的响应 |
media:metadata:write | 用于替代文本、说明文字与焦点的 ctx.media.updateMetadata() |
media:write | ctx.media.getUploadUrl()、ctx.media.upload()、ctx.media.delete()(隐含 media:read) |
network:request | ctx.http.fetch() — 受 allowedHosts 限制 |
network:request:unrestricted | 无主机限制的 ctx.http.fetch()(仅用于用户配置的 URL) |
users:read | ctx.users.get()、ctx.users.getByEmail()、ctx.users.list() |
email:send | ctx.email.send()(需要已配置的邮件提供商插件) |
hooks.email-transport:register | 允许注册独占的 email:deliver 钩子(传输提供商) |
hooks.email-events:register | 允许注册 email:beforeSend / email:afterSend 钩子 |
hooks.page-fragments:register | 允许注册 page:fragments 钩子(仅限原生插件) |
以下规则会影响插件需要哪些 capabilities:
- 隐含关系。
content:write、content:revisions:read与content:publish会自动隐含content:read;comments:moderate隐含comments:read;taxonomies:write隐含taxonomies:read;media:write隐含media:read;redirects:write隐含redirects:read;network:request:unrestricted隐含network:request。你无需两者都列出。 - 媒体权限是分开的。
media:read、media:bytes:read与media:metadata:write互不隐含。插件用到的每一项操作都要单独声明。现有的media:writecapability 为兼容性仍隐含media:read。 - 分类法与内容是分开的。 分类法 capabilities 不会授予
content:read或content:write。若插件还要读取或编辑条目字段,请声明相应的内容 capability。 - 发布策略与内容访问是分开的。
hooks.content-policy:register允许插件通过策略钩子事件检查并拒绝发布状态变更。它不提供ctx.content,也不授予内容编辑或发布操作。 network:request:unrestricted用于用户配置的 URL。 运营者自行输入目标 URL 的 webhook 类插件,需要访问清单中未列出的主机。始终调用已知 API 的插件应使用network:request+allowedHosts。email:send受配置门控,而不仅仅是 capability。 插件可以声明email:send,但只有当某个其他插件已注册email:deliver传输时,ctx.email才会被填充。
content:read 返回安全的条目身份信息,包括作者 ID、翻译组、修订指针与行版本。使用 getTranslations() 发现同级语言版本,使用 getPublicUrl() 按站点的语言与尾部斜杠规则解析已发布路由。对于草稿、不可路由的集合、缺失的 slug,以及站点不提供服务的语言,getPublicUrl() 返回 null。它从不返回预览 URL。
修订快照可能包含管理员后来删除的字段值。仅在插件需要保留的历史时声明 content:revisions:read。修订结果会省略修订作者身份。
schema:read 暴露集合与字段定义,不含数据库 ID、时间戳、迁移元数据或 SQL 列类型。隐藏集合仍然可见,因为 hidden 控制的是管理导航,而非数据访问。
创建与翻译内容
ctx.content.create() 接受可选的第三个参数,用于指定新条目的语言:
const post = await ctx.content.create(
"posts",
{ title: "繁體中文" },
{ locale: "zh-tw" },
);
语言匹配不区分大小写,并会存储站点语言配置中的大小写形式,因此当配置为 zh-TW 时,zh-tw 会变为 zh-TW。格式错误的显式语言总是会抛错;当已配置 i18n 时,不在已配置列表中的显式语言也会抛错。省略该选项时,EmDash 使用站点已配置的默认语言;未配置 i18n 的站点保持 en 默认值。
要为已有条目添加一种语言,将其数据库 ID 作为 translationOf 传入:
const translatedPost = await ctx.content.create(
"posts",
{ title: "Bienvenue", sku: "ignored-for-shared-fields" },
{ locale: "fr", translationOf: sourcePost.id },
);
源条目必须是同一集合中的活动条目。新条目加入其翻译组,继承其署名积分与分类法分配,并以源条目的值作为标记为不可翻译字段的起始值。为不可翻译字段提供的值在创建翻译时不会覆盖源值。内容验证与保存钩子走与其他内容创建相同的运行时路径。EmDash 不会重入创建方自己的 content:afterSave 钩子,从保存钩子内部创建的内容也不会再次运行保存钩子。
每个翻译组每个语言只能有一个活动条目。为同一组与语言创建第二个条目会抛出 CONFLICT 错误。缺失的源抛出 NOT_FOUND,无效或未配置的语言抛出 VALIDATION_ERROR,保存钩子可用 SAVE_REJECTED 阻止创建。
更改发布状态
声明 content:publish 以发布、取消发布、排期或取消排期条目。每项操作都需要 getVersioned() 或上一次操作返回的不透明 _rev。EmDash 将这些方法路由到与 REST 和 MCP 操作相同的策略钩子、修订提升、语言同步、重定向、媒体使用更新、缓存失效与 after-hooks。
以下路由仅在自读取以来条目未更改时发布当前草稿:
const current = await ctx.content!.getVersioned!("posts", postId);
if (!current) return { ok: false, error: "NOT_FOUND" };
try {
const published = await ctx.content!.publish!("posts", postId, {
_rev: current._rev,
});
return { ok: true, content: published.item, _rev: published._rev };
} catch (error) {
return { ok: false, error: "PUBLISH_FAILED" };
}
schedule() 接受 { scheduledAt, _rev };其他发布方法接受 { _rev }。这些方法不接受 publishedAt 覆盖。
单独声明 content:restore 以读取并恢复回收站中的条目。对于活动或缺失的条目,getTrashedVersioned() 返回 null。将其 _rev 传给 restore(),以便并发更改返回冲突,而不是恢复过时状态。
创建并分配分类术语
taxonomies:write 允许插件创建术语并应用分配增量。传入术语行 ID 或翻译组 ID。不接受术语 slug,因为它们按分类法与语言限定。
以下示例创建子类别并将其分配,而不替换条目的其他类别:
const releaseNotes = await ctx.taxonomies!.createTerm!("category", {
label: "Release notes",
parentId: productUpdatesId,
locale: "en",
});
await ctx.taxonomies!.addEntryTerms!("posts", postId, "category", [releaseNotes.id]);
addEntryTerms() 与 removeEntryTerms() 是幂等的集合增量。并发添加会保留各项分配。EmDash 检查分类法是否已附加到集合、条目是否存在,以及每个术语是否属于指定的分类法。当分类法不是层级式时,createTerm() 会拒绝 parentId,而不是忽略它。使用 translationOf 创建翻译术语会加入源术语的翻译组;源必须属于同一分类法,且该组每种语言只能有一个术语。
分类法定义创建、集合附加、替换、术语更新与术语删除不通过 taxonomies:write 提供。
读取媒体元数据与字节
media:read 返回就绪的媒体记录,包含尺寸、替代文本、说明文字、焦点、blurhash、主色、文件夹 ID,以及基于 ID 的已认证资产 URL。具有 media:read 权限的已认证调用方可跟随该 URL;未登录请求会在路由读取媒体记录之前被拒绝。元数据不返回存储键、作者身份、内容哈希或文件字节。内容哈希仅在 readBytes() 中可用,因为它可能暴露站点是否存储了某个已知文件。
在钩子或路由处理器中,以下调用最多读取 2 MiB 的就绪媒体项:
const file = await ctx.media!.readBytes!(mediaId, {
maxBytes: 2 * 1024 * 1024,
});
const digest = file.contentHash;
const bytes = file.bytes;
readBytes() 会缓冲结果。省略 maxBytes 时默认为 10 MiB,并拒绝高于主机最大值 16 MiB 的值。EmDash 在消费存储流时计数字节,因此错误的已存储大小无法绕过请求的上限。缺失、待处理与失败的媒体会被拒绝,且不暴露其存储位置。
以下更新更改无访问性文本与焦点,而不授予上传、替换或删除权限:
const updated = await ctx.media!.updateMetadata!(mediaId, {
alt: "Two people reviewing a printed proof",
focalX: 0.42,
focalY: 0.36,
});
将两个焦点坐标都作为 0 到 1 的数字提供,或将两者都设为 null。对不同元数据字段的并发补丁不会互相覆盖。
上传媒体
ctx.media.upload() 接受图像、视频、音频与 PDF 内容,并对任何其他内容类型抛错。在受信任插件中,upload() 与 getUploadUrl() 强制执行默认媒体上传允许列表:PNG、JPEG、GIF、WebP 与 AVIF 图像、任何 video/* 或 audio/* 类型,以及 application/pdf。其他类型抛出状态为 415 的 PluginRouteError,格式错误的内容类型抛出状态为 400 的错误;路由处理器可将任一错误作为响应向上传播。在受信任插件中,由 upload() 存储或由 getUploadUrl() 预留的文件还会采用与内容类型匹配的扩展名,无论文件名扩展名是什么;当内容类型没有已知扩展名时,仅当文件名扩展名属于允许的媒体类型时才会保留。沙箱插件在文件名扩展名为 1–10 个字母或数字时保留该扩展名。
安全地管理重定向
redirects:read 提供基于游标的分页规则列表,以及单条规则的版本化读取。当插件创建、更新或删除规则时,添加 redirects:write。写访问可以改变访客被发送到的位置。
更新或删除规则时,原样传入 get()、create() 或 update() 返回的 _rev。EmDash 会拒绝过时的修订,以便插件可以重新读取规则并重新计算其更改,而不是覆盖并发工作。
修订跟踪重定向配置。访问计数不会使修订过时。
以下示例仅在自读取以来未更改时更新重定向:
const current = await ctx.redirects!.get(redirectId);
if (current) {
await ctx.redirects!.update!(redirectId, {
destination: "/guides/current",
_rev: current._rev,
});
}
创建操作对路径模式、终端 410 与 451 规则、重复源、自循环以及多跳循环使用与 EmDash 重定向 API 相同的规则进行验证。当源或目标更改时,更新会应用循环验证。仅启用的更新可能重新激活预先存在的循环,Redirects 页面会报告这一点。auto 标记属于从主机内容变更创建的重定向;插件输入不能设置它。
读取与审核评论
comments:read 授予对未移入回收站的评论的访问权限。结果包括作者姓名与电子邮件地址、评论正文、假名化 IP 哈希、用户代理、审核元数据、状态、目标内容 ID 与时间戳。它们排除关联的 EmDash 用户账户 ID。当插件还需要查找用户账户时,请单独声明 users:read。
list() 先返回最新评论。它接受 status、collection 与 contentId 过滤器、游标,以及 1 到 100 的限制。默认限制为 50。count() 接受相同的过滤器,不含分页。
以下路由仅在评论仍为待处理时批准它:
const comment = await ctx.comments!.setStatus!(commentId, "approved", {
expectedStatus: "pending",
});
若另一位审核者在插件读取后更改了状态,setStatus() 会以 COMMENT_STATUS_CONFLICT 拒绝。再次读取评论并重新计算决定后再重试。在先前转换的状态可见之前重叠的请求会以 COMMENT_MODERATION_IN_PROGRESS 拒绝;等待该转换完成,然后读取当前评论再重试。成功的转换会以 origin: { source: "plugin", pluginId } 运行一次 comment:afterModerate。批准会发送与管理员批准相同的核心作者通知。将评论设为其当前状态是空操作,不会运行钩子或发送另一次通知。
网络主机允许列表
具有 network:request 的插件只能获取列在 allowedHosts 中的主机。前导的 *. 同时匹配指定域名及其子域名:
"capabilities": ["network:request"],
"allowedHosts": [
"api.example.com", // exact host
"*.cdn.example.com" // cdn.example.com and any subdomain
]
Bridge 在转发请求之前,会对照允许列表检查请求 URL 的主机。对未声明主机的请求会在插件内抛错,而不会离开沙箱。
network:request:unrestricted 绕过清单主机列表。沙箱 bridge 仍只接受 HTTP 与 HTTPS,阻止已知的内部主机与字面私有地址,重新检查每次重定向,并在跨源重定向时剥离凭证头。仅在运营者在运行时提供目标时使用不受限制的访问。对于固定目标,声明带有显式主机的 network:request,以便同意对话框能点名这些主机。
ctx.http.fetch() 会缓冲请求与响应体,并将每个解码后的主体限制为 8 MiB。返回的 WHATWG Response 在两个沙箱 runner 上都保留二进制字节、状态文本、头、最终 URL、重定向状态与 clone() 行为。使用 arrayBuffer() 或 blob() 读取二进制数据。
沙箱强制执行什么
当沙箱 runner 处于活动状态时,运行时会强制执行:
-
按 capability 门控。 PluginContext 工厂仅在声明了相应 capability 时填充
ctx.content、ctx.comments、ctx.schema、ctx.taxonomies、ctx.redirects、ctx.media、ctx.http、ctx.users、ctx.email。无法在未声明的 capability 上调用方法——那里根本没有对象。 -
Storage 与 KV 作用域。 每一次 storage 与 KV 操作都限定在运行时的插件 ID 内。插件无法读取其他插件的 KV 或 storage 集合,且只能访问其清单中声明的集合。
-
网络隔离。 Runner 会阻止直接的
fetch()及其他网络原语。到达网络的唯一方式是ctx.http.fetch(),它会经过 bridge 的主机验证。 -
无主机绑定。 沙箱插件看不到环境变量、文件系统或平台绑定——即使其主机 worker 拥有它们。插件运行时是一个干净的 isolate,仅包含 bridge 与已声明的 capabilities。
-
资源限制。 Cloudflare runner 默认为每次调用 50 ms CPU、10 个子请求与 30 秒挂钟时间。Worker Loader 强制执行 CPU 与子请求;runner 强制执行挂钟时间。Worker Loader 有平台内存上限,但其按插件的
memoryMb选项目前不可强制执行。Node.js workerd runner 仅强制执行默认的 30 秒挂钟时间;当站点配置了独立 workerd 无法强制执行的 CPU、内存或子请求限制时,它会发出警告。按钩子的timeout仅在进程内运行的沙箱格式插件上适用。
沙箱不强制执行什么
能力系统不覆盖也无法覆盖的一些内容:
- 已授予 capability 内的行为。 具有
content:write的插件可以编辑任何内容,而不仅是它自己的。Capabilities 是粗粒度的——它们表示「此插件可以写入内容」,而不是「此插件只能写入它创建的内容」。运营者在授予该访问权限之前应评估插件的代码与发布者。 - 条目编辑锁。
ctx.content.update()与ctx.content.delete()是程序化写入。持有条目咨询性编辑锁的编辑者不会阻止它们。当两者都可能更新同一条目时,请与编辑者协调插件写入。 - Node.js 上的运营者信任。 当已配置的沙箱 runner 报告不可用时(没有 Cloudflare Worker Loader、没有安装 Node 侧 runner 等),
sandboxed: []插件会在启动时被跳过。你可以将它们移到plugins: []以在进程内运行——但那样就没有 V8 isolate、没有资源限制,插件可以直接调用fetch()或读取环境变量。将此视为原生级信任。 - 侧信道。 时序、日志输出与存储数据对具有适当主机环境访问权限的人可见。不要将沙箱用作对抗运行它的运营者的机密性边界。
Capability 同意
当运营者从注册表安装沙箱插件时,EmDash 会显示列出已声明 capabilities 的同意对话框。添加 capabilities 的更新——例如,之前仅读取内容、现在想发起网络请求的插件——会显示为 capability 差异,并需要新的批准后新版本才会生效。
为可能的未来用途声明 capabilities,会使每次安装或更新都请求不必要的访问。列出当前版本使用的内容,然后在开始使用该项 capability 的版本中再添加它。
打包时验证
emdash-plugin bundle 与 emdash-plugin publish 会运行额外检查:
- 每个已声明的 capability 都必须在已识别集合中(拼写错误会使构建失败)。
network:request需要非空的allowedHosts;network:request:unrestricted需要它为空。参见 Capabilities and hosts。- 打包的
backend.js不能导入 Node.js 内置模块(fs、path、child_process等)——沙箱运行时不提供它们。
参见 the manifest reference 了解编写字段,以及 Bundling and publishing 了解打包检查。