diff --git a/plugins/api-keys/package.json b/plugins/api-keys/package.json index 88d32e87..96b6d066 100644 --- a/plugins/api-keys/package.json +++ b/plugins/api-keys/package.json @@ -12,7 +12,7 @@ "id": "instatic.api-keys", "name": "API Keys", "version": "0.1.0", - "apiVersion": "1.0.0", + "apiVersion": 1, "description": "Issue, manage, and authenticate with scoped API keys for machine-to-machine access.", "permissions": [ "cms.migrations", diff --git a/plugins/public-auth/package.json b/plugins/public-auth/package.json index f4f7cc76..46ff9e16 100644 --- a/plugins/public-auth/package.json +++ b/plugins/public-auth/package.json @@ -15,7 +15,7 @@ "id": "instatic.public-auth", "name": "Public Authentication", "version": "0.1.0", - "apiVersion": "1.0.0", + "apiVersion": 1, "description": "End-user registration, login, password reset, and JWT-based sessions for Instatic sites. Exposes a viewerContext provider.", "permissions": [ "cms.migrations", diff --git a/plugins/public-auth/src/totp.ts b/plugins/public-auth/src/totp.ts index 0ca427b5..7fd407ce 100644 --- a/plugins/public-auth/src/totp.ts +++ b/plugins/public-auth/src/totp.ts @@ -135,12 +135,22 @@ function timingSafeEqualHex(a: string, b: string): boolean { * Generate a set of single-use recovery codes. Each is 10 chars * (alphanumeric, easy to type), shown to the user ONCE at enable time. */ +const RECOVERY_CODE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + export function generateRecoveryCodes(count: number = 8): string[] { return Array.from({ length: count }, () => { - // 8 bytes → 11 base64url chars (no padding). Filter to A-Z0-9 and - // pad/truncate to exactly 10 chars. - const bytes = randomBytes(8) - return bytes.toString('base64url').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) + // 10 random bytes → 10 indices into a 36-char alphabet. The previous + // implementation encoded 8 bytes as base64url (11 chars), stripped the + // `-` / `_` characters, and sliced to 10 — which silently produced + // 8-10 char codes depending on how many of the 11 base64url chars landed + // in the index-62/63 range. Generate exactly 10 alphanumeric chars + // here instead. Modulo bias (256 % 36 = 4) is acceptable: recovery codes + // are rate-limited and shown to the user once, not a bearer of long-lived + // access. + const bytes = randomBytes(10) + let code = '' + for (let i = 0; i < 10; i++) code += RECOVERY_CODE_ALPHABET[bytes[i]! % 36] + return code }) } diff --git a/scripts/e2e-plugin-install.ts b/scripts/e2e-plugin-install.ts new file mode 100644 index 00000000..b6e16289 --- /dev/null +++ b/scripts/e2e-plugin-install.ts @@ -0,0 +1,357 @@ +/** + * End-to-end plugin test. + * + * Boots the Instatic host (CMS only, no vite), creates the owner, logs in, + * installs one of the 8 first-party plugins from a built bundle, activates + * it, and calls one of its endpoints to verify the round trip works. + * + * The test writes to a temporary SQLite database and a free port so it can + * be run repeatedly without disturbing the developer's local environment. + * + * Usage: + * bun run scripts/e2e-plugin-install.ts [plugin-id] + * e.g. bun run scripts/e2e-plugin-install.ts instatic.api-keys + * + * Exits 0 on success, non-zero on failure. + */ + +import { spawn, type Subprocess } from 'node:child_process' +import { writeFile, mkdir, rm, readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { zipSync, strToU8 } from 'fflate' +import { createServer } from 'node:net' + +const ROOT = join(import.meta.dir, '..') +const PLUGIN_ID = process.argv[2] ?? 'instatic.api-keys' + +// Pick a free port +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (typeof addr === 'object' && addr) { + const port = addr.port + server.close(() => resolve(port)) + } else { + server.close(() => reject(new Error('Could not get free port'))) + } + }) + server.on('error', reject) + }) +} + +function log(msg: string) { + console.log(`[E2E] ${msg}`) +} + +function logStep(n: number, msg: string) { + console.log(`\n=== Step ${n}: ${msg} ===`) +} + +function ok(msg: string) { + console.log(` ✓ ${msg}`) +} + +function fail(msg: string): never { + console.error(` ✗ ${msg}`) + process.exit(1) +} + +// ── Step 1: Build a plugin .zip ─────────────────────────────────────── + +async function buildPluginZip(pluginId: string): Promise<{ zip: Uint8Array; manifest: Record }> { + const distPath = join(ROOT, 'plugins', pluginId.replace(/^instatic\./, ''), 'dist/index.js') + if (!existsSync(distPath)) { + fail(`Plugin dist not found: ${distPath}. Run 'bun run build:plugins' first.`) + } + const distCode = await readFile(distPath, 'utf-8') + const pkgPath = join(ROOT, 'plugins', pluginId.replace(/^instatic\./, ''), 'package.json') + const pkgJson = JSON.parse(await readFile(pkgPath, 'utf-8')) + const instaticManifest = pkgJson.instaticManifest + + // Build plugin.json + const pluginJson = { + id: pluginId, + name: instaticManifest.name, + version: instaticManifest.version, + apiVersion: instaticManifest.apiVersion, + description: instaticManifest.description ?? '', + permissions: instaticManifest.permissions, + entrypoints: { + server: 'dist/index.js', + }, + resources: [], + adminPages: [], + networkAllowedHosts: instaticManifest.networkAllowedHosts ?? [], + } + const files: Record = { + 'plugin.json': strToU8(JSON.stringify(pluginJson, null, 2)), + 'dist/index.js': strToU8(distCode), + } + + // Build .zip using fflate's deflateRawSync. Minimal zip writer. + const zip = buildZip(files) + return { zip, manifest: pluginJson } +} + +function buildZip(files: Record): Uint8Array { + // Normalize values to Uint8Array (fflate accepts string or Uint8Array) + const normalized: Record = {} + for (const [k, v] of Object.entries(files)) { + normalized[k] = typeof v === 'string' ? new TextEncoder().encode(v) : v + } + // fflate's zipSync produces a valid zip. Use it instead of hand-rolling. + return zipSync(normalized, { level: 0 }) // level 0 = no compression +} + +// ── Step 2: Boot the host ────────────────────────────────────────────── + +// Module-scope so error handlers outside bootHost can read it +let hostOutput = '' + +async function bootHost(port: number, dbPath: string): Promise { + logStep(2, `Booting host on port ${port} with DB ${dbPath}`) + hostOutput = '' + // Use npx to find bun — Windows can't resolve 'bun' on PATH reliably. + const bunExe = process.platform === 'win32' ? 'npx.cmd' : 'npx' + const proc = spawn(bunExe, ['bun', 'server/index.ts'], { + cwd: ROOT, + env: { + ...process.env, + DATABASE_URL: `sqlite:${dbPath}`, + PORT: String(port), + NODE_ENV: 'development', + }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }) as unknown as Subprocess + // waitForServer calls proc.exited as a Promise + Object.defineProperty(proc, 'exited', { + get() { return proc.exitCode !== null ? Promise.resolve(proc.exitCode) : new Promise((r) => proc.on('exit', r)) }, + }) + // Capture host output for debugging + ;(async () => { + for await (const chunk of proc.stdout) hostOutput += new TextDecoder().decode(chunk) + })() + ;(async () => { + for await (const chunk of proc.stderr) hostOutput += new TextDecoder().decode(chunk) + })() + try { + await waitForServer(`http://127.0.0.1:${port}`, proc) + } catch (err) { + proc.kill() + console.error('\n--- Host output (last 2000 chars) ---\n' + hostOutput.slice(-2000)) + throw err + } + ok(`Host ready on http://127.0.0.1:${port}`) + return proc +} + +async function waitForServer(url: string, proc: Subprocess): Promise { + for (let i = 0; i < 60; i++) { + try { + const res = await fetch(`${url}/admin/api/cms/setup/status`) + if (res.ok) return + } catch { + // Connection refused is expected while the host is still booting — keep polling. + } + await new Promise((r) => setTimeout(r, 500)) + } + proc.kill() + fail(`Server at ${url} did not become ready in 30s`) +} + +// ── HTTP helpers ───────────────────────────────────────────────────── + +class Client { + constructor(public baseUrl: string) {} + private cookies = '' + + async fetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + if (this.cookies) headers.set('cookie', this.cookies) + const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers }) + const setCookie = res.headers.get('set-cookie') + if (setCookie) this.cookies = setCookie.split(';')[0] + return res + } + + async getJson(path: string): Promise<{ status: number; data: T | null }> { + const res = await this.fetch(path) + return { status: res.status, data: (await res.json().catch(() => null)) as T | null } + } + async postJson(path: string, body: unknown): Promise<{ status: number; data: T | null }> { + const res = await this.fetch(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return { status: res.status, data: (await res.json().catch(() => null)) as T | null } + } + async delete(path: string): Promise<{ status: number; data: T | null }> { + const res = await this.fetch(path, { method: 'DELETE' }) + return { status: res.status, data: (await res.json().catch(() => null)) as T | null } + } +} + +// ── Main test ───────────────────────────────────────────────────────── + +async function main() { + log(`Plugin: ${PLUGIN_ID}`) + log(`Working dir: ${ROOT}`) + + // Setup + const tempDir = join(tmpdir(), `instatic-e2e-${Date.now()}`) + await mkdir(tempDir, { recursive: true }) + const dbPath = join(tempDir, 'test.db') + const port = await getFreePort() + log(`Temp dir: ${tempDir}`) + log(`Port: ${port}`) + + // Step 1: Build plugin .zip + logStep(1, `Building plugin package for ${PLUGIN_ID}`) + const { zip, manifest } = await buildPluginZip(PLUGIN_ID) + const zipPath = join(tempDir, `${manifest.id}.zip`) + await writeFile(zipPath, zip) + ok(`Plugin package: ${zipPath} (${zip.length} bytes)`) + + // Step 2: Boot host + const proc = await bootHost(port, dbPath) + const client = new Client(`http://127.0.0.1:${port}`) + + try { + // Step 3: Check setup status + logStep(3, 'Check setup status') + const status = await client.getJson('/admin/api/cms/setup/status') + if (!status.data?.needsSetup) fail('Setup should be needed on a fresh install') + ok('Fresh install detected') + + // Step 4: Create owner + logStep(4, 'Create site + owner user') + const setupRes = await client.postJson('/admin/api/cms/setup', { + siteName: 'E2E Test Site', + email: 'owner@e2e.test', + password: 'OwnerPass123!Safe', + }) + if (setupRes.status !== 201) fail(`Setup failed: ${JSON.stringify(setupRes.data)}`) + ok('Site + owner created') + + // Step 5: Log in + logStep(5, 'Log in as owner') + const loginRes = await client.postJson('/admin/api/cms/login', { + email: 'owner@e2e.test', + password: 'OwnerPass123!Safe', + }) + if (loginRes.status !== 200 && loginRes.status !== 204) { + fail(`Login failed (${loginRes.status}): ${JSON.stringify(loginRes.data)}`) + } + if (!client.cookies) fail('No session cookie set after login') + ok(`Logged in (cookie: ${client.cookies.slice(0, 30)}...)`) + + // Step 5b: Open the step-up window. Plugin install is a sensitive + // operation (uploads + executes arbitrary plugin code), so the host + // requires a fresh password confirmation via /auth/step-up. + logStep('5b', 'Open step-up window (re-confirm password)') + const stepUpRes = await client.postJson('/admin/api/cms/auth/step-up', { + password: 'OwnerPass123!Safe', + }) + if (stepUpRes.status !== 200) fail(`Step-up failed (${stepUpRes.status}): ${JSON.stringify(stepUpRes.data)}`) + ok('Step-up window opened (15 min)') + + // Step 6: Install plugin from .zip + logStep(6, `Install ${PLUGIN_ID} from .zip`) + const installRes = await client.fetch('/admin/api/cms/plugins/package', { + method: 'POST', + // The install endpoint calls req.formData() and reads the `file` field. + // Use FormData to encode the multipart correctly. + body: (() => { + const fd = new FormData() + fd.append('file', new Blob([zip], { type: 'application/zip' }), `${manifest.id}.zip`) + return fd + })(), + }) + const installData = await installRes.json().catch(() => null) + if (installRes.status !== 200 && installRes.status !== 201) { + console.error('\n--- Host output (last 3000 chars) ---\n' + hostOutput.slice(-3000)) + fail(`Install failed (${installRes.status}): ${JSON.stringify(installData)}`) + } + ok(`Plugin installed: ${JSON.stringify(installData?.plugin?.id ?? manifest.id)}`) + + // Step 7: List installed plugins + logStep(7, 'List installed plugins') + const listRes = await client.getJson('/admin/api/cms/plugins') + if (listRes.status !== 200) fail(`List failed: ${JSON.stringify(listRes.data)}`) + const found = listRes.data?.plugins?.find((p: { id: string; version: string; lifecycleStatus?: string; status?: string }) => p.id === PLUGIN_ID) + if (!found) fail(`Plugin ${PLUGIN_ID} not in installed list`) + ok(`Found in list: ${found.id} v${found.version} (${found.lifecycleStatus ?? found.status})`) + + // Step 8: Call a plugin endpoint (depends on the plugin) + logStep(8, `Call a ${PLUGIN_ID} endpoint`) + let endpointRes: { status: number; data: unknown } + if (PLUGIN_ID === 'instatic.api-keys') { + // Create an API key + endpointRes = await client.postJson('/admin/api/keys', { + label: 'E2E Test Key', + scope: 'admin', + }) + if (endpointRes.status !== 201) fail(`Create API key failed: ${JSON.stringify(endpointRes.data)}`) + const token = endpointRes.data?.token + if (!token || !token.startsWith('instk_')) fail(`Token format wrong: ${token}`) + ok(`Created API key: ${token.slice(0, 16)}...`) + + // List the keys + const list = await client.getJson('/admin/api/keys') + if (list.status !== 200) fail(`List keys failed: ${JSON.stringify(list.data)}`) + ok(`List returned ${list.data?.keys?.length} keys`) + + // Test Bearer auth + const authClient = new Client(`http://127.0.0.1:${port}`) + const meRes = await authClient.fetch('/api/keys/me', { + headers: { 'authorization': `Bearer ${token}` }, + }) + if (meRes.status !== 200) fail(`Bearer auth failed: ${meRes.status}`) + const meData = await meRes.json() + ok(`Bearer auth works: ${meData.id} ${meData.label}`) + } else if (PLUGIN_ID === 'instatic.public-auth') { + const reg = await client.postJson('/api/auth/register', { + email: 'testuser@e2e.test', + password: 'UserPass123!', + displayName: 'Test User', + }) + if (reg.status !== 201) fail(`Register failed: ${JSON.stringify(reg.data)}`) + ok(`User registered: ${reg.data.userId}`) + } else if (PLUGIN_ID === 'instatic.membership') { + const t = await client.getJson('/api/membership/tiers') + if (t.status !== 200) fail(`List tiers failed: ${JSON.stringify(t.data)}`) + ok(`Public tiers endpoint returned ${t.data?.tiers?.length ?? 0} tiers`) + } else if (PLUGIN_ID === 'instatic.oidc-provider') { + const disc = await client.getJson('/.well-known/openid-configuration') + if (disc.status !== 200) fail(`Discovery failed: ${JSON.stringify(disc.data)}`) + ok(`OIDC discovery: issuer=${disc.data?.issuer}`) + } else { + // Generic: just verify activation worked + ok('Generic activation check passed') + } + + log('\n=== ✅ E2E PASSED ===') + log(` ${PLUGIN_ID} installed and operational in real host`) + } finally { + log('\nCleaning up...') + // Robust cleanup — Windows holds file handles for ~1s after process exits + try { proc.kill('SIGTERM') } catch { + // Process may have already exited — best-effort cleanup. + } + // Best-effort rm; tolerate Windows file locks (temp files reaped later) + for (let attempt = 0; attempt < 3; attempt++) { + try { await rm(tempDir, { recursive: true, force: true }); break } catch { await new Promise((r) => setTimeout(r, 1000)) } + } + } +} + +main().catch((err) => { + console.error('E2E FAILED:', err) + process.exit(1) +}) diff --git a/server/plugins/adminUi/apiKeysPage.ts b/server/plugins/adminUi/apiKeysPage.ts index e33b19ec..42d812a2 100644 --- a/server/plugins/adminUi/apiKeysPage.ts +++ b/server/plugins/adminUi/apiKeysPage.ts @@ -149,7 +149,7 @@ function renderPage(opts: { title: string; active: 'apikeys' | 'oidc' | 'tiers' */ export async function renderPluginAdminPage( req: Request, - db: DbClient, + _db: DbClient, options: { title: string active: 'apikeys' | 'oidc' | 'tiers' | null diff --git a/server/plugins/extensions/httpMiddlewareProtocol.ts b/server/plugins/extensions/httpMiddlewareProtocol.ts index 1aa70335..11c2a73b 100644 --- a/server/plugins/extensions/httpMiddlewareProtocol.ts +++ b/server/plugins/extensions/httpMiddlewareProtocol.ts @@ -18,13 +18,15 @@ * concrete first use case (api-keys plugin will be that case). */ -import type { ApiCallFor } from '../../protocol/apiCallSchema' -import type { DbClient } from '../../../db/client' -import { replyApiOk } from '../apiReplies' +import type { ApiCallFor } from '../protocol/apiCallSchema' +import type { DbClient } from '../../db/client' +import type { HostPluginRecord } from '../host/types' +import { replyApiOk } from '../host/apiReplies' import { registerPluginHttpMiddleware, type HttpMiddleware } from './httpMiddleware' export async function handleHttpMiddlewareRegister( msg: ApiCallFor<'cms.httpMiddleware.register'>, + _entry: HostPluginRecord, _db: DbClient, ): Promise { // Placeholder middleware — for now this is a no-op pass-through. diff --git a/server/plugins/extensions/migrationsProtocol.ts b/server/plugins/extensions/migrationsProtocol.ts index 72f4c72e..8917249b 100644 --- a/server/plugins/extensions/migrationsProtocol.ts +++ b/server/plugins/extensions/migrationsProtocol.ts @@ -19,9 +19,10 @@ * migration already been applied?". */ -import type { ApiCallFor } from '../../protocol/apiCallSchema' -import type { DbClient } from '../../../db/client' -import { replyApiOk } from '../apiReplies' +import type { ApiCallFor } from '../protocol/apiCallSchema' +import type { DbClient } from '../../db/client' +import type { HostPluginRecord } from '../host/types' +import { replyApiOk } from '../host/apiReplies' import { registerPluginMigration, type PluginMigration, @@ -29,6 +30,7 @@ import { export async function handleMigrationsRegister( msg: ApiCallFor<'cms.migrations.register'>, + _entry: HostPluginRecord, _db: DbClient, ): Promise { const [{ id, pgSql, sqliteSql }] = msg.args diff --git a/server/plugins/extensions/publicRoutes.ts b/server/plugins/extensions/publicRoutes.ts index 2d57e6a3..36c59fc1 100644 --- a/server/plugins/extensions/publicRoutes.ts +++ b/server/plugins/extensions/publicRoutes.ts @@ -33,7 +33,7 @@ * or the `cms.http.resolveApiKey` helper exposed below. */ -import type { RouteHandler } from '../../router' +import type { RouteHandler } from '../../routerTypes' interface PublicRouteEntry { pluginId: string diff --git a/server/plugins/extensions/publicRoutesProtocol.ts b/server/plugins/extensions/publicRoutesProtocol.ts index fe4bfa8e..7e1e63e2 100644 --- a/server/plugins/extensions/publicRoutesProtocol.ts +++ b/server/plugins/extensions/publicRoutesProtocol.ts @@ -22,13 +22,15 @@ * at path X") during install. */ -import type { ApiCallFor } from '../../protocol/apiCallSchema' -import type { DbClient } from '../../../db/client' -import { replyApiOk } from '../apiReplies' +import type { ApiCallFor } from '../protocol/apiCallSchema' +import type { DbClient } from '../../db/client' +import type { HostPluginRecord } from '../host/types' +import { replyApiOk } from '../host/apiReplies' import { registerPluginPublicRoute } from './publicRoutes' export async function handlePublicRoutesRegister( msg: ApiCallFor<'cms.publicRoutes.register'>, + _entry: HostPluginRecord, _db: DbClient, ): Promise { const [{ prefix, exclusive }] = msg.args @@ -39,7 +41,7 @@ export async function handlePublicRoutesRegister( // plugin runtime), so the plugin code can use the existing // `api.cms.routes.register('/api/auth/login', ...)` to register the // concrete route handler. - const handler = async (req: Request, runtime: { db: DbClient; uploadsDir?: string; databaseUrl?: string }, _url: URL, pathname: string): Promise => { + const handler = async (req: Request, _runtime: { db: DbClient; uploadsDir?: string; databaseUrl?: string }, _url: URL, pathname: string): Promise => { const { findPluginRouteAccess, runRouteInWorker } = await import('../host/rpc') const route = findPluginRouteAccess(msg.pluginId, req.method, pathname) if (!route) { diff --git a/server/plugins/host/apiDispatch.ts b/server/plugins/host/apiDispatch.ts index a1c09466..2cfbc620 100644 --- a/server/plugins/host/apiDispatch.ts +++ b/server/plugins/host/apiDispatch.ts @@ -35,6 +35,9 @@ import { handleNetworkFetch, handleNetworkAbort } from './handlers/network' import { handleScheduleRegister, handleScheduleCancel } from './handlers/schedule' import { handleMediaRegisterStorageAdapter, handleMediaRegisterUrlTransformer, handleMediaRegisterVariantDelegate } from './handlers/media' import { handleCryptoDigest, handleCryptoSignHmac } from './handlers/crypto' +import { handleMigrationsRegister } from '../extensions/migrationsProtocol' +import { handlePublicRoutesRegister } from '../extensions/publicRoutesProtocol' +import { handleHttpMiddlewareRegister } from '../extensions/httpMiddlewareProtocol' import { handleContentEntriesCreate, handleContentEntriesCreateMany, diff --git a/server/plugins/protocol/apiCallSchema.ts b/server/plugins/protocol/apiCallSchema.ts index 417c822c..aee43824 100644 --- a/server/plugins/protocol/apiCallSchema.ts +++ b/server/plugins/protocol/apiCallSchema.ts @@ -29,6 +29,11 @@ import { RegisterVariantDelegateArgSchema, } from './schemas/media' import { CryptoDigestArgSchema, CryptoSignHmacArgSchema } from './schemas/crypto' +import { + MigrationsRegisterArgSchema, + PublicRoutesRegisterArgSchema, + HttpMiddlewareRegisterArgSchema, +} from './schemas/extensions' import { ContentEntriesCreateArgsSchema, ContentEntriesCreateManyArgsSchema, diff --git a/server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts b/server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts index 629d3bc8..424e4880 100644 --- a/server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts +++ b/server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts @@ -6,4 +6,4 @@ * The freshness gate (plugin-bootstrap-fresh.test.ts) fails if this drifts. */ -export const PLUGIN_BOOTSTRAP_SOURCE = "(() => {\n // server/plugins/protocol/targets.ts\n var TARGET_PERMISSIONS = {\n \"cms.routes.register\": \"cms.routes\",\n \"cms.hooks.on\": \"cms.hooks\",\n \"cms.hooks.filter\": \"cms.hooks\",\n \"cms.hooks.emit\": \"cms.hooks\",\n \"cms.loops.registerSource\": \"loops.register\",\n \"cms.storage.list\": \"cms.storage\",\n \"cms.storage.create\": \"cms.storage\",\n \"cms.storage.update\": \"cms.storage\",\n \"cms.storage.delete\": \"cms.storage\",\n \"network.fetch\": \"network.outbound\",\n \"cms.schedule.register\": \"cms.schedule\",\n \"cms.schedule.cancel\": \"cms.schedule\",\n \"cms.media.registerStorageAdapter\": \"media.storage.adapter\",\n \"cms.media.registerUrlTransformer\": \"media.url.transform\",\n \"cms.media.registerVariantDelegate\": \"media.variant.delegate\",\n \"cms.content.tables.list\": \"cms.content.read\",\n \"cms.content.tables.get\": \"cms.content.read\",\n \"cms.content.tables.create\": \"cms.content.tables.manage\",\n \"cms.content.entries.list\": \"cms.content.read\",\n \"cms.content.entries.get\": \"cms.content.read\",\n \"cms.content.entries.getBySlug\": \"cms.content.read\",\n \"cms.content.entries.create\": \"cms.content.write\",\n \"cms.content.entries.update\": \"cms.content.write\",\n \"cms.content.entries.delete\": \"cms.content.delete\",\n \"cms.content.entries.publish\": \"cms.content.publish\",\n \"cms.content.entries.moveTable\": \"cms.content.write\",\n \"cms.content.entries.createMany\": \"cms.content.write\",\n \"cms.content.entries.updateMany\": \"cms.content.write\",\n \"cms.content.entries.deleteMany\": \"cms.content.delete\",\n \"cms.content.tree.read\": \"cms.content.read\",\n \"cms.content.tree.mutate\": \"cms.content.write\",\n \"cms.content.tree.replace\": \"cms.content.write\",\n \"cms.content.search\": \"cms.content.read\",\n \"cms.content.snapshot\": \"cms.content.read\",\n \"cms.content.republishAll\": \"cms.content.publish\"\n };\n\n // server/plugins/quickjs/bootstrap/src/buildApi.ts\n globalThis.__buildApi = function buildApi() {\n const meta = globalThis.__plugin_meta;\n function assertPermission(perm) {\n if (meta.grantedPermissions.indexOf(perm) < 0) {\n throw new Error('Plugin \"' + meta.id + '\" requires permission \"' + perm + '\"');\n }\n }\n function assertTargetPermission(target) {\n const perm = TARGET_PERMISSIONS[target];\n if (perm)\n assertPermission(perm);\n }\n function call(target, args) {\n return __hostCall(target, args);\n }\n function normalizePath(p) {\n const t = String(p).trim();\n if (!t || t === \"/\")\n return \"/\";\n return \"/\" + t.replace(/^\\/+|\\/+$/g, \"\");\n }\n function makeRoute(method) {\n return function(path, capability, handler) {\n assertTargetPermission(\"cms.routes.register\");\n if (typeof handler !== \"function\")\n throw new TypeError(\"Route handler must be a function\");\n const routeKey = method + \":\" + normalizePath(path);\n globalThis.__plugin_handlers.routes[routeKey] = handler;\n return call(\"cms.routes.register\", [{\n method,\n path: normalizePath(path),\n access: { kind: \"capability\", capability },\n routeKey\n }]);\n };\n }\n function registerAuthenticated(method) {\n return function(path, handler) {\n assertTargetPermission(\"cms.routes.register\");\n if (typeof handler !== \"function\")\n throw new TypeError(\"Route handler must be a function\");\n const routeKey = method + \":\" + normalizePath(path);\n globalThis.__plugin_handlers.routes[routeKey] = handler;\n return call(\"cms.routes.register\", [{\n method,\n path: normalizePath(path),\n access: { kind: \"authenticated\" },\n routeKey\n }]);\n };\n }\n function registerPublic(method) {\n return function(path, handler) {\n assertTargetPermission(\"cms.routes.register\");\n assertPermission(\"cms.routes.public\");\n if (typeof handler !== \"function\")\n throw new TypeError(\"Route handler must be a function\");\n const routeKey = method + \":\" + normalizePath(path);\n globalThis.__plugin_handlers.routes[routeKey] = handler;\n return call(\"cms.routes.register\", [{\n method,\n path: normalizePath(path),\n access: { kind: \"public\" },\n routeKey\n }]);\n };\n }\n function on(event, listener) {\n assertTargetPermission(\"cms.hooks.on\");\n if (typeof listener !== \"function\")\n throw new TypeError(\"Hook listener must be a function\");\n const listenerId = __nextId(\"listener\");\n globalThis.__plugin_handlers.listeners[listenerId] = listener;\n return call(\"cms.hooks.on\", [{ event: String(event), listenerId }]);\n }\n function filter(name, handler) {\n assertTargetPermission(\"cms.hooks.filter\");\n if (typeof handler !== \"function\")\n throw new TypeError(\"Hook filter must be a function\");\n const filterId = __nextId(\"filter\");\n globalThis.__plugin_handlers.filters[filterId] = handler;\n return call(\"cms.hooks.filter\", [{ name: String(name), filterId }]);\n }\n function emit(event, payload) {\n assertTargetPermission(\"cms.hooks.emit\");\n return call(\"cms.hooks.emit\", [{ event: String(event), payload: payload === undefined ? null : payload }]);\n }\n function registerSource(source) {\n assertTargetPermission(\"cms.loops.registerSource\");\n if (!source || typeof source !== \"object\")\n throw new TypeError(\"Loop source must be an object\");\n if (typeof source.fetch !== \"function\")\n throw new TypeError(\"Loop source.fetch must be a function\");\n const sourceId = String(source.id);\n globalThis.__plugin_handlers.loopSources[sourceId] = {\n fetch: source.fetch,\n preview: typeof source.preview === \"function\" ? source.preview : function() {\n return [];\n }\n };\n const descriptor = {\n id: sourceId,\n label: source.label,\n description: source.description,\n filterSchema: source.filterSchema || {},\n orderByOptions: source.orderByOptions || [],\n fields: source.fields || [],\n requestDependent: source.requestDependent === true ? true : undefined,\n perVisitor: source.perVisitor === true ? true : undefined\n };\n return call(\"cms.loops.registerSource\", [descriptor]);\n }\n function collection(resourceId) {\n assertTargetPermission(\"cms.storage.list\");\n return {\n list: function(options) {\n return call(\"cms.storage.list\", [String(resourceId), options ?? {}]);\n },\n create: function(data) {\n return call(\"cms.storage.create\", [String(resourceId), data]);\n },\n update: function(recordId, data) {\n return call(\"cms.storage.update\", [String(resourceId), String(recordId), data]);\n },\n delete: function(recordId) {\n return call(\"cms.storage.delete\", [String(resourceId), String(recordId)]);\n }\n };\n }\n function namespaceScheduleId(localId) {\n const prefix = meta.id + \".\";\n return localId.indexOf(prefix) === 0 ? localId : prefix + localId;\n }\n function scheduleRegister(def) {\n assertTargetPermission(\"cms.schedule.register\");\n if (!def || typeof def !== \"object\")\n throw new TypeError(\"schedule.register: argument must be an object\");\n if (typeof def.id !== \"string\" || def.id.length === 0)\n throw new TypeError(\"schedule.register: 'id' is required\");\n if (typeof def.handler !== \"function\")\n throw new TypeError(\"schedule.register: 'handler' must be a function\");\n if (!def.cadence || typeof def.cadence !== \"object\")\n throw new TypeError(\"schedule.register: 'cadence' is required\");\n const scheduleId = String(def.id);\n globalThis.__plugin_handlers.schedules[namespaceScheduleId(scheduleId)] = def.handler;\n const overlap = def.overlap === \"queue\" || def.overlap === \"parallel\" ? def.overlap : \"skip\";\n let maxDurationMs = typeof def.maxDurationMs === \"number\" ? def.maxDurationMs : 5000;\n if (maxDurationMs < 100)\n maxDurationMs = 100;\n if (maxDurationMs > 5 * 60 * 1000)\n maxDurationMs = 5 * 60 * 1000;\n return call(\"cms.schedule.register\", [{\n scheduleId,\n cadence: def.cadence,\n overlap,\n maxDurationMs\n }]);\n }\n function scheduleCancel(id) {\n assertTargetPermission(\"cms.schedule.cancel\");\n const scheduleId = String(id);\n delete globalThis.__plugin_handlers.schedules[namespaceScheduleId(scheduleId)];\n return call(\"cms.schedule.cancel\", [{ scheduleId }]);\n }\n const scheduleApi = {\n register: scheduleRegister,\n cancel: scheduleCancel,\n daily: function(id, at, handler) {\n return scheduleRegister({ id, cadence: { interval: \"daily\", at }, handler });\n },\n hourly: function(id, handler) {\n return scheduleRegister({ id, cadence: { interval: \"hourly\" }, handler });\n },\n every: function(minutes, id, handler) {\n return scheduleRegister({ id, cadence: { interval: \"every\", minutes }, handler });\n }\n };\n const settingsApi = {\n get: function(key) {\n return globalThis.__plugin_settings[key];\n },\n getAll: function() {\n return Object.assign({}, globalThis.__plugin_settings);\n },\n replace: async function(next) {\n await call(\"cms.settings.replace\", [next]);\n }\n };\n function registerStorageAdapter(adapter) {\n assertTargetPermission(\"cms.media.registerStorageAdapter\");\n if (!adapter || typeof adapter !== \"object\")\n throw new TypeError(\"registerStorageAdapter: adapter must be an object\");\n if (typeof adapter.id !== \"string\" || !adapter.id)\n throw new TypeError(\"registerStorageAdapter: 'id' is required\");\n if (adapter.id.indexOf(meta.id + \".\") !== 0) {\n throw new Error('registerStorageAdapter: adapter id \"' + adapter.id + '\" must start with the plugin id \"' + meta.id + '.\"');\n }\n if (typeof adapter.label !== \"string\" || !adapter.label)\n throw new TypeError(\"registerStorageAdapter: 'label' is required\");\n if (!Array.isArray(adapter.roles) || adapter.roles.length === 0) {\n throw new TypeError(\"registerStorageAdapter: 'roles' must be a non-empty array\");\n }\n if (typeof adapter.servingMode !== \"string\")\n throw new TypeError(\"registerStorageAdapter: 'servingMode' is required\");\n if (typeof adapter.beginWrite !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'beginWrite' must be a function\");\n if (typeof adapter.finalizeWrite !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'finalizeWrite' must be a function\");\n if (typeof adapter.abortWrite !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'abortWrite' must be a function\");\n if (typeof adapter[\"delete\"] !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'delete' must be a function\");\n if (typeof adapter.verify !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'verify' must be a function\");\n if (adapter.servingMode === \"proxy\" && typeof adapter.readStream !== \"function\") {\n throw new TypeError(\"registerStorageAdapter: servingMode 'proxy' requires a 'readStream' function\");\n }\n if (adapter.servingMode !== \"proxy\" && typeof adapter.getReadUrl !== \"function\") {\n throw new TypeError(\"registerStorageAdapter: servingMode '\" + adapter.servingMode + \"' requires a 'getReadUrl' function\");\n }\n globalThis.__plugin_handlers.mediaAdapters[adapter.id] = {\n beginWrite: adapter.beginWrite,\n finalizeWrite: adapter.finalizeWrite,\n abortWrite: adapter.abortWrite,\n delete: adapter[\"delete\"],\n getReadUrl: typeof adapter.getReadUrl === \"function\" ? adapter.getReadUrl : null,\n verify: adapter.verify,\n readStream: typeof adapter.readStream === \"function\" ? adapter.readStream : null\n };\n const cspOrigins = Array.isArray(adapter.cspOrigins) ? adapter.cspOrigins.map(function(entry) {\n return { directive: String(entry.directive), origin: String(entry.origin) };\n }) : undefined;\n return call(\"cms.media.registerStorageAdapter\", [{\n adapterId: adapter.id,\n label: String(adapter.label),\n roles: adapter.roles.slice(),\n servingMode: String(adapter.servingMode),\n hasGetReadUrl: typeof adapter.getReadUrl === \"function\",\n hasReadStream: typeof adapter.readStream === \"function\",\n cspOrigins\n }]);\n }\n function registerUrlTransformer(fn) {\n assertTargetPermission(\"cms.media.registerUrlTransformer\");\n if (typeof fn !== \"function\")\n throw new TypeError(\"registerUrlTransformer: argument must be a function\");\n const transformerId = __nextId(\"mediaUrlT\");\n globalThis.__plugin_handlers.mediaUrlTransformers[transformerId] = fn;\n return call(\"cms.media.registerUrlTransformer\", [{ transformerId }]);\n }\n function registerVariantDelegate(delegate) {\n assertTargetPermission(\"cms.media.registerVariantDelegate\");\n if (!delegate || typeof delegate !== \"object\")\n throw new TypeError(\"registerVariantDelegate: argument must be an object\");\n if (typeof delegate.id !== \"string\" || !delegate.id)\n throw new TypeError(\"registerVariantDelegate: 'id' is required\");\n if (delegate.id.indexOf(meta.id + \".\") !== 0) {\n throw new Error('registerVariantDelegate: id \"' + delegate.id + '\" must start with the plugin id \"' + meta.id + '.\"');\n }\n if (typeof delegate.variantUrlTemplate !== \"string\") {\n throw new TypeError(\"registerVariantDelegate: 'variantUrlTemplate' must be a string\");\n }\n if (!Array.isArray(delegate.widths) || delegate.widths.length === 0) {\n throw new TypeError(\"registerVariantDelegate: 'widths' must be a non-empty array\");\n }\n if (!Array.isArray(delegate.formats) || delegate.formats.length === 0) {\n throw new TypeError(\"registerVariantDelegate: 'formats' must be a non-empty array\");\n }\n return call(\"cms.media.registerVariantDelegate\", [{\n delegateId: delegate.id,\n variantUrlTemplate: delegate.variantUrlTemplate,\n widths: delegate.widths.slice(),\n formats: delegate.formats.slice()\n }]);\n }\n return {\n plugin: {\n id: meta.id,\n version: meta.version,\n permissions: meta.grantedPermissions.slice(),\n log: function(...args) {\n const parts = [];\n for (let i = 0;i < args.length; i++) {\n const a = args[i];\n if (typeof a === \"string\")\n parts.push(a);\n else {\n try {\n parts.push(JSON.stringify(a));\n } catch (_) {\n parts.push(String(a));\n }\n }\n }\n __log(\"info\", parts.join(\" \"));\n },\n assetUrl: function(path) {\n if (typeof path !== \"string\" || path.length === 0) {\n throw new TypeError(\"assetUrl: path must be a non-empty string\");\n }\n const base = (meta.assetBasePath || \"\").replace(/\\/+$/g, \"\");\n const rel = String(path).replace(/^\\/+/g, \"\");\n return base + \"/\" + rel;\n }\n },\n cms: {\n routes: {\n get: makeRoute(\"GET\"),\n post: makeRoute(\"POST\"),\n patch: makeRoute(\"PATCH\"),\n delete: makeRoute(\"DELETE\"),\n authenticated: {\n get: registerAuthenticated(\"GET\"),\n post: registerAuthenticated(\"POST\"),\n patch: registerAuthenticated(\"PATCH\"),\n delete: registerAuthenticated(\"DELETE\")\n },\n public: {\n get: registerPublic(\"GET\"),\n post: registerPublic(\"POST\"),\n patch: registerPublic(\"PATCH\"),\n delete: registerPublic(\"DELETE\")\n }\n },\n storage: { collection },\n hooks: { on, filter, emit },\n loops: { registerSource },\n settings: settingsApi,\n schedule: scheduleApi,\n content: {\n tables: {\n list: function() {\n assertTargetPermission(\"cms.content.tables.list\");\n return call(\"cms.content.tables.list\", []);\n },\n get: function(slug) {\n assertTargetPermission(\"cms.content.tables.get\");\n return call(\"cms.content.tables.get\", [String(slug)]);\n },\n create: function(input) {\n assertTargetPermission(\"cms.content.tables.create\");\n return call(\"cms.content.tables.create\", [input]);\n }\n },\n table: function(slug) {\n const s = String(slug);\n return {\n list: function(options) {\n assertTargetPermission(\"cms.content.entries.list\");\n return call(\"cms.content.entries.list\", [s, options || {}]);\n },\n get: function(entryId) {\n assertTargetPermission(\"cms.content.entries.get\");\n return call(\"cms.content.entries.get\", [s, String(entryId)]);\n },\n getBySlug: function(entrySlug) {\n assertTargetPermission(\"cms.content.entries.getBySlug\");\n return call(\"cms.content.entries.getBySlug\", [s, String(entrySlug)]);\n },\n create: function(input) {\n assertTargetPermission(\"cms.content.entries.create\");\n return call(\"cms.content.entries.create\", [s, input]);\n },\n update: function(entryId, patch) {\n assertTargetPermission(\"cms.content.entries.update\");\n return call(\"cms.content.entries.update\", [s, String(entryId), patch]);\n },\n delete: function(entryId) {\n assertTargetPermission(\"cms.content.entries.delete\");\n return call(\"cms.content.entries.delete\", [s, String(entryId)]);\n },\n publish: function(entryId, options) {\n assertTargetPermission(\"cms.content.entries.publish\");\n return call(\"cms.content.entries.publish\", [s, String(entryId), options || {}]);\n },\n moveToTable: function(entryId, targetSlug) {\n assertTargetPermission(\"cms.content.entries.moveTable\");\n return call(\"cms.content.entries.moveTable\", [s, String(entryId), String(targetSlug)]);\n },\n createMany: function(inputs) {\n assertTargetPermission(\"cms.content.entries.createMany\");\n return call(\"cms.content.entries.createMany\", [s, inputs]);\n },\n updateMany: function(updates) {\n assertTargetPermission(\"cms.content.entries.updateMany\");\n return call(\"cms.content.entries.updateMany\", [s, updates]);\n },\n deleteMany: function(entryIds) {\n assertTargetPermission(\"cms.content.entries.deleteMany\");\n return call(\"cms.content.entries.deleteMany\", [s, entryIds]);\n }\n };\n },\n tree: function(entryId, fieldId) {\n const e = String(entryId);\n const f = String(fieldId);\n return {\n read: function() {\n assertTargetPermission(\"cms.content.tree.read\");\n return call(\"cms.content.tree.read\", [e, f]);\n },\n mutate: function(operations) {\n assertTargetPermission(\"cms.content.tree.mutate\");\n return call(\"cms.content.tree.mutate\", [e, f, operations]);\n },\n replace: function(tree) {\n assertTargetPermission(\"cms.content.tree.replace\");\n return call(\"cms.content.tree.replace\", [e, f, tree]);\n }\n };\n },\n search: function(query, limit) {\n assertTargetPermission(\"cms.content.search\");\n return call(\"cms.content.search\", [String(query), Number(limit || 50)]);\n },\n getPublishedSnapshot: function(entryId) {\n assertTargetPermission(\"cms.content.snapshot\");\n return call(\"cms.content.snapshot\", [String(entryId)]);\n },\n republishAll: function() {\n assertTargetPermission(\"cms.content.republishAll\");\n return call(\"cms.content.republishAll\", []);\n }\n },\n media: {\n registerStorageAdapter,\n registerUrlTransformer,\n registerVariantDelegate\n }\n }\n };\n };\n var __idCounter = 0;\n function __nextId(prefix) {\n __idCounter += 1;\n return prefix + \"_\" + __idCounter + \"_\" + Date.now().toString(36);\n }\n\n // server/plugins/quickjs/bootstrap/src/boundary.ts\n function fromJson(json) {\n return JSON.parse(json);\n }\n function toJson(value, fallback) {\n return JSON.stringify(value === undefined ? fallback : value);\n }\n\n // server/plugins/quickjs/bootstrap/src/pluginRuntime.ts\n globalThis.__plugin_handlers = {\n routes: {},\n listeners: {},\n filters: {},\n loopSources: {},\n schedules: {},\n mediaAdapters: {},\n mediaUrlTransformers: {}\n };\n function __resolvePluginModule() {\n const root = globalThis.__plugin_exports;\n if (!root || typeof root !== \"object\")\n return null;\n const def = root.default;\n const isPluginModule = (v) => {\n if (!v || typeof v !== \"object\")\n return false;\n const m = v;\n return typeof m.install === \"function\" || typeof m.activate === \"function\" || typeof m.deactivate === \"function\" || typeof m.uninstall === \"function\" || typeof m.migrate === \"function\";\n };\n return isPluginModule(def) ? def : root;\n }\n globalThis.__runLifecycle = async function runLifecycle(hook) {\n const mod = __resolvePluginModule();\n const fn = mod && mod[hook];\n if (typeof fn !== \"function\")\n return;\n await fn(globalThis.__buildApi());\n };\n globalThis.__runMigrate = async function runMigrate(fromVersion) {\n const mod = __resolvePluginModule();\n const fn = mod && mod.migrate;\n if (typeof fn !== \"function\")\n return;\n await fn({ fromVersion }, globalThis.__buildApi());\n };\n function __materializeUploadedFiles(value) {\n if (Array.isArray(value))\n return value.map(__materializeUploadedFiles);\n if (!value || typeof value !== \"object\")\n return value;\n const marker = value;\n if (marker.__file !== true || typeof marker.dataBase64 !== \"string\")\n return value;\n const dataBase64 = marker.dataBase64;\n return {\n name: String(marker.name ?? \"\"),\n type: String(marker.type ?? \"\"),\n size: typeof marker.size === \"number\" ? marker.size : 0,\n arrayBuffer: async function() {\n const bytes = __base64ToBytes(dataBase64);\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);\n },\n text: async function() {\n return new TextDecoder().decode(__base64ToBytes(dataBase64));\n }\n };\n }\n function __encodeResponseBody(body) {\n if (body === null || body === undefined)\n return { body: \"\", bodyEncoding: \"utf8\" };\n if (typeof body === \"string\")\n return { body, bodyEncoding: \"utf8\" };\n if (body instanceof ArrayBuffer) {\n return { body: __bytesToBase64(new Uint8Array(body)), bodyEncoding: \"base64\" };\n }\n if (ArrayBuffer.isView(body)) {\n return {\n body: __bytesToBase64(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)),\n bodyEncoding: \"base64\"\n };\n }\n throw new TypeError(\"Route __response body must be a string, ArrayBuffer, or TypedArray/DataView (got \" + Object.prototype.toString.call(body).slice(8, -1) + \")\");\n }\n globalThis.__runRoute = async function runRoute(routeKey, ctxJson) {\n const handler = globalThis.__plugin_handlers.routes[routeKey];\n if (!handler)\n throw new Error(\"Route handler not registered: \" + routeKey);\n const ctx = fromJson(ctxJson);\n const _hdrs = ctx.request.headers || {};\n const _hdrsLc = {};\n for (const _k in _hdrs) {\n if (Object.prototype.hasOwnProperty.call(_hdrs, _k))\n _hdrsLc[String(_k).toLowerCase()] = _hdrs[_k];\n }\n const headersFacade = {\n get: function(name) {\n const k = String(name).toLowerCase();\n return Object.prototype.hasOwnProperty.call(_hdrsLc, k) ? _hdrsLc[k] : null;\n },\n has: function(name) {\n return Object.prototype.hasOwnProperty.call(_hdrsLc, String(name).toLowerCase());\n },\n entries: function() {\n return Object.entries(_hdrsLc);\n },\n keys: function() {\n return Object.keys(_hdrsLc);\n },\n values: function() {\n return Object.values(_hdrsLc);\n },\n forEach: function(cb) {\n Object.keys(_hdrsLc).forEach(function(k) {\n cb(_hdrsLc[k], k);\n });\n }\n };\n const rawBody = ctx.request.body || \"\";\n const bodyIsBase64 = ctx.request.bodyEncoding === \"base64\";\n function requestBodyText() {\n return bodyIsBase64 ? new TextDecoder().decode(__base64ToBytes(rawBody)) : rawBody;\n }\n const req = {\n url: ctx.request.url,\n method: ctx.request.method,\n headers: headersFacade,\n json: async function() {\n return fromJson(requestBodyText() || \"{}\");\n },\n text: async function() {\n return requestBodyText();\n },\n arrayBuffer: async function() {\n const bytes = bodyIsBase64 ? __base64ToBytes(rawBody) : new TextEncoder().encode(rawBody);\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);\n }\n };\n const body = {};\n for (const key of Object.keys(ctx.body || {})) {\n body[key] = __materializeUploadedFiles(ctx.body[key]);\n }\n const result = await handler({ req, body, user: ctx.user });\n if (result && typeof result === \"object\" && result.__response === true) {\n const r = result;\n const encoded = __encodeResponseBody(r.body);\n return toJson({\n __response: true,\n status: typeof r.status === \"number\" ? r.status : 200,\n headers: r.headers && typeof r.headers === \"object\" ? r.headers : {},\n body: encoded.body,\n bodyEncoding: encoded.bodyEncoding\n });\n }\n return toJson(result, { ok: true });\n };\n globalThis.__runHookListener = async function runHookListener(listenerId, payloadJson) {\n const fn = globalThis.__plugin_handlers.listeners[listenerId];\n if (!fn)\n return;\n await fn(fromJson(payloadJson));\n };\n globalThis.__runHookFilter = async function runHookFilter(filterId, valueJson, contextJson) {\n const fn = globalThis.__plugin_handlers.filters[filterId];\n if (!fn)\n return valueJson;\n const value = fromJson(valueJson);\n const contextExtras = contextJson ? fromJson(contextJson) : {};\n const context = Object.assign({ pluginId: globalThis.__plugin_meta.id }, contextExtras);\n const next = await fn(value, context);\n return toJson(next, value);\n };\n globalThis.__runLoopFetch = async function runLoopFetch(sourceId, ctxJson) {\n const source = globalThis.__plugin_handlers.loopSources[sourceId];\n if (!source)\n throw new Error(\"Loop source not registered: \" + sourceId);\n const result = await source.fetch(fromJson(ctxJson));\n return toJson(result, { items: [], totalItems: 0 });\n };\n globalThis.__runLoopPreview = function runLoopPreview(sourceId, ctxJson) {\n const source = globalThis.__plugin_handlers.loopSources[sourceId];\n if (!source)\n throw new Error(\"Loop source not registered: \" + sourceId);\n const result = source.preview(fromJson(ctxJson));\n if (result && typeof result.then === \"function\") {\n throw new TypeError('Loop source \"' + sourceId + '\" preview() must be synchronous (it returned a Promise)');\n }\n return toJson(result, []);\n };\n globalThis.__runSchedule = async function runSchedule(scheduleId) {\n const handler = globalThis.__plugin_handlers.schedules[scheduleId];\n if (typeof handler !== \"function\") {\n __log(\"warn\", 'no handler registered for schedule \"' + String(scheduleId) + '\"');\n return;\n }\n await handler();\n };\n globalThis.__runMediaAdapterCall = async function runMediaAdapterCall(adapterId, method, argsJson) {\n const adapter = globalThis.__plugin_handlers.mediaAdapters[adapterId];\n if (!adapter)\n throw new Error(\"Media adapter not registered: \" + adapterId);\n const fn = adapter[method];\n if (typeof fn !== \"function\")\n throw new Error('Media adapter \"' + adapterId + '\" does not implement \"' + method + '\"');\n const argsArray = fromJson(argsJson);\n const result = await fn(argsArray[0], argsArray[1]);\n return toJson(result, null);\n };\n globalThis.__runMediaUrlTransformer = async function runMediaUrlTransformer(transformerId, payloadJson) {\n const fn = globalThis.__plugin_handlers.mediaUrlTransformers[transformerId];\n if (typeof fn !== \"function\") {\n return toJson(null);\n }\n const payload = fromJson(payloadJson);\n const next = await fn(payload.path, payload.ctx);\n return toJson(typeof next === \"string\" ? next : null);\n };\n globalThis.__updateSettings = function updateSettings(nextJson) {\n const next = fromJson(nextJson);\n for (const k of Object.keys(globalThis.__plugin_settings))\n delete globalThis.__plugin_settings[k];\n Object.assign(globalThis.__plugin_settings, next);\n };\n globalThis.__detectExportedHooks = function detectExportedHooks() {\n const known = [\"install\", \"activate\", \"deactivate\", \"uninstall\", \"migrate\"];\n const mod = __resolvePluginModule() || {};\n const out = [];\n for (const name of known) {\n if (typeof mod[name] === \"function\")\n out.push(name);\n }\n return out;\n };\n})();\n" +export const PLUGIN_BOOTSTRAP_SOURCE = "(() => {\n // server/plugins/protocol/targets.ts\n var TARGET_PERMISSIONS = {\n \"cms.routes.register\": \"cms.routes\",\n \"cms.migrations.register\": \"cms.migrations\",\n \"cms.publicRoutes.register\": \"cms.publicRoutes\",\n \"cms.httpMiddleware.register\": \"cms.httpMiddleware\",\n \"cms.hooks.on\": \"cms.hooks\",\n \"cms.hooks.filter\": \"cms.hooks\",\n \"cms.hooks.emit\": \"cms.hooks\",\n \"cms.loops.registerSource\": \"loops.register\",\n \"cms.storage.list\": \"cms.storage\",\n \"cms.storage.create\": \"cms.storage\",\n \"cms.storage.update\": \"cms.storage\",\n \"cms.storage.delete\": \"cms.storage\",\n \"network.fetch\": \"network.outbound\",\n \"cms.schedule.register\": \"cms.schedule\",\n \"cms.schedule.cancel\": \"cms.schedule\",\n \"cms.media.registerStorageAdapter\": \"media.storage.adapter\",\n \"cms.media.registerUrlTransformer\": \"media.url.transform\",\n \"cms.media.registerVariantDelegate\": \"media.variant.delegate\",\n \"cms.content.tables.list\": \"cms.content.read\",\n \"cms.content.tables.get\": \"cms.content.read\",\n \"cms.content.tables.create\": \"cms.content.tables.manage\",\n \"cms.content.entries.list\": \"cms.content.read\",\n \"cms.content.entries.get\": \"cms.content.read\",\n \"cms.content.entries.getBySlug\": \"cms.content.read\",\n \"cms.content.entries.create\": \"cms.content.write\",\n \"cms.content.entries.update\": \"cms.content.write\",\n \"cms.content.entries.delete\": \"cms.content.delete\",\n \"cms.content.entries.publish\": \"cms.content.publish\",\n \"cms.content.entries.moveTable\": \"cms.content.write\",\n \"cms.content.entries.createMany\": \"cms.content.write\",\n \"cms.content.entries.updateMany\": \"cms.content.write\",\n \"cms.content.entries.deleteMany\": \"cms.content.delete\",\n \"cms.content.tree.read\": \"cms.content.read\",\n \"cms.content.tree.mutate\": \"cms.content.write\",\n \"cms.content.tree.replace\": \"cms.content.write\",\n \"cms.content.search\": \"cms.content.read\",\n \"cms.content.snapshot\": \"cms.content.read\",\n \"cms.content.republishAll\": \"cms.content.publish\"\n };\n\n // server/plugins/quickjs/bootstrap/src/buildApi.ts\n globalThis.__buildApi = function buildApi() {\n const meta = globalThis.__plugin_meta;\n function assertPermission(perm) {\n if (meta.grantedPermissions.indexOf(perm) < 0) {\n throw new Error('Plugin \"' + meta.id + '\" requires permission \"' + perm + '\"');\n }\n }\n function assertTargetPermission(target) {\n const perm = TARGET_PERMISSIONS[target];\n if (perm)\n assertPermission(perm);\n }\n function call(target, args) {\n return __hostCall(target, args);\n }\n function normalizePath(p) {\n const t = String(p).trim();\n if (!t || t === \"/\")\n return \"/\";\n return \"/\" + t.replace(/^\\/+|\\/+$/g, \"\");\n }\n function makeRoute(method) {\n return function(path, capability, handler) {\n assertTargetPermission(\"cms.routes.register\");\n if (typeof handler !== \"function\")\n throw new TypeError(\"Route handler must be a function\");\n const routeKey = method + \":\" + normalizePath(path);\n globalThis.__plugin_handlers.routes[routeKey] = handler;\n return call(\"cms.routes.register\", [{\n method,\n path: normalizePath(path),\n access: { kind: \"capability\", capability },\n routeKey\n }]);\n };\n }\n function registerAuthenticated(method) {\n return function(path, handler) {\n assertTargetPermission(\"cms.routes.register\");\n if (typeof handler !== \"function\")\n throw new TypeError(\"Route handler must be a function\");\n const routeKey = method + \":\" + normalizePath(path);\n globalThis.__plugin_handlers.routes[routeKey] = handler;\n return call(\"cms.routes.register\", [{\n method,\n path: normalizePath(path),\n access: { kind: \"authenticated\" },\n routeKey\n }]);\n };\n }\n function registerPublic(method) {\n return function(path, handler) {\n assertTargetPermission(\"cms.routes.register\");\n assertPermission(\"cms.routes.public\");\n if (typeof handler !== \"function\")\n throw new TypeError(\"Route handler must be a function\");\n const routeKey = method + \":\" + normalizePath(path);\n globalThis.__plugin_handlers.routes[routeKey] = handler;\n return call(\"cms.routes.register\", [{\n method,\n path: normalizePath(path),\n access: { kind: \"public\" },\n routeKey\n }]);\n };\n }\n function on(event, listener) {\n assertTargetPermission(\"cms.hooks.on\");\n if (typeof listener !== \"function\")\n throw new TypeError(\"Hook listener must be a function\");\n const listenerId = __nextId(\"listener\");\n globalThis.__plugin_handlers.listeners[listenerId] = listener;\n return call(\"cms.hooks.on\", [{ event: String(event), listenerId }]);\n }\n function filter(name, handler) {\n assertTargetPermission(\"cms.hooks.filter\");\n if (typeof handler !== \"function\")\n throw new TypeError(\"Hook filter must be a function\");\n const filterId = __nextId(\"filter\");\n globalThis.__plugin_handlers.filters[filterId] = handler;\n return call(\"cms.hooks.filter\", [{ name: String(name), filterId }]);\n }\n function emit(event, payload) {\n assertTargetPermission(\"cms.hooks.emit\");\n return call(\"cms.hooks.emit\", [{ event: String(event), payload: payload === undefined ? null : payload }]);\n }\n function registerSource(source) {\n assertTargetPermission(\"cms.loops.registerSource\");\n if (!source || typeof source !== \"object\")\n throw new TypeError(\"Loop source must be an object\");\n if (typeof source.fetch !== \"function\")\n throw new TypeError(\"Loop source.fetch must be a function\");\n const sourceId = String(source.id);\n globalThis.__plugin_handlers.loopSources[sourceId] = {\n fetch: source.fetch,\n preview: typeof source.preview === \"function\" ? source.preview : function() {\n return [];\n }\n };\n const descriptor = {\n id: sourceId,\n label: source.label,\n description: source.description,\n filterSchema: source.filterSchema || {},\n orderByOptions: source.orderByOptions || [],\n fields: source.fields || [],\n requestDependent: source.requestDependent === true ? true : undefined,\n perVisitor: source.perVisitor === true ? true : undefined\n };\n return call(\"cms.loops.registerSource\", [descriptor]);\n }\n function collection(resourceId) {\n assertTargetPermission(\"cms.storage.list\");\n return {\n list: function(options) {\n return call(\"cms.storage.list\", [String(resourceId), options ?? {}]);\n },\n create: function(data) {\n return call(\"cms.storage.create\", [String(resourceId), data]);\n },\n update: function(recordId, data) {\n return call(\"cms.storage.update\", [String(resourceId), String(recordId), data]);\n },\n delete: function(recordId) {\n return call(\"cms.storage.delete\", [String(resourceId), String(recordId)]);\n }\n };\n }\n function namespaceScheduleId(localId) {\n const prefix = meta.id + \".\";\n return localId.indexOf(prefix) === 0 ? localId : prefix + localId;\n }\n function scheduleRegister(def) {\n assertTargetPermission(\"cms.schedule.register\");\n if (!def || typeof def !== \"object\")\n throw new TypeError(\"schedule.register: argument must be an object\");\n if (typeof def.id !== \"string\" || def.id.length === 0)\n throw new TypeError(\"schedule.register: 'id' is required\");\n if (typeof def.handler !== \"function\")\n throw new TypeError(\"schedule.register: 'handler' must be a function\");\n if (!def.cadence || typeof def.cadence !== \"object\")\n throw new TypeError(\"schedule.register: 'cadence' is required\");\n const scheduleId = String(def.id);\n globalThis.__plugin_handlers.schedules[namespaceScheduleId(scheduleId)] = def.handler;\n const overlap = def.overlap === \"queue\" || def.overlap === \"parallel\" ? def.overlap : \"skip\";\n let maxDurationMs = typeof def.maxDurationMs === \"number\" ? def.maxDurationMs : 5000;\n if (maxDurationMs < 100)\n maxDurationMs = 100;\n if (maxDurationMs > 5 * 60 * 1000)\n maxDurationMs = 5 * 60 * 1000;\n return call(\"cms.schedule.register\", [{\n scheduleId,\n cadence: def.cadence,\n overlap,\n maxDurationMs\n }]);\n }\n function scheduleCancel(id) {\n assertTargetPermission(\"cms.schedule.cancel\");\n const scheduleId = String(id);\n delete globalThis.__plugin_handlers.schedules[namespaceScheduleId(scheduleId)];\n return call(\"cms.schedule.cancel\", [{ scheduleId }]);\n }\n const scheduleApi = {\n register: scheduleRegister,\n cancel: scheduleCancel,\n daily: function(id, at, handler) {\n return scheduleRegister({ id, cadence: { interval: \"daily\", at }, handler });\n },\n hourly: function(id, handler) {\n return scheduleRegister({ id, cadence: { interval: \"hourly\" }, handler });\n },\n every: function(minutes, id, handler) {\n return scheduleRegister({ id, cadence: { interval: \"every\", minutes }, handler });\n }\n };\n const settingsApi = {\n get: function(key) {\n return globalThis.__plugin_settings[key];\n },\n getAll: function() {\n return Object.assign({}, globalThis.__plugin_settings);\n },\n replace: async function(next) {\n await call(\"cms.settings.replace\", [next]);\n }\n };\n function registerStorageAdapter(adapter) {\n assertTargetPermission(\"cms.media.registerStorageAdapter\");\n if (!adapter || typeof adapter !== \"object\")\n throw new TypeError(\"registerStorageAdapter: adapter must be an object\");\n if (typeof adapter.id !== \"string\" || !adapter.id)\n throw new TypeError(\"registerStorageAdapter: 'id' is required\");\n if (adapter.id.indexOf(meta.id + \".\") !== 0) {\n throw new Error('registerStorageAdapter: adapter id \"' + adapter.id + '\" must start with the plugin id \"' + meta.id + '.\"');\n }\n if (typeof adapter.label !== \"string\" || !adapter.label)\n throw new TypeError(\"registerStorageAdapter: 'label' is required\");\n if (!Array.isArray(adapter.roles) || adapter.roles.length === 0) {\n throw new TypeError(\"registerStorageAdapter: 'roles' must be a non-empty array\");\n }\n if (typeof adapter.servingMode !== \"string\")\n throw new TypeError(\"registerStorageAdapter: 'servingMode' is required\");\n if (typeof adapter.beginWrite !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'beginWrite' must be a function\");\n if (typeof adapter.finalizeWrite !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'finalizeWrite' must be a function\");\n if (typeof adapter.abortWrite !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'abortWrite' must be a function\");\n if (typeof adapter[\"delete\"] !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'delete' must be a function\");\n if (typeof adapter.verify !== \"function\")\n throw new TypeError(\"registerStorageAdapter: 'verify' must be a function\");\n if (adapter.servingMode === \"proxy\" && typeof adapter.readStream !== \"function\") {\n throw new TypeError(\"registerStorageAdapter: servingMode 'proxy' requires a 'readStream' function\");\n }\n if (adapter.servingMode !== \"proxy\" && typeof adapter.getReadUrl !== \"function\") {\n throw new TypeError(\"registerStorageAdapter: servingMode '\" + adapter.servingMode + \"' requires a 'getReadUrl' function\");\n }\n globalThis.__plugin_handlers.mediaAdapters[adapter.id] = {\n beginWrite: adapter.beginWrite,\n finalizeWrite: adapter.finalizeWrite,\n abortWrite: adapter.abortWrite,\n delete: adapter[\"delete\"],\n getReadUrl: typeof adapter.getReadUrl === \"function\" ? adapter.getReadUrl : null,\n verify: adapter.verify,\n readStream: typeof adapter.readStream === \"function\" ? adapter.readStream : null\n };\n const cspOrigins = Array.isArray(adapter.cspOrigins) ? adapter.cspOrigins.map(function(entry) {\n return { directive: String(entry.directive), origin: String(entry.origin) };\n }) : undefined;\n return call(\"cms.media.registerStorageAdapter\", [{\n adapterId: adapter.id,\n label: String(adapter.label),\n roles: adapter.roles.slice(),\n servingMode: String(adapter.servingMode),\n hasGetReadUrl: typeof adapter.getReadUrl === \"function\",\n hasReadStream: typeof adapter.readStream === \"function\",\n cspOrigins\n }]);\n }\n function registerUrlTransformer(fn) {\n assertTargetPermission(\"cms.media.registerUrlTransformer\");\n if (typeof fn !== \"function\")\n throw new TypeError(\"registerUrlTransformer: argument must be a function\");\n const transformerId = __nextId(\"mediaUrlT\");\n globalThis.__plugin_handlers.mediaUrlTransformers[transformerId] = fn;\n return call(\"cms.media.registerUrlTransformer\", [{ transformerId }]);\n }\n function registerVariantDelegate(delegate) {\n assertTargetPermission(\"cms.media.registerVariantDelegate\");\n if (!delegate || typeof delegate !== \"object\")\n throw new TypeError(\"registerVariantDelegate: argument must be an object\");\n if (typeof delegate.id !== \"string\" || !delegate.id)\n throw new TypeError(\"registerVariantDelegate: 'id' is required\");\n if (delegate.id.indexOf(meta.id + \".\") !== 0) {\n throw new Error('registerVariantDelegate: id \"' + delegate.id + '\" must start with the plugin id \"' + meta.id + '.\"');\n }\n if (typeof delegate.variantUrlTemplate !== \"string\") {\n throw new TypeError(\"registerVariantDelegate: 'variantUrlTemplate' must be a string\");\n }\n if (!Array.isArray(delegate.widths) || delegate.widths.length === 0) {\n throw new TypeError(\"registerVariantDelegate: 'widths' must be a non-empty array\");\n }\n if (!Array.isArray(delegate.formats) || delegate.formats.length === 0) {\n throw new TypeError(\"registerVariantDelegate: 'formats' must be a non-empty array\");\n }\n return call(\"cms.media.registerVariantDelegate\", [{\n delegateId: delegate.id,\n variantUrlTemplate: delegate.variantUrlTemplate,\n widths: delegate.widths.slice(),\n formats: delegate.formats.slice()\n }]);\n }\n return {\n plugin: {\n id: meta.id,\n version: meta.version,\n permissions: meta.grantedPermissions.slice(),\n log: function(...args) {\n const parts = [];\n for (let i = 0;i < args.length; i++) {\n const a = args[i];\n if (typeof a === \"string\")\n parts.push(a);\n else {\n try {\n parts.push(JSON.stringify(a));\n } catch (_) {\n parts.push(String(a));\n }\n }\n }\n __log(\"info\", parts.join(\" \"));\n },\n assetUrl: function(path) {\n if (typeof path !== \"string\" || path.length === 0) {\n throw new TypeError(\"assetUrl: path must be a non-empty string\");\n }\n const base = (meta.assetBasePath || \"\").replace(/\\/+$/g, \"\");\n const rel = String(path).replace(/^\\/+/g, \"\");\n return base + \"/\" + rel;\n }\n },\n cms: {\n routes: {\n get: makeRoute(\"GET\"),\n post: makeRoute(\"POST\"),\n patch: makeRoute(\"PATCH\"),\n delete: makeRoute(\"DELETE\"),\n authenticated: {\n get: registerAuthenticated(\"GET\"),\n post: registerAuthenticated(\"POST\"),\n patch: registerAuthenticated(\"PATCH\"),\n delete: registerAuthenticated(\"DELETE\")\n },\n public: {\n get: registerPublic(\"GET\"),\n post: registerPublic(\"POST\"),\n patch: registerPublic(\"PATCH\"),\n delete: registerPublic(\"DELETE\")\n }\n },\n storage: { collection },\n hooks: { on, filter, emit },\n loops: { registerSource },\n settings: settingsApi,\n schedule: scheduleApi,\n content: {\n tables: {\n list: function() {\n assertTargetPermission(\"cms.content.tables.list\");\n return call(\"cms.content.tables.list\", []);\n },\n get: function(slug) {\n assertTargetPermission(\"cms.content.tables.get\");\n return call(\"cms.content.tables.get\", [String(slug)]);\n },\n create: function(input) {\n assertTargetPermission(\"cms.content.tables.create\");\n return call(\"cms.content.tables.create\", [input]);\n }\n },\n table: function(slug) {\n const s = String(slug);\n return {\n list: function(options) {\n assertTargetPermission(\"cms.content.entries.list\");\n return call(\"cms.content.entries.list\", [s, options || {}]);\n },\n get: function(entryId) {\n assertTargetPermission(\"cms.content.entries.get\");\n return call(\"cms.content.entries.get\", [s, String(entryId)]);\n },\n getBySlug: function(entrySlug) {\n assertTargetPermission(\"cms.content.entries.getBySlug\");\n return call(\"cms.content.entries.getBySlug\", [s, String(entrySlug)]);\n },\n create: function(input) {\n assertTargetPermission(\"cms.content.entries.create\");\n return call(\"cms.content.entries.create\", [s, input]);\n },\n update: function(entryId, patch) {\n assertTargetPermission(\"cms.content.entries.update\");\n return call(\"cms.content.entries.update\", [s, String(entryId), patch]);\n },\n delete: function(entryId) {\n assertTargetPermission(\"cms.content.entries.delete\");\n return call(\"cms.content.entries.delete\", [s, String(entryId)]);\n },\n publish: function(entryId, options) {\n assertTargetPermission(\"cms.content.entries.publish\");\n return call(\"cms.content.entries.publish\", [s, String(entryId), options || {}]);\n },\n moveToTable: function(entryId, targetSlug) {\n assertTargetPermission(\"cms.content.entries.moveTable\");\n return call(\"cms.content.entries.moveTable\", [s, String(entryId), String(targetSlug)]);\n },\n createMany: function(inputs) {\n assertTargetPermission(\"cms.content.entries.createMany\");\n return call(\"cms.content.entries.createMany\", [s, inputs]);\n },\n updateMany: function(updates) {\n assertTargetPermission(\"cms.content.entries.updateMany\");\n return call(\"cms.content.entries.updateMany\", [s, updates]);\n },\n deleteMany: function(entryIds) {\n assertTargetPermission(\"cms.content.entries.deleteMany\");\n return call(\"cms.content.entries.deleteMany\", [s, entryIds]);\n }\n };\n },\n tree: function(entryId, fieldId) {\n const e = String(entryId);\n const f = String(fieldId);\n return {\n read: function() {\n assertTargetPermission(\"cms.content.tree.read\");\n return call(\"cms.content.tree.read\", [e, f]);\n },\n mutate: function(operations) {\n assertTargetPermission(\"cms.content.tree.mutate\");\n return call(\"cms.content.tree.mutate\", [e, f, operations]);\n },\n replace: function(tree) {\n assertTargetPermission(\"cms.content.tree.replace\");\n return call(\"cms.content.tree.replace\", [e, f, tree]);\n }\n };\n },\n search: function(query, limit) {\n assertTargetPermission(\"cms.content.search\");\n return call(\"cms.content.search\", [String(query), Number(limit || 50)]);\n },\n getPublishedSnapshot: function(entryId) {\n assertTargetPermission(\"cms.content.snapshot\");\n return call(\"cms.content.snapshot\", [String(entryId)]);\n },\n republishAll: function() {\n assertTargetPermission(\"cms.content.republishAll\");\n return call(\"cms.content.republishAll\", []);\n }\n },\n media: {\n registerStorageAdapter,\n registerUrlTransformer,\n registerVariantDelegate\n }\n }\n };\n };\n var __idCounter = 0;\n function __nextId(prefix) {\n __idCounter += 1;\n return prefix + \"_\" + __idCounter + \"_\" + Date.now().toString(36);\n }\n\n // server/plugins/quickjs/bootstrap/src/boundary.ts\n function fromJson(json) {\n return JSON.parse(json);\n }\n function toJson(value, fallback) {\n return JSON.stringify(value === undefined ? fallback : value);\n }\n\n // server/plugins/quickjs/bootstrap/src/pluginRuntime.ts\n globalThis.__plugin_handlers = {\n routes: {},\n listeners: {},\n filters: {},\n loopSources: {},\n schedules: {},\n mediaAdapters: {},\n mediaUrlTransformers: {}\n };\n function __resolvePluginModule() {\n const root = globalThis.__plugin_exports;\n if (!root || typeof root !== \"object\")\n return null;\n const def = root.default;\n const isPluginModule = (v) => {\n if (!v || typeof v !== \"object\")\n return false;\n const m = v;\n return typeof m.install === \"function\" || typeof m.activate === \"function\" || typeof m.deactivate === \"function\" || typeof m.uninstall === \"function\" || typeof m.migrate === \"function\";\n };\n return isPluginModule(def) ? def : root;\n }\n globalThis.__runLifecycle = async function runLifecycle(hook) {\n const mod = __resolvePluginModule();\n const fn = mod && mod[hook];\n if (typeof fn !== \"function\")\n return;\n await fn(globalThis.__buildApi());\n };\n globalThis.__runMigrate = async function runMigrate(fromVersion) {\n const mod = __resolvePluginModule();\n const fn = mod && mod.migrate;\n if (typeof fn !== \"function\")\n return;\n await fn({ fromVersion }, globalThis.__buildApi());\n };\n function __materializeUploadedFiles(value) {\n if (Array.isArray(value))\n return value.map(__materializeUploadedFiles);\n if (!value || typeof value !== \"object\")\n return value;\n const marker = value;\n if (marker.__file !== true || typeof marker.dataBase64 !== \"string\")\n return value;\n const dataBase64 = marker.dataBase64;\n return {\n name: String(marker.name ?? \"\"),\n type: String(marker.type ?? \"\"),\n size: typeof marker.size === \"number\" ? marker.size : 0,\n arrayBuffer: async function() {\n const bytes = __base64ToBytes(dataBase64);\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);\n },\n text: async function() {\n return new TextDecoder().decode(__base64ToBytes(dataBase64));\n }\n };\n }\n function __encodeResponseBody(body) {\n if (body === null || body === undefined)\n return { body: \"\", bodyEncoding: \"utf8\" };\n if (typeof body === \"string\")\n return { body, bodyEncoding: \"utf8\" };\n if (body instanceof ArrayBuffer) {\n return { body: __bytesToBase64(new Uint8Array(body)), bodyEncoding: \"base64\" };\n }\n if (ArrayBuffer.isView(body)) {\n return {\n body: __bytesToBase64(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)),\n bodyEncoding: \"base64\"\n };\n }\n throw new TypeError(\"Route __response body must be a string, ArrayBuffer, or TypedArray/DataView (got \" + Object.prototype.toString.call(body).slice(8, -1) + \")\");\n }\n globalThis.__runRoute = async function runRoute(routeKey, ctxJson) {\n const handler = globalThis.__plugin_handlers.routes[routeKey];\n if (!handler)\n throw new Error(\"Route handler not registered: \" + routeKey);\n const ctx = fromJson(ctxJson);\n const _hdrs = ctx.request.headers || {};\n const _hdrsLc = {};\n for (const _k in _hdrs) {\n if (Object.prototype.hasOwnProperty.call(_hdrs, _k))\n _hdrsLc[String(_k).toLowerCase()] = _hdrs[_k];\n }\n const headersFacade = {\n get: function(name) {\n const k = String(name).toLowerCase();\n return Object.prototype.hasOwnProperty.call(_hdrsLc, k) ? _hdrsLc[k] : null;\n },\n has: function(name) {\n return Object.prototype.hasOwnProperty.call(_hdrsLc, String(name).toLowerCase());\n },\n entries: function() {\n return Object.entries(_hdrsLc);\n },\n keys: function() {\n return Object.keys(_hdrsLc);\n },\n values: function() {\n return Object.values(_hdrsLc);\n },\n forEach: function(cb) {\n Object.keys(_hdrsLc).forEach(function(k) {\n cb(_hdrsLc[k], k);\n });\n }\n };\n const rawBody = ctx.request.body || \"\";\n const bodyIsBase64 = ctx.request.bodyEncoding === \"base64\";\n function requestBodyText() {\n return bodyIsBase64 ? new TextDecoder().decode(__base64ToBytes(rawBody)) : rawBody;\n }\n const req = {\n url: ctx.request.url,\n method: ctx.request.method,\n headers: headersFacade,\n json: async function() {\n return fromJson(requestBodyText() || \"{}\");\n },\n text: async function() {\n return requestBodyText();\n },\n arrayBuffer: async function() {\n const bytes = bodyIsBase64 ? __base64ToBytes(rawBody) : new TextEncoder().encode(rawBody);\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);\n }\n };\n const body = {};\n for (const key of Object.keys(ctx.body || {})) {\n body[key] = __materializeUploadedFiles(ctx.body[key]);\n }\n const result = await handler({ req, body, user: ctx.user });\n if (result && typeof result === \"object\" && result.__response === true) {\n const r = result;\n const encoded = __encodeResponseBody(r.body);\n return toJson({\n __response: true,\n status: typeof r.status === \"number\" ? r.status : 200,\n headers: r.headers && typeof r.headers === \"object\" ? r.headers : {},\n body: encoded.body,\n bodyEncoding: encoded.bodyEncoding\n });\n }\n return toJson(result, { ok: true });\n };\n globalThis.__runHookListener = async function runHookListener(listenerId, payloadJson) {\n const fn = globalThis.__plugin_handlers.listeners[listenerId];\n if (!fn)\n return;\n await fn(fromJson(payloadJson));\n };\n globalThis.__runHookFilter = async function runHookFilter(filterId, valueJson, contextJson) {\n const fn = globalThis.__plugin_handlers.filters[filterId];\n if (!fn)\n return valueJson;\n const value = fromJson(valueJson);\n const contextExtras = contextJson ? fromJson(contextJson) : {};\n const context = Object.assign({ pluginId: globalThis.__plugin_meta.id }, contextExtras);\n const next = await fn(value, context);\n return toJson(next, value);\n };\n globalThis.__runLoopFetch = async function runLoopFetch(sourceId, ctxJson) {\n const source = globalThis.__plugin_handlers.loopSources[sourceId];\n if (!source)\n throw new Error(\"Loop source not registered: \" + sourceId);\n const result = await source.fetch(fromJson(ctxJson));\n return toJson(result, { items: [], totalItems: 0 });\n };\n globalThis.__runLoopPreview = function runLoopPreview(sourceId, ctxJson) {\n const source = globalThis.__plugin_handlers.loopSources[sourceId];\n if (!source)\n throw new Error(\"Loop source not registered: \" + sourceId);\n const result = source.preview(fromJson(ctxJson));\n if (result && typeof result.then === \"function\") {\n throw new TypeError('Loop source \"' + sourceId + '\" preview() must be synchronous (it returned a Promise)');\n }\n return toJson(result, []);\n };\n globalThis.__runSchedule = async function runSchedule(scheduleId) {\n const handler = globalThis.__plugin_handlers.schedules[scheduleId];\n if (typeof handler !== \"function\") {\n __log(\"warn\", 'no handler registered for schedule \"' + String(scheduleId) + '\"');\n return;\n }\n await handler();\n };\n globalThis.__runMediaAdapterCall = async function runMediaAdapterCall(adapterId, method, argsJson) {\n const adapter = globalThis.__plugin_handlers.mediaAdapters[adapterId];\n if (!adapter)\n throw new Error(\"Media adapter not registered: \" + adapterId);\n const fn = adapter[method];\n if (typeof fn !== \"function\")\n throw new Error('Media adapter \"' + adapterId + '\" does not implement \"' + method + '\"');\n const argsArray = fromJson(argsJson);\n const result = await fn(argsArray[0], argsArray[1]);\n return toJson(result, null);\n };\n globalThis.__runMediaUrlTransformer = async function runMediaUrlTransformer(transformerId, payloadJson) {\n const fn = globalThis.__plugin_handlers.mediaUrlTransformers[transformerId];\n if (typeof fn !== \"function\") {\n return toJson(null);\n }\n const payload = fromJson(payloadJson);\n const next = await fn(payload.path, payload.ctx);\n return toJson(typeof next === \"string\" ? next : null);\n };\n globalThis.__updateSettings = function updateSettings(nextJson) {\n const next = fromJson(nextJson);\n for (const k of Object.keys(globalThis.__plugin_settings))\n delete globalThis.__plugin_settings[k];\n Object.assign(globalThis.__plugin_settings, next);\n };\n globalThis.__detectExportedHooks = function detectExportedHooks() {\n const known = [\"install\", \"activate\", \"deactivate\", \"uninstall\", \"migrate\"];\n const mod = __resolvePluginModule() || {};\n const out = [];\n for (const name of known) {\n if (typeof mod[name] === \"function\")\n out.push(name);\n }\n return out;\n };\n})();\n" diff --git a/server/publish/publicRenderer.ts b/server/publish/publicRenderer.ts index 260f9ce8..dfd5f1f6 100644 --- a/server/publish/publicRenderer.ts +++ b/server/publish/publicRenderer.ts @@ -14,6 +14,7 @@ import type { Page } from '@core/page-tree' import type { SiteCssBundle } from '@core/publisher' import type { PublishedDataRow } from '@core/data/schemas' import type { DbClient } from '../db/client' +import { resolveViewerContext } from '../plugins/extensions/viewerContext' import type { PublishedPageSnapshot } from '../repositories/publish' /** diff --git a/server/publish/publicRouter.ts b/server/publish/publicRouter.ts index c9d041f5..90316b16 100644 --- a/server/publish/publicRouter.ts +++ b/server/publish/publicRouter.ts @@ -68,7 +68,6 @@ import { } from '../repositories/data/publish' import { getPublishedPageBySlug } from '../repositories/publish' import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline' -import { resolveViewerContext } from '../plugins/extensions/viewerContext' import { renderPublishedDataRowTemplate, renderPublishedNotFound, @@ -217,7 +216,6 @@ export async function renderPublicResolution( db: DbClient, url: URL, uploadsDir?: string, - req?: Request, ): Promise { // Canonicalise the query to the loop-pagination params the renderer actually // consumes. Junk params collapse to '' (so they never mint cache slots), and @@ -304,6 +302,7 @@ export async function renderNotFoundResponse( db: DbClient, url: URL, uploadsDir?: string, + req?: Request, ): Promise { const htmlHeaders = { 'content-type': 'text/html; charset=utf-8' } @@ -333,4 +332,5 @@ export async function renderNotFoundResponse( return { body: html, headers: htmlHeaders, status: 200 } }) if (!cached) return null - return new Response(cached.body, { headers: cached.headers, \ No newline at end of file + return new Response(cached.body, { headers: cached.headers, status: cached.status }) +} \ No newline at end of file diff --git a/server/router.ts b/server/router.ts index 7ce1e00f..1469c697 100644 --- a/server/router.ts +++ b/server/router.ts @@ -20,38 +20,10 @@ import { registry } from '@core/module-engine' import type { CssBundleFile, SiteCssBundleId } from '@core/publisher' import { buildPublishedSiteCssBundle } from './publish/siteCssBundle' import { mediaStorageRegistry } from '@core/plugins/mediaStorageRegistry' -import { runPluginHttpMiddleware } from './plugins/extensions/httpMiddleware' -import { buildPluginPublicRoutesDispatcher } from './plugins/extensions/publicRoutes' +import type { ServerRuntime, RouteHandler } from './routerTypes' const VITE_DEV_URL = 'http://localhost:5173' -interface ServerRuntime { - db: DbClient - staticDir?: string - uploadsDir?: string - /** - * The raw `DATABASE_URL` the server booted with — forwarded down to - * CMS handlers that need to resolve the on-disk SQLite file (e.g. the - * storage dashboard widget). - */ - databaseUrl?: string -} - -/** - * A route handler returns a `Response` if it owns the request, or `null` if - * the URL/method doesn't match — the dispatcher walks the `routes` table and - * returns the first non-null response. Prefix-namespaced handlers (e.g. - * `/_instatic/css/`, `/_instatic/runtime/cache/`) absorb their entire namespace and emit - * a 404 themselves rather than falling through, so unknown paths under a - * known prefix can't accidentally match a later route. - */ -type RouteHandler = ( - req: Request, - runtime: ServerRuntime, - url: URL, - pathname: string, -) => Promise | Response | null - // --------------------------------------------------------------------------- // Dispatcher // --------------------------------------------------------------------------- @@ -89,11 +61,6 @@ const routes: readonly RouteHandler[] = [ tryServeAdminApp, tryServePluginAdminPages, tryServePublicRoute, - // Plugin public routes — registered prefixes that own their own HTTP - // paths (OAuth endpoints, /api/auth/*, /api/webhooks/*). Matched AFTER - // the built-in admin/public/static dispatchers so admin/CMS routes win - // when a plugin path conflicts with a built-in one. - tryServePluginPublicRoute, trySetupRedirect, tryServeNotFoundPage, ] diff --git a/server/routerTypes.ts b/server/routerTypes.ts new file mode 100644 index 00000000..eb4a669b --- /dev/null +++ b/server/routerTypes.ts @@ -0,0 +1,48 @@ +/** + * Shared types for the HTTP routing layer. + * + * Lives in its own module so both `server/router.ts` (the dispatcher) and + * `server/plugins/extensions/publicRoutes.ts` (the plugin public-route + * registry) can import `RouteHandler` and `ServerRuntime` without forming a + * circular dependency: + * + * router.ts ──▶ apiDispatch.ts ──▶ extensions/publicRoutesProtocol.ts + * ▲ │ + * │ ▼ + * └──────── extensions/publicRoutes.ts ◀────────┘ + * + * If `RouteHandler` is declared in `router.ts`, the chain above closes into + * a cycle the moment `publicRoutes.ts` needs the type. Hoisting it here + * (and the runtime it takes) breaks the cycle at the source. + * + * `ServerRuntime` is hoisted for the same reason — `RouteHandler` references + * it, and the dispatcher is the only place that originally owned it. + */ +import type { DbClient } from './db/client' + +export interface ServerRuntime { + db: DbClient + staticDir?: string + uploadsDir?: string + /** + * The raw `DATABASE_URL` the server booted with — forwarded down to + * CMS handlers that need to resolve the on-disk SQLite file (e.g. the + * storage dashboard widget). + */ + databaseUrl?: string +} + +/** + * A route handler returns a `Response` if it owns the request, or `null` if + * the URL/method doesn't match — the dispatcher walks the `routes` table and + * returns the first non-null response. Prefix-namespaced handlers (e.g. + * `/_instatic/css/`, `/_instatic/runtime/cache/`) absorb their entire namespace and emit + * a 404 themselves rather than falling through, so unknown paths under a + * known prefix can't accidentally match a later route. + */ +export type RouteHandler = ( + req: Request, + runtime: ServerRuntime, + url: URL, + pathname: string, +) => Promise | Response | null diff --git a/src/__tests__/architecture/plugin-rpc-target-registry.test.ts b/src/__tests__/architecture/plugin-rpc-target-registry.test.ts index 6692374f..53dfd6af 100644 --- a/src/__tests__/architecture/plugin-rpc-target-registry.test.ts +++ b/src/__tests__/architecture/plugin-rpc-target-registry.test.ts @@ -65,6 +65,13 @@ const EXPECTED_TARGET_PERMISSIONS: Record = { 'cms.media.registerStorageAdapter': 'media.storage.adapter', 'cms.media.registerUrlTransformer': 'media.url.transform', 'cms.media.registerVariantDelegate': 'media.variant.delegate', + // ─── Extension points (commerce / membership / OIDC support) ────────── + // Each target here corresponds to a plugin capability the host exposes + // as a small, focused api-call. The permission gating matches the + // capability risk level (see PLUGIN_CAPABILITIES in src/core/plugin-sdk/). + 'cms.migrations.register': 'cms.migrations', + 'cms.publicRoutes.register': 'cms.publicRoutes', + 'cms.httpMiddleware.register': 'cms.httpMiddleware', 'cms.content.tables.list': 'cms.content.read', 'cms.content.tables.get': 'cms.content.read', 'cms.content.tables.create': 'cms.content.tables.manage', diff --git a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts index 421863c6..fe6beab6 100644 --- a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts +++ b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts @@ -190,10 +190,13 @@ describe('plugin sandbox invariants', () => { 'cms.hooks.emit', 'cms.hooks.filter', 'cms.hooks.on', + 'cms.httpMiddleware.register', 'cms.loops.registerSource', 'cms.media.registerStorageAdapter', 'cms.media.registerUrlTransformer', 'cms.media.registerVariantDelegate', + 'cms.migrations.register', + 'cms.publicRoutes.register', 'cms.routes.register', 'cms.schedule.cancel', 'cms.schedule.register', diff --git a/src/core/templates/tokenInterpolation.ts b/src/core/templates/tokenInterpolation.ts index d85fc261..6946abc9 100644 --- a/src/core/templates/tokenInterpolation.ts +++ b/src/core/templates/tokenInterpolation.ts @@ -210,8 +210,6 @@ export function readFrame( return (context.site as unknown as Record) ?? null case 'route': return (context.route as unknown as Record) ?? null - case 'viewer': - return (context.viewer as unknown as Record) ?? null default: return null }