Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
436 changes: 436 additions & 0 deletions PLUGIN_FRAMEWORK_CONVERGENCE_PLAN.md

Large diffs are not rendered by default.

266 changes: 266 additions & 0 deletions PLUGIN_FRAMEWORK_DEV_PLAN.md

Large diffs are not rendered by default.

46 changes: 43 additions & 3 deletions PLUGIN_SYSTEM_OVERHAUL_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,9 +452,10 @@ Each phase is independently shippable and test-gated. **No phase merges without
- **Exit:** a plugin's `onCronTick` fires on schedule in a Miniflare/integration test; graceful skip when credentials/bindings absent.

### Phase 5 — `definePlugin()` + Email reference migration (PR-E equivalent)
- Ship `definePlugin()`.
- Migrate the **email plugin** as the reference (it touches capabilities, hooks, cron, singleton, routes — if it holds for email it holds for all). Close the `reset_link` leak; add `email_log` observability.
- Fix **magic-link** (`c.env.plugins?.get('email')` → `getEmailService().send()`).
- **5a — `definePlugin()` ✅ DONE** (stacked branch `lane711/plugin-system-define`). See Appendix G.
- 5b — Migrate the **email plugin** as the reference (it touches capabilities, hooks, cron, singleton, routes — if it holds for email it holds for all). Close the `reset_link` leak; add `email_log` observability.
- 5c — Fix **magic-link** (`c.env.plugins?.get('email')` → `getEmailService().send()`).
- 5d — Add real `dispatch()` sites in content/auth routes (this *activates* the now-subscribed core plugin hooks; behavior-changing — needs dedicated integration tests).
- **Exit:** email plugin runs fully on v3; documented as the template for the rest.

### Phase 6 — Migrate remaining core plugins (~20)
Expand Down Expand Up @@ -651,3 +652,42 @@ Built on the stacked branch `lane711/plugin-system-cron` (PR base = the foundati
- **Strapi v5** — docs.strapi.io (plugin-structure, server-api, admin-panel-api, configurations/plugins, document-service middlewares) + `strapi/strapi` `main` (`loaders/plugins/*`, `registries/plugins.ts`, `types/.../strapi-server/*`).
- **Payload v3** — payloadcms.com/docs (plugins/overview, build-your-own, hooks/*, jobs-queue/*, admin/components, database/migrations) + `payloadcms/payload` `main` (`config/types.ts`, `templates/plugin/src/index.ts`).
- **SonicJS current source** — `packages/core/src/plugins/*`, `services/plugin-service.ts`, `app.ts`, `db/schema.ts`, `routes/admin-plugins.ts`, `tests/e2e/*plugin*`.

---

## Appendix G — Phase 5a status (`definePlugin()`)

Built on the stacked branch `lane711/plugin-system-define` (PR base = the cron branch). Purely additive authoring API — no behavior change; nothing yet *uses* it in core.

**Landed:**
- `plugins/sdk/define-plugin.ts` — `definePlugin(input)` returns a unified `DefinedPlugin` that structurally satisfies `MountablePlugin` (routes/register), `WirablePlugin` (onBoot), and `CronablePlugin` (crons/onCronTick), plus the legacy metadata fields the admin/registry read. Normalizes `id → name`; validates declared `capabilities` (warns on unknown); tags output with `__sonicV3` (+ `isDefinedPlugin()` guard).
- **Enriched context:** the author's `onBoot`/`onCronTick` receive `{ hooks, cap, env, raw }` — 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`). `definePlugin` wraps the author functions so the runtime keeps passing the plain `PluginBootContext`/`CronContext`; providers ride on `raw.providers` (host-supplied).
- Exported from `plugins/index.ts`.

**Tests:** `define-plugin.test.ts` (13: shape/normalization, id/version required, unknown-capability warning, mounts via `registerPluginRoutes`, typed-hook subscription fires through the live system, capability gating throws, provider resolution, env exposure, cron enriched context) + `define-plugin-integration.test.ts` (3: mounts through `createSonicJSApp`, honors `disableAll`, `onBoot` runs exactly once on first request via live wiring). Full core suite **1565 passed, 0 failed**; `tsc` clean.

**Next (5b–5d):** migrate the email plugin onto `definePlugin` (capabilities + `onBoot` hook subscriptions + `email_log` + reconciliation cron, closing the `reset_link` leak), fix magic-link, and add the content/auth `dispatch()` sites — all behavior-changing, each with its own integration coverage.

---

## Appendix H — Phase 5b status (provider-agnostic email + leak fix)

Same stacked branch. **Decision (from product):** developers must be able to use *any* email provider, and every send must be recorded.

**5b-1 — EmailService core (additive):**
- `services/email`: `EmailProvider` interface + built-in **Resend / SendGrid / Console** providers; a dev can pass any `EmailProvider`. `EmailService.send()` normalizes → sends → records to `email_log` (best-effort; logging never fails a send; a throwing provider becomes 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** — a missing key becomes "logged, not delivered", never a silent token leak.
- `email-service` singleton (env-independent, for cron reconciliation). Core `email_log` table: Drizzle schema + **migration 037** (bundled), with `failed_at_send` / `delivery_state` / `delivery_synced_at` for reconciliation.
- Tests: `email-service.test.ts` (19).

**5b-2 — wiring + call-site migration (behavior-changing):**
- `app.ts`: `email` config (`{ provider | providerName | from }`); first request resolves the provider and publishes the EmailService singleton (isolated init — can't block plugin wiring); `ctx.cap.email` now 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 (`flow: 'password-reset'`) and returns only the generic enumeration-safe message.
- magic-link: replaced the broken `c.env.plugins?.get('email')` registry lookup (links were only console-logged) with `getEmailService().send({ flow: 'magic-link' })`.
- Tests: `email-wiring-integration.test.ts` (3) through the real app — provider initialized on first request; reset omits `reset_link` + the token and sends instead; unknown email stays generic and sends nothing.

Full core suite **1587 passed, 0 failed**; `tsc` clean.

**Verified findings (from the requested audit):** transport was fractured (Resend hardcoded in OTP, SendGrid in email-templates, broken registry in magic-link); `email_log` existed only in the optional email-templates-plugin; the reset link **was** leaked in the API response. All three are now addressed except the OTP migration (works today via Resend; deferred to limit blast radius).

**Still deferred (5c/5d):** migrate the email *plugin* itself onto `definePlugin` with a reconciliation cron; OTP → shared EmailService; content/auth `dispatch()` sites that activate the now-subscribed hooks; an `email_log` admin browser.
27 changes: 27 additions & 0 deletions packages/core/migrations/037_email_log.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Email log
-- One row per email send attempt routed through the core, provider-agnostic
-- EmailService. Replaces the per-flow ad-hoc sends that wrote nowhere.
-- Timestamps are epoch milliseconds (plain integers).

CREATE TABLE IF NOT EXISTS email_log (
id TEXT PRIMARY KEY,
to_email TEXT NOT NULL, -- comma-joined recipients
from_email TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'sent' | 'failed'
provider TEXT, -- 'resend' | 'sendgrid' | 'console' | custom
provider_id TEXT, -- provider-side message id
error TEXT,
flow TEXT, -- 'password-reset' | 'otp' | 'magic-link' | 'welcome' | 'test' | ...
metadata TEXT, -- JSON
failed_at_send INTEGER, -- epoch ms; set when the send failed immediately
delivery_state TEXT, -- populated by the reconciliation cron
delivery_synced_at INTEGER, -- epoch ms; reconciliation sync marker
created_at INTEGER NOT NULL -- epoch ms
);

CREATE INDEX IF NOT EXISTS idx_email_log_created_at ON email_log(created_at);
CREATE INDEX IF NOT EXISTS idx_email_log_status ON email_log(status);
CREATE INDEX IF NOT EXISTS idx_email_log_to_email ON email_log(to_email);
CREATE INDEX IF NOT EXISTS idx_email_log_flow ON email_log(flow);
CREATE INDEX IF NOT EXISTS idx_email_log_delivery_synced_at ON email_log(delivery_synced_at);
48 changes: 48 additions & 0 deletions packages/core/src/__tests__/plugins/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
hasCapability,
assertCapability,
validateCapabilities,
normalizeCapability,
normalizeCapabilities,
CAPABILITY_RENAMES,
createCapabilityContext,
} from '../../plugins/capabilities'
import { createServiceSingleton } from '../../plugins/singletons/service-singleton'
Expand All @@ -32,6 +35,51 @@ describe('capability vocabulary', () => {
expect(validateCapabilities(['email:send', 'db:logs'])).toEqual([])
expect(validateCapabilities(['email:send', 'nope', 'also:bad'])).toEqual(['nope', 'also:bad'])
})

it('reserves hooks.email:subscribe in the canonical vocabulary', () => {
expect(FIXED_CAPABILITIES).toContain('hooks.email:subscribe')
})
})

describe('normalizeCapability (cross-fork / deprecated spellings)', () => {
it('is identity for an already-canonical capability', () => {
expect(normalizeCapability('media:write')).toBe('media:write')
expect(normalizeCapability('db:email_log')).toBe('db:email_log')
})

it('resolves deprecated/sibling-fork spellings to canonical names', () => {
expect(normalizeCapability('storage:read')).toBe('media:read')
expect(normalizeCapability('storage:write')).toBe('media:write')
expect(normalizeCapability('hooks.cron:register')).toBe('cron:register')
expect(normalizeCapability('hooks.auth:register')).toBe('hooks.auth:subscribe')
expect(normalizeCapability('hooks.content-read:register')).toBe('hooks.content:subscribe')
expect(normalizeCapability('hooks.content-write:register')).toBe('hooks.content:subscribe')
expect(normalizeCapability('hooks.email-events:register')).toBe('hooks.email:subscribe')
})

it('returns null for an unknown capability', () => {
expect(normalizeCapability('totally:made-up')).toBeNull()
// request:intercept has no canonical target — surfaces as unknown, not silent
expect(normalizeCapability('request:intercept')).toBeNull()
})

it('every rename target is itself a known capability', () => {
for (const target of Object.values(CAPABILITY_RENAMES)) {
expect(isKnownCapability(target)).toBe(true)
}
})

it('normalizeCapabilities dedupes canonical results and collects unknowns', () => {
const { capabilities, unknown } = normalizeCapabilities([
'storage:write',
'media:write', // dupe of the normalized storage:write
'hooks.content-read:register',
'hooks.content-write:register', // both collapse to hooks.content:subscribe
'bogus:cap',
])
expect(capabilities).toEqual(['media:write', 'hooks.content:subscribe'])
expect(unknown).toEqual(['bogus:cap'])
})
})

describe('hasCapability / assertCapability', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* End-to-end: a definePlugin() plugin through the real app factory.
*
* Proves the v3 authoring API drops into `plugins.register` and is mounted by
* `createSonicJSApp` with no adapter — the same introspection approach as
* mount-integration.test.ts (construction needs no Workers bindings).
*/
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { createSonicJSApp } from '../../app'
import { definePlugin } from '../../plugins/sdk/define-plugin'

function routePaths(app: ReturnType<typeof createSonicJSApp>): string[] {
return app.routes.map((r) => r.path)
}
const hasPrefix = (paths: string[], prefix: string) => paths.some((p) => p.startsWith(prefix))

describe('definePlugin via createSonicJSApp', () => {
it('mounts a v3 plugin passed through plugins.register', () => {
const plugin = definePlugin({
id: 'v3-demo',
version: '1.0.0',
routes: [{ path: '/api/v3-demo', handler: new Hono().get('/', (c) => c.json({ ok: true })) }],
})
const app = createSonicJSApp({ plugins: { register: [plugin as any] } })
expect(hasPrefix(routePaths(app), '/api/v3-demo')).toBe(true)
})

it('honors disableAll for v3 plugins too', () => {
const plugin = definePlugin({
id: 'v3-off',
version: '1.0.0',
routes: [{ path: '/api/v3-off', handler: new Hono().get('/', (c) => c.text('x')) }],
})
const app = createSonicJSApp({ plugins: { disableAll: true, register: [plugin as any] } })
expect(hasPrefix(routePaths(app), '/api/v3-off')).toBe(false)
})

it('runs a v3 plugin onBoot once on the first request (live wiring)', async () => {
let boots = 0
const plugin = definePlugin({
id: 'v3-boot',
version: '1.0.0',
// public route so we can drive a request without admin auth
routes: [{ path: '/api/v3-boot/ping', handler: new Hono().get('/', (c) => c.text('pong')) }],
onBoot() {
boots++
},
})

const app = createSonicJSApp({ plugins: { register: [plugin as any] } })

// Two requests; onBoot must run exactly once (once-guarded wiring).
await app.request('/api/v3-boot/ping', {}, { DB: undefined, CACHE_KV: undefined } as any)
await app.request('/api/v3-boot/ping', {}, { DB: undefined, CACHE_KV: undefined } as any)

expect(boots).toBe(1)
})
})
Loading