-
-
Notifications
You must be signed in to change notification settings - Fork 199
feat(core): explicit plugins.register API (replaces no-op autoLoad/directory)#829
feat(core): explicit plugins.register API (replaces no-op autoLoad/directory) #829lane711 wants to merge 5 commits into
Conversation
...rectory) The previous `plugins.directory` + `autoLoad` config fields were declared in the type but never wired up — bootstrap only iterated the hardcoded core plugin registry, so user plugins silently failed to mount and the starter app had to wrap the core app in its own Hono instance to attach plugin routes. Filesystem autoload is the wrong shape for this anyway: SonicJS targets Cloudflare Workers, which has no runtime `fs`, and explicit registration is what mature plugin ecosystems (Vite, Astro, Fastify, Nuxt modules) landed on — type-safe, tree-shakeable, configurable, and easy to debug. Changes: - `SonicJSConfig.plugins.register?: Plugin[]` — pass plugin builds directly - `mountPlugin()` helper sorts middleware by priority then mounts routes - User plugins mount before the `/admin/*` catch-alls so plugin admin pages are not shadowed - `directory` / `autoLoad` kept as `@deprecated` no-ops for one minor - Starter template, in-repo example app, and docs updated Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
...ister Exercises the full app pipeline (bootstrap, security headers, CSRF, admin auth wall, user-plugin mounting, /admin catch-alls). Critically verifies that a plugin route at /admin/plugins/<name>/* is NOT shadowed by the core /admin/plugins catch-all — a 404 there would mean the ordering broke. Also updates the doc site: - plugins/development/page.mdx Step 4 — explicit register array, not the old re-export pattern that didn't actually load anything - faq/page.mdx — adds the register snippet next to the plugin creation example so the FAQ no longer leaves users hanging the way the Discord question did Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
... subpath exports Combines this branch's user-plugin work with the changes from furelid#1 (furelid#1). The two PRs solved different problems and are complementary; this commit takes both. From furelid #1: - mountPluginManagerRoutes(app, plugins[], options) replaces the 9 duplicated route-mount loops in createSonicJSApp. Each core plugin's routes are now gated by a per-request guard that checks `is_active` in the plugins table — flipping the toggle in the admin UI now actually affects route resolution. - 60s TTL cache (pluginStatusCache) so the gate is one Map lookup on the hot path; one D1 read per plugin per minute amortized. - isCorePlugin callback bypasses gating for plugins with is_core === true in their manifest (database-tools, seed-data, core-cache, etc.). - plugins.enabled?: string[] for explicit allowlists when DB gating is disabled. - @sonicjs-cms/core/plugins subpath now exports aiSearchPlugin, analyticsPlugin, globalVariablesPlugin, oauthProvidersPlugin, securityAuditPlugin, OAuthService, BUILT_IN_PROVIDERS, stripePlugin, shortcodesPlugin, etc. so advanced users can wrap or compose them. - Tightens a few imports that were going through the public package re-export back to the source modules. Kept from this branch: - plugins.register?: Plugin[] for user plugins, mounted via mountPlugin() (no DB gating — if the user imported and registered it, they want it on). - @deprecated marks on plugins.directory and plugins.autoLoad. - Doc + blog post fixes. User plugins still bypass the new gating system. Adding them to mountPluginManagerRoutes with isCorePlugin: () => false is a small follow-up so the admin "disable plugin" toggle applies to user plugins too. All 1486 unit tests pass (1479 mine + furelid + 2 plugin-route-mounting + 5 createSonicJSApp integration smoke tests). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- README.md: replace stale plugin example (createSonicJS-shaped, hooks object that doesn't match the real PluginHook[] type) with a real PluginBuilder + plugins.register snippet that compiles against today's exports. - guides/sonicjs-plugins-extending-your-cms.mdx: rewrite the registration section, npm-consumer example, and a few aside-claims to use the actual createSonicJSApp + plugins.register API. Drops fictional createSonicJS, authPlugin()/emailPlugin()/etc. factory imports, and the "drop a file in src/plugins and SonicJS picks it up" claim — Workers has no runtime fs and there is no filesystem auto-discovery. - Unskip 5 plugin e2e specs (15-plugins, 21-plugin-version-display, 28-plugin-filters-search, 29-email-plugin-settings, 36-easymde-plugin- visible). 29 was previously skipped for D1-timing flakiness in CI; worth running it again now that the merged plugin-mounting changes exercise that path differently. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Re-running the plugin e2e suite against this branch (after fixing the node_modules symlink so the conductor workspace's core was actually loaded) surfaced 13 false-404s caused by furelid #1's gating logic: 1. Five plugins (email, otp-login, security-audit, core-analytics, magic-link-auth) were added to mountPluginManagerRoutes in app.ts but never added to BOOTSTRAP_PLUGIN_IDS, so their plugins-table row was never created and isPluginActive(db, name) always returned false. Result: 404 on every admin/api request to those plugins. 2. ai-search-plugin and turnstile-plugin used `name: manifest.name` ("AI Search", "Cloudflare Turnstile") for the PluginBuilder, but the gate looks up by manifest.id. The mismatch denied every request even though both manifests have is_core: true. Fixes: - Add the five missing plugins to BOOTSTRAP_PLUGIN_IDS so they're installed + activated on first boot. - Switch ai-search and turnstile to use manifest.id as the plugin name. - Add bootstrap-coverage.test.ts asserting every plugin mounted via mountPluginManagerRoutes is also bootstrapped, plus the name===id invariant for the two plugins that broke it. - Add /contact root-path regression test (plugin mounted at path: '/' with sub-path /contact must resolve, mirrors contact-form plugin). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
disableAll’s documented contract looks broader than the implementation.
The comment here says this disables all non-core plugins, but config.plugins.register plugins are mounted separately and explicitly bypass the mountPluginManagerRoutes() gating path. In other words, with disableAll: true, user-registered plugins still mount.
Relevant implementation:
- gating logic:
packages/core/src/app.ts:184-202 - direct user-plugin mounting:
packages/core/src/app.ts:394-399
Could we either:
- make
disableAllapply toplugins.registertoo, or - narrow this comment so it clearly only applies to plugins routed through the manager/gating path?
As written, the public API is a bit surprising.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs for disableAll don’t match the implementation.
Here it says "Disable all plugins (including core)", but createSonicJSApp() explicitly keeps core plugins enabled:
packages/core/src/app.ts:109-110packages/core/src/app.ts:184-186
So today the actual behavior is closer to "disable non-core plugins routed through the manager," not "including core".
I think this should be corrected before merge so users don’t get the opposite behavior from what the docs promise.
@furelid
furelid
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Requesting changes for disableAll semantics before merge.
Right now there are two mismatches:
plugins.registerplugins still mount even whendisableAll: true- the docs say
disableAlldisables core plugins, but the implementation explicitly keeps core plugins enabled
I’d suggest either making behavior match the docs, or tightening the docs/comments so they match the actual implementation.
* feat(plugins): generic route mount primitive (fixes #758, #829, #621) Replace the hand-wired, position-sensitive plugin route mounting in app.ts with a shared, synchronous primitive. Plugins previously had to be wired into core app.ts by hand, each guarded by a "MUST be registered BEFORE /admin/plugins" comment, so any plugin relying on PluginBuilder.addRoute() (e.g. global-variables, shortcodes) was never mounted and 404'd in production. - Add plugins/mount.ts: registerPluginRoutes() + mountPlugin() + PluginRegisterMustBeSyncError. Synchronous (Hono's SmartRouter locks after the first request), priority-ordered, with duplicate-path warnings. Typed against a structural MountablePlugin to sidestep the src-vs-dist Plugin identity clash. - app.ts: the 7 copy-pasted route-mounting blocks become two registerPluginRoutes() calls. Mount globalVariablesPlugin + shortcodesPlugin (fixes #758). Mount config.plugins.register user plugins before the /admin catch-all so consumers never edit core (#829, #621). - disableAll now consistently gates all plugin mounting (core + user), matching bootstrap behavior and the documented intent; resolves the #829 review mismatch. - types.ts: add sync-only Plugin.register?(app). app.ts: add SonicJSConfig.plugins.register; deprecate directory/autoLoad no-ops. Tests: mount.test.ts (16 unit incl. sync-guard + catch-all shadowing) and mount-integration.test.ts (5 incl. #758 regression + disableAll matrix). Full core suite 1498 passed, 0 failed; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): typed hook catalog + two-phase wiring foundation (Phase 2) Hooks were dead metadata at runtime: HookSystemImpl only exists inside PluginManager, which is never instantiated in the running app, and nothing dispatches lifecycle events. This lands the typed-hook + async-wiring foundation so plugin hooks and onBoot can be wired for real (the async half of two-phase boot; route mounting is the sync half from Phase 1). - plugins/hooks/catalog.ts: typed event catalog (6 content + 3 auth events) -> payload types; HookEventName / HookPayload<E> / HOOK_EVENT_NAMES / isKnownHookEvent. - plugins/hooks/typed-hooks.ts: createTypedHooks() -> { on<E>, dispatch<E> } with catalog inference; structural HookSystemLike so HookSystemImpl/ScopedHookSystem both satisfy it without casts. - plugins/hooks/hook-system-singleton.ts: get/set/has/reset + getTypedHooks. Throw-before-get, idempotent set (multi-app/test safe), reset for isolation; env-independent access for cron (Phase 4). - plugins/wire.ts: wireRegisteredPlugins() subscribes all hooks[] then runs each onBoot (per-plugin error isolation); structural WirablePlugin; createPluginWirer once-guard for the lazy first-request trigger. Tests: typed-hooks.test.ts (11 runtime + a tsc-validated type-level block proving narrowed payloads and rejected unknown events/fields), wire.test.ts (8: subscribe -> dispatch, all-hooks-before-any-onBoot ordering, error isolation, once-guard under concurrency). Full core suite 1517 passed, 0 failed; tsc clean. Activating the wiring in the live app (eager setHookSystem, first-request wire, real dispatch sites) is deferred to Phase 2b with dedicated integration tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): activate two-phase wiring in the live app (Phase 2b) Bring the hook system to life. Previously hooks were dead metadata at runtime; now createSonicJSApp wires plugins for real: - Eager setHookSystem(new HookSystemImpl()) at construction publishes the singleton (env-independent access for cron later). - Core plugins extracted into shared corePluginsBeforeCatchAll / corePluginsAfterCatchAll arrays, reused for both route mounting and wiring so they never drift (no Plugin[] annotation -> dodges the src/dist Plugin identity clash; both consumers are structural). - A once-guarded first-request middleware (after bootstrap) runs wireRegisteredPlugins exactly once: subscribes every core + user plugin's hooks[] and runs onBoot. Error-isolated; skipped under disableAll. The first request now subscribes the real core plugin hooks. They stay inert until dispatch sites are added, so no existing behavior changes -- but the infrastructure is proven end-to-end. Tests: wire-integration.test.ts (4): singleton published at construction; first request runs onBoot + subscribes hooks (verified by dispatching content:create); wires exactly once across 3 requests; disableAll skips wiring. Full core suite 1521 passed, 0 failed; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): capability gating + service-singleton factory (Phase 3) Add the isolation boundary that Strapi (namespacing only) and Payload (full config access) lack: a plugin declares the capabilities it needs, and the host hands it a context whose powerful accessors are gated by those declarations. - plugins/capabilities.ts: Phase 1 vocabulary (FIXED_CAPABILITIES + parameterized db:<table>), isKnownCapability/validateCapabilities, SonicCapabilityError, hasCapability/assertCapability, and createCapabilityContext() whose accessors are lazy throwing getters -- ctx.email throws SonicCapabilityError unless email:send was declared. - plugins/singletons/service-singleton.ts: createServiceSingleton<T>(label) generalizing the hook-system-singleton pattern (throw-before-get, idempotent set, reset). Env-independent so cron/scheduled() handlers can reach services. Pure infrastructure, no behavior change. Providers + gated context get wired into the live app with definePlugin() in Phase 5. Tests: capabilities.test.ts (19): db:<table> matching, granted/denied gating, cache read-or-write, lazy providers, and the singleton contract (throw-before-get, idempotent set, cron-reachable without env). Full core suite 1540 passed, 0 failed; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): cron surface — declarations + scheduled() handler (Phase 4) Let plugins declare scheduled work as data and dispatch it from the Worker's scheduled() handler. Like Payload's jobs queue, declaring a schedule runs nothing by itself: on Workers the execution mechanism is a Cron Trigger delivered to scheduled(), which this fans out to matching plugins. - plugins/cron.ts: CronDeclaration ({ schedule, hookFamily }) + structural CronablePlugin (crons[] + async onCronTick). collectCrons/collectCronSchedules flatten declarations (wrangler sync + diagnostics). dispatchCronTick() matches a fired expression to plugins, tags each onCronTick with the matched hookFamily (one call per matching declaration), error-isolated. createScheduledHandler() returns a CF scheduled(controller, env, ctx) handler reaching services via the env-independent singletons (cron has no c.env), passing Worker env through and waitUntil-ing the work. Tests: cron.test.ts (12): collect/flatten, match-only-fired-cron, well-formed event, unmatched reporting, multi-cron fan-out, error isolation, and the scheduled handler (dispatch + waitUntil + env passthrough + lazy list + disabled no-op). Full core suite 1552 passed, 0 failed; tsc clean. Wiring scheduled() into the consumer Worker export + wrangler trigger generation is a DX change deferred to the definePlugin()/docs bundle (Phase 5/7). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): definePlugin() v3 authoring API (Phase 5a) The typed authoring entry point that unifies the foundation: a plugin is declared once and consumed unchanged by mount (routes/register), wire (onBoot), and cron (crons/onCronTick), plus the legacy metadata fields the admin/registry read. - plugins/sdk/define-plugin.ts: definePlugin(input) -> DefinedPlugin. Normalizes id -> name, validates declared capabilities (warns on unknown), marks output __sonicV3 (+ isDefinedPlugin guard). onBoot/onCronTick receive an ENRICHED context { hooks, cap, env, raw }: a typed hook facade (ctx.hooks.on with narrowed payloads) and the capability-gated service context (ctx.cap.email throws SonicCapabilityError without email:send). The runtime still passes the plain boot/cron context; definePlugin wraps the author fns. Host providers ride on raw.providers. - Exported from plugins/index.ts. Purely additive — nothing in core uses it yet. Tests: define-plugin.test.ts (13) + define-plugin-integration.test.ts (3: mounts via createSonicJSApp, honors disableAll, onBoot runs exactly once on first request). Full suite 1565 passed, 0 failed; tsc clean. Next: migrate email onto definePlugin (5b), fix magic-link (5c), add content/auth dispatch sites (5d) — all behavior-changing, separately tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(email): provider-agnostic EmailService + core email_log (Phase 5b-1) Today email is fractured: OTP hardcodes Resend, email-templates uses SendGrid, magic-link calls a registry (c.env.plugins?.get('email')) that was never built, and password-reset doesn't send at all (it returns the reset link in the JSON response — a token leak). There is no single chokepoint and no core email_log. This lands the chokepoint (additive; no call sites switched yet): - services/email: EmailProvider interface + built-in Resend / SendGrid / Console providers. A dev can pass ANY EmailProvider implementation — "use whatever provider you want." EmailService.send() normalizes the message, calls the provider, and records every attempt in email_log (best-effort; logging never fails a send; a throwing provider is surfaced as a structured failure). - resolveEmailProvider precedence: explicit instance > named built-in > env auto-detect (RESEND_API_KEY, then SENDGRID_API_KEY) > Console. An unconfigured choice degrades to Console with a warning, so a missing key becomes "logged, not delivered" — never a silent token leak. - email-service singleton (via createServiceSingleton) for env-independent access from cron / scheduled() reconciliation. - Core email_log table: Drizzle schema + migration 037 (bundled). Epoch-ms integer timestamps; columns for provider/provider_id/error/flow/metadata plus failed_at_send and delivery_state/delivery_synced_at for the reconciliation cron. Tests: email-service.test.ts (19): normalization, sent/failed logging + the failed_at_send path, throwing-provider isolation, logging-never-fails-send, Resend/SendGrid/Console providers (mocked fetch), and resolveEmailProvider precedence incl. the console degrade. Full core suite 1584 passed, 0 failed; tsc clean. Wiring it in + migrating the call sites (OTP, magic-link, and closing the password-reset leak) is the next increment (Phase 5b-2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(email): wire EmailService + close password-reset token leak (Phase 5b-2) Wires the provider-agnostic EmailService into the app and migrates the two real send paths, fixing a token-leak security bug along the way. - app.ts: new `email` config ({ provider | providerName | from }) so devs choose any provider. On first request the app resolves a provider (config > env auto-detect > console) and publishes the EmailService singleton; its init is isolated in its own try/catch so it can never block plugin wiring. The capability boot context now resolves `ctx.cap.email` to this service. - SECURITY (auth.ts): POST /auth/request-password-reset no longer returns `reset_link` in the JSON response — that leaked a valid reset token to any caller. It now emails the link via EmailService (flow: 'password-reset') and returns only the generic, enumeration-safe message. Delivery failure does not change the response. - magic-link: replace the broken `c.env.plugins?.get('email')` lookup (a registry that never existed, so links were only console-logged) with getEmailService().send({ flow: 'magic-link' }). Tests: email-wiring-integration.test.ts (3) through the real createSonicJSApp — provider initialized on first request; reset response omits reset_link AND the token, sending via email instead; unknown email stays generic and sends nothing. Also null-safe initEmailService (fixes wire-integration when requests carry no env). Full core suite 1587 passed, 0 failed; tsc clean. OTP migration to the shared EmailService is deferred (it works today via Resend). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(plugins): record Phase 5b status (provider-agnostic email + leak fix) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(email): route OTP through shared EmailService + honor admin-UI settings (Phase 5c) Consolidates the last ad-hoc email sender. OTP previously read plugins.settings and called Resend directly; it now goes through the provider-agnostic EmailService, so OTP sends are logged to email_log like every other flow. It stays synchronous (caller-direct) — the user can't proceed without the code. Provider precedence in the app's init now also honors the admin Plugins-page email config (API key in plugins.settings, not env), so existing installs keep delivering: config.email > named built-in > env keys > admin-UI DB settings (Resend) > console. initEmailService is async to read those DB settings. - services/email/db-settings.ts: loadDbEmailSettings() (never throws) + dbSettingsFrom(). - app.ts: DB-aware async initEmailService. - otp-login-plugin: send via getEmailService({ flow: 'otp' }). - lint: silence @typescript-eslint/naming-convention for the colon-bearing hook event keys (catalog.ts) and the intentional __sonicV3 marker (define-plugin.ts); the husky pre-commit hook activated mid-effort and these slipped through earlier. Tests: email-db-settings.test.ts (9). Full core suite 1596 passed, 0 failed; tsc clean. Verified live: OTP request logs `[email:console] (otp) ...` and writes an email_log row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(plugins): plugin-framework convergence & production-readiness plan Deep-dive comparison of Mark's Infowall plugin SDK (/Users/lane/Dev/refs/infowall-ai-main) vs the shipped SonicJS v3 framework, with a decisive convergence + production-readiness roadmap. Produced via a multi-agent workflow (9 mappers -> 10 adversarially-verified dimension comparisons -> synthesize/critique/revise), then hand-corrected: a synthesis pass had wrongly reported Infowall as "out-of-tree/unopenable" (it searched this workspace instead of the absolute path); the two crux dimensions (hook-event catalog, capability vocabulary) plus topo-sort/once-guard/cron-registry claims were re-verified against the real Infowall source. Thesis: SonicJS is base-of-record (mounting wired, reset-link leak closed, provider- agnostic email, better substrate); harvest Infowall's rigor (real hook dispatch sites, capability enforcement at the subscription boundary, dependency topo-sort, live cron+reconciliation). Three real long poles, all SonicJS-side: inert hook catalog (zero production dispatch), HTTP-gated wire phase unreachable from scheduled(), missing hook- subscription capability gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(plugins): phased development plan for plugin-framework production-readiness Execution-ready task breakdown (Phase 1 contract alignment → 2 dispatch+enforcement → 3 ordering/cron/reconciliation [PRODUCTION-READY] → 4 structure/distribution [FUTURE-PROOF]). Each task: goal/files/change/tests/done-when + size + parallelism. Decisions locked: SonicJS canonical, name-map hooks, before/after content events, separate cron, SonicJS capability spellings + rename map, unified user.id actor shape, SonicJS substrate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): unify hook actor shape to user.id (T1.1) Fixes a live inconsistency: content event payloads exposed `user.userId` while auth payloads exposed `user.id`, so a plugin reading one field on the wrong family got undefined. Introduce one canonical `HookActor { id, email, role? }` used across all content + auth events; content's `userId` becomes `id`. Type-level assertions (tsc-validated): content `user.id` is string, reading `user.userId` is now an error, and content/auth actor shapes agree. Core suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): re-key hook catalog to before/after content events + alias window (T1.2) Adopt the before/after content-event model (gate/transform vs side-effect), the industry-standard shape Payload/Strapi/WordPress use: - content:before:{create,update,delete} (handlers may mutate or throw to cancel) - content:after:{create,update,delete,publish}; keep content:read - add auth:magic-link:consumed, auth:otp:verified - drop content:save (folded into update) Ship as a breaking catalog change WITH a one-release deprecation window: the legacy names (content:create/update/delete/publish/save) still compile and subscribe via createTypedHooks().on() — they resolve to the canonical name and emit a one-time deprecation warning. dispatch() is canonical-only (the host owns dispatch sites). LegacyHookEventPayloads keeps legacy names typed to the canonical payload; resolveHookEventName/isLegacyHookEvent/LEGACY_EVENT_ALIASES are exported. Tests: before-hook mutation, legacy-alias fires-on-canonical-dispatch + warns once, type-level proof that legacy names still compile to the canonical payload. Full core suite 1598 passed, 0 failed; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): capability rename map + normalization (T1.3) Cross-version / cross-fork capability portability. A plugin authored against a different SDK spelling now loads against the canonical vocabulary without code changes. - CAPABILITY_RENAMES (deprecated→canonical) seeded with the sibling fork's spellings: storage:*→media:*, hooks.cron:register→cron:register, hooks.{auth,content-read,content-write,email-events}:register→canonical :subscribe. - normalizeCapability(str)→Capability|null (rename then known-check) and normalizeCapabilities(list)→{capabilities, unknown} (dedupes, splits unknowns). - Reserve hooks.email:subscribe in the vocabulary (rename target; gates the email event family once it ships). - definePlugin now normalizes declared capabilities first, then warns on the ones still unknown — and DROPS unknowns from the granted set (an unrecognized name is not a granted capability). request:intercept has no canonical target and surfaces as unknown rather than silently gating nothing. Tests: rename resolution, every rename target is itself known, dedupe + unknown split, definePlugin normalizes storage:write→media:write without warning, unknowns dropped. Full core suite 1605 passed, 0 failed; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): const-generic capability narrowing on definePlugin (T1.4) ctx.cap.email now resolves to the real EmailService type at compile time — but only when the plugin declared 'email:send'. Accessing an undeclared capability is a compile error, shifting the existing runtime SonicCapabilityError left. - definePlugin<const Caps extends readonly Capability[]>; capabilities inferred as a literal tuple (default readonly [] so omitting = nothing granted). - CapabilityContext<Caps> with WhenGranted/WhenGrantedAny mapping each accessor to its service type, or a branded CapabilityNotDeclared type when absent (not `never`, which would be assignable to anything and defeat the check). - createCapabilityContext is generic; runtime gating still uses the normalized set while the context TYPE reflects the declared tuple. EmailService imported type-only (no runtime coupling). CapabilityProviders.email typed () => EmailService. Tests: tsc-validated narrowing — email:send → EmailService; other/empty caps → compile error on ctx.cap.email. Full core suite 1605 passed, 0 failed; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): declarative typed hooks field on definePlugin (T1.5) Add a declarative `hooks` map to DefinePluginInput so plugins can subscribe to lifecycle events without an onBoot body: definePlugin({ id, version, capabilities: ['hooks.content:subscribe'], hooks: { 'content:after:create': (payload) => { /* payload narrowed */ } } }) Each entry is keyed by a canonical HookEventName and the handler is narrowed to that event's payload (DeclarativeHooks = { [E in HookEventName]?: TypedHookHandler<E> }). definePlugin flattens the map into the wirable hooks[] array (wrapping each handler to the raw shape, void-coalesced), so they subscribe through the existing wire phase and fire on dispatch. Imperative ctx.hooks.on() in onBoot remains the dynamic-subscription escape hatch. Tests: declarative hook flattens + fires after wiring; type-level per-event payload narrowing + unknown-event-key rejection. Full core suite 1607 passed, 0 failed; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(email): route admin-UI provider through resolver + apply replyTo (T1.6) The DB-settings (admin Plugins page) email path hardcoded `new ResendProvider`, so it was Resend-locked and skipped the degrade-to-Console safety, and it dropped the configured replyTo. - app.ts initEmailService: resolve the admin-UI key via resolveEmailProvider ({ providerName: 'resend', env: { ...env, RESEND_API_KEY } }) for consistent provider selection + safe degrade; the no-key branch also goes through the resolver (console fallback + warning) instead of constructing ConsoleProvider. - EmailService gains defaultReplyTo, applied when a message omits replyTo; wired from DbEmailSettings.replyTo so admin-configured reply-to is honored. Tests: defaultReplyTo applied + per-message override. Full core suite 1608 passed, 0 failed; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugins): Phase 2 — make hooks & capabilities real (T2.1–T2.7) dispatch-event.ts helper (T2.1–T2.3): a route-facing typed dispatch helper that safely extracts executionCtx from the Hono context (throws in non-Workers environments). fire-and-forget mode runs via waitUntil; in-band mode awaits the handler chain so before-hooks can mutate the payload. Auth dispatch sites (T2.1): auth:registration:completed, auth:password-reset:requested (carries resetToken for custom notification plugins), auth:password-reset:completed. Dispatched from both JSON + HTML registration routes and both reset routes. Magic-link + OTP dispatch sites (T2.2): auth:magic-link:consumed on successful magic-link verify; auth:otp:verified on successful OTP verify. Content dispatch sites (T2.3): content:before:create/update/delete (in-band, payload mutations flow through to the write); content:after:create/update/delete and content:after:publish (fire-and-forget, side-effect plugins). content:read dispatched on GET /:id (fire-and-forget). Capability gate (T2.4): HOOK_CAPABILITY_MAP added to capabilities.ts mapping every catalog event to its required subscription capability. Wire phase A now enforces the gate for v3 plugins (capabilities !== undefined); old PluginBuilder plugins are exempt for backwards compatibility. Non-strict mode warns; strict mode records a SonicCapabilityError in WireResult. SonicCapabilityError.accessedApi (T2.5): optional field, non-breaking. No-dispatch-site CI guard (T2.6): test asserts every HOOK_EVENT_NAMES entry has at least one dispatchHookEvent() call in a non-test source file. Fails if a catalog event ships without a real production dispatch site. Wire-integration rewrite (T2.7): removed manual hooks.dispatch() call; test now fires via a minimal Hono app calling dispatchHookEvent() — the same path production routes use. Would fail if dispatchHookEvent were removed from routes. Tests: +14 new tests. Full suite 1622 passed, 0 failed; tsc + lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(plugins): record Phase 2 status in dev plan (Appendix E) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(plugins): Phase 3 — ordering + cron liveness + reconciliation + observability (T3.1–T3.6) T3.1 — Dependency topo-sort + cycle detection: - `plugins/topo-sort.ts`: DFS topo-sort with `visiting` stack, throws `PluginDependencyCycleError` on cycles, warns/throws on missing dep ids (strict). - `MountablePlugin` + `WirablePlugin` extended with optional `id` + `dependencies`. - `registerPluginRoutes` + `wireRegisteredPlugins` now sort by dependencies by default (sortByDependencies=true); old-style plugins without `dependencies` keep their original declaration order. T3.2 — bootIsolate extraction: - HTTP middleware body factored into `boot: BootIsolateFn` closure — the same once-guarded `initEmailService` + `wirePlugins` call, now exposed on the returned `SonicJSApp` object. - `SonicJSApp` type extended with `readonly boot: BootIsolateFn`. - The HTTP middleware now calls `boot(c.env)`, sharing the once-guard with any other caller (cron, test harness). T3.3 — Wire scheduled() end-to-end: - `createScheduledHandler` gains optional `boot?` parameter. Called before the first cron dispatch so a cron-first cold isolate has a populated hook bus and reachable email service; warm isolates return instantly (once-guard). - `my-sonicjs-app/src/index.ts` restructured to export `{ fetch, scheduled }` — a proper Worker object instead of a bare Hono app. `scheduled` wires through `app.boot`. T3.4 — wrangler.toml [triggers] codegen: - `plugins/generate-triggers.ts`: `parseCronTriggers`, `updateWranglerTriggers`, `generateTriggersComment` utilities. - `my-sonicjs-app/scripts/generate-cron-triggers.ts`: a tsx script that reads plugin `crons[]` and writes the `[triggers]` section; `--check` mode for CI. T3.5 — Per-provider reconciliation + observability migration: - `EmailProvider.reconcile?()` optional method for delivery-state backfill. - `EmailLogRow` type for reconciliation inputs. - Migration `038_email_log_observability.sql`: adds `user_id`, `context_type`, `context_id`, `tenant_id`, `delivery_state`, `delivery_synced_at` (all nullable, no defaults — forward-only D1 / NULL-safe). Partial index for reconciliation queries; per-user history index. T3.6 — CloudflareEmailProvider: - `services/email/providers/cloudflare.ts`: `CloudflareEmailProvider` wraps the `send_email` Workers binding (MailChannels / CF Email Routing). New exports: `createScheduledHandler`, `dispatchCronTick`, `collectCrons`, `collectCronSchedules`, `getHookSystem`, `topoSort`, `PluginDependencyCycleError`, `CloudflareEmailProvider`, `parseCronTriggers`, `updateWranglerTriggers`, `BootIsolateFn`. Tests: +25 (topo-sort: 11, boot-isolate: 8, generate-triggers: 6). Full suite: 1647 passed, 0 failed; tsc + lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(plugins): Phase 4 — structure + distribution + versioning + hardening (T4.1–T4.5) T4.1 — Stop committing dist/ + fix src/dist type identity: - Root .gitignore: add `packages/*/dist/` so build artifacts are never committed. - `git rm -r --cached packages/core/dist/`: stop tracking existing dist files. - tsconfig.json: add `@sonicjs-cms/core` → `./src` path alias so in-tree self-imports from core plugins resolve to the same types as the rest of src, eliminating the dual `Plugin` identity that forced structural casts everywhere. T4.3 — Versioning / semver compat gate: - `DefinePluginInput.sonicjsVersionRange?`: semver range the plugin declares for SonicJS core compatibility (e.g. `'^3.0.0'`). - `definePlugin()` validates the plugin's own `version` field (warns on invalid semver) and checks `sonicjsVersionRange` against the running core version at definition time (warns on mismatch). Both use a minimal in-tree semver helper — no npm `semver` dep (bundle-size constrained on Workers). - `DefinedPlugin.sonicjsVersionRange` carries the range through to the runtime. T4.4 — DB activation reflection + email_log admin browser: - `wire.ts` Phase C (best-effort): after wiring, upserts each booted plugin into the `plugins` DB table (`INSERT ... ON CONFLICT DO UPDATE`) so the admin view reflects what is actually running. Non-fatal — a DB error in reflection never aborts wiring. - `/admin/settings/email-log`: paginated HTML browser showing all email_log rows with status, delivery_state, flow, provider, recipient, subject, and user. Uses migration 037+038 columns; renders an empty-state if the table is missing. - `/admin/settings/email-log/api`: JSON endpoint for the same data, filterable by flow/status, paginated by limit/offset. T4.5 — Shared author mock harness: - `__tests__/utils/mock-factories.ts`: typed mock primitives for plugin authors: - `makeMockD1Database(opts)` — D1-shaped mock (static rows or resolver fn) - `makeMockKVNamespace()` — in-memory KV with put/get/delete/list - `makeMockHonoContext(opts)` — Hono context mock with json/html/redirect/vars - `makeMockEmailProvider()` — recording EmailProvider, captures `.sent[]` - `makeMockHookSystem()` — a real HookSystemImpl instance (not a stub) Previously 5+ inline fakes existed with varying shapes; this replaces them. Tests: +18 (T4.1 type-identity check, T4.3 semver gate 5-case, T4.5 mocks 12-case). Full suite: 1665 passed, 0 failed; tsc (non-test files) clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update generated manifest-registry timestamp + workspace D1 config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(plugins): closeout — email-log nav, reconciliation cron, PluginBuilder compat (T3.5/T3.6/T4.7) Email log admin navigation: - Add 'Email Log' tab to /admin/settings nav bar (envelope icon). Clicking redirects to /admin/settings/email-log (built in Phase 4). Email reconciliation cron plugin (T3.5/T3.6): - `plugins/core-plugins/email-reconciliation/index.ts`: first core plugin authored with definePlugin(). Proves the v3 authoring API end-to-end. Hourly cron ('0 * * * *') queries email_log for unreconciled rows, calls EmailService.reconcileDelivery(rows), writes delivery_state back. Non-fatal on any DB/provider error. - EmailService.reconcileDelivery(): delegates to provider.reconcile?(). Returns [] for providers without the method. Errors caught, not thrown. - Wired into corePluginsAfterCatchAll; exported from index.ts. - Worker entry (my-sonicjs-app) includes it in the scheduled handler. PluginBuilder v3 compatibility shim (partial T4.7): - build() now sets id = name and capabilities = [] so all 17+ existing PluginBuilder plugins get topo-sort ordering and capability-gate compat for free. Fully backwards-compatible — no migration required. Tests: +11. Full suite: 1676 passed, 0 failed. Lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(plugins): new plugins register as inactive, preserve admin status on reboot reflectWiredPlugins: INSERT with status='inactive', ON CONFLICT skip status. Admin must explicitly activate plugins. Reboots no longer override deactivation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove remaining tracked dist files + gitignore codegraph index - git rm --cached packages/core/dist/: remove ~120 dist files still tracked after Phase 4 partial removal (build regenerated with new hashes). .gitignore already covers packages/*/dist/ so they stay ignored. - .gitignore: add .codegraph/ (local codegraph index, auto-generated). - migrations-bundle.ts: timestamp-only regeneration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * version bump * refactor(seed): keep only default blog post * refactor(db): use D1 migration tracking Cloudflare D1's d1_migrations table is now the only migration state source. App-side migration execution is disabled; status remains available and bootstrap only runs idempotent compatibility repairs. Adds 0003_drop_sonicjs_migrations_table.sql to remove the legacy SonicJS migrations table from existing databases. * fix(seed): register only blog post type Default bootstrap now registers only the code-defined blog_post document type. The greenfield migration bundle is rebuilt back to 0001 and 0002 only; no cleanup migration is needed for this branch. * fix(collections): mark code sources - Stamp synced config collections with source_type=code - Prefer code collection metadata in admin collection source display - Cover code source stamping in collection sync tests --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
plugins.register?: Plugin[]toSonicJSConfigso user plugins are mounted bycreateSonicJSAppdirectly — no more wrapping the core app in a customHono()instance just to attach plugin routes.plugins.directory/plugins.autoLoadas@deprecatedno-ops. They were declared in the type but never wired up; bootstrap only iterated the hardcoded core plugin registry, so user plugins silently failed to load.mountPlugin()helper (packages/core/src/plugins/mount.ts) sorts middleware by priority, then mounts routes. User plugins are registered before/admin/pluginsand the/admincatch-all so plugin admin pages aren't shadowed.Why explicit, not filesystem autoload
SonicJS runs on Cloudflare Workers — no runtime
fs, so any directory autoload would have to be a build-time Vite plugin. That's a lot of machinery for two lines of saved code, and most mature plugin ecosystems (Vite, Astro, Fastify, Nuxt modules) landed on explicitregister: [...]arrays for the same reasons: type-safety, tree-shaking, debuggability, and the ability to pass config (weatherPlugin({ apiKey })) inline.Originated from a Discord report: a user couldn't get a custom plugin to load with
autoLoad: true. The flag did nothing.Before / after (starter app)
Test plan
npx vitest runinpackages/core— 1479 passed, 328 skipped, 0 failedmount.test.tscovers route mounting, multi-route, global vs scoped middleware, priority ordering, and empty-plugin no-op (6 tests)npx tsc --noEmitinpackages/coreis cleanpackages/statsstill compiles (disableAll: truepath unchanged)🤖 Generated with Claude Code