Skip to content

feat(plugins): definePlugin() + provider-agnostic email & reset-link fix (Phase 5a/5b)#844

Open
lane711 wants to merge 15 commits into
lane711/plugin-system-cronfrom
lane711/plugin-system-define
Open

feat(plugins): definePlugin() + provider-agnostic email & reset-link fix (Phase 5a/5b)#844
lane711 wants to merge 15 commits into
lane711/plugin-system-cronfrom
lane711/plugin-system-define

Conversation

@lane711
Copy link
Copy Markdown
Collaborator

@lane711 lane711 commented Jun 2, 2026

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 an email_log.

Stacked on #842 (cron) → #841 (foundation). Review/merge those first.

Phase 5a — definePlugin() (additive)

plugins/sdk/define-plugin.tsdefinePlugin(input) returns a unified plugin that structurally satisfies the mount / wire / cron contracts plus the legacy metadata fields. Its onBoot/onCronTick receive an enriched context: a typed hook facade (ctx.hooks.on('auth:registration:completed', …) with narrowed payloads) and the capability-gated service context (ctx.cap.email throws SonicCapabilityError without email: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: EmailProvider interface + built-in Resend / SendGrid / Console providers — or pass your own. EmailService.send() normalizes → sends → records to email_log (best-effort; logging never fails a send). 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 — a missing key becomes "logged, not delivered", never a silent leak.
  • Core email_log table (Drizzle + migration 037), with failed_at_send / delivery_state / delivery_synced_at for reconciliation. Env-independent singleton for cron.
  • app.ts: email config ({ provider | providerName | from }); first request publishes the EmailService singleton (isolated init); ctx.cap.email resolves to it.
  • 🔒 SECURITY FIX: POST /auth/request-password-reset no longer returns reset_link in 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.
  • magic-link: replaced the broken c.env.plugins?.get('email') lookup (links were only console-logged) with getEmailService().send().

Verified audit findings

Transport was fractured (Resend hardcoded in OTP, SendGrid in email-templates, broken registry in magic-link); email_log lived 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).

  • Full core suite: 1587 passed, 0 failed
  • tsc --noEmit: clean

Deferred (5c/5d)

Migrate the email plugin onto definePlugin + reconciliation cron; OTP → shared EmailService; content/auth dispatch() sites that activate the subscribed hooks; an email_log admin browser.

🤖 Generated with Claude Code

lane711 and others added 4 commits June 1, 2026 17:06
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>
@lane711 lane711 changed the title feat(plugins): definePlugin() v3 authoring API (Phase 5a) feat(plugins): definePlugin() + provider-agnostic email & reset-link fix (Phase 5a/5b) Jun 2, 2026
lane711 and others added 11 commits June 3, 2026 13:08
…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>
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.

1 participant