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(plugins): definePlugin() + provider-agnostic email & reset-link fix (Phase 5a/5b)#844

Open
lane711 wants to merge 15 commits into
lane711/plugin-system-cron from
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-cron from
lane711/plugin-system-define

Conversation

@lane711

@lane711 lane711 commented Jun 2, 2026
edited
Loading

Copy link
Copy Markdown
Collaborator

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

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

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