API 라우트

이 페이지

플러그인은 관리 UI와 외부 연동을 위해 API 라우트를 노출할 수 있습니다. 라우트는 /_emdash/api/plugins/<slug>/<route-name> 아래에 마운트되며(<slug>는 emdash-plugin.jsonc의 플러그인 slug 필드 — 런타임에서는 ctx.plugin.id로 노출), 훅이 받는 것과 같은 PluginContext를 가진 샌드박스 런타임 안에서 실행됩니다.

이 페이지는 샌드박스 플러그인을 다룹니다. 네이티브 플러그인은 같은 라우트 옵션, 인증, URL 레이아웃을 사용하지만, 핸들러는 하나의 결합된 컨텍스트 객체를 받습니다. 그 시그니처는 Your first native plugin을 보세요.

라우트 정의

라우트는 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 권한을 가진 플러그인은 컬렉션이 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>에 마운트됩니다. 라우트 이름에는 중첩 경로용 슬래시를 포함할 수 있습니다.

Plugin idRoute nameURL
formsstatus/_emdash/api/plugins/forms/status
formssubmissions/_emdash/api/plugins/forms/submissions
seosettings/save/_emdash/api/plugins/seo/settings/save
analyticsevents/recent/_emdash/api/plugins/analytics/events/recent

인증과 CSRF

플러그인 라우트는 기본적으로 인증됩니다. 디스패처는 핸들러를 호출하기 전에 세션(또는 admin 스코프 토큰)을 요구합니다. 비공개 라우트는 하위 호환을 위해 기본적으로 plugins:manage 권한입니다. 작업이 기존 콘텐츠·미디어·스키마·설정 능력에 속하면 permission을 더 좁은 EmDash RBAC 권한으로 설정하세요.

routes: {
	create: {
		permission: "content:create",
		handler: async (routeCtx, ctx) => {
			// Validate routeCtx.input, then create content through ctx.
		},
	},
},

비공개 라우트는 선언한 권한을 모든 HTTP 메서드에 요구합니다. 플러그인 라우트가 어떤 메서드에서도 같은 핸들러를 실행할 수 있으므로, GET과 HEAD를 포함한 쿠키 인증 요청에는 CSRF 헤더 X-EmDash-Request: 1도 필요합니다. 관리 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입니다(인증을 건너뛰므로 호출자가 바인딩되지 않음 — 방문자가 관리 세션을 가지고 있어도 마찬가지). 또한 사용자에 바인딩되지 않은 토큰 인증 요청(머신 토큰)에서도 undefined입니다. 형태는 ctx.users가 반환하는 UserInfo와 같습니다: { id, email, name, role, createdAt } — 민감 필드 없음.

호출자 신원은 users:read 능력과 별개입니다. routeCtx.user는 누가 호출하는지 알려 주며 비공개 라우트에서 항상 사용 가능합니다. 반면 ctx.users는 능력이 필요한 사용자 디렉터리 조회입니다.

라우트를 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] }

오류

샌드박스 라우트가 완료할 수 없으면 throw하세요. EmDash는 예외를 로그하고 ROUTE_ERROR를 반환합니다. throw한 메시지가 그 응답에 포함될 수 있으므로 예외 메시지에 자격 증명, 개인 데이터, 내부 경로, 스택 트레이스를 넣지 마세요.

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를 throw해 임의 HTTP 상태를 고를 수 없습니다. Response는 모든 샌드박스 러너 경계를 구조화 오류로 넘지 않습니다. EmDash는 핸들러 실행 전에 인증·인가·CSRF·누락 라우트 실패에 상태를 할당합니다. 예상되는 검증·도메인 결과에는 JSON 결과를 반환하고, 예외는 예상치 못한 실패에 예약하세요.

JSON으로 반환한 예상 오류는 여전히 라우트의 성공 HTTP 응답을 쓰며 EmDash 바깥 { success: true, data: ... } 엔벨로프 안에 나타납니다. 클라이언트가 구분할 수 있도록 안정적인 애플리케이션 수준 코드를 포함하세요.

HTTP 메서드

라우트 이름은 하나의 핸들러를 선택합니다. 어떤 HTTP 메서드가 호출할 수 있는지 제한하려면 methods를 선언하세요. 요청 메서드가 선언되지 않으면 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를 사용합니다.

원시 라우트는 활성 same-origin 콘텐츠를 제공할 수 없습니다. 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를 사용합니다. Settings에 로드·검증·폼·암호화 시크릿의 전체 패턴이 있습니다.

플러그인 데이터를 나열하는 라우트는 ctx.storage.<collection>.query()의 커서를 반환해야 합니다. Storage pagination은 커서를 전달하고 페이지당 최대 100개를 넘지 않고 여러 페이지를 처리하는 방법을 보여 줍니다.

외부 API 프록시

ctx.http를 통해 외부 서비스로 요청을 프록시합니다(network:request 능력과 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는 올바른 URL과 CSRF 헤더로 page_load, block_action, form_submit 상호작용을 그 비공개 라우트로 보냅니다. 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": "user@example.com"}'

라우트 컨텍스트 레퍼런스

다음 인터페이스는 샌드박스 라우트 핸들러가 사용할 수 있는 휴대용 값을 요약합니다.

// 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
	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 인자를 받습니다 — 그 경로를 가면 Creating native plugins을 보세요.