Skip to content

feat(plugins): host-served plugin redirects (api.cms.redirects) - #581

Draft
Mariomarquezt wants to merge 1 commit into
CoreBunch:mainfrom
Mariomarquezt:mb/2026-09-30-redirects
Draft

Mariomarquezt wants to merge 1 commit into
CoreBunch:mainfrom
Mariomarquezt:mb/2026-09-30-redirects

Conversation

@Mariomarquezt

Copy link
Copy Markdown
Contributor

Closes #383.

Opened as a draft so the direction can be checked before review, as asked on #383. Happy to rename, reshape or split any of it.

Summary

Plugins can now answer URLs that have no content with a redirect served by Instatic itself. Today a plugin cannot take part in routing at all, so an SEO plugin can only export nginx / Caddy / Cloudflare rules for the operator to apply by hand.

  • Permission: a new redirects.manage manifest permission (risk: high). Without it the API fails closed, like the cms.content.* surfaces.
  • API: api.cms.redirects.list / set / delete / replaceAll. Each plugin sees and changes only its own rules.
  • Storage: migration 031_plugin_redirects (SQLite + Postgres), FK to installed_plugins with on delete cascade, so uninstalling a plugin removes its rules.
  • Routing: one new dispatcher step, tryServePluginRedirect, placed immediately before the designed 404 page. A rule never shadows a live page, data row, baked artefact or row-rename redirect. It costs one indexed query per otherwise-unmatched GET/HEAD and nothing for matched routes.
  • Statuses: 301 / 302 / 307 / 308 / 410. 410 reuses the notFound template body. 302/307 are no-store. The request query string is carried over when the target has none.
  • Validation lives in the repository, and errors reach the plugin as "<field>: <message>":
    • from: printable-ASCII absolute path; no //, ?, #; not under /admin, /_instatic or /uploads; at most 2048 characters.
    • to: a same-origin path (no // or /\) or an absolute http(s) URL; no control characters; not equal to from.
    • At most 5000 rules per plugin.
  • Conflicts: when two plugins own the same path, the oldest rule wins, then the lower plugin id.
  • Docs: docs/features/plugin-system.md (API + permission), docs/server.md and docs/features/publisher.md (route order).

Differences from the proposal in #383

These are narrower on purpose, to keep the first version small. Each is easy to extend later.

#383 proposed This PR
exact or pattern from_path exact paths only
301 / 302 / 410 301 / 302 / 307 / 308 / 410
cms.redirects permission redirects.manage permission
list / create / update / delete list / set / delete / replaceAll (set upserts, replaceAll supports sync-style plugins)
consulted before the data-row slug fallback consulted only when nothing else matched, right before the 404 page
— no admin UI; plugins own their rules, and a core UI could follow in a separate PR

Verification

  • bun run build
  • bun test: 7078 pass, 2 fail. Neither failure is in code this PR touches:
    • Step-up auth > successful step-up clears the per-IP limiter… also fails on an untouched main checkout on the same machine.
    • Circular dependencies > keeps the tsconfig-aware source graph cycle-free hits its 15 s timeout on a slow laptop during the full run, and passes when run alone (14.9 s).
  • bun run lint, tsc, and bootstrap:check (generated bootstrap is fresh)
  • Docker/deployment check: not relevant
  • Postgres: the Postgres migration and queries mirror the SQLite ones, but I ran the tests on SQLite only.

New tests:

  • pluginRedirects.test.ts: repository and validation.
  • pluginRedirectRoute.test.ts: dispatcher responses.
  • pluginRedirectsApi.test.ts: sandbox, RPC and host dispatch against a real test DB.
  • pluginRedirectsEndToEnd.test.ts: installs real plugin zips, calls the API from the sandbox, and checks the dispatcher. It covers uninstall, and a published page beating a rule.

Checklist

  • Tests cover behavior changes.
  • Docs were updated when behavior, config, deployment, or public surfaces changed.
  • No compatibility shim was added for old pre-release behavior.
  • No secrets, local databases, uploads, or generated artifacts are included. The only generated file is the committed pluginBootstrap.ts, regenerated with bun run bootstrap:sync.

🤖 Generated with Claude Code

Closes CoreBunch#383.

Plugins can now answer URLs that have no content with a redirect served by
the host. A plugin with the new `redirects.manage` permission manages its
own exact-path rules through `api.cms.redirects` (list / set / delete /
replaceAll); the public dispatcher consults them in one new step placed
immediately before the designed 404 page, so a rule never shadows a live
page, data row, baked artefact or row-rename redirect.

- Migration 031 `plugin_redirects` (SQLite + Postgres), FK to
  installed_plugins with on delete cascade: uninstalling a plugin removes
  its rules.
- Statuses 301 / 302 / 307 / 308 / 410. 410 reuses the notFound template
  body; 302/307 are no-store; the request query is carried over when the
  target has none.
- Validation lives in the repository: from = printable-ASCII absolute
  path (no //, ?, #, reserved /admin, /_instatic, /uploads, <= 2048);
  to = same-origin path (no //, /\) or absolute http(s) URL, no control
  characters, != from; 5000 rules per plugin. Errors reach the plugin as
  "<field>: <message>". The RPC schemas are only a safety ceiling.
- Two plugins owning the same path: the oldest rule wins, then plugin id.
- One indexed query per otherwise-unmatched GET/HEAD; none for matched
  routes.
- Docs: plugin-system.md (API + permission), server.md and publisher.md
  (route order).

Tests: repository, route, sandbox/RPC/host dispatch, and an end-to-end
test that installs real plugin zips, calls the API from the sandbox and
checks the dispatcher responses (including uninstall and a published
page winning over a rule).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin API for arbitrary redirects (cms.redirects) — 301/302/410 + regex

1 participant