Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

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

feat(core): explicit plugins.register API (replaces no-op autoLoad/directory) #829
lane711 wants to merge 5 commits into
main from
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

1 more reviewer

@furelid furelid furelid requested changes

Reviewers whose approvals may not affect merge requirements

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

2 participants

AltStyle によって変換されたページ (->オリジナル) /