diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index d07fa28f4..56dfcdb80 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -38,6 +38,7 @@ A plugin is a zip package containing a `plugin.json` manifest and one or more bu | Byte-safe body wire format | `server/plugins/protocol/bodyEncoding.ts` | | Route request/response I/O | `server/plugins/host/routeIo.ts` | | Media extension handlers | `server/plugins/host/handlers/media.ts`, `src/core/plugins/mediaStorageRegistry.ts`, `src/core/plugins/mediaVariantDelegateRegistry.ts` | +| Redirect handlers + repository | `server/plugins/host/handlers/redirects.ts`, `server/repositories/pluginRedirects.ts` | | Published-page asset injection | `server/publish/frontendInjections.ts` | | Dashboard widget registry | `src/core/dashboard/registry.ts` | | Plugin asset path containment | `server/util/pathWithin.ts` | @@ -891,6 +892,47 @@ api.cms.media.registerVariantDelegate({ Storage adapter and variant delegate ids must be namespaced under the plugin id (`.`). Registration is host-side state; re-registering the same id on re-activation replaces the previous definition. +### Site redirects — requires `redirects.manage` + +A plugin (an SEO redirect manager, a migration importer) can make the site answer an old URL with a redirect or a `410 Gone`. Rules are exact paths stored in the `plugin_redirects` table, owned by the plugin that wrote them. + +```js +await api.cms.redirects.set({ from: '/old-post', to: '/blog/new-post', status: 301 }) +await api.cms.redirects.set({ from: '/retired', status: 410 }) +await api.cms.redirects.set({ from: '/docs', to: 'https://docs.example.com/', status: 308 }) + +const rules = await api.cms.redirects.list() // this plugin's rules, ordered by `from` +await api.cms.redirects.delete('/old-post') // → true if a rule was removed + +// Replace this plugin's whole rule set in one transaction. +await api.cms.redirects.replaceAll([ + { from: '/a', to: '/b', status: 301 }, + { from: '/c', to: '/d', status: 302 }, +]) +``` + +| Method | Returns | +|--------|---------| +| `list()` | `PluginRedirectRule[]` — `{ from, to, status, createdAt, updatedAt }`, only the caller's rules | +| `set(rule)` | The stored rule. Inserts, or updates the caller's rule for the same `from` (keeps `createdAt`) | +| `delete(from)` | `true` when one of the caller's rules was removed; `false` otherwise (also for an invalid path, including `''`) | +| `replaceAll(rules)` | `{ count }`. All rules are validated first; on any error nothing changes. Duplicate `from` values after normalization: the last one wins | + +**When a rule answers.** The public router consults plugin redirects only for `GET`/`HEAD` requests that nothing else answered — after pages, data rows, published disk artefacts, and data-row rename redirects, immediately before the site's 404 page. A rule therefore never shadows live content: publish a page at `/about` and a rule from `/about` stops applying. `POST`/`PUT`/`DELETE` are never redirected. The lookup is one indexed query, and only for otherwise-unmatched requests. + +**Response.** `301`/`302`/`307`/`308` reply with `location: `; the request query string is appended unless `to` already has a `?`. `302`/`307` responses carry `cache-control: no-store`. `410` replies with the site's 404 page body (when a notFound template exists) and status 410. + +**Rules** (validated host-side in `server/repositories/pluginRedirects.ts`; a violation rejects the call with `: `, where the field is `fromPath`, `toLocation`, `status`, or `limit`): + +- `from` starts with `/` (not `//`), has no `?`, `#`, whitespace, or control characters, is at most 2048 characters, and is not under `/admin`, `/_instatic`, or `/uploads` (segment match — `/administrator` is allowed). A trailing slash is dropped (`/old/` is stored as `/old`; root `/` stays `/`) and a request for `/old/` matches it. Matching is otherwise exact and case-sensitive. +- `to` is required for 301/302/307/308 and must be absent or `null` for 410. It is a path starting with `/` (not `//`) or an absolute `http:`/`https:` URL, with no whitespace or control characters, at most 2048 characters, and not equal to `from`. +- `status` is one of `301`, `302`, `307`, `308`, `410`. +- A plugin holds at most 5000 rules: `set` of a new `from` past the cap and a `replaceAll` over 5000 both reject with `limit`. + +The RPC arg schemas check only the call's shape and a payload-size safety ceiling (every string ≤ 8192 characters, `replaceAll` ≤ 10000 items, `status` a number from the list above). A call past that ceiling, or with a wrong type, is rejected before it reaches the repository with a generic `Invalid api-call payload for cms.redirects.: …` message — not a field-named one. Everything inside the ceiling reaches the repository, so the 2048-character and 5000-rule limits above come back as `fromPath:` / `toLocation:` / `limit:`. A repository failure that is not a validation error (for example a database error) rejects with its plain message. + +**Ownership.** Every call is scoped to the calling plugin. Two plugins may own a rule for the same `from`; they are separate rows, and the rule with the oldest `createdAt` wins (tie → lower plugin id). Uninstalling a plugin deletes its rules (`on delete cascade` from `installed_plugins`). Without `redirects.manage`, every `api.cms.redirects.*` call throws inside the sandbox and the host dispatcher rejects the RPC as well. + ### Outbound HTTP — requires `network.outbound` + `networkAllowedHosts` ```js @@ -988,6 +1030,7 @@ Risk levels: | `media.storage.adapter` | Server / CMS media | Dangerous | Register an electable media storage backend | | `media.url.transform` | Server / CMS media | Medium | Rewrite media URLs at render/preview/admin read time | | `media.variant.delegate` | Server / CMS media | High | Replace local responsive variant generation with URL templates | +| `redirects.manage` | Server / CMS | High | Answer URLs that have no page with plugin-owned 301/302/307/308/410 rules; never overrides live content | | `unstable.internals` | Admin / editor / server | Dangerous | Reserved for trusted first-party plugins | Full descriptions and labels live in `src/core/plugin-sdk/capabilities.ts` — the source of truth. @@ -1161,6 +1204,7 @@ export default definePlugin({ - `src/core/plugin-sdk/types/editorApi.ts` — editor / dashboard browser API - `src/core/plugin-sdk/types/frontend.ts` — published-page frontend asset declarations - `src/core/plugin-sdk/types/media.ts` — media storage / URL / variant plugin API + - `src/core/plugin-sdk/types/redirects.ts` — `api.cms.redirects` plugin API - `src/core/plugin-sdk/builders/definePlugin.ts` — typed config builder - `src/core/plugin-sdk/builders/permissions.ts` — permission aliases for plugin authors - `src/core/plugin-sdk/builders/settings.ts` — setting field shapes and secret sentinel @@ -1174,6 +1218,8 @@ export default definePlugin({ - `server/plugins/host/apiDispatch.ts` — centralized host-side RPC permission enforcement - `server/plugins/protocol/apiCallSchema.ts` — RPC target schemas - `server/plugins/host/handlers/media.ts` — media extension RPC handlers + - `server/plugins/host/handlers/redirects.ts` — `cms.redirects.*` RPC handlers (SDK ↔ repository shape, `: ` errors) + - `server/repositories/pluginRedirects.ts` — redirect validation, storage, and request-time lookup - `src/core/plugins/mediaStorageRegistry.ts` — registered/elected media storage adapters - `src/core/plugins/mediaVariantDelegateRegistry.ts` — registered/elected variant delegates - `src/core/dashboard/registry.ts` — dashboard widget registration @@ -1223,6 +1269,7 @@ export default definePlugin({ - `src/__tests__/server/pluginMediaAdapterBoundary.test.ts` — media adapter RPC boundary - `src/__tests__/server/pluginVmBinaryIo.test.ts` — VM-side byte safety: fetch `arrayBuffer()`/`text()`/`json()` decoding, binary request bodies, unsupported-body TypeError, route file facades + binary `__response` - `src/__tests__/plugins/pluginModulePack.test.ts` — module pack activation, re-activation, deactivation, and VM disposal + - `src/__tests__/server/pluginRedirectsApi.test.ts` — `api.cms.redirects`: VM + host permission gate, arg-schema safety ceiling, SDK ↔ repository mapping, per-plugin scoping, field-named validation and limit errors through the real `parseApiCall` - `src/__tests__/server/pluginVmPermissions.test.ts` — VM-side permission check: declared-but-not-granted permissions are denied at the VM boundary before host dispatch - `src/__tests__/server/pluginVmLoopDispatch.test.ts` — loop fetch/preview dispatcher robustness (no-return fallbacks, async-preview detection) - `src/__tests__/server/pluginVmDeadlines.test.ts` — hang hardening: top-level loops abort at load, overlapping evals keep their deadlines, runaway timer callbacks are interrupted, VM stacks survive with the `plugin:` filename diff --git a/docs/features/publisher.md b/docs/features/publisher.md index cf4bc0d37..0f32facf1 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -513,7 +513,8 @@ tryServePublicRoute (server/router.ts) │ (publishedSnapshotCache.ts) — no per-request full-site parse │ redirects → 301 (not cached) │ not-found → null (router falls through: trySetupRedirect, then - │ tryServeNotFoundPage → renderNotFoundResponse serves the site's + │ tryServePluginRedirect, then tryServeNotFoundPage → + │ renderNotFoundResponse serves the site's │ 404 page — baked `404.html` artefact first, else live render │ through the LRU under the reserved `/404` key — with status 404; │ no notFound template → the dispatcher's bare JSON 404) diff --git a/docs/server.md b/docs/server.md index f051bbed0..910de2770 100644 --- a/docs/server.md +++ b/docs/server.md @@ -105,6 +105,10 @@ const routes: readonly RouteHandler[] = [ // OR data row + template, live-renders, runs the // publish.html pipeline trySetupRedirect, // first-run redirect → /admin/setup + tryServePluginRedirect, // unmatched GET/HEAD → plugin-owned exact-path + // 301/302/307/308/410; publicRoutes.ts queries + // plugin_redirects once via pluginRedirects.ts; + // live content and row-rename redirects win tryServeNotFoundPage, // fall-through GET → site's 404 page (notFound // template; baked 404.html artefact, else live // render) with status 404; null → JSON 404 diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index 3b9799ab5..d9f21f481 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1359,4 +1359,21 @@ export const pgMigrations: Migration[] = [ id: '030_iso_timestamps', sql: 'select 1', }, + { + id: '031_plugin_redirects', + sql: ` + create table if not exists plugin_redirects ( + plugin_id text not null references installed_plugins(id) on delete cascade, + from_path text not null, + to_location text, + status integer not null, + created_at timestamptz not null, + updated_at timestamptz not null, + primary key (plugin_id, from_path) + ); + + create index if not exists plugin_redirects_from_idx + on plugin_redirects (from_path, created_at); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index c88159de1..6f853c9db 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1515,4 +1515,21 @@ export const sqliteMigrations: Migration[] = [ id: '030_iso_timestamps', sql: isoTimestampRewrite030(), }, + { + id: '031_plugin_redirects', + sql: ` + create table if not exists plugin_redirects ( + plugin_id text not null references installed_plugins(id) on delete cascade, + from_path text not null, + to_location text, + status integer not null, + created_at text not null, + updated_at text not null, + primary key (plugin_id, from_path) + ); + + create index if not exists plugin_redirects_from_idx + on plugin_redirects (from_path, created_at); + `, + }, ] diff --git a/server/plugins/host/apiDispatch.ts b/server/plugins/host/apiDispatch.ts index 1e091bed0..b30b96569 100644 --- a/server/plugins/host/apiDispatch.ts +++ b/server/plugins/host/apiDispatch.ts @@ -40,6 +40,12 @@ import { handleMediaUpsert, } from './handlers/media' import { handleCryptoDigest, handleCryptoSignHmac } from './handlers/crypto' +import { + handleRedirectsDelete, + handleRedirectsList, + handleRedirectsReplaceAll, + handleRedirectsSet, +} from './handlers/redirects' import { handleContentEntriesCreate, handleContentEntriesCreateMany, @@ -123,6 +129,10 @@ const apiHandlers = { 'cms.content.search': handleContentSearch, 'cms.content.snapshot': handleContentSnapshot, 'cms.content.republishAll': handleContentRepublishAll, + 'cms.redirects.list': handleRedirectsList, + 'cms.redirects.set': handleRedirectsSet, + 'cms.redirects.delete': handleRedirectsDelete, + 'cms.redirects.replaceAll': handleRedirectsReplaceAll, } satisfies HostApiHandlerTable export async function dispatchApiCall(msg: ValidatedApiCall): Promise { diff --git a/server/plugins/host/handlers/redirects.ts b/server/plugins/host/handlers/redirects.ts new file mode 100644 index 000000000..3443ed838 --- /dev/null +++ b/server/plugins/host/handlers/redirects.ts @@ -0,0 +1,100 @@ +/** + * Redirect plugin handlers — implement the `cms.redirects.*` api-calls over + * the plugin-owned redirect table. + * + * Every target is gated by `redirects.manage`, enforced centrally in + * apiDispatch.ts (via TARGET_PERMISSIONS) before these handlers run. Every + * repository call is scoped to `msg.pluginId`, so a plugin only ever sees, + * changes, or deletes its own rules. + * + * The handlers map the SDK shape (`from` / `to`) to the repository shape + * (`fromPath` / `toLocation`) and back. Validation lives in the repository; + * a `PluginRedirectValidationError` is replied as `: ` so + * the plugin learns which field was wrong. + */ + +import type { PluginRedirectRule, PluginRedirectRuleInput } from '@core/plugin-sdk' +import { + PluginRedirectValidationError, + deletePluginRedirect, + listPluginRedirects, + replacePluginRedirects, + setPluginRedirect, + type PluginRedirectInput, + type PluginRedirectRow, +} from '../../../repositories/pluginRedirects' +import type { ApiCallFor } from '../../protocol/apiCallSchema' +import type { DbClient } from '../../../db/client' +import { replyApiError, replyApiOk } from '../apiReplies' +import type { HostPluginRecord } from '../types' + +function toRepositoryInput(rule: PluginRedirectRuleInput): PluginRedirectInput { + return { fromPath: rule.from, toLocation: rule.to ?? null, status: rule.status } +} + +function toSdkRule(row: PluginRedirectRow): PluginRedirectRule { + return { + from: row.fromPath, + to: row.toLocation, + status: row.status, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } +} + +/** + * Runs a repository call and replies. Validation errors become + * `: ` replies; anything else bubbles to the dispatcher's + * generic error reply. + */ +async function replyWith( + msg: { pluginId: string; correlationId: string }, + run: () => Promise, +): Promise { + let value: unknown + try { + value = await run() + } catch (err) { + if (err instanceof PluginRedirectValidationError) { + replyApiError(msg.pluginId, msg.correlationId, `${err.field}: ${err.message}`) + return + } + throw err + } + replyApiOk(msg.pluginId, msg.correlationId, value) +} + +export async function handleRedirectsList( + msg: ApiCallFor<'cms.redirects.list'>, + _entry: HostPluginRecord, + db: DbClient, +): Promise { + await replyWith(msg, async () => (await listPluginRedirects(db, msg.pluginId)).map(toSdkRule)) +} + +export async function handleRedirectsSet( + msg: ApiCallFor<'cms.redirects.set'>, + _entry: HostPluginRecord, + db: DbClient, +): Promise { + const [rule] = msg.args + await replyWith(msg, async () => toSdkRule(await setPluginRedirect(db, msg.pluginId, toRepositoryInput(rule)))) +} + +export async function handleRedirectsDelete( + msg: ApiCallFor<'cms.redirects.delete'>, + _entry: HostPluginRecord, + db: DbClient, +): Promise { + const [from] = msg.args + await replyWith(msg, () => deletePluginRedirect(db, msg.pluginId, from)) +} + +export async function handleRedirectsReplaceAll( + msg: ApiCallFor<'cms.redirects.replaceAll'>, + _entry: HostPluginRecord, + db: DbClient, +): Promise { + const [rules] = msg.args + await replyWith(msg, () => replacePluginRedirects(db, msg.pluginId, rules.map(toRepositoryInput))) +} diff --git a/server/plugins/protocol/apiCallSchema.ts b/server/plugins/protocol/apiCallSchema.ts index 0a85b48fc..b1e05adc7 100644 --- a/server/plugins/protocol/apiCallSchema.ts +++ b/server/plugins/protocol/apiCallSchema.ts @@ -30,6 +30,12 @@ import { MediaUpsertArgSchema, } from './schemas/media' import { CryptoDigestArgSchema, CryptoSignHmacArgSchema } from './schemas/crypto' +import { + RedirectsDeleteArgsSchema, + RedirectsListArgsSchema, + RedirectsReplaceAllArgsSchema, + RedirectsSetArgsSchema, +} from './schemas/redirects' import { ContentEntriesCreateArgsSchema, ContentEntriesCreateManyArgsSchema, @@ -145,6 +151,10 @@ export const ApiCallSchemas = { 'cms.content.search': apiCallSchema('cms.content.search', ContentSearchArgsSchema), 'cms.content.snapshot': apiCallSchema('cms.content.snapshot', ContentSnapshotArgsSchema), 'cms.content.republishAll': apiCallSchema('cms.content.republishAll', ContentRepublishAllArgsSchema), + 'cms.redirects.list': apiCallSchema('cms.redirects.list', RedirectsListArgsSchema), + 'cms.redirects.set': apiCallSchema('cms.redirects.set', RedirectsSetArgsSchema), + 'cms.redirects.delete': apiCallSchema('cms.redirects.delete', RedirectsDeleteArgsSchema), + 'cms.redirects.replaceAll': apiCallSchema('cms.redirects.replaceAll', RedirectsReplaceAllArgsSchema), 'crypto.digest': apiCallSchema('crypto.digest', Type.Tuple([CryptoDigestArgSchema])), 'crypto.signHmac': apiCallSchema('crypto.signHmac', Type.Tuple([CryptoSignHmacArgSchema])), } satisfies Record diff --git a/server/plugins/protocol/schemas/redirects.ts b/server/plugins/protocol/schemas/redirects.ts new file mode 100644 index 000000000..ca0367992 --- /dev/null +++ b/server/plugins/protocol/schemas/redirects.ts @@ -0,0 +1,45 @@ +/** + * TypeBox schemas for `cms.redirects.*` api-call arguments. These check the + * shape and apply a safety ceiling on payload size only. Every redirect rule + * — path/location syntax, reserved prefixes, header-injection characters, + * `to` required unless 410, the 2048-character length limit, and the + * 5000-rules-per-plugin cap — is validated by + * `server/repositories/pluginRedirects.ts`, the single source of truth. The + * ceilings sit well above those limits so an over-limit rule still reaches + * the repository and the plugin gets a `fromPath:` / `toLocation:` / + * `limit:` error instead of a generic schema error. + */ + +import { Type } from '@sinclair/typebox' + +const REDIRECT_STRING_CEILING = 8192 +const REDIRECTS_ITEMS_CEILING = 10000 + +const RedirectStringSchema = Type.String({ maxLength: REDIRECT_STRING_CEILING }) + +const RedirectStatusSchema = Type.Union([ + Type.Literal(301), + Type.Literal(302), + Type.Literal(307), + Type.Literal(308), + Type.Literal(410), +]) + +const RedirectRuleInputSchema = Type.Object( + { + from: RedirectStringSchema, + to: Type.Optional(Type.Union([RedirectStringSchema, Type.Null()])), + status: RedirectStatusSchema, + }, + { additionalProperties: false }, +) + +export const RedirectsListArgsSchema = Type.Tuple([]) + +export const RedirectsSetArgsSchema = Type.Tuple([RedirectRuleInputSchema]) + +export const RedirectsDeleteArgsSchema = Type.Tuple([RedirectStringSchema]) + +export const RedirectsReplaceAllArgsSchema = Type.Tuple([ + Type.Array(RedirectRuleInputSchema, { maxItems: REDIRECTS_ITEMS_CEILING }), +]) diff --git a/server/plugins/protocol/targets.ts b/server/plugins/protocol/targets.ts index b12f9408b..90b8236aa 100644 --- a/server/plugins/protocol/targets.ts +++ b/server/plugins/protocol/targets.ts @@ -86,4 +86,9 @@ export const TARGET_PERMISSIONS = { 'cms.content.search': 'cms.content.read', 'cms.content.snapshot': 'cms.content.read', 'cms.content.republishAll': 'cms.content.publish', + // Plugin-owned redirects — answered only just before the 404 page. + 'cms.redirects.list': 'redirects.manage', + 'cms.redirects.set': 'redirects.manage', + 'cms.redirects.delete': 'redirects.manage', + 'cms.redirects.replaceAll': 'redirects.manage', } satisfies Partial> diff --git a/server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts b/server/plugins/quickjs/bootstrap/generated/pluginBootstrap.ts index 9369178cb..b2669a39b 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.upsert\": \"media.import\",\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 upsert(input) {\n assertTargetPermission(\"cms.media.upsert\");\n return call(\"cms.media.upsert\", [input]);\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 upsert,\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.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.upsert\": \"media.import\",\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 \"cms.redirects.list\": \"redirects.manage\",\n \"cms.redirects.set\": \"redirects.manage\",\n \"cms.redirects.delete\": \"redirects.manage\",\n \"cms.redirects.replaceAll\": \"redirects.manage\"\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 upsert(input) {\n assertTargetPermission(\"cms.media.upsert\");\n return call(\"cms.media.upsert\", [input]);\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 function redirectRuleArg(rule) {\n if (!rule || typeof rule !== \"object\")\n throw new TypeError(\"redirects: rule must be an object\");\n return { from: rule.from, to: rule.to === undefined ? null : rule.to, status: rule.status };\n }\n const redirectsApi = {\n list: function() {\n assertTargetPermission(\"cms.redirects.list\");\n return call(\"cms.redirects.list\", []);\n },\n set: function(rule) {\n assertTargetPermission(\"cms.redirects.set\");\n return call(\"cms.redirects.set\", [redirectRuleArg(rule)]);\n },\n delete: function(from) {\n assertTargetPermission(\"cms.redirects.delete\");\n return call(\"cms.redirects.delete\", [String(from)]);\n },\n replaceAll: function(rules) {\n assertTargetPermission(\"cms.redirects.replaceAll\");\n if (!Array.isArray(rules))\n throw new TypeError(\"redirects.replaceAll: rules must be an array\");\n return call(\"cms.redirects.replaceAll\", [rules.map(redirectRuleArg)]);\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 upsert,\n registerStorageAdapter,\n registerUrlTransformer,\n registerVariantDelegate\n },\n redirects: redirectsApi\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/plugins/quickjs/bootstrap/src/buildApi.ts b/server/plugins/quickjs/bootstrap/src/buildApi.ts index 96633f222..b311cffe2 100644 --- a/server/plugins/quickjs/bootstrap/src/buildApi.ts +++ b/server/plugins/quickjs/bootstrap/src/buildApi.ts @@ -340,6 +340,35 @@ globalThis.__buildApi = function buildApi() { }]) } + // ---- plugin-owned redirects ---------------------------------------------- + // Exact-path rules the public router consults just before the 404 page. + // Validation (paths, locations, statuses, the per-plugin cap) is host-side + // in the repository; the VM only normalizes the call shape. + function redirectRuleArg(rule: PluginInput) { + if (!rule || typeof rule !== 'object') throw new TypeError('redirects: rule must be an object') + return { from: rule.from, to: rule.to === undefined ? null : rule.to, status: rule.status } + } + + const redirectsApi = { + list: function () { + assertTargetPermission('cms.redirects.list') + return call('cms.redirects.list', []) + }, + set: function (rule: PluginInput) { + assertTargetPermission('cms.redirects.set') + return call('cms.redirects.set', [redirectRuleArg(rule)]) + }, + 'delete': function (from: unknown) { + assertTargetPermission('cms.redirects.delete') + return call('cms.redirects.delete', [String(from)]) + }, + replaceAll: function (rules: unknown) { + assertTargetPermission('cms.redirects.replaceAll') + if (!Array.isArray(rules)) throw new TypeError('redirects.replaceAll: rules must be an array') + return call('cms.redirects.replaceAll', [rules.map(redirectRuleArg)]) + }, + } + return { plugin: { id: meta.id, @@ -508,6 +537,7 @@ globalThis.__buildApi = function buildApi() { registerUrlTransformer: registerUrlTransformer, registerVariantDelegate: registerVariantDelegate, }, + redirects: redirectsApi, }, } } diff --git a/server/publish/publicRoutes.ts b/server/publish/publicRoutes.ts index aedc60a77..aefe82f2e 100644 --- a/server/publish/publicRoutes.ts +++ b/server/publish/publicRoutes.ts @@ -13,6 +13,7 @@ import { resolvePreviewToken, } from '../branches/previewLinks' import { getSetupStatusCached } from '../repositories/setup' +import { findPluginRedirect } from '../repositories/pluginRedirects' import { setCookieHeader } from '../http' import { renderBranchPreview } from './branchPreview' import { renderNotFoundResponse, renderPublicResolution } from './publicRouter' @@ -72,6 +73,31 @@ export async function trySetupRedirect(req: Request, runtime: ServerRuntime, _ur : null } +/** Plugin-owned exact-path rules only claim URLs left unmatched by the site. */ +export async function tryServePluginRedirect(req: Request, runtime: ServerRuntime, url: URL, _pathname: string): Promise { + if (req.method !== 'GET' && req.method !== 'HEAD') return null + const rule = await findPluginRedirect(runtime.db, url.pathname) + if (!rule) return null + if (rule.status === 410) { + const notFound = await renderNotFoundResponse(runtime.db, url, runtime.uploadsDir) + return notFound + ? new Response(req.method === 'HEAD' ? null : notFound.body, { status: 410, headers: notFound.headers }) + : new Response(req.method === 'HEAD' ? null : 'Gone', { status: 410, headers: { 'content-type': 'text/plain; charset=utf-8' } }) + } + let location = rule.toLocation! + if (url.search && !location.includes('?')) { + const fragmentIndex = location.indexOf('#') + location = fragmentIndex < 0 + ? location + url.search + : location.slice(0, fragmentIndex) + url.search + location.slice(fragmentIndex) + } + // Location is an HTTP header: encode Unicode while preserving authored URL escapes. + location = location.replace(/[^\u0021-\u007e]/gu, (char) => encodeURIComponent(char.replace(/[\ud800-\udfff]/u, '\ufffd'))) + const headers = new Headers({ location }) + if (rule.status === 302 || rule.status === 307) headers.set('cache-control', 'no-store') + return new Response(null, { status: rule.status, headers }) +} + /** * Last route before the dispatcher's bare JSON 404: serve the site's designed * 404 page (the `notFound` template) for any GET no other route claimed. diff --git a/server/repositories/pluginRedirects.ts b/server/repositories/pluginRedirects.ts new file mode 100644 index 000000000..39a2993bd --- /dev/null +++ b/server/repositories/pluginRedirects.ts @@ -0,0 +1,204 @@ +import type { DbClient } from '../db/client' + +export type PluginRedirectStatus = 301 | 302 | 307 | 308 | 410 +export const PLUGIN_REDIRECT_STATUSES: readonly PluginRedirectStatus[] = [301, 302, 307, 308, 410] +export const PLUGIN_REDIRECTS_MAX_PER_PLUGIN = 5000 +export const PLUGIN_REDIRECT_MAX_LENGTH = 2048 + +export interface PluginRedirectInput { + fromPath: string + toLocation: string | null + status: PluginRedirectStatus +} + +export interface PluginRedirectRow { + pluginId: string + fromPath: string + toLocation: string | null + status: PluginRedirectStatus + createdAt: string + updatedAt: string +} + +export class PluginRedirectValidationError extends Error { + readonly field: 'fromPath' | 'toLocation' | 'status' | 'limit' + + constructor(field: PluginRedirectValidationError['field'], message: string) { + super(message) + this.name = 'PluginRedirectValidationError' + this.field = field + } +} + +function hasWhitespaceOrControl(value: string): boolean { + return /\s/u.test(value) || [...value].some((char) => { + const code = char.charCodeAt(0) + return code < 32 || (code >= 127 && code <= 159) + }) +} + +function stripTrailingSlash(path: string): string { + return path.replace(/\/+$/, '') || '/' +} + +/** Normalize the exact, case-sensitive source pathname without decoding it. */ +export function normalizeRedirectFromPath(raw: string): string { + if (typeof raw === 'string' && [...raw].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) > 126)) { + throw new PluginRedirectValidationError('fromPath', 'Must contain only printable ASCII; percent-encode other characters (e.g. /caf%C3%A9)') + } + if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//') + || raw.includes('?') || raw.includes('#') || hasWhitespaceOrControl(raw) + || raw.length > PLUGIN_REDIRECT_MAX_LENGTH) { + throw new PluginRedirectValidationError('fromPath', 'Must be a path starting with /, without query, fragment, whitespace or control characters, at most 2048 characters') + } + const fromPath = stripTrailingSlash(raw) + if (['/admin', '/_instatic', '/uploads'].some((prefix) => fromPath === prefix || fromPath.startsWith(`${prefix}/`))) { + throw new PluginRedirectValidationError('fromPath', 'Reserved path prefix') + } + return fromPath +} + +export function validatePluginRedirectInput(input: PluginRedirectInput): PluginRedirectInput { + const fromPath = normalizeRedirectFromPath(input.fromPath) + if (!PLUGIN_REDIRECT_STATUSES.includes(input.status)) { + throw new PluginRedirectValidationError('status', 'Must be 301, 302, 307, 308 or 410') + } + if (input.status === 410) { + if (input.toLocation != null) { + throw new PluginRedirectValidationError('toLocation', 'Must be absent or null for status 410') + } + return { fromPath, toLocation: null, status: input.status } + } + const toLocation = input.toLocation + if (typeof toLocation !== 'string' || toLocation.length === 0 + || toLocation.length > PLUGIN_REDIRECT_MAX_LENGTH || hasWhitespaceOrControl(toLocation)) { + throw new PluginRedirectValidationError('toLocation', 'Required, without whitespace or control characters, at most 2048 characters') + } + if (toLocation.startsWith('/') && !toLocation.startsWith('//') && !toLocation.startsWith('/\\')) { + if (stripTrailingSlash(toLocation) === fromPath) { + throw new PluginRedirectValidationError('toLocation', 'Must not equal the normalized fromPath') + } + } else { + let target: URL + try { + target = new URL(toLocation) + } catch (_err) { + // Invalid URL syntax is a field validation failure. + throw new PluginRedirectValidationError('toLocation', 'Must be a path starting with / or an absolute http: or https: URL') + } + if (!/^https?:\/\//i.test(toLocation) || !['http:', 'https:'].includes(target.protocol)) { + throw new PluginRedirectValidationError('toLocation', 'Must be a path starting with / or an absolute http: or https: URL') + } + } + return { fromPath, toLocation, status: input.status } +} + +interface StoredRedirectRow { + plugin_id: string + from_path: string + to_location: string | null + status: PluginRedirectStatus + created_at: string + updated_at: string +} + +function mapRow(row: StoredRedirectRow): PluginRedirectRow { + return { + pluginId: row.plugin_id, + fromPath: row.from_path, + toLocation: row.to_location, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export async function setPluginRedirect(db: DbClient, pluginId: string, input: PluginRedirectInput): Promise { + const rule = validatePluginRedirectInput(input) + return db.transaction(async (tx) => { + await lockPluginRedirectWrites(tx, pluginId) + const { rows } = await tx<{ count: number }>` + select count(*) as count from plugin_redirects + where plugin_id = ${pluginId} and from_path <> ${rule.fromPath} + ` + if (Number(rows[0]!.count) >= PLUGIN_REDIRECTS_MAX_PER_PLUGIN) { + throw new PluginRedirectValidationError('limit', 'At most 5000 rules per plugin') + } + return writePluginRedirect(tx, pluginId, rule) + }) +} + +async function lockPluginRedirectWrites(db: DbClient, pluginId: string): Promise { + // A no-op owner-row update serializes set/replace across Postgres connections; + // SQLite serializes transactions in its adapter. Both use the same SQL. + await db`update installed_plugins set id = id where id = ${pluginId}` +} + +async function writePluginRedirect(db: DbClient, pluginId: string, rule: PluginRedirectInput): Promise { + const nowIso = new Date().toISOString() + const { rows } = await db` + insert into plugin_redirects (plugin_id, from_path, to_location, status, created_at, updated_at) + values (${pluginId}, ${rule.fromPath}, ${rule.toLocation}, ${rule.status}, ${nowIso}, ${nowIso}) + on conflict (plugin_id, from_path) do update set + to_location = excluded.to_location, + status = excluded.status, + updated_at = excluded.updated_at + returning plugin_id, from_path, to_location, status, created_at, updated_at + ` + return mapRow(rows[0]!) +} + +export async function replacePluginRedirects(db: DbClient, pluginId: string, inputs: PluginRedirectInput[]): Promise<{ count: number }> { + if (inputs.length > PLUGIN_REDIRECTS_MAX_PER_PLUGIN) { + throw new PluginRedirectValidationError('limit', 'At most 5000 rules per plugin') + } + const rules = new Map() + for (const input of inputs) { + const rule = validatePluginRedirectInput(input) + rules.set(rule.fromPath, rule) + } + return db.transaction(async (tx) => { + await lockPluginRedirectWrites(tx, pluginId) + await tx`delete from plugin_redirects where plugin_id = ${pluginId}` + for (const rule of rules.values()) await writePluginRedirect(tx, pluginId, rule) + return { count: rules.size } + }) +} + +export async function deletePluginRedirect(db: DbClient, pluginId: string, fromPath: string): Promise { + let normalized: string + try { + normalized = normalizeRedirectFromPath(fromPath) + } catch (err) { + if (err instanceof PluginRedirectValidationError) return false + throw err + } + const { rowCount } = await db` + delete from plugin_redirects where plugin_id = ${pluginId} and from_path = ${normalized} + ` + return rowCount > 0 +} + +export async function listPluginRedirects(db: DbClient, pluginId: string): Promise { + const { rows } = await db` + select plugin_id, from_path, to_location, status, created_at, updated_at + from plugin_redirects where plugin_id = ${pluginId} order by from_path asc + ` + return rows.map(mapRow) +} + +export async function findPluginRedirect(db: DbClient, pathname: string): Promise { + let fromPath: string + try { + fromPath = normalizeRedirectFromPath(pathname) + } catch (err) { + if (err instanceof PluginRedirectValidationError) return null + throw err + } + const { rows } = await db` + select plugin_id, from_path, to_location, status, created_at, updated_at + from plugin_redirects where from_path = ${fromPath} + order by created_at asc, plugin_id asc limit 1 + ` + return rows[0] ? mapRow(rows[0]) : null +} diff --git a/server/router.ts b/server/router.ts index 5da4d651c..c5ae6f3bf 100644 --- a/server/router.ts +++ b/server/router.ts @@ -6,6 +6,7 @@ import { readPreviewAsset } from './publish/branchPreviewAssets' import { tryServeBranchPreviewLink, tryServeNotFoundPage, + tryServePluginRedirect, tryServePublicRoute, trySetupRedirect, } from './publish/publicRoutes' @@ -76,6 +77,7 @@ const routes: readonly RouteHandler[] = [ tryServeAdminApp, tryServePublicRoute, trySetupRedirect, + tryServePluginRedirect, tryServeNotFoundPage, ] diff --git a/src/__tests__/architecture/plugin-rpc-target-registry.test.ts b/src/__tests__/architecture/plugin-rpc-target-registry.test.ts index 24a09921f..58231a2e2 100644 --- a/src/__tests__/architecture/plugin-rpc-target-registry.test.ts +++ b/src/__tests__/architecture/plugin-rpc-target-registry.test.ts @@ -86,6 +86,10 @@ const EXPECTED_TARGET_PERMISSIONS: Record = { 'cms.content.search': 'cms.content.read', 'cms.content.snapshot': 'cms.content.read', 'cms.content.republishAll': 'cms.content.publish', + 'cms.redirects.list': 'redirects.manage', + 'cms.redirects.set': 'redirects.manage', + 'cms.redirects.delete': 'redirects.manage', + 'cms.redirects.replaceAll': 'redirects.manage', } /** Targets that intentionally require NO permission (must be absent from map). */ diff --git a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts index 796d0c799..36f740ed8 100644 --- a/src/__tests__/architecture/plugin-sandbox-invariants.test.ts +++ b/src/__tests__/architecture/plugin-sandbox-invariants.test.ts @@ -199,6 +199,10 @@ describe('plugin sandbox invariants', () => { 'cms.media.registerUrlTransformer', 'cms.media.registerVariantDelegate', 'cms.media.upsert', + 'cms.redirects.delete', + 'cms.redirects.list', + 'cms.redirects.replaceAll', + 'cms.redirects.set', 'cms.routes.register', 'cms.schedule.cancel', 'cms.schedule.register', diff --git a/src/__tests__/server/dataCms.test.ts b/src/__tests__/server/dataCms.test.ts index affd2eb03..c49949d61 100644 --- a/src/__tests__/server/dataCms.test.ts +++ b/src/__tests__/server/dataCms.test.ts @@ -365,6 +365,12 @@ describe('data CMS public routes', () => { // the request as a 404 rather than inventing a half-styled fallback // document. const db = makeDataFakeDb([ + (sql) => { + if (sql.includes('from plugin_redirects')) { + return { rows: [], rowCount: 0 } + } + return undefined + }, (sql) => { if (sql.startsWith('select id, name, version, enabled, lifecycle_status')) { return { rows: [], rowCount: 0 } diff --git a/src/__tests__/server/pluginRedirectRoute.test.ts b/src/__tests__/server/pluginRedirectRoute.test.ts new file mode 100644 index 000000000..a91252fce --- /dev/null +++ b/src/__tests__/server/pluginRedirectRoute.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import { setPluginRedirect } from '../../../server/repositories/pluginRedirects' +import { tryServePluginRedirect } from '../../../server/publish/publicRoutes' +import type { ServerRuntime } from '../../../server/serverRuntime' +import { bumpPublishVersion } from '../../../server/publish/publishState' +import { prepareInactiveSlot, swapSlot, writeArtefact } from '../../../server/publish/staticArtefact' +import { renderNotFoundResponse } from '../../../server/publish/publicRouter' +import { persistSitePublish } from '../../../server/repositories/publish' +import { createDataRow, saveDataRowDraft } from '../../../server/repositories/data' +import { persistDataRowPublish } from '../../../server/repositories/data/publish' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { makePage, makeSite } from '../publisher/helpers' +import type { Page } from '@core/page-tree' +import { createSite } from '../../../server/repositories/setup' +import { handleServerRequest } from '../../../server/router' +import type { DbClient, DbResult } from '../../../server/db' +import { deletePlugin } from '../../../server/repositories/plugins' +import { listPluginRedirects } from '../../../server/repositories/pluginRedirects' + +describe('plugin redirect public route', () => { + let testDb: TestDb + let uploadsDir: string + let runtime: ServerRuntime + + beforeEach(async () => { + testDb = await createTestDb() + uploadsDir = await mkdtemp(join(tmpdir(), 'instatic-redirect-')) + runtime = { db: testDb.db, uploadsDir } + bumpPublishVersion() + await testDb.db` + insert into installed_plugins (id, name, version, manifest_json) + values (${'plugin.a'}, ${'Redirect test'}, ${'1.0.0'}, ${JSON.stringify({ id: 'plugin.a' })}) + ` + await createSite(testDb.db, 'Redirect test', {}) + await testDb.db` + insert into users (id, email, email_normalized, display_name, password_hash, role_id) + values (${'owner'}, ${'redirect@example.test'}, ${'redirect@example.test'}, ${'Test owner'}, ${'unused'}, ${'owner'}) + ` + }) + + afterEach(async () => { + await testDb.cleanup() + await rm(uploadsDir, { recursive: true, force: true }) + }) + + async function request(path: string, method = 'GET'): Promise { + const url = new URL(path, 'http://localhost') + return tryServePluginRedirect(new Request(url, { method }), runtime, url, url.pathname) + } + + async function publishPages(pages: Page[]): Promise { + for (const page of pages) { + await createDataRow(testDb.db, MAIN_SCOPE, { + id: page.id, tableId: 'pages', slug: page.slug, + cells: { title: page.title, body: { nodes: page.nodes, rootNodeId: page.rootNodeId } }, + }) + } + await persistSitePublish(testDb.db, { + siteSnapshotId: crypto.randomUUID(), site: makeSite({ pages }), serializedImportmap: null, + publishedByUserId: 'owner', + pages: pages.map((page) => ({ + pageId: page.id, title: page.title, slug: page.slug, versionId: crypto.randomUUID(), + versionNumber: 1, runtimeAssets: null, runtimeFiles: [], + })), + }) + bumpPublishVersion() + } + + it('AC-1: an unmatched GET receives the stored permanent redirect', async () => { + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/old', toLocation: '/new', status: 301 }) + const response = await request('/old') + expect(response?.status).toBe(301) + expect(response?.headers.get('location')).toBe('/new') + expect(await response!.text()).toBe('') + expect(await request('/missing')).toBeNull() + }) + + it('AC-7/AC-8: rejects a raw Unicode source and matches its percent-encoded pathname on GET', async () => { + await expect(setPluginRedirect(testDb.db, 'plugin.a', { + fromPath: '/café', toLocation: '/new', status: 301, + })).rejects.toMatchObject({ name: 'PluginRedirectValidationError', field: 'fromPath', message: expect.stringContaining('percent-encode') }) + const rule = await setPluginRedirect(testDb.db, 'plugin.a', { + fromPath: '/caf%C3%A9', toLocation: '/new', status: 301, + }) + expect(rule.fromPath).toBe('/caf%C3%A9') + const response = await handleServerRequest(new Request('http://localhost/caf%C3%A9'), runtime) + expect(response.status).toBe(301) + expect(response.headers.get('location')).toBe('/new') + }) + + it('AC-1: valid Unicode destinations are encoded for the Location header without double-encoding escapes', async () => { + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/old', toLocation: '/你好?own=%20#café', status: 301 }) + const response = await request('/old') + expect(response?.status).toBe(301) + expect(response?.headers.get('location')).toBe('/%E4%BD%A0%E5%A5%BD?own=%20#caf%C3%A9') + }) + + it('AC-2: preserves the request query unless the target already contains a query', async () => { + for (const [toLocation, expected] of [ + ['/new', '/new?utm=x&v=%2F'], + ['/new?own=y', '/new?own=y'], + ['/new?', '/new?'], + ['/new#section', '/new?utm=x&v=%2F#section'], + ['https://example.com/new', 'https://example.com/new?utm=x&v=%2F'], + ]) { + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/old', toLocation: toLocation!, status: 301 }) + expect((await request('/old?utm=x&v=%2F'))?.headers.get('location')).toBe(expected) + } + }) + + it('AC-4: 410 reuses the live or baked 404 body and headers, else returns plain text', async () => { + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/gone', toLocation: null, status: 410 }) + const plain = await request('/gone') + expect(plain?.status).toBe(410) + expect(plain?.headers.get('location')).toBeNull() + expect(plain?.headers.get('content-type')).toContain('text/plain') + expect(await plain!.text()).toBe('Gone') + const notFound = makePage({ + root: { moduleId: 'base.body', children: ['text'] }, + text: { moduleId: 'base.text', props: { text: 'This page is missing', tag: 'h1' } }, + }) + notFound.id = 'not-found' + notFound.slug = 'not-found' + notFound.template = { enabled: true, target: { kind: 'notFound' }, priority: 0 } + await publishPages([notFound]) + const url = new URL('http://localhost/gone') + const designed = await renderNotFoundResponse(testDb.db, url, uploadsDir) + const live = await request('/gone') + expect(live?.status).toBe(410) + expect([...live!.headers]).toEqual([...designed!.headers]) + const liveBody = await live!.text() + expect(liveBody).toContain('This page is missing') + expect(liveBody).toBe(await designed!.text()) + const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) + await writeArtefact(slotDir, '/404', 'Baked missing page') + await swapSlot(uploadsDir, slot) + const baked = await request('/gone') + expect(baked?.status).toBe(410) + expect(baked?.headers.get('content-type')).toContain('text/html') + expect(await baked!.text()).toBe('Baked missing page') + }) + + it('AC-5: preserves each redirect status and prevents caching of 302 and 307', async () => { + for (const status of [301, 302, 307, 308] as const) { + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/old', toLocation: '/new', status }) + const response = await request('/old') + expect(response?.status).toBe(status) + expect(response?.headers.get('location')).toBe('/new') + expect(response?.headers.get('cache-control')).toBe(status === 302 || status === 307 ? 'no-store' : null) + } + }) + + it('AC-6: HEAD matches GET status and location without a body; write methods fall through', async () => { + for (const status of [301, 302, 307, 308, 410] as const) { + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/old', toLocation: status === 410 ? null : '/new', status }) + const get = await request('/old?utm=x') + const head = await request('/old?utm=x', 'HEAD') + expect(head?.status).toBe(get?.status) + expect(head?.headers.get('location')).toBe(get?.headers.get('location')) + expect(head?.headers.get('content-type')).toBe(get?.headers.get('content-type')) + expect(await head!.text()).toBe('') + } + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) { + expect(await request('/old', method)).toBeNull() + } + }) + + it('AC-6: the dispatcher gives HEAD the same redirect status and headers as GET', async () => { + for (const status of [301, 302, 307, 308, 410] as const) { + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/old', toLocation: status === 410 ? null : '/new', status }) + const get = await handleServerRequest(new Request('http://localhost/old?utm=x'), runtime) + const head = await handleServerRequest(new Request('http://localhost/old?utm=x', { method: 'HEAD' }), runtime) + expect(head.status).toBe(status) + expect(head.status).toBe(get.status) + expect([...head.headers]).toEqual([...get.headers]) + } + for (const method of ['POST', 'PUT', 'DELETE']) { + const response = await handleServerRequest(new Request('http://localhost/old', { method }), runtime) + expect(response.status).toBe(404) + expect(response.headers.get('location')).toBeNull() + } + }) + + it('AC-3: the dispatcher serves live pages, rows, artefacts and rename redirects before plugin rules', async () => { + const db = testDb.db + const about = makePage({ + root: { moduleId: 'base.body', children: ['text'] }, + text: { moduleId: 'base.text', props: { text: 'Published about', tag: 'h1' } }, + }) + about.id = 'about' + about.slug = 'about' + const entry = makePage({ + root: { moduleId: 'base.body', children: ['text'] }, + text: { moduleId: 'base.text', props: { text: '{currentEntry.title}', tag: 'h1' } }, + }) + entry.id = 'entry' + entry.slug = 'entry' + entry.template = { enabled: true, target: { kind: 'postTypes', tableSlugs: ['posts'] }, priority: 0 } + await publishPages([about, entry]) + await db`update data_tables set route_base = ${'/posts'} where id = ${'posts'}` + const post = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', slug: 'live', cells: { title: 'Published row' } }) + await persistDataRowPublish(db, post.id, 'owner') + const renamed = await createDataRow(db, MAIN_SCOPE, { tableId: 'posts', slug: 'old-name', cells: { title: 'Renamed row' } }) + await persistDataRowPublish(db, renamed.id, 'owner') + await saveDataRowDraft(db, MAIN_SCOPE, renamed.id, { slug: 'new-name', cells: renamed.cells }) + await persistDataRowPublish(db, renamed.id, 'owner') + bumpPublishVersion() + const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) + await writeArtefact(slotDir, '/baked', 'Baked live page') + await swapSlot(uploadsDir, slot) + for (const fromPath of ['/about', '/posts/live', '/baked', '/posts/old-name', '/orphan']) { + await setPluginRedirect(db, 'plugin.a', { fromPath, toLocation: '/plugin-target', status: 302 }) + } + let redirectQueries = 0 + const trackingDb: DbClient = Object.assign( + async >(strings: TemplateStringsArray, ...values: unknown[]): Promise> => { + if (strings.join(' ').includes('from plugin_redirects')) redirectQueries += 1 + return db(strings, ...values) + }, + { unsafe: db.unsafe, transaction: db.transaction, close: db.close, dialect: db.dialect }, + ) + const trackingRuntime = { ...runtime, db: trackingDb } + const unmatched = await handleServerRequest(new Request('http://localhost/orphan'), trackingRuntime) + expect(unmatched.status).toBe(302) + expect(unmatched.headers.get('location')).toBe('/plugin-target') + expect(redirectQueries).toBe(1) + for (const [path, content] of [['/about', 'Published about'], ['/posts/live', 'Published row'], ['/baked', 'Baked live page']]) { + const response = await handleServerRequest(new Request(`http://localhost${path}`), trackingRuntime) + expect(response.status).toBe(200) + expect(await response.text()).toContain(content!) + } + const rename = await handleServerRequest(new Request('http://localhost/posts/old-name'), trackingRuntime) + expect(rename.status).toBe(301) + expect(rename.headers.get('location')).toBe('/posts/new-name') + expect(redirectQueries).toBe(1) + const missing = await handleServerRequest(new Request('http://localhost/missing'), trackingRuntime) + expect(missing.status).toBe(404) + expect(redirectQueries).toBe(2) + }) + + it('AC-11: uninstall cascades all owned rules and the old URL falls through to the site 404', async () => { + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/old', toLocation: '/new', status: 301 }) + await setPluginRedirect(testDb.db, 'plugin.a', { fromPath: '/another', toLocation: null, status: 410 }) + const { slot, slotDir } = await prepareInactiveSlot(uploadsDir) + await writeArtefact(slotDir, '/404', 'Site missing page') + await swapSlot(uploadsDir, slot) + expect((await handleServerRequest(new Request('http://localhost/old'), runtime)).status).toBe(301) + expect(await deletePlugin(testDb.db, 'plugin.a')).toBe(true) + expect(await listPluginRedirects(testDb.db, 'plugin.a')).toEqual([]) + const after = await handleServerRequest(new Request('http://localhost/old'), runtime) + expect(after.status).toBe(404) + expect(await after.text()).toBe('Site missing page') + }) + + it('AC-15: the public route-order documentation places plugin rules immediately before the 404 handler', async () => { + const doc = await readFile(new URL('../../../docs/server.md', import.meta.url), 'utf8') + const table = doc.split('const routes: readonly RouteHandler[] = [')[1]!.split(']')[0]! + const handlers = [...table.matchAll(/^ {2}(try\w+),/gm)].map((match) => match[1]) + expect(handlers.slice(-3)).toEqual(['trySetupRedirect', 'tryServePluginRedirect', 'tryServeNotFoundPage']) + expect(table).toContain('301/302/307/308/410') + const publisherDoc = await readFile(new URL('../../../docs/features/publisher.md', import.meta.url), 'utf8') + const fallthrough = publisherDoc.split('router falls through:')[1]!.split('→ renderNotFoundResponse')[0]! + const publisherHandlers = [...fallthrough.matchAll(/\btry\w+/g)].map((match) => match[0]) + expect(publisherHandlers).toEqual(['trySetupRedirect', 'tryServePluginRedirect', 'tryServeNotFoundPage']) + }) +}) diff --git a/src/__tests__/server/pluginRedirects.test.ts b/src/__tests__/server/pluginRedirects.test.ts new file mode 100644 index 000000000..4d4f518e9 --- /dev/null +++ b/src/__tests__/server/pluginRedirects.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { pgMigrations } from '../../../server/db/migrations-pg' +import { sqliteMigrations } from '../../../server/db/migrations-sqlite' +import { createTestDb, type TestDb } from '../helpers/createTestDb' +import type { DbClient, DbResult } from '../../../server/db' +import { + normalizeRedirectFromPath, + validatePluginRedirectInput, + PluginRedirectValidationError, + type PluginRedirectInput, + setPluginRedirect, + listPluginRedirects, + deletePluginRedirect, + findPluginRedirect, + replacePluginRedirects, +} from '../../../server/repositories/pluginRedirects' + +function expectInvalid(input: PluginRedirectInput, field: PluginRedirectValidationError['field']): void { + try { + validatePluginRedirectInput(input) + throw new Error('Expected redirect validation to reject') + } catch (err) { + expect(err).toBeInstanceOf(PluginRedirectValidationError) + expect((err as PluginRedirectValidationError).field).toBe(field) + } +} + +describe('plugin redirects repository', () => { + let testDb: TestDb + + beforeEach(async () => { + testDb = await createTestDb() + for (const id of ['plugin.a', 'plugin.b']) { + await testDb.db` + insert into installed_plugins (id, name, version, manifest_json) + values (${id}, ${id}, ${'1.0.0'}, ${JSON.stringify({ id })}) + ` + } + }) + + afterEach(async () => { + await testDb.cleanup() + }) + + it('AC-12: replaceAll validates before replacing, deduplicates normalized paths, and enforces the cap', async () => { + const db = testDb.db + const original = await setPluginRedirect(db, 'plugin.a', { fromPath: '/original', toLocation: '/target', status: 301 }) + const other = await setPluginRedirect(db, 'plugin.b', { fromPath: '/other', toLocation: '/target', status: 301 }) + await expect(replacePluginRedirects(db, 'plugin.a', [ + { fromPath: '/valid', toLocation: '/target', status: 301 }, + { fromPath: '/bad', toLocation: '//bad', status: 301 }, + ])).rejects.toMatchObject({ field: 'toLocation' }) + expect(await listPluginRedirects(db, 'plugin.a')).toEqual([original]) + expect(await replacePluginRedirects(db, 'plugin.a', [ + { fromPath: '/old/', toLocation: '/first', status: 301 }, + { fromPath: '/old', toLocation: '/last', status: 308 }, + ])).toEqual({ count: 1 }) + expect((await listPluginRedirects(db, 'plugin.a')).map((rule) => [rule.fromPath, rule.toLocation])).toEqual([['/old', '/last']]) + const rules: PluginRedirectInput[] = Array.from({ length: 5000 }, (_, index) => ({ + fromPath: `/rule-${index}`, toLocation: '/target', status: 301, + })) + expect(await replacePluginRedirects(db, 'plugin.a', rules)).toEqual({ count: 5000 }) + await expect(replacePluginRedirects(db, 'plugin.a', [...rules, rules[0]!])).rejects.toMatchObject({ field: 'limit' }) + await expect(setPluginRedirect(db, 'plugin.a', { fromPath: '/overflow', toLocation: '/target', status: 301 })).rejects.toMatchObject({ field: 'limit' }) + expect((await setPluginRedirect(db, 'plugin.a', { fromPath: '/rule-0', toLocation: '/updated', status: 302 })).toLocation).toBe('/updated') + expect(await listPluginRedirects(db, 'plugin.a')).toHaveLength(5000) + expect(await listPluginRedirects(db, 'plugin.b')).toEqual([other]) + expect(await replacePluginRedirects(db, 'plugin.a', [])).toEqual({ count: 0 }) + expect(await listPluginRedirects(db, 'plugin.a')).toEqual([]) + await replacePluginRedirects(db, 'plugin.a', rules.slice(0, 4999)) + const attempts = await Promise.allSettled([ + setPluginRedirect(db, 'plugin.a', { fromPath: '/last-slot-a', toLocation: '/target', status: 301 }), + setPluginRedirect(db, 'plugin.a', { fromPath: '/last-slot-b', toLocation: '/target', status: 301 }), + ]) + expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(attempts.filter((result) => result.status === 'rejected')).toHaveLength(1) + expect(await listPluginRedirects(db, 'plugin.a')).toHaveLength(5000) + }) + + it('AC-12: replaceAll uses one transaction and rolls back deletion and inserts on a database failure', async () => { + const db = testDb.db + const original = await setPluginRedirect(db, 'plugin.a', { fromPath: '/original', toLocation: '/target', status: 301 }) + let transactions = 0 + const failingDb: DbClient = Object.assign( + async >(strings: TemplateStringsArray, ...values: unknown[]): Promise> => db(strings, ...values), + { + unsafe: db.unsafe, close: db.close, dialect: db.dialect, + transaction: async (fn: (tx: DbClient) => Promise): Promise => { + transactions += 1 + return db.transaction(async (tx) => { + const failingTx: DbClient = Object.assign( + async >(strings: TemplateStringsArray, ...values: unknown[]): Promise> => { + if (strings.join(' ').includes('insert into plugin_redirects') && values[1] === '/fail') { + throw new Error('Injected database write failure') + } + return tx(strings, ...values) + }, + { unsafe: tx.unsafe, transaction: tx.transaction, close: tx.close, dialect: tx.dialect }, + ) + return fn(failingTx) + }) + }, + }, + ) + await expect(replacePluginRedirects(failingDb, 'plugin.a', [ + { fromPath: '/valid', toLocation: '/target', status: 301 }, + { fromPath: '/fail', toLocation: '/target', status: 301 }, + ])).rejects.toThrow('Injected database write failure') + expect(transactions).toBe(1) + expect(await listPluginRedirects(db, 'plugin.a')).toEqual([original]) + }) + + it('AC-13: the oldest rule wins across plugins, with lower plugin id breaking timestamp ties', async () => { + const db = testDb.db + await setPluginRedirect(db, 'plugin.b', { fromPath: '/shared', toLocation: '/b', status: 308 }) + await setPluginRedirect(db, 'plugin.a', { fromPath: '/shared', toLocation: '/a', status: 301 }) + await db`update plugin_redirects set created_at = ${'2026-09-29T00:00:00.000Z'} where plugin_id = ${'plugin.b'}` + expect((await findPluginRedirect(db, '/shared'))?.pluginId).toBe('plugin.b') + await setPluginRedirect(db, 'plugin.b', { fromPath: '/shared', toLocation: '/b-updated', status: 302 }) + expect((await findPluginRedirect(db, '/shared'))?.toLocation).toBe('/b-updated') + await db`update plugin_redirects set created_at = ${'2026-09-29T00:00:00.000Z'} where plugin_id = ${'plugin.a'}` + expect((await findPluginRedirect(db, '/shared'))?.pluginId).toBe('plugin.a') + await deletePluginRedirect(db, 'plugin.a', '/shared') + expect((await findPluginRedirect(db, '/shared'))?.pluginId).toBe('plugin.b') + }) + + it('AC-8: trailing slashes share a rule while raw path matching stays exact and case-sensitive', async () => { + const db = testDb.db + const first = await setPluginRedirect(db, 'plugin.a', { fromPath: '/old/', toLocation: '/new', status: 301 }) + expect(first.fromPath).toBe('/old') + expect(await findPluginRedirect(db, '/old')).toEqual(first) + expect(await findPluginRedirect(db, '/old/')).toEqual(first) + expect(await findPluginRedirect(db, '/Old')).toBeNull() + expect(await findPluginRedirect(db, '/%6Fld')).toBeNull() + expect(await findPluginRedirect(db, '/old/child')).toBeNull() + expect(await findPluginRedirect(db, '/old?x=1')).toBeNull() + expect(await findPluginRedirect(db, '/admin/x')).toBeNull() + await setPluginRedirect(db, 'plugin.a', { fromPath: '/old', toLocation: '/updated', status: 308 }) + expect(await listPluginRedirects(db, 'plugin.a')).toHaveLength(1) + expect((await findPluginRedirect(db, '/old/'))?.toLocation).toBe('/updated') + await setPluginRedirect(db, 'plugin.a', { fromPath: '/', toLocation: '/home', status: 301 }) + expect((await findPluginRedirect(db, '/'))?.fromPath).toBe('/') + expect(await deletePluginRedirect(db, 'plugin.a', '/old/')).toBe(true) + expect(await findPluginRedirect(db, '/old')).toBeNull() + }) + + it('AC-10: set, list and delete isolate rules by their owning plugin', async () => { + const db = testDb.db + const first = await setPluginRedirect(db, 'plugin.a', { fromPath: '/old', toLocation: '/a', status: 301 }) + const other = await setPluginRedirect(db, 'plugin.b', { fromPath: '/old', toLocation: '/b', status: 302 }) + const updated = await setPluginRedirect(db, 'plugin.a', { fromPath: '/old', toLocation: '/updated', status: 308 }) + expect(updated.createdAt).toBe(first.createdAt) + expect(updated.pluginId).toBe('plugin.a') + expect(updated.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/) + await setPluginRedirect(db, 'plugin.a', { fromPath: '/aaa', toLocation: null, status: 410 }) + expect((await listPluginRedirects(db, 'plugin.a')).map((rule) => rule.fromPath)).toEqual(['/aaa', '/old']) + expect(await listPluginRedirects(db, 'plugin.b')).toEqual([other]) + expect(await deletePluginRedirect(db, 'plugin.b', '/aaa')).toBe(false) + expect(await deletePluginRedirect(db, 'plugin.a', '/old/')).toBe(true) + expect(await deletePluginRedirect(db, 'plugin.a', '/old')).toBe(false) + expect(await deletePluginRedirect(db, 'plugin.a', '//invalid')).toBe(false) + expect(await listPluginRedirects(db, 'plugin.b')).toEqual([other]) + expect((await listPluginRedirects(db, 'plugin.a')).map((rule) => rule.fromPath)).toEqual(['/aaa']) + }) + + it('AC-7: source paths require printable ASCII and explain percent-encoding', () => { + const valid: PluginRedirectInput = { fromPath: '/caf%C3%A9', toLocation: '/new', status: 301 } + for (const fromPath of ['/café', '/你好', '/old\u0000', '/old\u007f', '/old\u0085']) { + expectInvalid({ ...valid, fromPath }, 'fromPath') + expect(() => normalizeRedirectFromPath(fromPath)).toThrow(PluginRedirectValidationError) + expect(() => normalizeRedirectFromPath(fromPath)).toThrow(/percent-encode.*\/caf%C3%A9/) + } + expect(validatePluginRedirectInput(valid)).toEqual(valid) + }) + + it('AC-7: validates every redirect field at the repository boundary', () => { + const valid: PluginRedirectInput = { fromPath: '/old', toLocation: '/new', status: 301 } + for (const fromPath of ['', 'old', '//old', '/old?q=x', '/old#x', '/old path', '/old\r\n', '/old\u0000', '/old\u007f', '/old\u0085', '/' + 'x'.repeat(2048), '/admin', '/admin/x', '/admin/', '/_instatic', '/_instatic/x', '/uploads', '/uploads/x']) { + expectInvalid({ ...valid, fromPath }, 'fromPath') + expect(() => normalizeRedirectFromPath(fromPath)).toThrow(PluginRedirectValidationError) + } + for (const toLocation of [null, '', 'new', '//example.com/new', '/\\evil.com', '/\\\\x', 'ftp://example.com/new', 'javascript:alert(1)', 'https://', '/new path', '/new\r\nlocation:/evil', '/new\u0000', '/new\u007f', '/new\u0085', '/' + 'x'.repeat(2048), '/old', '/old/']) { + expectInvalid({ ...valid, toLocation }, 'toLocation') + } + expectInvalid({ ...valid, status: 200 as PluginRedirectInput['status'] }, 'status') + expectInvalid({ ...valid, status: 410 }, 'toLocation') + for (const status of [301, 302, 307, 308] as const) { + expect(validatePluginRedirectInput({ ...valid, status }).status).toBe(status) + } + expect(validatePluginRedirectInput({ fromPath: '/gone', toLocation: null, status: 410 }).toLocation).toBeNull() + for (const fromPath of ['/', '/administrator', '/_instatic-extra', '/uploads-other', '/' + 'x'.repeat(2047)]) { + expect(validatePluginRedirectInput({ ...valid, fromPath }).fromPath).toBe(fromPath) + } + for (const toLocation of ['/new?x=y#anchor', 'http://example.com/new', 'https://example.com/new?x=y', '/' + 'x'.repeat(2047)]) { + expect(validatePluginRedirectInput({ ...valid, toLocation }).toLocation).toBe(toLocation) + } + expect(validatePluginRedirectInput({ ...valid, fromPath: '/old/' }).fromPath).toBe('/old') + }) + + it('AC-14: both dialects add migration 031 with equivalent redirect storage', async () => { + const pg = pgMigrations.find((migration) => migration.id === '031_plugin_redirects') + const sqlite = sqliteMigrations.find((migration) => migration.id === '031_plugin_redirects') + expect(pg).toBeDefined() + expect(sqlite).toBeDefined() + expect(pg!.sql.replaceAll('timestamptz', 'text')).toBe(sqlite!.sql) + await testDb.db` + insert into plugin_redirects (plugin_id, from_path, to_location, status, created_at, updated_at) + values (${'plugin.a'}, ${'/old'}, ${'/new'}, ${301}, ${'2026-09-30T00:00:00.000Z'}, ${'2026-09-30T00:00:00.000Z'}) + ` + const { rows } = await testDb.db`select from_path, to_location, status from plugin_redirects` + expect(rows).toEqual([{ from_path: '/old', to_location: '/new', status: 301 }]) + }) +}) diff --git a/src/__tests__/server/pluginRedirectsApi.test.ts b/src/__tests__/server/pluginRedirectsApi.test.ts new file mode 100644 index 000000000..402b0e582 --- /dev/null +++ b/src/__tests__/server/pluginRedirectsApi.test.ts @@ -0,0 +1,554 @@ +/** + * `api.cms.redirects` — the sandboxed plugin surface over the plugin-owned + * redirect table. + * + * Three layers are pinned here: + * 1. VM — every `api.cms.redirects.*` call throws synchronously inside the + * sandbox without `redirects.manage`, and forwards the SDK shape to the + * right RPC target with it. + * 2. Protocol — the TypeBox arg schemas are a safety ceiling only + * (`replaceAll` ≤ 10000 items, every string ≤ 8192 characters). The real + * limits (2048 characters, 5000 rules) belong to the repository, so a + * plugin learns about them as `fromPath:` / `toLocation:` / `limit:`. + * 3. Host — the dispatcher rejects the RPC without the grant (defense in + * depth), the handler maps `from`/`to` ↔ `fromPath`/`toLocation`, scopes + * every call to the calling plugin, and replies with + * `: ` for repository validation errors. + * + * The host runs against the real repository + * (`server/repositories/pluginRedirects.ts`) on a real test database from + * `createTestDb()`. The handle given to the host is a thin recording wrapper, + * so a test can assert which calls reached the database and can make the + * next query fail with a plain (non-validation) error. + * + * Host calls go through the real `parseApiCall`, so the error a plugin + * receives is asserted end to end: a wire rejection returns the parser's + * message, exactly as `workerPool.ts` replies it. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { Value } from '@sinclair/typebox/value' +import type { DbClient, DbResult } from '../../../server/db' +import { dispatchApiCall } from '../../../server/plugins/host/apiDispatch' +import { hostPlugins, setPluginWorkerDbClient } from '../../../server/plugins/host/registry' +import type { HostPluginRecord } from '../../../server/plugins/host/types' +import { workers } from '../../../server/plugins/host/workerState' +import { ApiCallSchemas, isAllowedApiTarget } from '../../../server/plugins/protocol/apiCallSchema' +import { parseApiCall } from '../../../server/plugins/protocol/parser' +import { TARGET_PERMISSIONS } from '../../../server/plugins/protocol/targets' +import { createPluginVm } from '../../../server/plugins/quickjs/vm' +import { + PLUGIN_REDIRECTS_MAX_PER_PLUGIN, + replacePluginRedirects, +} from '../../../server/repositories/pluginRedirects' +import { PLUGIN_PERMISSION_VALUES, PLUGIN_CAPABILITIES, permissions } from '@core/plugin-sdk' +import { createTestDb, type TestDb } from '../helpers/createTestDb' + +// --------------------------------------------------------------------------- +// Recording DbClient wrapper +// --------------------------------------------------------------------------- + +/** SQL text of every query the host sent to the database, in order. */ +const queries: string[] = [] +/** When set, the next query rejects with this (a non-validation failure). */ +let failNext: Error | null = null + +function recordingDb(target: DbClient): DbClient { + function query>( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise> { + queries.push(strings.join('?').replace(/\s+/g, ' ').trim()) + const err = failNext + failNext = null + if (err) return Promise.reject(err) + return target(strings, ...values) + } + return Object.assign(query, { + unsafe: >(sql: string, params?: unknown[]) => { + queries.push(sql) + return target.unsafe(sql, params) + }, + transaction: (fn: (tx: DbClient) => Promise) => target.transaction((tx) => fn(recordingDb(tx))), + close: () => target.close(), + dialect: target.dialect, + }) +} + +// --------------------------------------------------------------------------- +// Host harness +// --------------------------------------------------------------------------- + +interface Reply { kind: string; correlationId: string; ok: boolean; value?: unknown; error?: string; wire?: boolean } + +const PLUGIN_A = 'acme.seo' +const PLUGIN_B = 'other.seo' +const replies: Reply[] = [] + +function makeEntry(pluginId: string, granted: string[]): HostPluginRecord { + return { + manifest: { id: pluginId, grantedPermissions: granted }, + inflightFetches: new Map(), + } as unknown as HostPluginRecord +} + +function installHostPlugin(pluginId: string, granted: string[]): void { + hostPlugins.set(pluginId, makeEntry(pluginId, granted)) + workers.set(pluginId, { postMessage: (m: Reply) => replies.push(m) } as unknown as Worker) +} + +let correlation = 0 +/** + * Sends one api-call through the host exactly as `workerPool.ts` does: + * `parseApiCall` first (a wire rejection is replied with the parser's + * message), then `dispatchApiCall`. Returns the reply the plugin receives. + */ +async function callHost(pluginId: string, target: string, args: unknown[]): Promise { + correlation += 1 + const correlationId = 'c' + correlation + const raw = { kind: 'api-call', correlationId, pluginId, target, args } + let parsed: ReturnType + try { + parsed = parseApiCall(raw) + } catch (err) { + return { kind: 'api-reply', correlationId, ok: false, error: err instanceof Error ? err.message : String(err), wire: true } + } + await dispatchApiCall(parsed) + const reply = replies.find((r) => r.correlationId === correlationId) + if (!reply) throw new Error('no reply for ' + target) + return reply +} + +let testDb: TestDb + +beforeEach(async () => { + testDb = await createTestDb() + for (const id of [PLUGIN_A, PLUGIN_B]) { + await testDb.db` + insert into installed_plugins (id, name, version, manifest_json) + values (${id}, ${id}, ${'1.0.0'}, ${JSON.stringify({ id })}) + ` + } + queries.length = 0 + replies.length = 0 + failNext = null + setPluginWorkerDbClient(recordingDb(testDb.db)) +}) + +afterEach(async () => { + for (const id of [PLUGIN_A, PLUGIN_B]) { + hostPlugins.delete(id) + workers.delete(id) + } + await testDb.cleanup() +}) + +interface StoredRow { plugin_id: string; from_path: string; to_location: string | null; status: number } + +/** Every stored redirect row, read straight from the table (not through the host). */ +async function storedRows(): Promise { + const { rows } = await testDb.db` + select plugin_id, from_path, to_location, status from plugin_redirects + order by plugin_id asc, from_path asc + ` + return rows.map((r) => ({ ...r, status: Number(r.status) })) +} + +// --------------------------------------------------------------------------- +// Permission + target registration +// --------------------------------------------------------------------------- + +const REDIRECT_TARGETS = [ + 'cms.redirects.list', + 'cms.redirects.set', + 'cms.redirects.delete', + 'cms.redirects.replaceAll', +] as const + +describe('redirects.manage permission', () => { + it('is a known permission with a high-risk server/cms capability and a builder alias', () => { + expect(PLUGIN_PERMISSION_VALUES as readonly string[]).toContain('redirects.manage') + expect((permissions as Record).redirectsManage).toBe('redirects.manage') + const capability = PLUGIN_CAPABILITIES.find((c) => c.permission === ('redirects.manage' as never)) + expect(capability).toMatchObject({ + label: 'Manage site redirects', + risk: 'high', + surfaces: ['server', 'cms'], + }) + }) + + it('gates every cms.redirects.* target on redirects.manage', () => { + for (const target of REDIRECT_TARGETS) { + expect(isAllowedApiTarget(target)).toBe(true) + expect((TARGET_PERMISSIONS as Record)[target]).toBe('redirects.manage') + } + }) +}) + +// --------------------------------------------------------------------------- +// VM layer (AC-9, sandbox half) +// --------------------------------------------------------------------------- + +interface RecordedCall { target: string; args: unknown[] } + +function vmPluginSource(body: string): string { + return ` + ;(function () { + const __plugin_exports = (globalThis.__plugin_exports = {}); + __plugin_exports.activate = async function activate(api) { + ${body} + }; + })(); + ` +} + +async function runInVm(body: string, granted: string[], hostResult: unknown = null) { + const calls: RecordedCall[] = [] + const vm = await createPluginVm({ + pluginSource: vmPluginSource(body), + env: { + pluginId: PLUGIN_A, + manifestVersion: '1.0.0', + grantedPermissions: granted, + assetBasePath: '/uploads/plugins/acme.seo/1.0.0', + settings: {}, + hostCall: async (target, args) => { + calls.push({ target, args }) + return hostResult + }, + log: () => {}, + }, + }) + return { vm, calls } +} + +describe('api.cms.redirects in the sandbox', () => { + const CALLS: Record<(typeof REDIRECT_TARGETS)[number], string> = { + 'cms.redirects.list': 'await api.cms.redirects.list();', + 'cms.redirects.set': "await api.cms.redirects.set({ from: '/old', to: '/new', status: 301 });", + 'cms.redirects.delete': "await api.cms.redirects.delete('/old');", + 'cms.redirects.replaceAll': "await api.cms.redirects.replaceAll([{ from: '/old', to: '/new', status: 301 }]);", + } + + for (const target of REDIRECT_TARGETS) { + it(`${target} throws in the VM without redirects.manage (host never reached)`, async () => { + const { vm, calls } = await runInVm(CALLS[target], ['cms.hooks']) + try { + await expect(vm.runLifecycle('activate')).rejects.toThrow(/requires permission "redirects\.manage"/) + expect(calls).toEqual([]) + } finally { + vm.dispose() + } + }) + } + + it('forwards each call to its RPC target with the SDK-shaped arguments', async () => { + const body = ` + await api.cms.redirects.list(); + await api.cms.redirects.set({ from: '/old', to: '/new', status: 301 }); + await api.cms.redirects.set({ from: '/gone', status: 410 }); + await api.cms.redirects.delete('/old'); + await api.cms.redirects.replaceAll([{ from: '/a', to: 'https://example.com/b', status: 308 }]); + ` + const { vm, calls } = await runInVm(body, ['redirects.manage']) + try { + await vm.runLifecycle('activate') + expect(calls).toEqual([ + { target: 'cms.redirects.list', args: [] }, + { target: 'cms.redirects.set', args: [{ from: '/old', to: '/new', status: 301 }] }, + { target: 'cms.redirects.set', args: [{ from: '/gone', to: null, status: 410 }] }, + { target: 'cms.redirects.delete', args: ['/old'] }, + { target: 'cms.redirects.replaceAll', args: [[{ from: '/a', to: 'https://example.com/b', status: 308 }]] }, + ]) + } finally { + vm.dispose() + } + }) +}) + +// --------------------------------------------------------------------------- +// Protocol schemas (AC-12 caps) +// --------------------------------------------------------------------------- + +function apiCall(target: string, args: unknown[]) { + return { kind: 'api-call', correlationId: 'c', pluginId: PLUGIN_A, target, args } +} + +describe('cms.redirects.* arg schemas', () => { + function check(target: (typeof REDIRECT_TARGETS)[number], args: unknown[]): boolean { + return Value.Check(ApiCallSchemas[target], apiCall(target, args)) + } + + it('accept well-formed calls', () => { + expect(check('cms.redirects.list', [])).toBe(true) + expect(check('cms.redirects.set', [{ from: '/old', to: '/new', status: 301 }])).toBe(true) + expect(check('cms.redirects.set', [{ from: '/gone', to: null, status: 410 }])).toBe(true) + expect(check('cms.redirects.set', [{ from: '/gone', status: 410 }])).toBe(true) + expect(check('cms.redirects.delete', ['/old'])).toBe(true) + expect(check('cms.redirects.delete', [''])).toBe(true) + expect(check('cms.redirects.replaceAll', [[]])).toBe(true) + }) + + it('let the repository own the real limits: 5001 rules and 2049-char strings pass the wire', () => { + const rule = { from: '/x', to: '/y', status: 301 } + const long = '/' + 'a'.repeat(2048) + expect(check('cms.redirects.replaceAll', [Array.from({ length: 5001 }, () => rule)])).toBe(true) + expect(check('cms.redirects.set', [{ from: long, to: '/new', status: 301 }])).toBe(true) + expect(check('cms.redirects.set', [{ from: '/old', to: long, status: 301 }])).toBe(true) + expect(check('cms.redirects.delete', [long])).toBe(true) + }) + + it('cap replaceAll at 10000 items (safety ceiling)', () => { + const rule = { from: '/x', to: '/y', status: 301 } + expect(check('cms.redirects.replaceAll', [Array.from({ length: 10000 }, () => rule)])).toBe(true) + expect(check('cms.redirects.replaceAll', [Array.from({ length: 10001 }, () => rule)])).toBe(false) + }) + + it('cap every string at 8192 characters (safety ceiling)', () => { + const atCeiling = '/' + 'a'.repeat(8191) + const long = '/' + 'a'.repeat(8192) + expect(check('cms.redirects.delete', [atCeiling])).toBe(true) + expect(check('cms.redirects.set', [{ from: long, to: '/new', status: 301 }])).toBe(false) + expect(check('cms.redirects.set', [{ from: '/old', to: long, status: 301 }])).toBe(false) + expect(check('cms.redirects.delete', [long])).toBe(false) + expect(check('cms.redirects.replaceAll', [[{ from: long, to: '/new', status: 301 }]])).toBe(false) + }) + + it('reject unknown statuses and extra keys', () => { + expect(check('cms.redirects.set', [{ from: '/old', to: '/new', status: 303 }])).toBe(false) + expect(check('cms.redirects.set', [{ from: '/old', to: '/new', status: '301' }])).toBe(false) + expect(check('cms.redirects.set', [{ from: '/old', to: '/new', status: 301, regex: true }])).toBe(false) + expect(check('cms.redirects.list', ['extra'])).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Host dispatcher + handler (AC-7, AC-9 host half, AC-10, AC-12) +// --------------------------------------------------------------------------- + +describe('cms.redirects.* host dispatch', () => { + it('rejects every target without redirects.manage and never touches the repository (AC-9)', async () => { + installHostPlugin(PLUGIN_A, ['cms.hooks']) + const argsFor: Record<(typeof REDIRECT_TARGETS)[number], unknown[]> = { + 'cms.redirects.list': [], + 'cms.redirects.set': [{ from: '/old', to: '/new', status: 301 }], + 'cms.redirects.delete': ['/old'], + 'cms.redirects.replaceAll': [[{ from: '/old', to: '/new', status: 301 }]], + } + for (const target of REDIRECT_TARGETS) { + const reply = await callHost(PLUGIN_A, target, argsFor[target]) + expect(reply.ok).toBe(false) + expect(reply.error).toBe(`Plugin "${PLUGIN_A}" requires permission "redirects.manage"`) + } + expect(queries).toEqual([]) + }) + + it('maps the SDK shape to the repository and back', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + + const set = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/old/', to: '/new', status: 301 }]) + expect(set.ok).toBe(true) + expect(await storedRows()).toEqual([ + { plugin_id: PLUGIN_A, from_path: '/old', to_location: '/new', status: 301 }, + ]) + expect(set.value).toEqual({ + from: '/old', + to: '/new', + status: 301, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }) + + const gone = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/gone', status: 410 }]) + expect(gone.ok).toBe(true) + expect(await storedRows()).toContainEqual({ plugin_id: PLUGIN_A, from_path: '/gone', to_location: null, status: 410 }) + + const list = await callHost(PLUGIN_A, 'cms.redirects.list', []) + expect(list.value).toEqual([ + { from: '/gone', to: null, status: 410, createdAt: expect.any(String), updatedAt: expect.any(String) }, + { from: '/old', to: '/new', status: 301, createdAt: expect.any(String), updatedAt: expect.any(String) }, + ]) + + const del = await callHost(PLUGIN_A, 'cms.redirects.delete', ['/old']) + expect(del).toMatchObject({ ok: true, value: true }) + const delAgain = await callHost(PLUGIN_A, 'cms.redirects.delete', ['/old']) + expect(delAgain).toMatchObject({ ok: true, value: false }) + + const replaced = await callHost(PLUGIN_A, 'cms.redirects.replaceAll', [[ + { from: '/a', to: '/b', status: 302 }, + { from: '/c', status: 410 }, + ]]) + expect(replaced).toMatchObject({ ok: true, value: { count: 2 } }) + expect(await storedRows()).toEqual([ + { plugin_id: PLUGIN_A, from_path: '/a', to_location: '/b', status: 302 }, + { plugin_id: PLUGIN_A, from_path: '/c', to_location: null, status: 410 }, + ]) + }) + + it('scopes list/set/delete/replaceAll to the calling plugin (AC-10)', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + installHostPlugin(PLUGIN_B, ['redirects.manage']) + + await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/shared', to: '/a', status: 301 }]) + await callHost(PLUGIN_B, 'cms.redirects.set', [{ from: '/shared', to: '/b', status: 302 }]) + await callHost(PLUGIN_B, 'cms.redirects.set', [{ from: '/only-b', to: '/b2', status: 301 }]) + + // Same `from` in two plugins = two rows, no overwrite. + const listA = await callHost(PLUGIN_A, 'cms.redirects.list', []) + expect((listA.value as Array<{ from: string; to: string }>).map((r) => [r.from, r.to])).toEqual([['/shared', '/a']]) + + // A cannot delete B's rule. + const delB = await callHost(PLUGIN_A, 'cms.redirects.delete', ['/only-b']) + expect(delB).toMatchObject({ ok: true, value: false }) + + // A's replaceAll leaves B's rules alone. + await callHost(PLUGIN_A, 'cms.redirects.replaceAll', [[]]) + const listB = await callHost(PLUGIN_B, 'cms.redirects.list', []) + expect((listB.value as Array<{ from: string; to: string }>).map((r) => [r.from, r.to])).toEqual([ + ['/only-b', '/b2'], + ['/shared', '/b'], + ]) + + expect(await storedRows()).toEqual([ + { plugin_id: PLUGIN_B, from_path: '/only-b', to_location: '/b2', status: 301 }, + { plugin_id: PLUGIN_B, from_path: '/shared', to_location: '/b', status: 302 }, + ]) + }) + + it('turns repository validation errors into ": " replies (AC-7)', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + + const badFrom = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: 'old', to: '/new', status: 301 }]) + expect(badFrom.ok).toBe(false) + expect(badFrom.error).toStartWith('fromPath: ') + + const missingTo = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/old', status: 301 }]) + expect(missingTo.ok).toBe(false) + expect(missingTo.error).toStartWith('toLocation: ') + + const badReplace = await callHost(PLUGIN_A, 'cms.redirects.replaceAll', [[ + { from: '/ok', to: '/fine', status: 301 }, + { from: 'nope', to: '/x', status: 301 }, + ]]) + expect(badReplace.ok).toBe(false) + expect(badReplace.error).toStartWith('fromPath: ') + expect(await storedRows()).toEqual([]) + }) + + it('surfaces the per-plugin cap as a "limit:" error (AC-12)', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + await replacePluginRedirects( + testDb.db, + PLUGIN_A, + Array.from({ length: PLUGIN_REDIRECTS_MAX_PER_PLUGIN }, (_, i) => ({ fromPath: '/r' + i, toLocation: '/t', status: 301 })), + ) + const reply = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/one-more', to: '/t', status: 301 }]) + expect(reply.ok).toBe(false) + expect(reply.error).toStartWith('limit: ') + expect(await storedRows()).toHaveLength(PLUGIN_REDIRECTS_MAX_PER_PLUGIN) + }) + it('rejects oversized input with the repository field name, through the real parser (AC-7, AC-12)', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + const long = '/' + 'a'.repeat(2048) + + const tooMany = await callHost( + PLUGIN_A, + 'cms.redirects.replaceAll', + [Array.from({ length: PLUGIN_REDIRECTS_MAX_PER_PLUGIN + 1 }, (_, i) => ({ from: '/r' + i, to: '/t', status: 301 }))], + ) + expect(tooMany.ok).toBe(false) + expect(tooMany.error).toStartWith('limit:') + + const longFrom = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: long, to: '/new', status: 301 }]) + expect(longFrom.ok).toBe(false) + expect(longFrom.error).toStartWith('fromPath:') + + const longTo = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/old', to: long, status: 301 }]) + expect(longTo.ok).toBe(false) + expect(longTo.error).toStartWith('toLocation:') + + const longInReplace = await callHost(PLUGIN_A, 'cms.redirects.replaceAll', [[{ from: long, to: '/new', status: 301 }]]) + expect(longInReplace.error).toStartWith('fromPath:') + + // Nothing was stored by any of the rejected calls. + expect(await storedRows()).toEqual([]) + }) + + it('still rejects past the safety ceiling at the wire (> 8192 chars, > 10000 items)', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + const huge = '/' + 'a'.repeat(8192) + + const hugeFrom = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: huge, to: '/new', status: 301 }]) + expect(hugeFrom).toMatchObject({ ok: false, wire: true }) + expect(hugeFrom.error).toStartWith('Invalid api-call payload for cms.redirects.set:') + + const hugeDelete = await callHost(PLUGIN_A, 'cms.redirects.delete', [huge]) + expect(hugeDelete).toMatchObject({ ok: false, wire: true }) + + const tooManyItems = await callHost( + PLUGIN_A, + 'cms.redirects.replaceAll', + [Array.from({ length: 10001 }, () => ({ from: '/x', to: '/y', status: 301 }))], + ) + expect(tooManyItems).toMatchObject({ ok: false, wire: true }) + + const stringStatus = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/old', to: '/new', status: '301' }]) + expect(stringStatus).toMatchObject({ ok: false, wire: true }) + + expect(queries).toEqual([]) + }) + + it("delete('') resolves false instead of rejecting", async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + const reply = await callHost(PLUGIN_A, 'cms.redirects.delete', ['']) + expect(reply).toMatchObject({ ok: true, value: false }) + // The repository rejects '' as a path before it builds any SQL. + expect(queries).toEqual([]) + }) + + it('returns [] from an empty list() and { count: 0 } from replaceAll([]), clearing the caller', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + expect(await callHost(PLUGIN_A, 'cms.redirects.list', [])).toMatchObject({ ok: true, value: [] }) + + await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/old', to: '/new', status: 301 }]) + expect(await callHost(PLUGIN_A, 'cms.redirects.replaceAll', [[]])).toMatchObject({ ok: true, value: { count: 0 } }) + expect(await callHost(PLUGIN_A, 'cms.redirects.list', [])).toMatchObject({ ok: true, value: [] }) + }) + + it('names toLocation when `to` is missing or null for a 301, and when 410 carries a `to`', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + + const missing = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/old', status: 301 }]) + expect(missing.ok).toBe(false) + expect(missing.error).toStartWith('toLocation:') + + const explicitNull = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/old', to: null, status: 301 }]) + expect(explicitNull.ok).toBe(false) + expect(explicitNull.error).toStartWith('toLocation:') + + // The repository rejects both before any write: nothing is stored. + expect(await storedRows()).toEqual([]) + + const goneWithTo = await callHost(PLUGIN_A, 'cms.redirects.set', [{ from: '/gone', to: '/x', status: 410 }]) + expect(goneWithTo.ok).toBe(false) + expect(goneWithTo.error).toStartWith('toLocation:') + expect(await storedRows()).toEqual([]) + }) + + it('replies a non-validation repository failure as a plain message with no stack', async () => { + installHostPlugin(PLUGIN_A, ['redirects.manage']) + for (const [target, args] of [ + ['cms.redirects.list', []], + ['cms.redirects.set', [{ from: '/old', to: '/new', status: 301 }]], + ['cms.redirects.delete', ['/old']], + ['cms.redirects.replaceAll', [[]]], + ] as const) { + failNext = new Error('database is locked') + const reply = await callHost(PLUGIN_A, target, [...args]) + // The failure came from a real query, not from validation. + expect(failNext).toBeNull() + expect(reply).toMatchObject({ ok: false, error: 'database is locked' }) + expect(reply.error).not.toContain('\n') + expect(reply.error).not.toMatch(/\bat\s.+:\d+/) + } + }) +}) diff --git a/src/__tests__/server/pluginRedirectsEndToEnd.test.ts b/src/__tests__/server/pluginRedirectsEndToEnd.test.ts new file mode 100644 index 000000000..8bf8c10fa --- /dev/null +++ b/src/__tests__/server/pluginRedirectsEndToEnd.test.ts @@ -0,0 +1,210 @@ +/** + * `api.cms.redirects` end to end — real plugin package, real sandbox, real + * public dispatcher. + * + * Each plugin is a real `.zip` installed through the cms install endpoint, + * so its server module runs in the real plugin worker + QuickJS sandbox and + * its `api.cms.redirects.*` calls go through the real host dispatcher into + * the real repository on a `createTestDb()` database. The plugin exposes its + * calls as public runtime routes, so the test drives them over HTTP and reads + * the sandbox's results back as JSON. Public GETs go through + * `handleServerRequest`, the same dispatcher `Bun.serve` uses. + * + * Covers AC-1 (a stored rule redirects), AC-3 (a published page wins), + * AC-9 (no grant = sandbox rejection, no row), AC-11 (uninstall removes the + * plugin's rules). + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { strToU8, zipSync } from 'fflate' +import { createCapabilityTestHarness, type CapabilityTestHarness } from '../helpers/capabilityHarness' +import { handleServerRequest } from '../../../server/router' +import type { ServerRuntime } from '../../../server/serverRuntime' +import { bumpPublishVersion } from '../../../server/publish/publishState' +import { persistSitePublish } from '../../../server/repositories/publish' +import { createDataRow } from '../../../server/repositories/data' +import { MAIN_SCOPE } from '../../../server/branches/scope' +import { makePage, makeSite } from '../publisher/helpers' + +const GRANTED_ID = 'acme.redirects' +const UNGRANTED_ID = 'acme.noredirects' + +/** + * Plugin server module. `/set?from=…&to=…&status=…` calls + * `api.cms.redirects.set` inside the sandbox; `/list` calls `list()`. A + * sandbox rejection comes back as `{ error }` so the test can read it. + */ +const SERVER_ENTRYPOINT = ` + export function activate(api) { + api.cms.routes.public.get('/set', async ({ req }) => { + const q = new URL(req.url).searchParams + try { + const rule = await api.cms.redirects.set({ + from: q.get('from'), + to: q.get('to'), + status: Number(q.get('status')), + }) + return { rule } + } catch (err) { + return { error: String(err && err.message ? err.message : err) } + } + }) + api.cms.routes.public.get('/list', async () => ({ rules: await api.cms.redirects.list() })) + } +` + +function pluginZip(id: string, permissions: string[]): File { + const manifest = { + id, + name: id, + version: '1.0.0', + apiVersion: 1, + permissions, + entrypoints: { server: 'server/index.js' }, + resources: [], + adminPages: [], + } + const zipped = zipSync({ + 'plugin.json': strToU8(JSON.stringify(manifest)), + 'server/index.js': strToU8(SERVER_ENTRYPOINT), + }) + return new File([zipped], 'plugin.zip', { type: 'application/zip' }) +} + +interface StoredRule { from: string; to: string | null; status: number } + +describe('api.cms.redirects end to end', () => { + let harness: CapabilityTestHarness + let uploadsDir: string + let runtime: ServerRuntime + let cookie: string + const installed = new Set() + + beforeEach(async () => { + uploadsDir = await mkdtemp(join(tmpdir(), 'instatic-redirects-e2e-')) + harness = await createCapabilityTestHarness({ uploadsDir }) + runtime = { db: harness.db, uploadsDir } + cookie = await harness.setupOwner() + bumpPublishVersion() + }) + + afterEach(async () => { + // Stop any worker a failed test left running (worker state is process-global). + for (const id of installed) { + await harness.cms(`/admin/api/cms/plugins/${id}?force=true`, { method: 'DELETE', cookie }) + } + installed.clear() + await harness.cleanup() + await rm(uploadsDir, { recursive: true, force: true }) + }) + + async function install(id: string, permissions: string[]): Promise { + const form = new FormData() + form.set('file', pluginZip(id, permissions)) + form.set('grantedPermissions', JSON.stringify(permissions)) + const res = await harness.cms('/admin/api/cms/plugins/package', { method: 'POST', body: form, cookie }) + expect(res.status).toBe(201) + installed.add(id) + } + + async function uninstall(id: string): Promise { + const res = await harness.cms(`/admin/api/cms/plugins/${id}`, { method: 'DELETE', cookie }) + expect(res.status).toBe(200) + installed.delete(id) + } + + /** Calls one of the plugin's public runtime routes and returns its JSON. */ + async function pluginRoute(id: string, route: string): Promise { + const res = await harness.cms(`/admin/api/cms/plugins/${id}/runtime${route}`) + expect(res.status).toBe(200) + return (await res.json()) as T + } + + function get(path: string): Promise { + return handleServerRequest(new Request(`http://localhost${path}`), runtime) + } + + async function storedRows(): Promise> { + const { rows } = await harness.db<{ plugin_id: string; from_path: string }>` + select plugin_id, from_path from plugin_redirects order by plugin_id asc, from_path asc + ` + return rows + } + + async function publishPage(slug: string, text: string): Promise { + const page = makePage({ + root: { moduleId: 'base.body', children: ['text'] }, + text: { moduleId: 'base.text', props: { text, tag: 'h1' } }, + }) + page.id = slug + page.slug = slug + await createDataRow(harness.db, MAIN_SCOPE, { + id: page.id, tableId: 'pages', slug: page.slug, + cells: { title: page.title, body: { nodes: page.nodes, rootNodeId: page.rootNodeId } }, + }) + const { rows } = await harness.db<{ id: string }>`select id from users limit 1` + await persistSitePublish(harness.db, { + siteSnapshotId: crypto.randomUUID(), site: makeSite({ pages: [page] }), serializedImportmap: null, + publishedByUserId: rows[0]!.id, + pages: [{ + pageId: page.id, title: page.title, slug: page.slug, versionId: crypto.randomUUID(), + versionNumber: 1, runtimeAssets: null, runtimeFiles: [], + }], + }) + bumpPublishVersion() + } + + const PERMS = ['cms.routes', 'cms.routes.public'] + + it('a granted plugin sets a rule from the sandbox, the site redirects, and uninstall removes it', async () => { + await install(GRANTED_ID, [...PERMS, 'redirects.manage']) + + // Red first: no rule yet, so /old is the site 404. + expect((await get('/old')).status).toBe(404) + + const set = await pluginRoute<{ rule?: StoredRule; error?: string }>(GRANTED_ID, '/set?from=/old&to=/new&status=301') + expect(set.error).toBeUndefined() + expect(set.rule).toMatchObject({ from: '/old', to: '/new', status: 301 }) + + const redirected = await get('/old') + expect(redirected.status).toBe(301) + expect(redirected.headers.get('location')).toBe('/new') + + const list = await pluginRoute<{ rules: StoredRule[] }>(GRANTED_ID, '/list') + expect(list.rules).toHaveLength(1) + expect(list.rules[0]).toMatchObject({ from: '/old', to: '/new', status: 301 }) + + await uninstall(GRANTED_ID) + expect(await storedRows()).toEqual([]) + const after = await get('/old') + expect(after.status).toBe(404) + expect(after.headers.get('location')).toBeNull() + }) + + it('a plugin without redirects.manage is rejected inside the sandbox and writes no row', async () => { + await install(UNGRANTED_ID, PERMS) + + const set = await pluginRoute<{ rule?: StoredRule; error?: string }>(UNGRANTED_ID, '/set?from=/old&to=/new&status=301') + expect(set.rule).toBeUndefined() + expect(set.error).toMatch(/requires permission "redirects\.manage"/) + expect(await storedRows()).toEqual([]) + expect((await get('/old')).status).toBe(404) + }) + + it('a published page at the same path wins over the plugin rule', async () => { + await install(GRANTED_ID, [...PERMS, 'redirects.manage']) + const set = await pluginRoute<{ rule?: StoredRule; error?: string }>(GRANTED_ID, '/set?from=/about&to=/elsewhere&status=302') + expect(set.error).toBeUndefined() + + // Red first: the rule applies while no page exists at /about. + expect((await get('/about')).status).toBe(302) + + await publishPage('about', 'Published about') + const page = await get('/about') + expect(page.status).toBe(200) + expect(page.headers.get('location')).toBeNull() + expect(await page.text()).toContain('Published about') + }) +}) diff --git a/src/core/plugin-sdk/builders/permissions.ts b/src/core/plugin-sdk/builders/permissions.ts index 149780bcb..6bdee9e9d 100644 --- a/src/core/plugin-sdk/builders/permissions.ts +++ b/src/core/plugin-sdk/builders/permissions.ts @@ -46,6 +46,7 @@ export const permissions = { mediaStorageAdapter: 'media.storage.adapter', mediaUrlTransform: 'media.url.transform', mediaVariantDelegate: 'media.variant.delegate', + redirectsManage: 'redirects.manage', unstableInternals: 'unstable.internals', } as const satisfies Record diff --git a/src/core/plugin-sdk/capabilities.ts b/src/core/plugin-sdk/capabilities.ts index 86903aee6..8cffbd26d 100644 --- a/src/core/plugin-sdk/capabilities.ts +++ b/src/core/plugin-sdk/capabilities.ts @@ -209,6 +209,13 @@ export const PLUGIN_CAPABILITIES: PluginCapability[] = [ risk: 'high', surfaces: ['server', 'cms'], }, + { + permission: 'redirects.manage', + label: 'Manage site redirects', + description: 'Allows the plugin to answer site URLs that have no page, data row, or published file with a 301/302/307/308 redirect or a 410 Gone. Rules are exact paths owned by the plugin and removed when it is uninstalled. A rule never overrides live content: the host consults plugin redirects only just before the 404 page.', + risk: 'high', + surfaces: ['server', 'cms'], + }, { permission: 'unstable.internals', label: 'Use unstable internal APIs', diff --git a/src/core/plugin-sdk/types/index.ts b/src/core/plugin-sdk/types/index.ts index 8efc6bf43..97004900b 100644 --- a/src/core/plugin-sdk/types/index.ts +++ b/src/core/plugin-sdk/types/index.ts @@ -30,5 +30,6 @@ export * from './loops' export * from './settings' export * from './schedule' export * from './media' +export * from './redirects' export * from './serverApi' export * from './content' diff --git a/src/core/plugin-sdk/types/permissions.ts b/src/core/plugin-sdk/types/permissions.ts index ae9caabe0..8a714889e 100644 --- a/src/core/plugin-sdk/types/permissions.ts +++ b/src/core/plugin-sdk/types/permissions.ts @@ -99,6 +99,12 @@ export const PLUGIN_PERMISSION_VALUES = [ // `hourly`, `every: { minutes }`, …) by the host's scheduler tick. // The handler runs inside the same QuickJS sandbox as everything else. 'cms.schedule', + // Site redirects — answer URLs that have NO page, row, or artefact with a + // 301/302/307/308 redirect or a 410 Gone, via `api.cms.redirects.*`. The + // public router consults plugin rules only just before the 404 page, so a + // rule never overrides live content. Rules are plugin-scoped and removed + // on uninstall. + 'redirects.manage', // Reserved 'unstable.internals', ] as const diff --git a/src/core/plugin-sdk/types/redirects.ts b/src/core/plugin-sdk/types/redirects.ts new file mode 100644 index 000000000..b91eda4c5 --- /dev/null +++ b/src/core/plugin-sdk/types/redirects.ts @@ -0,0 +1,45 @@ +// --------------------------------------------------------------------------- +// Plugin-owned redirects — `api.cms.redirects`, gated by `redirects.manage`. +// +// A plugin keeps its own set of exact-path rules. The public router consults +// them only after every page, data row, disk artefact, and row-rename +// redirect has missed — just before the site's 404 page — so a rule never +// shadows live content. Rules are scoped to the plugin that wrote them and +// are removed when the plugin is uninstalled. +// --------------------------------------------------------------------------- + +export type PluginRedirectStatus = 301 | 302 | 307 | 308 | 410 + +export interface PluginRedirectRuleInput { + /** Exact request path (`/old`). A trailing slash is ignored (`/old/` = `/old`). */ + from: string + /** + * A path (`/new`) or an absolute `http:`/`https:` URL. Required for + * 301/302/307/308; omit or pass `null` for 410. + */ + to?: string | null + status: PluginRedirectStatus +} + +export interface PluginRedirectRule { + /** The stored (normalized) path. */ + from: string + to: string | null + status: PluginRedirectStatus + createdAt: string + updatedAt: string +} + +export interface ServerPluginRedirectsApi { + /** This plugin's rules, ordered by `from`. */ + list(): Promise + /** Insert or update the rule for `rule.from`. */ + set(rule: PluginRedirectRuleInput): Promise + /** `true` when a rule was removed. */ + delete(from: string): Promise + /** + * Atomically replace this plugin's whole rule set (max 5000). Every rule is + * validated first; on any error nothing changes. + */ + replaceAll(rules: PluginRedirectRuleInput[]): Promise<{ count: number }> +} diff --git a/src/core/plugin-sdk/types/serverApi.ts b/src/core/plugin-sdk/types/serverApi.ts index 84f8fc659..3eee93755 100644 --- a/src/core/plugin-sdk/types/serverApi.ts +++ b/src/core/plugin-sdk/types/serverApi.ts @@ -22,6 +22,7 @@ import type { LoopEntitySource } from './loops' import type { ServerPluginMediaApi } from './media' import type { PluginMigrationContext } from './lifecycle' import type { PluginPermission } from './permissions' +import type { ServerPluginRedirectsApi } from './redirects' import type { ServerPluginRouteHandler } from './routes' import type { ServerPluginScheduleApi } from './schedule' import type { ServerPluginSettingsApi } from './settings' @@ -194,6 +195,14 @@ export interface ServerPluginApi { * `network.outbound` and a matching `networkAllowedHosts` entry. */ media: ServerPluginMediaApi + /** + * Plugin-owned exact-path redirects. Requires `redirects.manage`. The + * public router consults these only when nothing else answers the URL — + * immediately before the site's 404 page — so a rule never overrides a + * live page, data row, disk artefact, or row-rename redirect. Each plugin + * sees and changes only its own rules. + */ + redirects: ServerPluginRedirectsApi } }