沙箱外掛預設是隔離的。除了讀寫自己的 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 了解打包檢查。