샌드박스 플러그인은 기본적으로 격리됩니다. 자체 KV와 스토리지 읽기·쓰기를 넘어선 작업을 하려면 플러그인은 매니페스트에 capability를 선언해야 합니다. 샌드박스 브리지는 해당 선언을 기준으로 호스트가 제공하는 모든 API를 게이트합니다. content:read를 선언하지 않은 플러그인은 ctx.content를 얻지 못하고, network:request를 선언하지 않은 플러그인은 ctx.http를 얻지 못합니다.
이 페이지는 각 capability가 무엇을 부여하는지, 샌드박스가 어떻게 강제하는지, 강제할 수 없는 것은 무엇인지 다룹니다.
Capability 선언
Capability는 emdash-plugin.jsonc에 있으며 slug와 신뢰 계약의 나머지와 함께 있습니다.
{
"slug": "plugin-hello",
// ...identity + profile...
"capabilities": ["content:read", "network:request"],
"allowedHosts": ["api.example.com"]
}
플러그인이 실제로 필요한 것만 선언하세요. 레지스트리는 설치 전에 이러한 capability를 사이트 운영자에게 보여 주므로, 추가 선언마다 플러그인이 사용하지 않는 접근 승인을 요청합니다.
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 훅 등록 허용 (네이티브 플러그인만) |
다음 규칙은 플러그인에 필요한 capability에 영향을 줍니다.
- 함의.
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를 함의합니다. - 택소노미는 콘텐츠와 분리됩니다. 택소노미 capability는
content:read나content:write를 부여하지 않습니다. 플러그인이 항목 필드도 읽거나 편집하면 일치하는 콘텐츠 capability를 선언하세요. - 게시 정책은 콘텐츠 접근과 분리됩니다.
hooks.content-policy:register는 정책 훅 이벤트를 통해 게시 상태 변경을 검사하고 거부할 수 있게 합니다.ctx.content를 제공하지 않으며 콘텐츠 편집이나 게시 액션도 부여하지 않습니다. network:request:unrestricted는 사용자 구성 URL용입니다. 운영자가 대상 URL을 입력하는 웹훅 플러그인은 매니페스트에 없는 호스트에 도달해야 합니다. 항상 알려진 API를 호출하는 플러그인은network:request+allowedHosts를 사용해야 합니다.email:send는 capability뿐 아니라 구성으로도 게이트됩니다. 플러그인이email:send를 선언할 수 있지만,ctx.email은 다른 플러그인이email:deliver전송을 등록한 경우에만 채워집니다.
content:read는 작성자 ID, 번역 그룹, 리비전 포인터, 행 버전을 포함한 안전한 항목 신원을 반환합니다. getTranslations()로 로케일 형제를 찾고, getPublicUrl()로 사이트의 로케일 및 후행 슬래시 규칙에 맞는 게시된 라우트를 해석하세요. 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가 됩니다. 잘못된 명시 로케일은 항상 예외를 던집니다. 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-훅을 통해 라우팅합니다.
다음 라우트는 읽은 이후 항목이 변경되지 않았을 때만 현재 초안을 게시합니다.
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를 전달하세요. 용어 슬러그는 택소노미와 로케일로 범위가 정해지므로 받지 않습니다.
다음 예는 자식 카테고리를 만들고 항목의 다른 카테고리를 교체하지 않고 할당합니다.
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,
});
}
생성 작업은 EmDash 리다이렉트 API와 같은 규칙으로 경로 패턴, 단말 410 및 451 규칙, 중복 소스, 자기 루프, 다중 홉 루프를 검증합니다. 업데이트는 소스 또는 대상이 바뀔 때 루프 검증을 적용합니다. 활성화만의 업데이트는 기존 루프를 다시 활성화할 수 있으며 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를 한 번 실행합니다. 승인은 관리자 승인과 같은 코어 작성자 알림을 보냅니다. 댓글을 현재 상태로 설정하는 것은 no-op이며 훅을 실행하거나 다른 알림을 보내지 않습니다.
네트워크 호스트 허용 목록
network:request가 있는 플러그인은 allowedHosts에 나열된 호스트만 가져올 수 있습니다. 선행 *.는 명명된 도메인과 그 하위 도메인 모두에 일치합니다.
"capabilities": ["network:request"],
"allowedHosts": [
"api.example.com", // exact host
"*.cdn.example.com" // cdn.example.com and any subdomain
]
브리지는 요청을 전달하기 전에 요청 URL의 호스트를 허용 목록과 대조합니다. 선언되지 않은 호스트에 대한 요청은 샌드박스를 떠나지 않고 플러그인 안에서 예외를 던집니다.
network:request:unrestricted는 매니페스트 호스트 허용 목록을 건너뜁니다. 샌드박스 브리지는 여전히 HTTP와 HTTPS만 받아들이고, 알려진 내부 호스트와 개인 리터럴 주소를 차단하며, 모든 리다이렉트를 다시 확인하고, 리다이렉트가 출처를 가로지를 때 자격 증명 헤더를 제거합니다. 제한 없는 접근은 운영자가 런타임에 대상을 제공할 때만 사용하세요. 고정 대상에는 명시 호스트와 함께 network:request를 선언해 동의 대화상자가 이름을 표시하게 하세요.
ctx.http.fetch()는 요청과 응답 본문을 버퍼링하고 각 디코딩된 본문을 8 MiB로 제한합니다. 반환된 WHATWG Response는 두 샌드박스 러너 모두에서 바이너리 바이트, 상태 텍스트, 헤더, 최종 URL, 리다이렉트 상태, clone() 동작을 보존합니다. 바이너리 데이터는 arrayBuffer() 또는 blob()으로 읽으세요.
샌드박스가 강제하는 것
샌드박스 러너가 활성일 때 런타임은 다음을 강제합니다.
-
Capability 게이팅. PluginContext 팩토리는 해당 capability가 선언된 경우에만
ctx.content,ctx.comments,ctx.schema,ctx.taxonomies,ctx.redirects,ctx.media,ctx.http,ctx.users,ctx.email을 채웁니다. 선언되지 않은 capability의 메서드를 호출하는 것은 불가능합니다. 거기에 객체가 없습니다. -
스토리지와 KV 범위. 모든 스토리지와 KV 작업은 런타임 플러그인 ID로 범위가 정해집니다. 플러그인은 다른 플러그인의 KV나 스토리지 컬렉션을 읽을 수 없으며, 매니페스트에 선언된 컬렉션에만 접근할 수 있습니다.
-
네트워크 격리. 직접
fetch()와 다른 네트워크 프리미티브는 러너가 차단합니다. 네트워크에 도달하는 유일한 방법은 브리지의 호스트 검증을 거치는ctx.http.fetch()입니다. -
호스트 바인딩 없음. 샌드박스 플러그인은 환경 변수, 파일 시스템, 플랫폼 바인딩을 보지 않습니다. 호스트 워커에 있어도 마찬가지입니다. 플러그인 런타임은 브리지와 선언된 capability만 있는 깨끗한 아이솔레이트입니다.
-
리소스 한도. Cloudflare 러너의 기본값은 호출당 CPU 50ms, 서브요청 10개, 월 타임 30초입니다. Worker Loader는 CPU와 서브요청을 강제하고, 러너는 월 타임을 강제합니다. Worker Loader에는 플랫폼 메모리 상한이 있지만 플러그인별
memoryMb옵션은 현재 강제할 수 없습니다. Node.js workerd 러너는 30초 월 타임 기본값만 강제하며, 독립 실행형 workerd가 강제할 수 없는 CPU·메모리·서브요청 한도를 사이트가 구성하면 경고합니다. 훅별timeout은 샌드박스 형식 플러그인이 프로세스 내에서 실행될 때만 적용됩니다.
샌드박스가 강제하지 않는 것
capability 시스템이 다루지 못하고 다룰 수 없는 몇 가지가 있습니다.
- 부여된 capability 안의 동작.
content:write가 있는 플러그인은 자신의 것만이 아니라 어떤 콘텐츠든 편집할 수 있습니다. Capability는 거칠습니다. 「이 플러그인은 콘텐츠를 쓸 수 있다」고 말하지 「이 플러그인이 만든 콘텐츠만 쓸 수 있다」고 말하지 않습니다. 운영자는 그 접근을 부여하기 전에 플러그인 코드와 게시자를 평가해야 합니다. - 항목 편집 잠금.
ctx.content.update()와ctx.content.delete()는 프로그래밍 방식 쓰기입니다. 항목의 권고 편집 잠금을 가진 편집자는 이를 막지 않습니다. 둘 다 같은 항목을 업데이트할 수 있으면 플러그인 쓰기를 편집자와 조율하세요. - Node.js에서의 운영자 신뢰. 구성된 샌드박스 러너가 사용 불가를 보고하면(Cloudflare Worker Loader 없음, Node 측 러너 미설치 등)
sandboxed: []플러그인은 시작 시 건너뜁니다.plugins: []로 옮겨 프로세스 내에서 실행할 수 있지만, 그때는 V8 아이솔레이트도 리소스 한도도 없고 플러그인이fetch()를 직접 호출하거나 환경 변수를 읽을 수 있습니다. 이를 네이티브 수준 신뢰로 취급하세요. - 사이드 채널. 타이밍, 로그 출력, 저장된 데이터는 호스트 환경에 적절한 접근이 있는 누구에게나 보입니다. 샌드박스를 이를 실행하는 운영자에 대한 기밀성 경계로 사용하지 마세요.
Capability 동의
운영자가 레지스트리에서 샌드박스 플러그인을 설치하면 EmDash는 선언된 capability를 나열하는 동의 대화상자를 표시합니다. capability를 추가하는 업데이트 — 예를 들어 이전에 콘텐츠만 읽던 플러그인이 이제 네트워크 요청을 하려는 경우 — 는 capability 차이로 나타나며 새 버전이 적용되기 전에 새로운 승인이 필요합니다.
가능한 향후 사용을 위해 capability를 선언하면 모든 설치나 업데이트가 불필요한 접근을 요청합니다. 현재 버전이 사용하는 것을 나열한 뒤, 사용을 시작하는 버전에서 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을 참고하세요.