/** * Plugin Context v2 * * Creates the unified context object provided to plugins in all hooks and routes. * */ import type { Kysely } from "kysely"; import { ulid } from "ulidx"; import { ContentRepository } from "../database/repositories/content.js"; import { MediaRepository } from "../database/repositories/media.js"; import { OptionsRepository } from "../database/repositories/options.js"; import { PluginStorageRepository } from "../database/repositories/plugin-storage.js"; import { SeoRepository } from "../database/repositories/seo.js"; import { UserRepository } from "../database/repositories/user.js"; import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; import { resolveAndValidateExternalUrl, SsrfError, stripCredentialHeaders, } from "../import/ssrf.js"; import { enrichImageMetadata } from "../media/enrich.js"; import { invalidateSiteSettingsCache } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; import { CronAccessImpl } from "./cron.js"; import type { EmailPipeline } from "./email.js"; import type { ResolvedPlugin, PluginContext, PluginStorageConfig, StorageCollection, KVAccess, CronAccess, EmailAccess, ContentAccess, ContentAccessWithWrite, MediaAccess, MediaAccessWithWrite, HttpAccess, LogAccess, SiteInfo, UserAccess, UserInfo, ContentItem, ContentItemSeoInput, ContentWriteInput, MediaItem, PaginatedResult, QueryOptions, ContentListOptions, MediaListOptions, } from "./types.js"; // ============================================================================= // KV Access // ============================================================================= /** * Create KV accessor for a plugin * All keys are automatically prefixed with the plugin ID */ export function createKVAccess(optionsRepo: OptionsRepository, pluginId: string): KVAccess { const prefix = `plugin:${pluginId}:`; return { async get(key: string): Promise { return optionsRepo.get(`${prefix}${key}`); }, async set(key: string, value: unknown): Promise { await optionsRepo.set(`${prefix}${key}`, value); }, async delete(key: string): Promise { return optionsRepo.delete(`${prefix}${key}`); }, async list(keyPrefix?: string): Promise> { const fullPrefix = `${prefix}${keyPrefix ?? ""}`; const entriesMap = await optionsRepo.getByPrefix(fullPrefix); const result: Array<{ key: string; value: unknown }> = []; for (const [fullKey, value] of entriesMap) { result.push({ key: fullKey.slice(prefix.length), value, }); } return result; }, }; } // ============================================================================= // Storage Access // ============================================================================= /** * Create storage collection accessor for a plugin * Wraps PluginStorageRepository with the v2 interface (no async iterators) */ function createStorageCollection( db: Kysely, pluginId: string, collectionName: string, indexes: Array, ): StorageCollection { const repo = new PluginStorageRepository(db, pluginId, collectionName, indexes); return { get: (id) => repo.get(id), put: (id, data) => repo.put(id, data), delete: (id) => repo.delete(id), exists: (id) => repo.exists(id), getMany: (ids) => repo.getMany(ids), putMany: (items) => repo.putMany(items), deleteMany: (ids) => repo.deleteMany(ids), count: (where) => repo.count(where), // Query returns PaginatedResult instead of the old format async query(options?: QueryOptions): Promise> { const result = await repo.query({ where: options?.where, orderBy: options?.orderBy, limit: options?.limit, cursor: options?.cursor, }); return { items: result.items, cursor: result.cursor, hasMore: result.hasMore, }; }, }; } /** * Create storage accessor with all declared collections */ export function createStorageAccess( db: Kysely, pluginId: string, storageConfig: T, ): Record { const storage: Record = {}; for (const [collectionName, config] of Object.entries(storageConfig)) { const allIndexes = [...config.indexes, ...(config.uniqueIndexes ?? [])]; storage[collectionName] = createStorageCollection(db, pluginId, collectionName, allIndexes); } return storage; } // ============================================================================= // Content Access // ============================================================================= /** * Extract `seo` from a plugin-supplied content write input and return both * parts. Mutates nothing — returns a new field map without the `seo` key. */ function splitSeoFromInput(input: ContentWriteInput): { fields: Record; seo: ContentItemSeoInput | undefined; } { const { seo, ...fields } = input; // Reject non-object seo values rather than silently dropping them. if (seo !== undefined && (seo === null || typeof seo !== "object" || Array.isArray(seo))) { throw new Error("content.seo must be an object"); } return { fields, seo }; } /** * Reject writing SEO to a collection that does not have it enabled. * Matches the REST API behavior (VALIDATION_ERROR). */ async function assertSeoEnabled( seoRepo: SeoRepository, collection: string, seo: ContentItemSeoInput | undefined, ): Promise { const hasSeo = await seoRepo.isEnabled(collection); if (seo !== undefined && !hasSeo) { throw new Error( `Collection "${collection}" does not have SEO enabled. ` + `Remove the seo field or enable SEO on this collection.`, ); } return hasSeo; } /** * Create read-only content access */ export function createContentAccess(db: Kysely): ContentAccess { const contentRepo = new ContentRepository(db); const seoRepo = new SeoRepository(db); return { async get(collection: string, id: string): Promise { const item = await contentRepo.findById(collection, id); if (!item) return null; const result: ContentItem = { id: item.id, type: item.type, slug: item.slug, status: item.status, data: item.data, createdAt: item.createdAt, updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, }; if (await seoRepo.isEnabled(collection)) { result.seo = await seoRepo.get(collection, item.id); } return result; }, async list( collection: string, options?: ContentListOptions, ): Promise> { // Convert orderBy format if provided let orderBy: { field: string; direction: "asc" | "desc" } | undefined; if (options?.orderBy) { const entries = Object.entries(options.orderBy); const first = entries[0]; if (first) { orderBy = { field: first[0], direction: first[1] }; } } const result = await contentRepo.findMany(collection, { limit: options?.limit ?? 50, cursor: options?.cursor, orderBy, where: options?.where, }); const items: ContentItem[] = result.items.map((item) => ({ id: item.id, type: item.type, slug: item.slug, status: item.status, data: item.data, createdAt: item.createdAt, updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, })); if (items.length > 0 && (await seoRepo.isEnabled(collection))) { const seoMap = await seoRepo.getMany( collection, items.map((i) => i.id), ); for (const item of items) { const seo = seoMap.get(item.id); if (seo) item.seo = seo; } } return { items, cursor: result.nextCursor, hasMore: !!result.nextCursor, }; }, }; } /** * Create full content access with write operations. * * `create` and `update` accept a reserved `seo` key in their `data` * argument. When present, it is routed to the core SEO panel * (`_emdash_seo`) via `SeoRepository.upsert`, in the same transaction as * the content write. The returned `ContentItem.seo` reflects the resulting * SEO state for SEO-enabled collections. */ export function createContentAccessWithWrite(db: Kysely): ContentAccessWithWrite { const readAccess = createContentAccess(db); return { ...readAccess, async create(collection: string, data: ContentWriteInput): Promise { const { fields, seo } = splitSeoFromInput(data); return withTransaction(db, async (trx) => { const trxContentRepo = new ContentRepository(trx); const trxSeoRepo = new SeoRepository(trx); const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo); const item = await trxContentRepo.create({ type: collection, data: fields, }); const result: ContentItem = { id: item.id, type: item.type, slug: item.slug, status: item.status, data: item.data, createdAt: item.createdAt, updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, }; if (hasSeo) { result.seo = seo !== undefined ? await trxSeoRepo.upsert(collection, item.id, seo) : await trxSeoRepo.get(collection, item.id); } return result; }); }, async update(collection: string, id: string, data: ContentWriteInput): Promise { const { fields, seo } = splitSeoFromInput(data); return withTransaction(db, async (trx) => { const trxContentRepo = new ContentRepository(trx); const trxSeoRepo = new SeoRepository(trx); const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo); // Pass the `data` payload to ContentRepository.update only when // there are field updates — passing an empty object would still // bump updated_at/version, but we want a seo-only call to touch // only the SEO table. ContentRepository.update handles the no-op // path by returning the current row. const hasFieldUpdates = Object.keys(fields).length > 0; const item = hasFieldUpdates ? await trxContentRepo.update(collection, id, { data: fields }) : await (async () => { const existing = await trxContentRepo.findById(collection, id); if (!existing) throw new Error("Content not found"); return existing; })(); const result: ContentItem = { id: item.id, type: item.type, slug: item.slug, status: item.status, data: item.data, createdAt: item.createdAt, updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, }; if (hasSeo) { result.seo = seo !== undefined ? await trxSeoRepo.upsert(collection, item.id, seo) : await trxSeoRepo.get(collection, item.id); } return result; }); }, async delete(collection: string, id: string): Promise { const contentRepo = new ContentRepository(db); return contentRepo.delete(collection, id); }, }; } // ============================================================================= // Media Access // ============================================================================= /** * Create read-only media access */ export function createMediaAccess(db: Kysely): MediaAccess { const mediaRepo = new MediaRepository(db); return { async get(id: string): Promise { const item = await mediaRepo.findById(id); if (!item) return null; return { id: item.id, filename: item.filename, mimeType: item.mimeType, size: item.size, // Construct URL from storage key (or use a sensible default path) url: `/media/${item.id}/${item.filename}`, createdAt: item.createdAt, }; }, async list(options?: MediaListOptions): Promise> { const result = await mediaRepo.findMany({ limit: options?.limit ?? 50, cursor: options?.cursor, mimeType: options?.mimeType, }); return { items: result.items.map((item) => ({ id: item.id, filename: item.filename, mimeType: item.mimeType, size: item.size, url: `/media/${item.id}/${item.filename}`, createdAt: item.createdAt, })), cursor: result.nextCursor, hasMore: !!result.nextCursor, }; }, }; } /** * Create full media access with write operations. * * `getUploadUrlFn` is optional: when omitted, `getUploadUrl()` is derived from * `storage` (create a pending record + a signed PUT URL), mirroring the REST * `/_emdash/api/media/upload-url` endpoint. `upload()` only needs `storage`. * If storage is not provided, both throw at call time. */ export function createMediaAccessWithWrite( db: Kysely, getUploadUrlFn: | ((filename: string, contentType: string) => Promise<{ uploadUrl: string; mediaId: string }>) | undefined, storage?: Storage, ): MediaAccessWithWrite { const mediaRepo = new MediaRepository(db); const readAccess = createMediaAccess(db); const getUploadUrl = getUploadUrlFn ?? (async (filename: string, contentType: string) => { if (!storage) { throw new Error( "Media getUploadUrl() requires a storage backend. Configure storage in PluginContextFactoryOptions.", ); } const basename = filename.split("/").pop() ?? filename; const dotIdx = basename.lastIndexOf("."); const ext = dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : ""; const storageKey = `${ulid()}${ext}`; const media = await mediaRepo.createPending({ filename: basename, mimeType: contentType, storageKey, }); const signed = await storage.getSignedUploadUrl({ key: storageKey, contentType, expiresIn: 3600, }); return { uploadUrl: signed.url, mediaId: media.id }; }); return { ...readAccess, getUploadUrl, async upload( filename: string, contentType: string, bytes: ArrayBuffer, ): Promise<{ mediaId: string; storageKey: string; url: string }> { if (!storage) { throw new Error( "Media upload() requires a storage backend. Configure storage in PluginContextFactoryOptions.", ); } // Generate a storage key with a unique prefix const keyPrefix = ulid(); // Extract extension from basename (ignore path separators) const basename = filename.split("/").pop() ?? filename; const dotIdx = basename.lastIndexOf("."); const ext = dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : ""; const storageKey = `${keyPrefix}${ext}`; // Upload to storage first await storage.upload({ key: storageKey, body: new Uint8Array(bytes), contentType, }); // Derive dimensions + LQIP placeholders (no-op for non-images). const enriched = await enrichImageMetadata(new Uint8Array(bytes), contentType); // Create DB record — clean up storage on failure let media; try { media = await mediaRepo.create({ filename: basename, mimeType: contentType, size: bytes.byteLength, storageKey, status: "ready", width: enriched.width, height: enriched.height, blurhash: enriched.blurhash, dominantColor: enriched.dominantColor, }); } catch (error) { try { await storage.delete(storageKey); } catch { // Best-effort cleanup } throw error; } return { mediaId: media.id, storageKey, url: `/_emdash/api/media/file/${storageKey}`, }; }, async delete(id: string): Promise { const deleted = await mediaRepo.delete(id); // Plugins can delete media that's referenced by site settings // (`logo`, `favicon`, `seo.defaultOgImage`); the worker-scoped // resolved-URL cache must be dropped or it will keep serving // 404s. Matches the invalidation in // `EmDashRuntime.handleMediaDelete`. if (deleted) { invalidateSiteSettingsCache(); } return deleted; }, }; } // ============================================================================= // HTTP Access // ============================================================================= /** Maximum number of redirects to follow in plugin HTTP access */ const MAX_PLUGIN_REDIRECTS = 5; /** * Check if a hostname matches any pattern in the allowed list. * Patterns: "*" matches all, "*.example.com" matches subdomains AND bare "example.com", * "api.example.com" matches exactly. */ function isHostAllowed(host: string, allowedHosts: string[]): boolean { return allowedHosts.some((pattern) => { if (pattern === "*") return true; if (pattern.startsWith("*.")) { const suffix = pattern.slice(1); // ".example.com" // Match subdomains (foo.example.com) and bare domain (example.com) return host.endsWith(suffix) || host === pattern.slice(2); } return host === pattern; }); } /** * Create HTTP access with host validation. * * Uses redirect: "manual" to re-validate each redirect target against * the allowedHosts list, preventing redirects to unauthorized hosts. */ export function createHttpAccess(pluginId: string, allowedHosts: string[]): HttpAccess { return { async fetch(url: string, init?: RequestInit): Promise { // Deny by default — plugins must declare allowed hosts if (allowedHosts.length === 0) { throw new Error( `Plugin "${pluginId}" has no allowed hosts configured. ` + `Add hosts to the plugin's allowedHosts array to enable HTTP requests.`, ); } let currentUrl = url; let currentInit = init; for (let i = 0; i <= MAX_PLUGIN_REDIRECTS; i++) { const hostname = new URL(currentUrl).hostname; if (!isHostAllowed(hostname, allowedHosts)) { throw new Error( `Plugin "${pluginId}" is not allowed to fetch from host "${hostname}". ` + `Allowed hosts: ${allowedHosts.join(", ")}`, ); } const response = await globalThis.fetch(currentUrl, { ...currentInit, redirect: "manual", }); // Not a redirect -- return directly if (response.status < 300 || response.status >= 400) { return response; } // Extract redirect target const location = response.headers.get("Location"); if (!location) { return response; } // Resolve relative redirects; strip credentials on cross-origin hops const previousOrigin = new URL(currentUrl).origin; currentUrl = new URL(location, currentUrl).href; const nextOrigin = new URL(currentUrl).origin; if (previousOrigin !== nextOrigin && currentInit) { currentInit = stripCredentialHeaders(currentInit); } } throw new Error(`Plugin "${pluginId}": too many redirects (max ${MAX_PLUGIN_REDIRECTS})`); }, }; } /** * Create unrestricted HTTP access (for plugins with network:fetch:any capability). * No host validation, but applies SSRF protection on redirect targets to * prevent plugins from being tricked into reaching internal services. */ export function createUnrestrictedHttpAccess(pluginId: string): HttpAccess { return { async fetch(url: string, init?: RequestInit): Promise { let currentUrl = url; let currentInit = init; for (let i = 0; i <= MAX_PLUGIN_REDIRECTS; i++) { // Validate each URL against SSRF rules (private IPs, metadata // endpoints, wildcard DNS, resolved-IP private ranges). try { await resolveAndValidateExternalUrl(currentUrl); } catch (e) { const msg = e instanceof SsrfError ? e.message : "SSRF validation failed"; throw new Error( `Plugin "${pluginId}": blocked fetch to "${new URL(currentUrl).hostname}": ${msg}`, { cause: e }, ); } const response = await globalThis.fetch(currentUrl, { ...currentInit, redirect: "manual", }); // Not a redirect -- return directly if (response.status < 300 || response.status >= 400) { return response; } // Extract redirect target const location = response.headers.get("Location"); if (!location) { return response; } // Resolve relative redirects; strip credentials on cross-origin hops const previousOrigin = new URL(currentUrl).origin; currentUrl = new URL(location, currentUrl).href; const nextOrigin = new URL(currentUrl).origin; if (previousOrigin !== nextOrigin && currentInit) { currentInit = stripCredentialHeaders(currentInit); } } throw new Error(`Plugin "${pluginId}": too many redirects (max ${MAX_PLUGIN_REDIRECTS})`); }, }; } /** * Create blocked HTTP access (for plugins without network:request capability) */ export function createBlockedHttpAccess(pluginId: string): HttpAccess { return { async fetch(): Promise { throw new Error( `Plugin "${pluginId}" does not have the "network:request" capability. ` + `Add "network:request" to the plugin's capabilities to enable HTTP requests.`, ); }, }; } // ============================================================================= // Log Access // ============================================================================= /** * Create logger for a plugin */ export function createLogAccess(pluginId: string): LogAccess { const prefix = `[plugin:${pluginId}]`; return { debug(message: string, data?: unknown): void { if (data !== undefined) { console.debug(prefix, message, data); } else { console.debug(prefix, message); } }, info(message: string, data?: unknown): void { if (data !== undefined) { console.info(prefix, message, data); } else { console.info(prefix, message); } }, warn(message: string, data?: unknown): void { if (data !== undefined) { console.warn(prefix, message, data); } else { console.warn(prefix, message); } }, error(message: string, data?: unknown): void { if (data !== undefined) { console.error(prefix, message, data); } else { console.error(prefix, message); } }, }; } // ============================================================================= // Site Info // ============================================================================= const TRAILING_SLASH_RE = /\/$/; /** * Options for creating site info */ export interface SiteInfoOptions { /** Site name from options table */ siteName?: string; /** Site URL from options table or Astro config */ siteUrl?: string; /** Site locale from options table */ locale?: string; } /** * Create site info from config and settings. * * Resolution order for URL: * 1. options table (emdash:site_url) * 2. Astro `site` config * 3. fallback to empty string */ export function createSiteInfo(options: SiteInfoOptions): SiteInfo { return { name: options.siteName ?? "", url: (options.siteUrl ?? "").replace(TRAILING_SLASH_RE, ""), // strip trailing slash locale: options.locale ?? "en", }; } /** * Create a URL helper that generates absolute URLs from relative paths. * Validates that path starts with "/" and rejects protocol-relative paths ("//"). */ export function createUrlHelper(siteUrl: string): (path: string) => string { const base = siteUrl.replace(TRAILING_SLASH_RE, ""); // strip trailing slash return (path: string): string => { if (!path.startsWith("/")) { throw new Error(`URL path must start with "/", got: "${path}"`); } if (path.startsWith("//")) { throw new Error(`URL path must not be protocol-relative, got: "${path}"`); } return `${base}${path}`; }; } // ============================================================================= // User Access // ============================================================================= /** * Convert a UserRepository user to the plugin-facing UserInfo shape. * Strips sensitive fields (avatarUrl, emailVerified, data). */ function toUserInfo(user: { id: string; email: string; name: string | null; role: number; createdAt: string; }): UserInfo { return { id: user.id, email: user.email, name: user.name, role: user.role, createdAt: user.createdAt, }; } /** * Create read-only user access for plugins. * Excludes sensitive fields (password hashes, sessions, passkeys, avatar URL, data). */ export function createUserAccess(db: Kysely): UserAccess { const userRepo = new UserRepository(db); return { async get(id: string): Promise { const user = await userRepo.findById(id); if (!user) return null; return toUserInfo(user); }, async getByEmail(email: string): Promise { const user = await userRepo.findByEmail(email); if (!user) return null; return toUserInfo(user); }, async list(opts?: { role?: number; limit?: number; cursor?: string; }): Promise<{ items: UserInfo[]; nextCursor?: string }> { const result = await userRepo.findMany({ role: opts?.role as 10 | 20 | 30 | 40 | 50 | undefined, cursor: opts?.cursor, limit: opts?.limit, }); return { items: result.items.map(toUserInfo), nextCursor: result.nextCursor, }; }, }; } // ============================================================================= // Plugin Context Factory // ============================================================================= export interface PluginContextFactoryOptions { db: Kysely; /** * Resolver for the database connection, preferred over `db` when present. * Called per `createContext()` so connection-backed adapters (e.g. Postgres * over Hyperdrive) get the current request/event-scoped connection from ALS * rather than a snapshot of the per-isolate singleton — reusing the * singleton's socket from a later event trips workerd's cross-request I/O * guard. When omitted, `db` is used directly (correct for stateless * adapters like D1 and Node SQLite). `db` remains required as the fallback. */ getDb?: () => Kysely; /** * Storage backend for direct media uploads. * If not provided, upload() will throw. */ storage?: Storage; /** * Explicit provider for `ctx.media.getUploadUrl()`. Optional: when omitted * but `storage` is configured, the factory derives a working `getUploadUrl()` * (and `upload()`) from storage. Only when neither `getUploadUrl` nor * `storage` is present do media write operations become unavailable. */ getUploadUrl?: ( filename: string, contentType: string, ) => Promise<{ uploadUrl: string; mediaId: string }>; /** * Site information for ctx.site and ctx.url(). * If not provided, site info will have empty defaults. */ siteInfo?: SiteInfoOptions; /** * Callback to notify the cron scheduler that the next due time may have changed. * If not provided, ctx.cron will not be available. */ cronReschedule?: () => void; /** * Email pipeline instance for ctx.email. * If not provided (or no provider configured), ctx.email will be undefined. */ emailPipeline?: EmailPipeline; /** * Pre-resolved list of trusted proxy header names (from the runtime * `EmDashConfig.trustedProxyHeaders` or the env var). Plugin route * handlers pass this to `extractRequestMeta` so plugins see the same * client IP the core auth path does. */ trustedProxyHeaders?: string[]; } /** * Factory for creating plugin contexts */ export class PluginContextFactory { private resolveDb: () => Kysely; private storage?: Storage; private getUploadUrl?: ( filename: string, contentType: string, ) => Promise<{ uploadUrl: string; mediaId: string }>; private site: SiteInfo; private urlHelper: (path: string) => string; private cronReschedule?: () => void; private emailPipeline?: EmailPipeline; /** * Plugin IDs already warned about a missing media-write backend, so the * warning fires once per factory instead of on every hook/route context * creation (which would spam logs for hook-participating plugins). */ private warnedMissingMediaBackend = new Set(); constructor(options: PluginContextFactoryOptions) { const fixedDb = options.db; this.resolveDb = options.getDb ?? (() => fixedDb); this.storage = options.storage; this.getUploadUrl = options.getUploadUrl; this.site = createSiteInfo(options.siteInfo ?? {}); this.urlHelper = createUrlHelper(this.site.url); this.cronReschedule = options.cronReschedule; this.emailPipeline = options.emailPipeline; } /** * Create the unified plugin context */ createContext(plugin: ResolvedPlugin): PluginContext { const capabilities = new Set(plugin.capabilities); // Resolve the connection once per context. For stateless adapters this // is the singleton; for connection-backed adapters it's the current // request/event-scoped connection from ALS. All repos below are built // from this local `db` so a hook never queries a stale singleton socket. const db = this.resolveDb(); const optionsRepo = new OptionsRepository(db); // Always available const kv = createKVAccess(optionsRepo, plugin.id); const log = createLogAccess(plugin.id); const storage = createStorageAccess(db, plugin.id, plugin.storage); // Capability-gated: content // Note: capabilities reach this point already normalized to the // canonical names by definePlugin / adaptSandboxEntry. Deprecated // names ("read:content", "write:content") never appear here. let content: ContentAccess | ContentAccessWithWrite | undefined; if (capabilities.has("content:write")) { content = createContentAccessWithWrite(db); } else if (capabilities.has("content:read")) { content = createContentAccess(db); } // Capability-gated: media // `upload()` only needs `storage`; `getUploadUrl()` is derived from // storage when no explicit provider is wired. Granting write access on // either avoids silently degrading media:write to read-only — the bug // where the runtime threads `storage` but not `getUploadUrl`. let media: MediaAccess | MediaAccessWithWrite | undefined; if (capabilities.has("media:write")) { if (this.getUploadUrl || this.storage) { media = createMediaAccessWithWrite(db, this.getUploadUrl, this.storage); } else { if (!this.warnedMissingMediaBackend.has(plugin.id)) { this.warnedMissingMediaBackend.add(plugin.id); log.warn( "declares the media:write capability but no storage backend is configured; upload() is unavailable.", ); } if (capabilities.has("media:read")) { media = createMediaAccess(db); } } } else if (capabilities.has("media:read")) { media = createMediaAccess(db); } // Capability-gated: http let http: HttpAccess | undefined; if (capabilities.has("network:request:unrestricted")) { http = createUnrestrictedHttpAccess(plugin.id); } else if (capabilities.has("network:request")) { http = createHttpAccess(plugin.id, plugin.allowedHosts); } // Capability-gated: users let users: UserAccess | undefined; if (capabilities.has("users:read")) { users = createUserAccess(db); } // Cron access — always available (scoped to plugin), but only if // the runtime provided a reschedule callback (i.e. cron is wired up). let cron: CronAccess | undefined; if (this.cronReschedule) { cron = new CronAccessImpl(db, plugin.id, this.cronReschedule); } // Email access — requires email:send capability AND a configured provider let email: EmailAccess | undefined; if (capabilities.has("email:send") && this.emailPipeline?.isAvailable()) { const pipeline = this.emailPipeline; const pluginId = plugin.id; email = { send: (message) => pipeline.send(message, pluginId), }; } return { plugin: { id: plugin.id, version: plugin.version, }, storage, kv, content, media, http, log, site: this.site, url: this.urlHelper, users, cron, email, }; } } /** * Create a plugin context for a resolved plugin */ export function createPluginContext( options: PluginContextFactoryOptions, plugin: ResolvedPlugin, ): PluginContext { const factory = new PluginContextFactory(options); return factory.createContext(plugin); }