Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -891,6 +892,47 @@ api.cms.media.registerVariantDelegate({

Storage adapter and variant delegate ids must be namespaced under the plugin id (`<pluginId>.<rest>`). 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: <to>`; 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 `<field>: <message>`, 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.<method>: …` 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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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, `<field>: <message>` 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
Expand Down Expand Up @@ -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:<id>` filename
Expand Down
3 changes: 2 additions & 1 deletion docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions server/db/migrations-pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
`,
},
]
17 changes: 17 additions & 0 deletions server/db/migrations-sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
`,
},
]
10 changes: 10 additions & 0 deletions server/plugins/host/apiDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
Expand Down
100 changes: 100 additions & 0 deletions server/plugins/host/handlers/redirects.ts
Original file line number Diff line number Diff line change
@@ -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 `<field>: <message>` 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
* `<field>: <message>` replies; anything else bubbles to the dispatcher's
* generic error reply.
*/
async function replyWith(
msg: { pluginId: string; correlationId: string },
run: () => Promise<unknown>,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const [rules] = msg.args
await replyWith(msg, () => replacePluginRedirects(db, msg.pluginId, rules.map(toRepositoryInput)))
}
10 changes: 10 additions & 0 deletions server/plugins/protocol/apiCallSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, TSchema>
Expand Down
45 changes: 45 additions & 0 deletions server/plugins/protocol/schemas/redirects.ts
Original file line number Diff line number Diff line change
@@ -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 }),
])
5 changes: 5 additions & 0 deletions server/plugins/protocol/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<AllowedApiTarget, PluginPermission>>

Large diffs are not rendered by default.

Loading
Loading