feat(core): extend plugin runtime capabilities#2018
Conversation
🦋 Changeset detectedLatest commit: ae8d4ed The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
PR template validation failedPlease fix the following issues by editing your PR description:
See CONTRIBUTING.md for the full contribution policy. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Provide database-backed cron access without an in-process scheduler, expose scheduled publication timestamps through content access, and authenticate private plugin routes with external providers.
16ebdd3 to
db41fe6
Compare
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
There was a problem hiding this comment.
This PR adds three useful Cloudflare/plugin-runtime capabilities: DB-backed cron when no in-process scheduler exists, scheduledAt in read-only plugin content access, and external-auth support for private plugin API routes. The approach is additive and fits EmDash’s architecture.
The code is generally careful — tests cover the new schedulerless cron context, scheduledAt reads, and external-auth private/public route behavior. I did not find SQL-safety, locale-filtering, or envelope-shape regressions.
Two things need addressing before merge:
-
createContentAccessWithWritereturnsContentItemwithout populating the newscheduledAtfield, while the read-onlycreateContentAccessdoes. That makes the write API inconsistent with the type and with the read API. -
The JSDoc for
handlePluginRouteAuthstill claims it "never block[s] unauthenticated requests," but the new external-auth branch hard-blocks private plugin routes in that mode. The comment is now misleading.
Process note: AGENTS.md requires features to link to a maintainer-approved Discussion. The PR checklist leaves that item unchecked with only an "internal gchat" note; this should be resolved or explicitly waived by a maintainer before merging.
Findings
-
[needs fixing]
packages/core/src/plugins/context.ts:402The
createmethod ofcreateContentAccessWithWritebuilds aContentItembut omitsscheduledAt, even though the repository row (item.scheduledAt) is available and the read-onlycreateContentAccesspath maps it. This makescontent.create()inconsistent with the updatedContentItemtype and withcontent.get()/content.list().publishedAt: item.publishedAt, scheduledAt: item.scheduledAt, }; -
[needs fixing]
packages/core/src/plugins/context.ts:459Same as the
createcase: theupdatemethod returns aContentItemwithoutscheduledAt. Add it so write and read paths agree on the shape.publishedAt: item.publishedAt, scheduledAt: item.scheduledAt, }; -
[suggestion]
packages/core/src/astro/middleware/auth.ts:382This JSDoc is no longer accurate: it says plugin-route auth "never block[s] unauthenticated requests," but the new external-auth branch below returns
handleExternalAuth(...)for private plugin routes, which returns 401 on any authentication failure. Update the comment to describe the current behavior (soft auth for public routes / Bearer/session, hard auth for private routes under external auth).* Plugin-route auth: resolve the user from Bearer token, external provider, or * session if present. Public routes are always allowed through; private routes * are blocked when credentials are invalid or absent. The catch-all handler enforces * the `plugins:manage` permission and CSRF for private invocations.
There was a problem hiding this comment.
Pull request overview
This PR extends the core plugin runtime to better support Cloudflare/serverless-hosted plugins by enabling schedulerless cron contexts, exposing scheduled publication timestamps to read-only plugin content access, and ensuring private plugin API routes can authenticate via external auth providers (e.g. Cloudflare Access).
Changes:
- Wire
cronRescheduleinto rebuilt hook pipelines and plugin route execution soctx.croncan exist even without an in-process scheduler. - Add
scheduledAtto the plugin-facingContentItemshape and populate it from the content repository. - Add external-auth support for private plugin API routes in the Astro auth middleware, with unit/integration regression coverage and a changeset.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
packages/core/src/emdash-runtime.ts |
Ensures cron reschedule context wiring survives hook-pipeline rebuilds and is passed into plugin route registries. |
packages/core/src/astro/middleware/auth.ts |
Adds external-auth handling for private plugin API routes while preserving public-route behavior. |
packages/core/src/plugins/types.ts |
Extends plugin ContentItem type with scheduledAt. |
packages/core/src/plugins/context.ts |
Threads scheduledAt through plugin read-only content access (get/list). |
packages/core/tests/unit/astro/plugin-route-external-auth.test.ts |
Adds unit tests covering external auth behavior for public vs private plugin routes and opaque failures. |
packages/core/tests/integration/runtime/plugin-cron-route.test.ts |
Adds integration coverage for cron availability in plugin routes/hooks when no in-process scheduler exists. |
packages/core/tests/integration/plugins/capabilities.test.ts |
Adds integration coverage ensuring scheduledAt is surfaced through plugin content capabilities. |
.changeset/plugin-runtime-capabilities.md |
Publishes the additive runtime capability changes via a patch changeset. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Re-review of #2018: the two blocking code issues from the previous pass are fixed.
createContentAccessWithWrite.create()and.update()now populatescheduledAt, so write and read paths return the sameContentItemshape.- The
handlePluginRouteAuthJSDoc has been updated to describe the new external-auth behavior for private plugin routes.
I re-read the full diff and traced the new behavior across call sites:
- The schedulerless
cronRescheduleclosure is registered in both the initial pipeline setup andrebuildHookPipeline(), makingctx.cronavailable to plugins and hook pipelines even whencreateSchedulerisnull(e.g. on Cloudflare). The closure correctly resolves to the in-process scheduler when present and a no-op otherwise. - Private plugin API routes under external auth now correctly invoke
handleExternalAuth(..., true)and return 401 on failure, while public plugin routes and bearer/session paths remain unchanged. scheduledAtis consistently mapped from the existingscheduled_atdatabase column increateContentAccessandcreateContentAccessWithWriteread/write paths.- Tests cover schedulerless cron context,
scheduledAtreads, and external-auth public/private route behavior, including opaque failure responses.
No SQL-safety, locale-filtering, envelope-shape, authorization, or index regressions were found. The changeset present-tense lead (Adds …) follows the AGENTS.md guidance.
One non-code process item remains from the previous review: AGENTS.md/CONTRIBUTING.md require a feature PR to link a maintainer-approved Discussion, and the PR checklist still leaves that item unchecked with only an "internal gchat" note. That should be resolved or explicitly waived by a maintainer before merge, but it does not gate the code review itself.
| * Plugin-route auth: resolve the user from Bearer token, external provider, or | ||
| * session if present. Public routes are always allowed through; private routes | ||
| * are blocked when credentials are invalid or absent. The catch-all handler enforces | ||
| * the `plugins:manage` permission and CSRF for private invocations. |
| beforeAll(async () => { | ||
| vi.stubEnv("DEV", false); | ||
| ({ onRequest } = await import("../../../src/astro/middleware/auth.js")); | ||
| }); | ||
|
|
| * 2. External provider — only for a *private* route in production external-auth | ||
| * mode. Here `handleExternalAuth` is the sole authority (the provider, e.g. | ||
| * Cloudflare Access, gates every request and EmDash mints no session of its | ||
| * own), so it hard-blocks with 401 on failure. Session auth is deliberately | ||
| * NOT a fallback in this case — there is no EmDash session to fall back to. |
Adds an afterAll cleanup (vi.unstubAllEnvs) so the DEV env stub can't leak into other test files sharing the Vitest worker.
External auth does persist an EmDash session (session.set("user", ...)) so
public pages can identify the user; the comment now says that session is
deliberately not consulted as a fallback on private plugin routes, rather than
implying no session exists.
| // Restore env stubs so `import.meta.env.DEV` does not leak into other test | ||
| // files sharing this Vitest worker. |
There was a problem hiding this comment.
Re-review of #2018: the two blocking code issues from the previous pass are resolved. createContentAccessWithWrite.create() and .update() now map scheduledAt, so write and read paths return the same ContentItem shape, and the handlePluginRouteAuth JSDoc accurately describes the new external-auth behavior for private plugin routes.
The implementation is additive and fits EmDash's existing architecture:
- The
cronRescheduleclosure is registered both at runtime startup and inrebuildHookPipeline(), soctx.cronis available to plugins and hook pipelines even whencreateSchedulerisnull(e.g. on Cloudflare). The closure resolves to the in-process scheduler when present and is a no-op otherwise. scheduledAtis consistently mapped from the existingscheduled_atdatabase column increateContentAccessandcreateContentAccessWithWriteread/write paths.- Private plugin API routes under external auth now invoke
handleExternalAuth(..., true)and return a 401 on failure, while public plugin routes and bearer/session paths remain unchanged.
I traced the new behavior across call sites and found no logic, SQL-safety, locale-filtering, envelope-shape, authorization, or index regressions. Test coverage is good for schedulerless cron contexts, scheduledAt reads, and public/private external-auth plugin routes.
Two non-blocking items remain:
- Process: This is a feature PR, but the maintainer-approved Discussion requirement in
CONTRIBUTING.md/AGENTS.mdis still unchecked (only noted as "internal gchat"). That should be resolved or explicitly waived by a maintainer before merge. - Test gap: The fix that adds
scheduledAttocreateContentAccessWithWrite.create()/.update()return values has no regression assertion. Adding one would prevent future shape drift.
Findings
-
[needs fixing]
.changeset/plugin-runtime-capabilities.md:5This changeset describes a new feature, but
CONTRIBUTING.mdandAGENTS.mdrequire feature PRs to link a maintainer-approved Discussion. The PR checklist still leaves that item unchecked with only an "internal gchat" note. Resolve the Discussion link or get an explicit maintainer waiver before merge. -
[suggestion]
packages/core/tests/integration/plugins/capabilities.test.ts:436The previous review noted that
createContentAccessWithWrite.create()and.update()were missingscheduledAtin their returnedContentItemshape; that is now fixed insrc/plugins/context.ts. Add a regression assertion so the write-return shape doesn't silently drift again.expect(created.data.title).toBe("New Post"); expect(created.scheduledAt).toBeNull();The same assertion should be added to the
update()test below (around line 497) so both write paths verify the field is preserved on return.
What does this PR do?
Extends the plugin runtime with three capabilities needed by Cloudflare-hosted plugins:
Makes database-backed
ctx.cronavailable when no in-process scheduler exists, including plugin API routes and rebuilt hook pipelines. External platform crons could not previously execute plugin scheduled crons, now they can.Exposes
scheduledAtthrough read-only plugin content access so plugins can handle scheduled content.Private plugin routes did not get authenticated with external auth providers (the auth only depended on the astro session), now it also supports external auth providers like CF Access.
The changes are additive and include regression coverage for schedulerless cron contexts, scheduled timestamps, private/public external-authenticated plugin routes, and opaque authentication failures.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
No visual changes.
Verified locally from a clean frozen-lockfile install:
pnpm buildpnpm typecheckpnpm lintpnpm formatandpnpm format:checkpnpm test, including the admin browser suitegit diff --checkNot run: the separate end-to-end Playwright suite (
pnpm test:e2e).