/** * 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 { TaxonomyRepository, type Taxonomy } from "../database/repositories/taxonomy.js"; import { AuthzRepository } from "../database/repositories/authz.js"; import { UserRepository } from "../database/repositories/user.js"; import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; import { resolveContentCreateLocale } from "../i18n/config.js"; import { resolveAndValidateExternalUrl, SsrfError, stripCredentialHeaders, } from "../import/ssrf.js"; import { enrichImageMetadata } from "../media/enrich.js"; import { markContentMediaUsageCollectionStaleSafely } from "../media/usage/content-refresh.js"; import { invalidateSiteSettingsCache } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; import { GitStorageCollection } from "./git-storage.js"; import { CronAccessImpl } from "./cron.js"; import type { EmailPipeline } from "./email.js"; import type { GitHubConnectionAccess, ResolvedPlugin, PluginContext, PluginStorageConfig, StorageCollection, KVAccess, CronAccess, EmailAccess, ContentAccess, ContentAccessWithWrite, MediaAccess, MediaAccessWithWrite, HttpAccess, LogAccess, SiteInfo, UserAccess, UserInfo, ContentItem, ContentCreateOptions, ContentItemSeoInput, ContentWriteInput, MediaItem, PaginatedResult, QueryOptions, ContentListOptions, MediaListOptions, TaxonomyAccess, TaxonomyDefInfo, TaxonomyTermInfo, TaxonomyReadOptions, } 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)) { if (config.storage === "git") { storage[collectionName] = new GitStorageCollection(db, pluginId, collectionName); continue; } 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; } /** * Parse the `collections` JSON column into a string array (`[]` on anything * else). Mirrors the guards in the Cloudflare/workerd bridges so an * in-process plugin degrades on malformed data instead of crashing. */ function parseCollectionsColumn(value: string | null): string[] { if (!value) return []; try { const parsed: unknown = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; } catch { return []; } } /** Map a repository `Taxonomy` row to the plugin-facing term shape. */ function taxonomyToTermInfo(term: Taxonomy): TaxonomyTermInfo { return { id: term.id, taxonomy: term.name, slug: term.slug, label: term.label, parentId: term.parentId, data: term.data, locale: term.locale, translationGroup: term.translationGroup, }; } /** * 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, scheduledAt: item.scheduledAt, }; 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, scheduledAt: item.scheduledAt, })); 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 read-only taxonomy access (gated on `taxonomies:read`). */ export function createTaxonomyAccess(db: Kysely): TaxonomyAccess { const taxonomyRepo = new TaxonomyRepository(db); return { async getAll(options?: TaxonomyReadOptions): Promise { let query = db.selectFrom("_emdash_taxonomy_defs").selectAll(); if (options?.locale !== undefined) query = query.where("locale", "=", options.locale); const rows = await query.orderBy("name", "asc").execute(); return rows.map((row) => ({ name: row.name, label: row.label, labelSingular: row.label_singular, hierarchical: row.hierarchical === 1, collections: parseCollectionsColumn(row.collections), locale: row.locale, })); }, async getTerms(taxonomy: string, options?: TaxonomyReadOptions): Promise { const terms = await taxonomyRepo.findByName(taxonomy, { locale: options?.locale }); return terms.map(taxonomyToTermInfo); }, async getEntryTerms( collection: string, entryId: string, options?: TaxonomyReadOptions & { taxonomy?: string }, ): Promise { const terms = await taxonomyRepo.getTermsForEntry( collection, entryId, options?.taxonomy, options?.locale, ); return terms.map(taxonomyToTermInfo); }, }; } /** * 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, beforeContentWrite?: () => Promise, ): ContentAccessWithWrite { const readAccess = createContentAccess(db); return { ...readAccess, async create( collection: string, data: ContentWriteInput, options?: ContentCreateOptions, ): Promise { const locale = resolveContentCreateLocale(options?.locale); await beforeContentWrite?.(); const { fields, seo } = splitSeoFromInput(data); let contentMutated = false; try { const created = await 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, locale, }); contentMutated = true; 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, scheduledAt: item.scheduledAt, }; if (hasSeo) { result.seo = seo !== undefined ? await trxSeoRepo.upsert(collection, item.id, seo) : await trxSeoRepo.get(collection, item.id); } return result; }); await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE"); return created; } catch (error) { if (contentMutated) { await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE"); } throw error; } }, async update(collection: string, id: string, data: ContentWriteInput): Promise { await beforeContentWrite?.(); const { fields, seo } = splitSeoFromInput(data); const hasFieldUpdates = Object.keys(fields).length > 0; let contentMutated = false; try { const updated = await 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.updateDraftAware 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. updateDraftAware delegates no-op writes to // ContentRepository.update. const item = hasFieldUpdates ? await trxContentRepo.updateDraftAware(collection, id, { data: fields }) : await (async () => { const existing = await trxContentRepo.findById(collection, id); if (!existing) throw new Error("Content not found"); return existing; })(); if (hasFieldUpdates) contentMutated = true; 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, scheduledAt: item.scheduledAt, }; if (hasSeo) { result.seo = seo !== undefined ? await trxSeoRepo.upsert(collection, item.id, seo) : await trxSeoRepo.get(collection, item.id); } return result; }); if (hasFieldUpdates) { await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE"); } return updated; } catch (error) { if (contentMutated) { await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE"); } throw error; } }, async delete(collection: string, id: string): Promise { await beforeContentWrite?.(); const contentRepo = new ContentRepository(db); const deleted = await contentRepo.delete(collection, id); if (deleted) { await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE"); } return deleted; }, async permanentDelete(collection: string, id: string): Promise { await beforeContentWrite?.(); const contentRepo = new ContentRepository(db); const deleted = await contentRepo.permanentDelete(collection, id); if (deleted) { await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE"); } return deleted; }, }; } // ============================================================================= // 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; /** The site's platform origin (`custom_domain:default_url`), when hosted by a control plane. */ platformUrl?: string; /** Site locale from options table */ locale?: string; /** Astro's `trailingSlash` config (from `virtual:emdash/config`). */ trailingSlash?: "always" | "never" | "ignore"; } /** * 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 ...(options.platformUrl ? { platformUrl: options.platformUrl.replace(TRAILING_SLASH_RE, "") } : {}), locale: options.locale ?? "en", trailingSlash: options.trailingSlash ?? "ignore", // Astro's default }; } /** * 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; roleId?: string | null; createdAt: string; }): UserInfo { return { id: user.id, email: user.email, name: user.name, role: user.role, roleId: user.roleId ?? null, 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, }; }, async listRoles() { const roles = await new AuthzRepository(db).listRoles(); return roles.map((r) => ({ id: r.id, slug: r.slug, name: r.name, level: r.level, builtin: r.builtin })); }, }; } // ============================================================================= // Plugin Context Factory // ============================================================================= export interface PluginContextFactoryOptions { db: Kysely; beforeContentWrite?: () => Promise; /** * 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 beforeContentWrite?: () => Promise; 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.beforeContentWrite = options.beforeContentWrite; 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, this.beforeContentWrite); } else if (capabilities.has("content:read")) { content = createContentAccess(db); } // Capability-gated: taxonomies (read-only) let taxonomies: TaxonomyAccess | undefined; if (capabilities.has("taxonomies:read")) { taxonomies = createTaxonomyAccess(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); } let github: GitHubConnectionAccess | undefined; if (capabilities.has("github:connection")) { github = { async get() { const map = await new OptionsRepository(db).getMany([ "github:token", "github:owner", "github:repo", "github:branch", "emdash:frontend_token", ]); const token = map.get("github:token") ?? ""; const owner = map.get("github:owner") ?? ""; const repo = map.get("github:repo") ?? ""; if (!token || !owner || !repo) return null; return { token, owner, repo, branch: map.get("github:branch") || "main", frontendToken: map.get("emdash:frontend_token") ?? "", }; }, }; } // 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, taxonomies, media, http, log, site: this.site, url: this.urlHelper, users, github, 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); }