Skip to content

feat(core): explicit plugins.register API (replaces no-op autoLoad/directory)#829

Open
lane711 wants to merge 5 commits into
mainfrom
lane711/plugin-register-api
Open

feat(core): explicit plugins.register API (replaces no-op autoLoad/directory)#829
lane711 wants to merge 5 commits into
mainfrom
lane711/plugin-register-api

Conversation

@lane711

@lane711 lane711 commented May 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds plugins.register?: Plugin[] to SonicJSConfig so user plugins are mounted by createSonicJSApp directly — no more wrapping the core app in a custom Hono() instance just to attach plugin routes.
  • Marks plugins.directory / plugins.autoLoad as @deprecated no-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.
  • New mountPlugin() helper (packages/core/src/plugins/mount.ts) sorts middleware by priority, then mounts routes. User plugins are registered before /admin/plugins and the /admin catch-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 explicit register: [...] 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)

// before — manual Hono wrapper, manual route loop
const coreApp = createSonicJSApp(config)
const app = new Hono()
if (contactFormPlugin.routes) {
  for (const route of contactFormPlugin.routes) app.route(route.path, route.handler)
}
app.route('/', coreApp)
export default app

// after
export default createSonicJSApp({
  collections: { autoSync: true },
  plugins: { register: [contactFormPlugin] },
})

Test plan

  • npx vitest run in packages/core — 1479 passed, 328 skipped, 0 failed
  • New mount.test.ts covers route mounting, multi-route, global vs scoped middleware, priority ordering, and empty-plugin no-op (6 tests)
  • npx tsc --noEmit in packages/core is clean
  • packages/stats still compiles (disableAll: true path unchanged)
  • Visual smoke test of starter template + a custom plugin in dev — recommended before merging

🤖 Generated with Claude Code

…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>
Comment thread packages/core/src/app.ts
Comment on lines +108 to +113
/**
* When true, disables all non-core plugins (is_core === false in manifest).
* Core plugins (is_core === true) are always active regardless of this flag.
* Takes precedence over the `enabled` list and the per-request DB active check.
*/
disableAll?: boolean

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.

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:

  1. make disableAll apply to plugins.register too, or
  2. 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.

directory?: string // Path to custom plugins
autoLoad?: boolean // Auto-load plugins from directory
register?: Plugin[] // User plugins (default export from PluginBuilder.build())
disableAll?: boolean // Disable all plugins (including core)

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.

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-110
  • packages/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

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.

Requesting changes for disableAll semantics before merge.

Right now there are two mismatches:

  1. plugins.register plugins still mount even when disableAll: true
  2. the docs say disableAll disables 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.

lane711 added a commit that referenced this pull request Jun 9, 2026
* 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>
pull Bot pushed a commit to sternelee/sonicjs that referenced this pull request Jun 18, 2026
…onicJs-Org#829, SonicJs-Org#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 SonicJs-Org#758). Mount
  config.plugins.register user plugins before the /admin catch-all so consumers
  never edit core (SonicJs-Org#829, SonicJs-Org#621).
- disableAll now consistently gates all plugin mounting (core + user), matching
  bootstrap behavior and the documented intent; resolves the SonicJs-Org#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. SonicJs-Org#758 regression + disableAll matrix).
Full core suite 1498 passed, 0 failed; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants