플러그인은 관리자 UI와 외부 통합을 위해 API 라우트를 노출할 수 있습니다. 라우트는 /_emdash/api/plugins/<slug>/<route-name> 아래에 마운트되며(<slug>는 emdash-plugin.jsonc에 있는 플러그인의 slug 필드이고, 런타임에는 ctx.plugin.id로 노출됩니다), 훅이 받는 것과 같은 PluginContext를 사용해 샌드박스 런타임 안에서 실행됩니다.
이 페이지는 샌드박스된 플러그인을 다룹니다. 네이티브 플러그인도 같은 라우트 옵션, 인증, URL 구조를 사용하지만, 핸들러는 하나로 합쳐진 컨텍스트 객체를 받습니다. 그 시그니처는 첫 번째 네이티브 플러그인을 참고하세요.
라우트 정의
라우트는 src/plugin.ts의 기본 내보내기에서 선언합니다. 라우트가 입력을 검증하거나 MCP 도구로 노출되는 경우에는 zod를 런타임 의존성으로 추가하세요.
pnpm add zod
다음 예시는 제출 요청을 검증하고 플러그인 스토리지를 쿼리합니다.
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const submissionsInput = z.object({
formId: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().optional(),
});
const plugin: SandboxedPlugin = {
routes: {
status: {
handler: async (_routeCtx, ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
submissions: {
handler: async (routeCtx, ctx) => {
const parsed = submissionsInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { formId, limit, cursor } = parsed.data;
const result = await ctx.storage.submissions.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return { ok: true, ...result };
},
},
},
};
export default plugin;
SandboxedPlugin 타입 주석은 라우트와 플러그인 컨텍스트의 타입을 추론하므로, 매개변수에는 주석이 필요 없습니다. 샌드박스된 라우트 핸들러는 두 개의 인수 (routeCtx, ctx)를 받습니다.
routeCtx는 요청과 관련된 데이터{ input, request, requestMeta }를 담고 있습니다.input은 여전히unknown이므로 사용하기 전에 검증하세요.ctx는 훅 안에서 받는 것과 같은PluginContext입니다.ctx.storage,ctx.settings,ctx.kv,ctx.content,ctx.http,ctx.log가 포함됩니다.
인덱싱된 콘텐츠 필드 필터링
content:read capability가 있는 플러그인은 컬렉션이 indexed로 표시한 사용자 지정 필드를 필터링할 수 있습니다. 필터는 데이터베이스에서 실행되며 AND 의미로 결합됩니다.
const result = await ctx.content.list("items", {
where: {
fieldFilters: {
priority: { in: ["urgent", "high"] },
score: { gte: 80 },
resolved: false,
},
},
});
스칼라 값은 정확히 일치하는 값만 비교합니다. null 일치에는 null을, 정확한 값의 집합에는 { in: [...] }를, 범위 비교에는 gt, gte, lt, lte를 사용합니다. EmDash는 인덱싱되지 않은 필드에 대한 필터, 필드 타입과 맞지 않는 값, 쿼리당 20개를 초과하는 필드 필터를 거부합니다. in 필터는 최대 50개의 값을 받으며, 모든 정확한 값, 범위 경계, in 구성원을 합쳐서 쿼리당 50개 피연산자의 한도가 있습니다. null 일치는 이 한도를 소모하지 않습니다.
라우트 URL
라우트는 /_emdash/api/plugins/<slug>/<route-name>에 마운트됩니다. 라우트 이름에는 슬래시를 포함해 중첩 경로를 만들 수 있습니다.
| 플러그인 id | 라우트 이름 | URL |
|---|---|---|
forms | status | /_emdash/api/plugins/forms/status |
forms | submissions | /_emdash/api/plugins/forms/submissions |
seo | settings/save | /_emdash/api/plugins/seo/settings/save |
analytics | events/recent | /_emdash/api/plugins/analytics/events/recent |
인증 및 CSRF
플러그인 라우트는 기본적으로 인증이 필요합니다. 디스패처는 핸들러를 호출하기 전에 세션(또는 admin 스코프가 있는 토큰)을 요구합니다. 하위 호환성을 위해 비공개 라우트의 기본 권한은 plugins:manage입니다. 작업이 기존의 콘텐츠, 미디어, 스키마, 설정 capability에 속한다면 permission을 더 좁은 EmDash RBAC 권한으로 설정하세요.
routes: {
create: {
permission: "content:create",
handler: async (routeCtx, ctx) => {
// Validate routeCtx.input, then create content through ctx.
},
},
},
비공개 라우트는 모든 HTTP 메서드에서 선언된 권한을 요구합니다. 쿠키로 인증된 요청에는 GET과 HEAD를 포함해 X-EmDash-Request: 1 CSRF 헤더도 필요합니다. 플러그인 라우트가 어떤 메서드에든 같은 핸들러를 실행할 수 있기 때문입니다. 관리자 UI는 이 헤더를 자동으로 보냅니다. 토큰으로 인증된 요청은 헤더가 면제되지만, 여전히 admin 토큰 스코프와 라우트 권한이 필요합니다.
라우트를 인증에서 제외하려면 public: true로 표시하세요.
routes: {
track: {
public: true,
handler: async (routeCtx, ctx) => {
const parsed = z.object({ event: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
ctx.log.info("Tracked", { event: parsed.data.event });
return { ok: true };
},
},
},
공개 라우트의 노출은 플러그인의 검토된 접근 범위의 일부입니다. 공개 라우트가 있는 플러그인을 설치하려면 동의가 필요합니다. 공개 라우트를 추가하거나 비공개 라우트를 공개로 바꾸면, 플러그인을 업데이트할 때 다시 동의해야 합니다.
인증된 호출자
비공개 라우트에서 routeCtx.user는 요청을 보낸 인증된 사용자입니다. 핸들러가 실행되기 전에 EmDash가 확인하고 권한을 부여했으므로, 사용자별 로직(사용자별 API 키, OAuth 연결, 플러그인이 관리하는 환경설정)에는 이 값을 신뢰할 수 있습니다.
routes: {
"connect/start": {
handler: async (routeCtx, ctx) => {
// Never read the acting user from the request body — any authenticated
// session could impersonate another user that way. Use routeCtx.user.
const caller = routeCtx.user;
if (!caller) throw new Error("No caller bound");
await ctx.kv.set(`user:${caller.id}:connection`, { startedAt: Date.now() });
return { userId: caller.id };
},
},
},
routeCtx.user는 공개 라우트(인증을 건너뛰므로, 방문자가 우연히 관리자 세션을 가지고 있더라도 호출자가 연결되지 않습니다)와, 토큰이 사용자에 연결되지 않은 토큰 인증 요청(머신 토큰)에서는 undefined입니다. 형태는 ctx.users가 반환하는 UserInfo인 { id, email, name, role, createdAt }와 같으며, 민감한 필드는 포함되지 않습니다.
호출자 식별은 users:read capability와는 별개라는 점에 유의하세요. routeCtx.user는 누가 호출하는지를 알려 주며 비공개 라우트에서는 항상 사용할 수 있습니다. 반면 ctx.users는 사용자 디렉터리 조회이며 capability가 필요합니다.
라우트를 MCP 도구로 노출
플러그인은 선택한 비공개 라우트를 EmDash의 MCP 서버를 통해 명시적으로 노출할 수 있습니다. MCP 노출은 라우트 목록에서 추론되지 않습니다.
const createEventInput = z.object({
title: z.string().min(1),
startsAt: z.string().datetime(),
});
const plugin: SandboxedPlugin = {
routes: {
"events/create": {
permission: "content:create",
handler: async (routeCtx, ctx) => {
const parsed = createEventInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
const input = parsed.data;
return { id: await createEvent(input, ctx) };
},
},
},
mcp: {
tools: {
createEvent: {
description: "Create a calendar event when the user asks to add one.",
route: "events/create",
input: createEventInput,
output: z.object({ id: z.string() }),
destructive: false,
},
},
},
};
export default plugin;
EmDash는 이것을 <pluginId>__createEvent로 노출합니다. 참조되는 라우트는 비공개여야 하며 permission을 선언해야 합니다. 입력 스키마는 필수이고, 출력 스키마는 선택 사항입니다. 삭제, 덮어쓰기, 게시, 결제처럼 되돌리기 어려운 작업을 수행하는 도구에는 destructive: true를 설정하세요.
관리자는 플러그인 MCP 도구의 이름, 설명, 라우트, 권한, 파괴적 플래그를 검토한 뒤 별도로 활성화해야 합니다. 이후 도구를 호출하려면 라우트 권한과 함께 mcp:tools 토큰 스코프 또는 mcp:tools:<pluginId> 중 하나가 필요합니다.
MCP 도구는 response: "raw"가 있는 라우트를 참조할 수 없습니다. MCP 도구는 JSON 라우트 계약을 사용합니다.
요청 본문
request 선언이 없는 라우트는 기존의 입력 동작을 유지합니다. EmDash는 POST, PUT, PATCH에서는 JSON 요청 본문을, GET, HEAD, DELETE에서는 쿼리 매개변수를 파싱합니다. 파싱된 값은 routeCtx.input: unknown으로 샌드박스된 핸들러에 전달됩니다.
라우트에 다른 본문 형식이나 특정 바이트 제한이 필요하면 request.body를 선언하세요. 사용할 수 있는 모드는 none, json, text, bytes, form-data입니다. 요청 본문은 버퍼링됩니다. 기본 최대값은 1 MiB이며, 라우트는 maxBytes를 최대 8 MiB까지 높일 수 있습니다.
pluginRoute()를 사용하면 선언된 본문 모드에서 입력 타입을 추론할 수 있습니다. 이 헬퍼는 런타임에 인수를 그대로 반환합니다.
import { pluginRoute, type SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
import: pluginRoute({
methods: ["POST"],
request: {
body: "bytes",
maxBytes: 4 * 1024 * 1024,
headers: ["content-type", "x-import-signature"],
},
handler: async (routeCtx) => {
const bytes = routeCtx.input; // Uint8Array
const signature = routeCtx.request.headers["x-import-signature"];
return { accepted: bytes.byteLength, signature };
},
}),
},
};
export default plugin;
body: "none"이면 routeCtx.input은 파싱된 쿼리 문자열 레코드입니다. json 선언은 입력 타입을 unknown으로 유지하므로 사용하기 전에 검증하세요. text 선언은 문자열을, bytes는 Uint8Array를 생성합니다.
form-data는 multipart/form-data와 application/x-www-form-urlencoded를 받습니다. 순서가 있는 entries 배열을 생성합니다. 텍스트 항목에는 { name, kind: "text", value }가, 파일 항목에는 { name, kind: "file", filename, contentType, bytes }가 들어 있습니다. EmDash는 최대 100개의 파트, 파트당 1 MiB, 최대 255 UTF-8 바이트의 파일 이름을 받습니다. 파일 이름에는 제어 문자나 경로 구분자를 넣을 수 없습니다. 인코딩된 전체 요청도 라우트의 본문 제한 안에 들어와야 합니다.
필드를 읽거나 부수 효과를 수행하기 전에 파싱된 값을 검증하세요. 잘못된 입력이 호출자의 예상 가능한 오류라면 safeParse를 사용하세요. 그러면 라우트가 잘못된 입력을 내부 예외로 바꾸는 대신 안정적인 JSON 결과를 반환할 수 있습니다.
const createInput = z.object({
title: z.string().min(1).max(200),
email: z.string().email(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
tags: z.array(z.string()).optional(),
});
routes: {
create: {
handler: async (routeCtx, ctx) => {
const parsed = createInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { title, email, priority, tags } = parsed.data;
await ctx.storage.items.put(`item_${Date.now()}`, {
title,
email,
priority,
tags: tags ?? [],
createdAt: new Date().toISOString(),
});
return { ok: true };
},
},
},
쿼리 문자열 입력(GET/HEAD/DELETE)
본문이 없는 메서드에는 요청 본문이 없으므로 입력이 URL 쿼리 문자열에서 옵니다. 모든 값은 문자열입니다. 반복된 키는 배열이 되므로 ?tag=a&tag=b는 { tag: ["a", "b"] }가 되고, 단일 ?tag=a는 { tag: "a" }로 유지됩니다. 숫자와 기타 문자열이 아닌 값에는 z.coerce를 사용하세요.
const listInput = z.object({
status: z.enum(["open", "closed"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tag: z.union([z.string(), z.array(z.string())]).optional(),
});
routes: {
list: {
// GET /_emdash/api/plugins/<slug>/list?status=open&limit=20&tag=a&tag=b
handler: async (routeCtx, ctx) => {
const parsed = listInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_QUERY" };
const { status, limit, tag } = parsed.data;
// ...
},
},
},
JSON 반환 값
라우트가 response: "raw"를 선언하지 않는 한 JSON 응답 계약을 사용합니다. JSON으로 직렬화할 수 있는 값이면 무엇이든 반환할 수 있습니다. 디스패처는 이를 EmDash의 표준 봉투({ success: true, data: <your value> })로 감싸서 application/json으로 제공합니다.
return { id: "abc", count: 42 }; // wrapped to { success: true, data: { id, count } }
return [1, 2, 3]; // wrapped to { success: true, data: [1, 2, 3] }
오류
샌드박스된 라우트가 작업을 완료할 수 없을 때는 예외를 던지세요. EmDash는 예외를 로그에 남기고 ROUTE_ERROR를 반환합니다. 던진 메시지가 그 응답에 포함될 수 있으므로, 예외 메시지에 자격 증명, 개인 데이터, 내부 경로, 스택 트레이스를 절대 넣지 마세요.
handler: async (_routeCtx, ctx) => {
try {
return await refreshRemoteIndex(ctx);
} catch {
ctx.log.error("Remote index refresh failed");
throw new Error("Remote index refresh failed");
}
},
샌드박스된 플러그인 코드는 Response를 던져서 임의의 HTTP 상태를 선택할 수 없습니다. Response는 모든 샌드박스 러너의 경계를 구조화된 오류로 넘지 못하기 때문입니다. 인증, 권한 부여, CSRF, 존재하지 않는 라우트 실패에 대한 상태는 핸들러가 실행되기 전에 EmDash가 할당합니다. 예상되는 검증 및 도메인 결과에는 JSON 결과를 반환하고, 예외는 예상치 못한 실패에만 쓰세요.
JSON으로 반환된 예상 오류도 라우트의 성공 HTTP 응답을 그대로 사용하며 EmDash의 바깥쪽 { success: true, data: ... } 봉투 안에 나타납니다. 클라이언트가 그 결과를 구분할 수 있도록 안정적인 애플리케이션 수준 코드를 포함하세요.
HTTP 메서드
라우트 이름이 하나의 핸들러를 선택합니다. methods를 선언하면 어떤 HTTP 메서드가 그 핸들러를 호출할 수 있는지 제한할 수 있습니다. 요청 메서드가 선언되어 있지 않으면 EmDash는 핸들러를 호출하기 전에 Allow 헤더와 함께 405 Method Not Allowed를 반환합니다.
routes: {
item: {
methods: ["GET", "DELETE"],
handler: async (routeCtx, ctx) => {
const parsed = z.object({ id: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_ID" };
const { id } = parsed.data;
switch (routeCtx.request.method) {
case "GET":
return await ctx.storage.items.get(id);
case "DELETE":
await ctx.storage.items.delete(id);
return { deleted: true };
}
},
},
},
methods가 없는 라우트는 호환성을 위해 메서드에 구애받지 않는 상태로 남습니다. 레거시 라우트에서 변경 작업을 수행하기 전에 routeCtx.request.method를 확인하거나, methods를 추가해 호스트가 제한을 강제하도록 하세요.
원시 응답
라우트가 감싸지 않은 텍스트나 바이트를 사용자 지정 상태와 안전한 응답 헤더와 함께 반환해야 한다면 response: "raw"를 선언하세요. emdash/plugin의 pluginResponse()를 반환하세요. WHATWG Response는 샌드박스 경계를 넘지 못합니다.
import { pluginResponse, pluginRoute, type SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
download: pluginRoute({
public: true,
methods: ["GET"],
request: { body: "none" },
response: "raw",
cacheControl: "public, max-age=60",
handler: async () =>
pluginResponse({
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="report.csv"',
},
body: { kind: "text", value: "name,count\nPublished,12\n" },
}),
}),
},
};
export default plugin;
응답 본문은 { kind: "text", value: string } 또는 { kind: "bytes", value: Uint8Array }이며 최대 8 MiB까지 버퍼링됩니다. 원시 응답은 Accept-Ranges, Content-Disposition, Content-Encoding, Content-Language, Content-Range, Content-Type, ETag, Last-Modified, Location, Retry-After를 설정할 수 있으며, 호스트는 플러그인이 제공한 다른 모든 헤더를 제거합니다. 호스트는 X-Content-Type-Options: nosniff, 샌드박스된 문서용 콘텐츠 보안 정책, Referrer-Policy: no-referrer를 추가합니다. 라우트의 cacheControl은 성공한 공개 GET 및 HEAD 응답에만 적용됩니다. 그 밖의 응답은 private, no-store를 사용합니다.
원시 라우트는 활성 동일 출처 콘텐츠를 제공할 수 없습니다. EmDash는 HTML, JavaScript와 ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related, multipart/x-mixed-replace 미디어 타입을 거부합니다. 응답에서 활성 브라우저 콘텐츠를 실행해야 한다면 네이티브 플러그인이나 별도의 출처를 사용하세요.
요청에 접근
routeCtx.request는 SandboxedRequest 입니다. 프로세스 내부와 isolate 내부에서 동일하게 동작하는 이식 가능한 { url, method, headers } 레코드입니다. headers는 소문자 헤더 이름을 키로 하는 Record<string, string>이므로 소문자 이름으로 인덱싱하거나 Object.entries로 순회하세요. url은 문자열이므로 new URL(request.url)로 쿼리 매개변수를 파싱할 수 있습니다. routeCtx.requestMeta는 사용 가능한 경우 플랫폼 전반에서 정규화된 IP, 사용자 에이전트, 지리 데이터를 담고 있습니다.
request 선언이 있는 라우트에서는 request.headers에 있는 이름만 핸들러에 전달됩니다. EmDash는 자격 증명, 쿠키, Cloudflare Access 헤더, 프록시 인증, Set-Cookie, X-EmDash-Request CSRF 헤더에 대한 선언을 거부합니다. 레거시 라우트를 포함해 샌드박스된 모든 요청에서 이러한 헤더를 제거합니다.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const signature = request.headers["x-import-signature"]; // lowercased key, no .get()
const url = new URL(request.url);
const page = url.searchParams.get("page");
ctx.log.info("Request", { meta: requestMeta });
if (request.method !== "POST") return { error: "POST_REQUIRED" };
},
일반적인 패턴
설정과 페이지네이션 데이터
플러그인 설정은 비공개 라우트, Block Kit 폼, ctx.settings를 사용합니다. 설정에서 로딩, 검증, 폼, 암호화된 시크릿 패턴을 모두 다룹니다.
플러그인 데이터를 나열하는 라우트는 ctx.storage.<collection>.query()가 반환하는 커서를 반환해야 합니다. 스토리지 페이지네이션에서는 커서를 전달하고, 페이지당 최대 100개 항목 제한을 넘지 않으면서 여러 페이지를 모두 가져오는 방법을 보여 줍니다.
외부 API 프록시
ctx.http를 통해 외부 서비스로 요청을 프록시합니다(network:request capability와 allowedHosts의 항목이 필요합니다).
routes: {
forecast: {
handler: async (routeCtx, ctx) => {
const parsed = z.object({ city: z.string().min(1) }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_CITY" };
if (!ctx.http) throw new Error("Network capability not granted");
const apiKey = await ctx.settings.get<string>("apiKey");
if (!apiKey) throw new Error("API key not configured");
const response = await ctx.http.fetch(
`https://api.weather.example.com/forecast?city=${encodeURIComponent(parsed.data.city)}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
return response.json();
},
},
},
ctx.http.fetch()는 두 샌드박스 러너 모두에서 버퍼링된 WHATWG Response를 반환합니다. arrayBuffer()와 blob() 같은 이진 메서드는 Cloudflare Worker Loader와 Node/workerd에서 바이트를 보존합니다. 요청 본문과 응답 본문은 각각 디코딩된 데이터 기준 8 MiB로 제한됩니다. 리디렉션 대상은 매 홉 전에 검사되며, 리디렉션이 출처를 넘나들면 자격 증명 헤더가 제거됩니다.
Block Kit에서 라우트 호출
샌드박스된 플러그인은 관리자에 React 코드를 배포하지 않습니다. admin 라우트를 선언하고 Block Kit 응답을 반환하세요. EmDash는 page_load, block_action, form_submit 상호작용을 올바른 URL과 CSRF 헤더와 함께 그 비공개 라우트로 보냅니다. Block Kit에서 상호작용 계약과 완전한 라우트를 보여 줍니다.
큐 및 스케줄 핸들러에서 라우트 호출
플랫폼 이벤트 핸들러(Cloudflare Queue 소비자, 사용자 지정 scheduled() 핸들러)에는 HTTP 요청이 없으므로 locals.emdash도 없습니다. emdash/middleware의 withEmDashRuntime()을 사용해 런타임을 직접 가져오고, 요청 없이 플러그인 라우트를 호출하세요.
import { withEmDashRuntime } from "emdash/middleware";
export default {
// ... fetch/scheduled from @emdash-cms/cloudflare/worker
async queue(batch: MessageBatch) {
await withEmDashRuntime(async (runtime) => {
for (const message of batch.messages) {
const result = await runtime.handlePluginApiRoute(
"my-plugin",
"POST",
"/finishJob",
new Request("https://internal/", {
method: "POST",
body: JSON.stringify(message.body),
}),
);
if (result.success) message.ack();
else message.retry();
}
});
},
};
이것은 요청 핸들러가 사용하는 것과 같은 캐시된 런타임을 확인하므로, 플러그인 스토리지, 훅, 미디어 접근이 요청 중과 똑같이 동작합니다. 연결 기반 데이터베이스 어댑터(예: Hyperdrive를 통한 Postgres)에서는 콜백이 이벤트 범위의 연결 아래에서 실행되며, 이 연결은 콜백이 반환될 때 커밋되고 닫힙니다.
외부에서 라우트 호출
공개 라우트는 직접 호출할 수 있습니다.
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
비공개 라우트에는 세션 자격 증명과 X-EmDash-Request: 1, 또는 admin 스코프가 있는 API 토큰이 필요합니다. 다음 서버 간 요청은 토큰을 사용합니다.
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Hello", "email": "[email protected]"}'
라우트 컨텍스트 레퍼런스
다음 인터페이스는 샌드박스된 라우트 핸들러에서 사용할 수 있는 이식 가능한 값을 요약합니다.
// What sandboxed route handlers receive as their two arguments
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // lowercased keys
}
interface SandboxedRouteContext {
input: unknown; // validate inside the handler before use
request: SandboxedRequest;
requestMeta?: unknown;
user?: UserInfo; // authenticated caller on private routes; undefined on public routes
}
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage;
kv: KVAccess;
log: LogAccess;
site: SiteInfo;
url(path: string): string;
cron?: CronAccess;
content?: ContentAccess; // when content:read or content:write declared
schema?: SchemaAccess; // when schema:read declared
taxonomies?: TaxonomyAccess; // when taxonomies:read declared
bylines?: BylineAccess; // when bylines:read declared
redirects?: RedirectAccess; // when redirects:read or redirects:write declared
media?: MediaAccess; // when any media capability is declared
http?: HttpAccess; // when network:request declared
users?: UserAccess; // when users:read declared
email?: EmailAccess; // when email:send declared and provider configured
}
네이티브 플러그인은 이 둘을 합친 단일 RouteContext 인수를 받습니다. 그쪽으로 진행한다면 첫 번째 네이티브 플러그인을 참고하세요.