feat(plugins): definePlugin() + provider-agnostic email & reset-link fix (Phase 5a/5b)#844
Open
lane711 wants to merge 15 commits into
Open
feat(plugins): definePlugin() + provider-agnostic email & reset-link fix (Phase 5a/5b)#844lane711 wants to merge 15 commits into
lane711 wants to merge 15 commits into
Conversation
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>
…b-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>
…e 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>
… fix) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ettings (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>
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>
…n-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>
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>
…lias 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>
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>
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>
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>
…(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>
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This was referenced Jun 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 5 of the plugin overhaul: the
definePlugin()authoring API, plus a provider-agnostic EmailService that fixes a real password-reset token-leak and gives every send anemail_log.Phase 5a —
definePlugin()(additive)plugins/sdk/define-plugin.ts—definePlugin(input)returns a unified plugin that structurally satisfies the mount / wire / cron contracts plus the legacy metadata fields. ItsonBoot/onCronTickreceive an enriched context: a typed hook facade (ctx.hooks.on('auth:registration:completed', …)with narrowed payloads) and the capability-gated service context (ctx.cap.emailthrowsSonicCapabilityErrorwithoutemail:send). Validates capabilities (warns on unknown);isDefinedPlugin()guard.Phase 5b — provider-agnostic email + leak fix
Product requirement: devs must be able to use any email provider, and every send must be logged.
services/email:EmailProviderinterface + built-in Resend / SendGrid / Console providers — or pass your own.EmailService.send()normalizes → sends → records toemail_log(best-effort; logging never fails a send).resolveEmailProviderprecedence: explicit instance > named built-in > env auto-detect (RESEND_API_KEY, thenSENDGRID_API_KEY) > Console. An unconfigured choice degrades to Console with a warning — a missing key becomes "logged, not delivered", never a silent leak.email_logtable (Drizzle + migration 037), withfailed_at_send/delivery_state/delivery_synced_atfor reconciliation. Env-independent singleton for cron.app.ts:emailconfig ({ provider | providerName | from }); first request publishes the EmailService singleton (isolated init);ctx.cap.emailresolves to it.POST /auth/request-password-resetno longer returnsreset_linkin the JSON response (it leaked a valid reset token to any caller). It now emails the link and returns only the generic enumeration-safe message.c.env.plugins?.get('email')lookup (links were only console-logged) withgetEmailService().send().Verified audit findings
Transport was fractured (Resend hardcoded in OTP, SendGrid in email-templates, broken registry in magic-link);
email_loglived only in the optional email-templates-plugin; the reset link was leaked in the API response. All addressed except the OTP migration (works today; deferred).Tests
define-plugin.test.ts+define-plugin-integration.test.ts,email-service.test.ts(19),email-wiring-integration.test.ts(3, incl. the leak regression through the real app).tsc --noEmit: cleanDeferred (5c/5d)
Migrate the email plugin onto
definePlugin+ reconciliation cron; OTP → shared EmailService; content/authdispatch()sites that activate the subscribed hooks; anemail_logadmin browser.🤖 Generated with Claude Code