Skip to content

feat(plugins): add sandboxed redirect access - #3184

Merged
ascorbic merged 11 commits into
mainfrom
feat/sandbox-redirects
Sep 19, 2026
Merged

ascorbic merged 11 commits into
mainfrom
feat/sandbox-redirects

Conversation

@ascorbic

@ascorbic ascorbic commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Adds capability-gated, versioned redirect CRUD for sandboxed plugins across native, Worker Loader, and Node/workerd runtimes. It reuses host validation and cache invalidation, adds serialized database guards, and extends the runtime-backed test host. Related foundation: #3162.

Merge order: #3182 is merged as migration 080; this PR uses 081. #3169 follows with 082.

Type of change

  • Feature

This is a maintainer-directed capability slice following #3162; there is no separate Discussion URL.

Checklist

  • I have read CONTRIBUTING.md
  • Typecheck passes
  • Lint passes
  • Tests pass
  • Formatting run
  • Tests added or updated
  • Admin consent copy uses Lingui
  • User-facing changeset added and reviewed
  • Approved Discussion link (not applicable; maintainer-directed plan)
  • Screenshots (not applicable; no component or layout change)

AI-generated code disclosure

  • AI-generated code — model/tool: gpt-5.6-sol / Codex

Screenshots / test output

Not applicable; no interface component or layout change.

Validated at 83ab759b3: full build, format check, typecheck, type-aware lint, 144 focused SQLite/PostgreSQL migration and redirect tests, 13 D1/workerd migration tests, both sandbox bridges and the combined Stack A authoring/manifest/test-host consumers, Worker startup closure (18 modules / 1,728,969 bytes), changeset validation, git diff --check, and independent adversarial conflict review.

@changeset-bot

changeset-bot Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 83ab759

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
@emdash-cms/plugin-types Minor
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Minor
@emdash-cms/plugin-test Minor
@emdash-cms/plugin-cli Minor
@emdash-cms/registry-lexicons Minor
@emdash-cms/admin Minor
@emdash-cms/registry-verification Patch
@emdash-cms/registry-client Patch
@emdash-cms/registry-loader Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 2,918 lines across 70 files. Large PRs are harder to review and more likely to be closed without review.
This PR spans 4 different areas (area/core, area/admin, area/docs, area/cloudflare). Consider breaking it into smaller, focused PRs.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@3184

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@3184

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@3184

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@3184

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@3184

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@3184

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@3184

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@3184

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@3184

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@3184

@emdash-cms/plugin-test

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-test@3184

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@3184

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@3184

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@3184

@emdash-cms/registry-loader

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-loader@3184

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-moderation@3184

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@3184

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@3184

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@3184

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@3184

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@3184

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@3184

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@3184

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@3184

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@3184

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@3184

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@3184

commit: 83ab759

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Sep 17, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://feat-sandbox-redirects.try.emdashcms.com, https://feat-sandbox-redirects-emdash-playground.emdash-cms.workers.dev (commit 83ab759)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://9c06e7a3.try.emdashcms.com, https://9c06e7a3-emdash-playground.emdash-cms.workers.dev 83ab759 2026-09-19T13:42:10.217Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://1e3dc1ab.try.emdashcms.com, https://1e3dc1ab-emdash-playground.emdash-cms.workers.dev 48e5292 2026-09-19T08:16:00.204Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://73733345.try.emdashcms.com, https://73733345-emdash-playground.emdash-cms.workers.dev 0c96a43 2026-09-19T07:10:48.381Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://1b952df0.try.emdashcms.com, https://1b952df0-emdash-playground.emdash-cms.workers.dev d938016 2026-09-17T18:18:01.736Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://bf95c1c8.try.emdashcms.com, https://bf95c1c8-emdash-playground.emdash-cms.workers.dev c9a023d 2026-09-17T17:41:02.017Z Visit the dashboard ↗

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs 83ab759 Sep 19 2026, 01:41 PM

@github-actions

Copy link
Copy Markdown
Contributor

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 17, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 17, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 17, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 17, 2026
@ascorbic

Copy link
Copy Markdown
Member Author

Review of d93801621. Two blockers; everything else I checked came back clean.

1. The new loop trigger aborts legitimate slug renames, rolling back the whole content save

createAutoRedirect collapses chains before deleting the rules whose source is the new URL:

await repository.collapseChains(oldUrl, newUrl, fence);
// The new URL serves live content again — any redirect from it would shadow the page.
... .deleteFrom("_emdash_redirects").where("source", "=", newUrl)

packages/core/src/database/repositories/redirect.ts:539

emdash_redirect_loop_update (migration 080_redirect_write_guards.ts:221, and the Postgres branch at :99-115) evaluates the intermediate state and rejects it.

Repro against SQLite 3.51 with the trigger body copied verbatim from the migration:

INSERT INTO _emdash_redirects VALUES ('r1','/blog/b','/promo',1);
INSERT INTO _emdash_redirects VALUES ('r2','/promo','/blog/a',1);
-- editor renames a post's slug a -> b; step 1 of createAutoRedirect:
UPDATE _emdash_redirects SET destination='/blog/b' WHERE destination='/blog/a' AND enabled=1;
Runtime error near line 25: redirect loop (19)

Two ordinary manual redirects that coexist fine today (/blog/b → /promo, /promo → /blog/a) make the rename impossible. The transient loop involves r1 — the row the very next statement deletes.

This is not confined to the redirects admin. createSlugChangeRedirect runs inside the content transaction (api/handlers/content.ts:1108, and the publish path at :1694, both passing trx) and has no try/catch, so the RAISE(ABORT) propagates out and rolls back the entire update. The editor gets CONTENT_UPDATE_ERROR, and the rename stays impossible until someone deletes the redirect by hand.

Fix: run the deleteFrom(_emdash_redirects).where("source","=",newUrl) statement before collapseChains(oldUrl, newUrl, fence). I verified the end state is identical (those rows are deleted either way) and the transient loop never forms:

r2|/promo|/blog/b

2. Migration 080 fails closed against previous-version code, breaking content saves mid-deploy

WHEN NEW.write_generation = 0 AND (
  NEW.is_pattern = 1 OR EXISTS (SELECT 1 FROM _emdash_redirects WHERE enabled = 1 AND is_pattern = 1))
BEGIN SELECT RAISE(ABORT, 'pattern redirect writes require the current runtime'); END

080_redirect_write_guards.ts:177-198, Postgres equivalent at :93-99

write_generation is notNull().defaultTo(0), so every write from the previously released binary aborts once the site has one enabled pattern rule.

The code comment frames this as protecting pattern writes, but the EXISTS branch catches non-pattern writes too — including the auto-redirect inside the content-update transaction. So on a site with any enabled pattern redirect, slug renames and publishes 500 on every still-running old instance (in-flight Workers isolates after a deploy, rolling Node replicas), and permanently after a code rollback, since migrations are forward-only and down() is a no-op at :258.

That's the case AGENTS.md § Migrations rules out: "old application code must tolerate the expanded schema during a rolling deploy". The migration test asserts the abort as intended behaviour and the changeset doesn't mention it.

Fix: drop emdash_redirect_legacy_pattern_insert / emdash_redirect_legacy_pattern_update and the write_generation = 0 branch in emdash_redirect_validate_write. Unfenced writes already lose the race safely, so old binaries are no worse off than they are today. If the guard stays, it must at minimum be unreachable from the content-save path and called out in the changeset as an upgrade-ordering requirement.


Checked and clean: source/destination both go through urlPath (leading /, rejects //, CRLF, traversal) on the plugin path too, and the middleware re-checks // and /\ and skips /_emdash* — no open redirect, no admin/API hijack, no javascript: or protocol-relative escape. Status codes are constrained in zod, the workerd bridge validator, and RedirectStatus. No new queries on the logged-out path. Both bridges gate every op on redirects:read/redirects:write, auto is rejected via assertNoAutomaticRedirectMarker, _rev is enforced atomically in the WHERE, findMany clamps to 100 and returns {items, nextCursor}, all SQL is parameterised, and the changeset covers all eight packages.

~ 🤖 Codex

@ascorbic

Copy link
Copy Markdown
Member Author

Cross-PR blocker: three open PRs all add migration 080

origin/main is at 079_datetime_normalization, and three open PRs each add a different migration numbered 080:

PR migration file sorts
#3182 080_content_translation_locale_unique.ts 1st
#3184 080_redirect_write_guards.ts 2nd
#3169 080_taxonomy_translation_locale_unique.ts 3rd

Individually each is fine. Together they can hard-break upgrades, because the runner builds a plain new Migrator({ provider: new StaticMigrationProvider() }) (packages/core/src/database/migrations/runner.ts:227-229) with no allowUnorderedMigrations, so Kysely enforces strict alphabetical ordering:

throw new Error(`corrupted migrations: expected previously executed migration ${executedMigrations[i]} to be at index ${i} but ${migrations[i].name} was found in its place. New migrations must always have a name that comes alphabetically after the last executed migration.`);

kysely@0.29.2/dist/migration/migrator.js:503, #ensureMigrationsInOrder

Failure scenario: #3184 merges and ships, and a site upgrades and runs 080_redirect_write_guards. #3182 then merges and ships 080_content_translation_locale_unique, which sorts before the already-executed 080. On the next upgrade migrateToLatest() throws — and because migrations run from runtime init, that site can't boot into a migrated state at all. Recovery means hand-editing the migration table on production.

This only bites when the 080s land in different releases. If they all ship in one release a fresh run executes them in sorted order and nothing notices, which is exactly why this will slip through CI — each PR is green on its own, and main never sees the conflict until the second one merges.

Fix: keep 080 for whichever merges first and renumber the others to 081/082 (file name, the static import and the getMigrations() key in runner.ts) before they merge. If the intent is instead to allow out-of-order migrations, that's a deliberate migrator-level decision and needs its own change, not three coincidental 080s.

For this PR: 080_redirect_write_guards sorts after #3182's migration. If #3182 ships first, this one is fine as-is; if this one ships first, #3182 must renumber. Coordinate before merging either.

This is separate from, and in addition to, the two blockers in my earlier comment.

~ 🤖 Codex

@ascorbic ascorbic mentioned this pull request Sep 19, 2026
10 of 19 tasks
@ascorbic

Copy link
Copy Markdown
Member Author

Re-reviewed at 0c96a431b. Blocker 1 and the migration renumber are genuinely fixed. Blocker 2 is fixed for the case I reported, but the same rolling-deploy rule is still violated by a different path — so this isn't quite done.

Fixed

Slug-rename abort. The delete now precedes collapseChains in createAutoRedirect (redirect.ts:538-555), and it's a real reorder rather than a relaxed trigger — emdash_redirect_loop_update/_insert are byte-identical to the previous head, and no try/catch was added around the call. My original repro now completes, leaving /blog/a → /blog/b and /promo → /blog/b. The fence EXISTS guard survives on the delete, idx_redirects_managed_source isn't newly exposed (the delete targets newUrl, the insert uses oldUrl), and the revert case A → B → A leaves no self-redirect.

Pattern guards. emdash_redirect_legacy_pattern_insert/_update and the write_generation = 0 branch of the plpgsql emdash_redirect_validate_write are gone — git grep 'legacy_pattern\|pattern redirect writes require' has no hits. An old binary's unfenced insert/update now succeeds.

Migration numbering. 081_redirect_write_guards.ts, with the static import and getMigrations() key updated and no leftover 080 anywhere on the branch. That matches the agreed split (#3182 = 080, this = 081, #3169 = 082 — both of the others have landed their side).

Still open: the loop trigger aborts the previous release's rename sequence

Blocker 2 was about old binaries failing against the new schema. Removing the pattern guards fixed the path I named, but the loop trigger reaches the same outcome, because the reorder that fixes blocker 1 only exists in the new code.

origin/main still does collapse-then-delete:

// Collapse chains: update any existing redirects pointing to the old URL
await this.collapseChains(oldUrl, newUrl);
...
await this.db.deleteFrom("_emdash_redirects").where("source", "=", newUrl).execute();

origin/main:packages/core/src/database/repositories/redirect.ts:385-390

That is exactly the order emdash_redirect_loop_update rejects, and an old binary has no write_generation to opt out of the loop triggers the way it can now opt out of the fence triggers. Same two-redirect seed as before, replayed with main's statement order against the trigger text as 081 creates it:

--- OLD BINARY (main's order) against migration 081 triggers ---
Runtime error near line 22: redirect loop (19)

createSlugChangeRedirect is un-caught inside the content transaction on main too, so a still-running old isolate 500s and rolls back the content save for that rename shape — during the rolling window, and permanently after a rollback, since down() is still a no-op. Postgres behaves the same: the RAISE EXCEPTION 'redirect loop' fires on any destination update regardless of write_generation.

The fence triggers just gained a generation-aware opt-out; the loop triggers need the equivalent, or they need to not fire on a write that an old binary can produce.

Changeset

.changeset/bright-rivers-redirect.md now says "The expanded redirect schema remains compatible with writes from previous host processes during rolling deployments." That's true for plain inserts and updates, but overstated while the above holds. Worth not shipping the unqualified claim until it does.

For completeness, I checked the fix commit for new regressions and found none beyond this: the NEW.write_generation <> OLD.write_generation fence relaxation doesn't open a hole (every current write still carries the application-level fence predicate and withLease() bumps generation), and the new emdash_redirect_revision_update AFTER trigger doesn't re-enter the fence/loop/guard triggers or disturb recordHit().

~ 🤖 Codex (claude-opus-5)

# Conflicts:
#	packages/cloudflare/src/sandbox/bridge.ts
# Conflicts:
#	apps/release-action/dist/index.js
#	docs/src/content/docs/plugins/creating-plugins/capabilities.mdx
#	docs/src/content/docs/plugins/creating-plugins/testing.mdx
#	docs/src/content/docs/plugins/installing.mdx
#	packages/cloudflare/src/sandbox/bridge-runtime.ts
#	packages/cloudflare/src/sandbox/bridge.ts
#	packages/cloudflare/src/sandbox/wrapper.ts
#	packages/core/src/database/migrations/runner.ts
#	packages/core/src/index.ts
#	packages/core/src/plugin-test-runtime.ts
#	packages/core/src/plugins/context.ts
#	packages/core/src/plugins/host.ts
#	packages/core/src/plugins/index.ts
#	packages/core/tests/integration/plugins/capabilities.test.ts
#	packages/plugin-cli/src/init/templates.ts
#	packages/plugin-test/src/runtime-host.ts
#	packages/plugin-test/test/fixture/emdash-plugin.jsonc
#	packages/plugin-test/test/fixture/src/plugin.ts
#	packages/plugin-types/tests/capabilities.test.ts
#	packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts
#	packages/workerd/src/sandbox/bridge-handler.ts
#	packages/workerd/src/sandbox/wrapper.ts
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 19, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 19, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 19, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds capability-gated, versioned redirect CRUD for sandboxed plugins across native, Cloudflare Worker Loader, and Node/workerd runtimes. It threads the new redirects:read/redirects:write capabilities through the manifest schema, registry lexicon, plugin context, all three sandbox bridges, the plugin test host, and the CLI bundle path, plus a migration that adds serialization and version columns.

The approach is sound and fits EmDash's architecture: it reuses the existing redirect handlers/validation, adds per-dialect serialized write guards, surfaces an opaque _rev for optimistic concurrency, and keeps capability enforcement at the bridge so all runtimes share the same policy. The changeset, docs, and tests are comprehensive.

I reviewed the implementation statically: major files (migration 081, redirect repository/handler, plugin context, both bridges, wrapper generators, manifest/lexicon changes, test fixture) and traced cross-cutting concerns (capability implication, revision round-trip, bridge parity, RBAC permission existence, index discipline, SQL safety, logged-out query impact). I found no logic bug, regression, authorization gap, or AGENTS.md convention violation. The code is clean enough to sign off.

One minor suggestion for clarity is noted below.

Comment thread packages/core/src/plugins/context.ts
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 19, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-review No maintainer or bot review yet labels Sep 19, 2026
@ascorbic
ascorbic merged commit 46784e1 into main Sep 19, 2026
62 checks passed
@ascorbic
ascorbic deleted the feat/sandbox-redirects branch September 19, 2026 14:28
@ascorbic ascorbic mentioned this pull request Sep 19, 2026
12 of 19 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant