@voltro/cli 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/apiBuild-BOqL1dlg.js +2 -0
  3. package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
  4. package/dist/appGraph-CvQCte0z.js +2 -0
  5. package/dist/appGraph-gQ_6GkQQ.js +110 -0
  6. package/dist/bin.js +3 -3
  7. package/dist/checkCommand-ClRvH_SN.js +2 -0
  8. package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
  9. package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
  10. package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
  11. package/dist/dbCommand-BwQ_Jl98.js +2 -0
  12. package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
  13. package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
  14. package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
  15. package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
  16. package/dist/index.js +1 -1
  17. package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
  18. package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
  19. package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
  20. package/dist/serveEntry.js +2 -2
  21. package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
  22. package/dist/startEntry.js +2 -2
  23. package/package.json +17 -17
  24. package/templates/AGENTS.md +43 -43
  25. package/templates/agent-docs/_index.md +43 -43
  26. package/templates/agent-docs/_manifest.json +84 -42
  27. package/templates/agent-docs/cli.md +4 -2
  28. package/templates/agent-docs/database/migrations.md +14 -1
  29. package/templates/agent-docs/internationalization.md +54 -3
  30. package/templates/agent-docs/plugins/ai-flows.md +148 -0
  31. package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
  32. package/templates/agent-docs/plugins/atlassian.md +378 -0
  33. package/templates/agent-docs/plugins/audit.md +324 -0
  34. package/templates/agent-docs/plugins/auth-auth0.md +92 -0
  35. package/templates/agent-docs/plugins/auth-clerk.md +90 -0
  36. package/templates/agent-docs/plugins/auth-kinde.md +88 -0
  37. package/templates/agent-docs/plugins/auth-oidc.md +95 -0
  38. package/templates/agent-docs/plugins/auth-supabase.md +103 -0
  39. package/templates/agent-docs/plugins/auth-workos.md +105 -0
  40. package/templates/agent-docs/plugins/auth.md +227 -0
  41. package/templates/agent-docs/plugins/billing.md +324 -0
  42. package/templates/agent-docs/plugins/broadcast.md +144 -0
  43. package/templates/agent-docs/plugins/cdc-out.md +116 -0
  44. package/templates/agent-docs/plugins/clickhouse.md +91 -0
  45. package/templates/agent-docs/plugins/datadog.md +105 -0
  46. package/templates/agent-docs/plugins/deactivation.md +114 -0
  47. package/templates/agent-docs/plugins/duckdb.md +82 -0
  48. package/templates/agent-docs/plugins/flags.md +146 -0
  49. package/templates/agent-docs/plugins/governance.md +143 -0
  50. package/templates/agent-docs/plugins/licensing.md +74 -0
  51. package/templates/agent-docs/plugins/logship.md +56 -0
  52. package/templates/agent-docs/plugins/mail.md +334 -0
  53. package/templates/agent-docs/plugins/moderation.md +65 -0
  54. package/templates/agent-docs/plugins/multitenancy.md +99 -0
  55. package/templates/agent-docs/plugins/notifications.md +252 -0
  56. package/templates/agent-docs/plugins/openapi.md +94 -0
  57. package/templates/agent-docs/plugins/postgis.md +140 -0
  58. package/templates/agent-docs/plugins/posthog.md +91 -0
  59. package/templates/agent-docs/plugins/presence.md +115 -0
  60. package/templates/agent-docs/plugins/prometheus.md +124 -0
  61. package/templates/agent-docs/plugins/ratelimit.md +274 -0
  62. package/templates/agent-docs/plugins/rbac.md +270 -0
  63. package/templates/agent-docs/plugins/scim.md +63 -0
  64. package/templates/agent-docs/plugins/search.md +116 -0
  65. package/templates/agent-docs/plugins/sentry.md +192 -0
  66. package/templates/agent-docs/plugins/soft-delete.md +72 -0
  67. package/templates/agent-docs/plugins/sso-saml.md +132 -0
  68. package/templates/agent-docs/plugins/storage.md +709 -0
  69. package/templates/agent-docs/plugins/tinybird.md +92 -0
  70. package/templates/agent-docs/plugins/versioning.md +226 -0
  71. package/templates/agent-docs/plugins/webhooks.md +585 -0
  72. package/templates/agent-docs/security.md +12 -1
  73. package/templates/agent-docs/whats-new.md +81 -37
  74. package/templates/apps/api-ai/package.json +7 -7
  75. package/templates/apps/api-auth/package.json +8 -8
  76. package/templates/apps/api-backend/package.json +7 -7
  77. package/templates/apps/api-backend-deactivation/package.json +7 -7
  78. package/templates/apps/api-backend-mail/package.json +8 -8
  79. package/templates/apps/api-backend-mariadb/package.json +9 -9
  80. package/templates/apps/api-backend-sqlite/package.json +8 -8
  81. package/templates/apps/api-backend-storage/package.json +8 -8
  82. package/templates/apps/api-cms/package.json +10 -10
  83. package/templates/apps/api-collab/package.json +8 -8
  84. package/templates/apps/api-data-advanced/package.json +8 -8
  85. package/templates/apps/api-durable/package.json +8 -8
  86. package/templates/apps/api-feature-flags/package.json +9 -9
  87. package/templates/apps/api-governance/package.json +8 -8
  88. package/templates/apps/api-kv/package.json +8 -8
  89. package/templates/apps/api-moderation/package.json +8 -8
  90. package/templates/apps/api-observability/package.json +8 -8
  91. package/templates/apps/api-ratelimit/package.json +8 -8
  92. package/templates/apps/api-rbac/package.json +8 -8
  93. package/templates/apps/api-rest/package.json +7 -7
  94. package/templates/apps/api-saas/package.json +11 -11
  95. package/templates/apps/api-saas-starter/package.json +10 -10
  96. package/templates/apps/api-search/package.json +8 -8
  97. package/templates/apps/api-status/package.json +8 -8
  98. package/templates/apps/api-versioning/package.json +8 -8
  99. package/templates/apps/api-webhooks/package.json +9 -9
  100. package/templates/apps/changelog/package.json +6 -6
  101. package/templates/apps/edge-functions/package.json +2 -2
  102. package/templates/apps/frontend-admin/package.json +8 -8
  103. package/templates/apps/frontend-app/package.json +8 -8
  104. package/templates/apps/frontend-auth/package.json +8 -8
  105. package/templates/apps/frontend-blank/package.json +7 -7
  106. package/templates/apps/frontend-cms/package.json +9 -9
  107. package/templates/apps/frontend-collab/package.json +9 -9
  108. package/templates/apps/frontend-contact/package.json +7 -7
  109. package/templates/apps/frontend-dashboard/package.json +7 -7
  110. package/templates/apps/frontend-docs/package.json +7 -7
  111. package/templates/apps/frontend-i18n/package.json +6 -6
  112. package/templates/apps/frontend-landing/package.json +7 -7
  113. package/templates/apps/frontend-portal/package.json +8 -8
  114. package/templates/apps/frontend-saas/package.json +8 -8
  115. package/templates/apps/frontend-spa/package.json +7 -7
  116. package/templates/apps/frontend-ssr/package.json +7 -7
  117. package/templates/apps/frontend-ssr-api/package.json +8 -8
  118. package/templates/apps/frontend-static-blog/package.json +6 -6
  119. package/templates/apps/frontend-status/package.json +8 -8
  120. package/templates/apps/mobile-app/package.json +2 -2
  121. package/dist/apiBuild-Ca6Sq5jy.js +0 -2
  122. package/dist/checkCommand-DHD0aBeR.js +0 -2
  123. package/dist/dbCommand-wfGi3FgG.js +0 -2
  124. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +0 -2
@@ -0,0 +1,143 @@
1
+ # Governance
2
+
3
+ > Data governance — retention TTL sweep, GDPR export + erasure, consent ledger, and field-level encryption for .encrypted() columns. Builds on audit + soft-delete.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/governance.md -->
10
+ ## Governance
11
+
12
+ _Data governance — retention TTL sweep, GDPR export + erasure, consent ledger, and field-level encryption for .encrypted() columns. Builds on audit + soft-delete._
13
+
14
+ `@voltro/plugin-governance` is the compliance layer: a periodic **retention sweep** that deletes or anonymises stale rows, **GDPR export + erasure** across every table that references a subject, a **consent ledger**, and **field-level encryption** for `.encrypted()` columns. It pairs with `@voltro/plugin-audit` (who/when) and `@voltro/plugin-soft-delete` (recoverable deletes).
15
+
16
+ ## Wiring
17
+
18
+ ```ts
19
+ // app.config.ts
20
+ import { governancePlugin } from '@voltro/plugin-governance'
21
+
22
+ export default {
23
+ type: 'api' as const,
24
+ name: 'api',
25
+ plugins: [
26
+ governancePlugin({
27
+ // Retention: delete/anonymise rows past a TTL on a periodic sweep.
28
+ retention: [
29
+ { table: 'events', ttlMs: 90 * 86_400_000 }, // delete after 90 days
30
+ { table: 'users', ttlMs: 365 * 86_400_000, action: 'anonymize', anonymizeFields: ['email', 'name'] },
31
+ ],
32
+ // GDPR: which tables reference a subject + by which column.
33
+ subjectScopes: [
34
+ { table: 'users', subjectField: 'id' },
35
+ { table: 'posts', subjectField: 'userId' },
36
+ { table: 'comments', subjectField: 'authorId' },
37
+ ],
38
+ sweepIntervalMs: 3_600_000, // default 1h
39
+ }),
40
+ ],
41
+ }
42
+ ```
43
+
44
+ ## Retention
45
+
46
+ `runRetention` walks each policy's table and, for rows older than `ttlMs` (measured from `dateField`, default `createdAt`), either deletes them (`action: 'delete'`, default) or nulls `anonymizeFields` (`action: 'anonymize'`). The sweep is armed in `bindDataStore` and stopped on `onDeactivate`; trigger it manually via the service's `runRetentionNow()`. On a multi-replica SQL deployment the sweep is **cluster-coordinated** — it runs on only ONE replica per tick (via the same claim gate the cron scheduler uses), so N replicas no longer each run the full paged scan; single-process / memory / SQLite deployments run it locally every tick. No configuration — it's automatic.
47
+
48
+ ## GDPR — export + erasure (admin-gated)
49
+
50
+ Two admin-only routes (guarded by `requireScope(ADMIN_SCOPE)`) walk `subjectScopes`:
51
+
52
+ - **`governance.export`** `{ subjectId }` → a portable bundle `{ [table]: rows[] }` of everything referencing the subject.
53
+ - **`governance.erase`** `{ subjectId, mode? }` → deletes (or anonymises) the subject across every scope; returns an immutable `ErasureLogEntry` (`{ subjectId, at, mode, affected: [{ table, count }] }`).
54
+
55
+ The same operations are available in-handler via `GovernanceService` (`exportSubject` / `eraseSubject`).
56
+
57
+ ## Consent ledger
58
+
59
+ `governance.consent` `{ purpose, granted }` records the calling subject's decision; `governance.hasConsent` `{ purpose }` reads the latest (latest-write-wins per `(subject, purpose)`).
60
+
61
+ The default store is **in-memory** — per-process + restart-cleared, so a consent recorded on one replica is invisible to others (the plugin **warns at boot** when it runs under this default). For production pass `consent: 'datastore'` for the shipped **durable, cross-instance** store: it contributes a `_voltro_consent` table (via `extendSchema`, under the `store:write` permission the plugin already declares) and appends one row per decision, so a consent recorded on any replica is visible everywhere and the full history persists for audit. Or pass a custom `ConsentStore`.
62
+
63
+ ```ts
64
+ governancePlugin({ consent: 'datastore' }) // durable + cross-instance
65
+ ```
66
+
67
+ ```ts
68
+ import { GovernanceService } from '@voltro/plugin-governance'
69
+
70
+ export default (input, ctx) => Effect.gen(function* () {
71
+ const gov = yield* GovernanceService
72
+ if (!(yield* Effect.promise(() => gov.hasConsent(ctx.request.subject.id, 'marketing')))) return { skipped: true }
73
+ // … send the marketing email …
74
+ })
75
+ ```
76
+
77
+ ## Field-level encryption
78
+
79
+ Flag a column `.encrypted()` in the schema and `fieldEncryption: true` registers an AES-256-GCM cipher that the store middleware applies transparently — writes encrypt, reads decrypt, handlers always see plaintext, the column stores an opaque `enc:v1:…` string on every dialect.
80
+
81
+ ```ts
82
+ // schema — database/patients.entity.ts
83
+ export const patients = table('patients', {
84
+ id: id(),
85
+ name: text(),
86
+ ssn: text().encrypted(), // AES-256-GCM at rest
87
+ notes: json().encrypted(), // any column type — JSON-encoded then encrypted
88
+ })
89
+
90
+ // app.config.ts
91
+ governancePlugin({ fieldEncryption: true }) // key from secret VOLTRO_FIELD_ENCRYPTION_KEY
92
+ // or: fieldEncryption: { secretKey: 'MY_KEY_NAME' }
93
+ ```
94
+
95
+ The key resolves through the [Secrets-Resolver](/docs/configuration/secrets) — pass a 64-hex-char string for raw key bytes, or any passphrase (scrypt-derived). Rules:
96
+
97
+ - **Lose the key, lose the data.** GCM authentication fails closed — a wrong/tampered value throws on read, never silently corrupts. The failure is a typed `FieldDecryptionError` naming the `table.column` (not a raw `malformed ciphertext` with no context, and never the ciphertext itself), so you can see WHICH column and `Effect.catchTag` it server-side. If it reaches the wire UNCAUGHT, it collapses to a generic `InternalError` for the client (the `table.column` and cause stay in the server log) — never an `ExitEncoded` schema dump.
98
+ - An `.encrypted()` column **can't be filtered or sorted by plaintext in SQL** (it's ciphertext on disk). Encrypt only what you read back whole — PII, tokens, free-text notes.
99
+ - Boot fails loud if an `.encrypted()` column exists but no cipher is registered (enable `fieldEncryption`), or if `fieldEncryption` is on but the key can't resolve.
100
+ - Reads of rows written *before* encryption was enabled pass through untouched (only `enc:v1:…` values are decrypted), so you can turn it on incrementally.
101
+
102
+ ### Restoring a snapshot encrypted under a different key
103
+
104
+ Importing a prod/staging DB dump into a dev DB brings ciphertext bound to the
105
+ *source* key — your dev `VOLTRO_FIELD_ENCRYPTION_KEY` can't read it, and by
106
+ default one such row throws a `FieldDecryptionError` that fails the whole read
107
+ (including its readable siblings). For dev/migration only, set:
108
+
109
+ ```bash
110
+ VOLTRO_FIELD_DECRYPT_ON_ERROR=null
111
+ ```
112
+
113
+ Now an undecryptable column degrades to `null` with one deduped warning per
114
+ `table.column` (scope `store.fieldEncryption`) instead of nuking the read — the
115
+ row's other columns still decrypt. This is the difference between "re-enter your
116
+ Jira PAT" and "the whole section 500s". **Never set it in production**, where a
117
+ key mismatch must fail loud. A cutover tool that writes rows into `.encrypted()`
118
+ columns should `NULL` those columns on import — the source ciphertext is useless
119
+ without the source key, and users re-enter the credential so it re-encrypts under
120
+ the local one.
121
+
122
+ ### Raw-SQL escape hatch — `encryptField` / `decryptField`
123
+
124
+ Transparent encryption runs INSIDE the `ctx.store` middleware, so a code path that reaches the DB by **raw SQL** — an auth strategy writing/reading a session token with no store handle, a one-off backfill — bypasses it. For those, `@voltro/runtime` exposes the SAME registered cipher standalone:
125
+
126
+ ```ts
127
+ import { encryptField, decryptField } from '@voltro/runtime'
128
+
129
+ // write path (raw SQL): encrypt by hand
130
+ await sql`INSERT INTO sessions (id, token) VALUES (${id}, ${encryptField(pat)})`
131
+ // read path (raw SQL): decrypt by hand
132
+ const token = decryptField(row.token) // a non-ciphertext value returns unchanged
133
+ ```
134
+
135
+ `decryptField` returns a non-`enc:v1:…` value **unchanged**, so you can switch a raw-SQL path to encryption while pre-existing plaintext rows keep working until they're re-written. Both throw a clear error if no cipher is registered (enable `fieldEncryption`). Encryption stops being all-or-nothing tied to going through `ctx.store`.
136
+
137
+ ## Dashboard panel
138
+
139
+ Both dashboards ship a **Governance** panel (api apps): retention-policy status + field-encryption state + last-sweep results (with a **Run sweep now** button), a **GDPR runner** (enter a subject id → Export / Erase delete / Erase anonymize), a consent-ledger lookup, and the erasure log. Write-actions gate on the `canRunGovernance` capability. Backed by `/_voltro/inspect/plugins/governance/{status,erasures,consent,export,erase,sweep}`.
140
+
141
+ ## Permissions
142
+
143
+ `store:write` (retention sweep + GDPR erasure mutate rows; field encryption rides the store middleware) + `inspect:read` (dashboard panel).
@@ -0,0 +1,74 @@
1
+ # Licensing
2
+
3
+ > Offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; pricing decided server-side, never baked into a published version.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/licensing.md -->
10
+ ## Licensing
11
+
12
+ _Offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; pricing decided server-side, never baked into a published version._
13
+
14
+ `@voltro/plugin-licensing` lets a **cloud** decide a customer's plan entitlements and pricing, and enforces them in a **running app** — without baking anything into an immutable published version. The license is a compact **EdDSA (Ed25519)** token, verified **offline** with a public key the app embeds. It's asymmetric on purpose: the signing secret never leaves the cloud, so a customer cannot forge a license.
15
+
16
+ The plugin does not enforce quotas itself — it feeds [`plugin-billing`](/docs/plugins/billing), which meters and enforces them via a new `resolveEntitlementLimit` seam.
17
+
18
+ ## Wiring
19
+
20
+ ```ts
21
+ // app.config.ts
22
+ import { licensingPlugin, entitlementResolver } from '@voltro/plugin-licensing'
23
+ import { billingPlugin } from '@voltro/plugin-billing'
24
+
25
+ export default {
26
+ type: 'api' as const,
27
+ name: 'api',
28
+ plugins: [
29
+ licensingPlugin(), // reads VOLTRO_LICENSE_* env
30
+ billingPlugin({
31
+ plans: { free: { entitlements: { projects: 1 } } }, // static fallback
32
+ resolveEntitlementLimit: entitlementResolver, // license overrides per-tenant
33
+ }),
34
+ ],
35
+ }
36
+ ```
37
+
38
+ With a valid `pro` license granting `{ projects: 3 }`, `requireEntitlement(ctx, 'projects', 1)` allows three and then fails `EntitlementExceeded`. With no license, the static `free` plan (`projects: 1`) applies — the resolver returns `null` and billing falls back to its own registry.
39
+
40
+ ## Keys
41
+
42
+ Generate a signing keypair:
43
+
44
+ ```sh
45
+ voltro secret keypair
46
+ # VOLTRO_LICENSE_SIGNING_KEY (Ed25519 private, cloud-only — signs licenses, NEVER ship)
47
+ # VOLTRO_LICENSE_PUBLIC_KEY (Ed25519 public — ship to apps for offline verify)
48
+ ```
49
+
50
+ Keep the private key in the cloud; the public key is safe to embed. The app reads `VOLTRO_LICENSE_PUBLIC_KEY` + `VOLTRO_LICENSE_KEY` (or the `publicKey` / `licenseKey` options).
51
+
52
+ ## Options
53
+
54
+ | Option | Env fallback | Meaning |
55
+ |---|---|---|
56
+ | `publicKey` | `VOLTRO_LICENSE_PUBLIC_KEY` | Ed25519 public key (SPKI/PEM). |
57
+ | `licenseKey` | `VOLTRO_LICENSE_KEY` | The signed license token, verified at boot. |
58
+ | `snapshotUrl` | — | URL to periodically re-fetch a fresh license (entitlement sync). |
59
+ | `refreshMs` | — | Refresh cadence for `snapshotUrl` (default 15 min, cluster-coordinated). |
60
+ | `graceSeconds` | — | Seconds an expired license is still honored (default 0). |
61
+
62
+ ## API
63
+
64
+ - **`entitlementResolver(tenantId, key)`** — pass to `billingPlugin({ resolveEntitlementLimit })`. Returns the license's limit for the pair, or `null` to fall back to the static plan registry.
65
+ - **`LicenseService`** — `yield* LicenseService` in a handler to read `limitFor` / `hasFeature` / `allowsPlugin` / `edition` / `snapshot` directly.
66
+ - **`licenseAllowsPlugin(name)` / `requireLicensedPlugin(name)`** — the plugin gate ("installed ≠ billable"). A license carries a `plugins: string[]` grant; `requireLicensedPlugin` fails `PluginNotLicensed` when a plugin's production use isn't licensed. Metered plugin pricing uses entitlement keys (`plugin:<name>:<meter>`); plan-gated plugins use the grant. Pricing is decided server-side and travels in the signed license — no `plugin_definitions` table needed in the app.
67
+ - **`verifyLicense(token, publicKeyPem)`** — offline EdDSA verify → `LicenseClaims`.
68
+ - **`@voltro/plugin-licensing/sign` → `signLicense(input, privateKeyPem)`** — server-only signer for the cloud/CLI to mint licenses.
69
+
70
+ ## Notes
71
+
72
+ - **Server-only** (imports `jose`) — never import from a browser-loaded descriptor.
73
+ - **Fail-closed**: an invalid/expired token grants nothing; billing falls back to the static plan. The last-good snapshot is honored within `graceSeconds`.
74
+ - Cloud-side issuance (a license endpoint + `voltro cloud register`) ships in a later milestone; today licenses are provided via env / config.
@@ -0,0 +1,56 @@
1
+ # Log shipping
2
+
3
+ > Ship structured logs to Better Stack / Axiom / Loki / any HTTP sink — batched, redacted, fail-soft. Rides the framework log-sink hook.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/logship.md -->
10
+ ## Log shipping
11
+
12
+ _Ship structured logs to Better Stack / Axiom / Loki / any HTTP sink — batched, redacted, fail-soft. Rides the framework log-sink hook._
13
+
14
+ `@voltro/plugin-logship` forwards the framework's structured `@voltro/logger` stream to a log backend. It rides the log-sink hook (`addSink`), batches records, redacts secrets, and fails soft — a backend outage never disturbs the app.
15
+
16
+ ## Wiring
17
+
18
+ ```ts
19
+ // app.config.ts
20
+ import { logshipPlugin } from '@voltro/plugin-logship'
21
+
22
+ export default {
23
+ type: 'api' as const,
24
+ name: 'api',
25
+ plugins: [
26
+ // Pick ONE backend (discriminated union):
27
+ logshipPlugin({ backend: 'betterstack', token: process.env.BETTERSTACK_TOKEN! }),
28
+ // logshipPlugin({ backend: 'axiom', token: process.env.AXIOM_TOKEN!, dataset: 'voltro' }),
29
+ // logshipPlugin({ backend: 'loki', endpoint: process.env.LOKI_URL!, tags: { app: 'api' } }),
30
+ // logshipPlugin({ backend: 'http', endpoint: 'https://logs.example.com/ingest', headers: { ... } }),
31
+ ],
32
+ }
33
+ ```
34
+
35
+ ## Behaviour
36
+
37
+ - **Batching** — records buffer and flush on a size/interval threshold (one HTTP POST per batch, not per line). The batch is bounded on BOTH axes: record count (`maxBatch`, default 200) and approximate payload bytes (`maxBatchBytes`, default 900 000 — headroom under the ~1 MB tiers Better Stack / Axiom / Loki cap at). A burst of large records flushes early so a batch can't exceed the intake's payload limit; a single record larger than the cap still ships alone (the cap bounds the batch, not the record).
38
+ - **Redaction** — a `redact: (record) => record` hook rewrites each record before shipping (drop or mask fields), e.g. `redact: (r) => ({ ...r, fields: { ...r.fields, token: undefined } })`.
39
+ - **Fail-soft** — a backend error logs a local warning and drops the batch; the app keeps running. Logs are observability, not a hard dependency.
40
+ - **Trace correlation** — every record carries `fields.traceId`, so the shipped logs join the same end-to-end chain `voltro logs --trace <id>` shows locally.
41
+ - **Deliberately not shipped (yet)** — no gzip compression (batches ship uncompressed; `maxBatchBytes` bounds the uncompressed payload), no built-in sampling (shed by severity with `minLevel` or by predicate with `redact`), and no bounded retry (a failed batch is dropped, not requeued — the shed-don't-requeue fail-soft posture). Each is additive and roadmap, not a phantom advertised feature.
42
+
43
+ ## Backends
44
+
45
+ | `backend` | Required | Endpoint |
46
+ |---|---|---|
47
+ | `betterstack` | `token` (opt. `endpoint`, `tags`) | Better Stack Logs ingest |
48
+ | `axiom` | `token`, `dataset` (opt. `endpoint`, `tags`) | Axiom `/v1/datasets/<dataset>/ingest` |
49
+ | `loki` | `endpoint` (opt. `tags`) | Grafana Loki `/loki/api/v1/push` |
50
+ | `http` | `endpoint` (opt. `headers`, `tags`) | any JSON-array ingest |
51
+
52
+ The request shaping per backend is pure (`betterStackRequest` / `axiomRequest` / `lokiRequest` / `httpRequest` → a `ShipRequest`), so the mapping is unit-tested without a network.
53
+
54
+ ## Permissions
55
+
56
+ `network:outbound:<host>` (declared from the backend host) — the plugin only ships to the configured host.
@@ -0,0 +1,334 @@
1
+ # Mail
2
+
3
+ > Transactional email — Resend / Postmark / SendGrid / SMTP, React-Email templates with auto-discovery, per-tenant suppression, bounce/complaint handling, durable delivery via workflows.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/mail.md -->
10
+ ## Mail
11
+
12
+ _Transactional email — Resend / Postmark / SendGrid / SMTP, React-Email templates with auto-discovery, per-tenant suppression, bounce/complaint handling, durable delivery via workflows._
13
+
14
+ `@voltro/plugin-mail` is transactional email behind one `MailService`. It ships
15
+ provider adapters, React-Email templates discovered from `*.email.tsx` files, a
16
+ per-tenant suppression list fed by bounce/complaint webhooks, and a dev
17
+ allowlist. Durable delivery rides the framework's workflow engine — there is no
18
+ separate queue/worker to run.
19
+
20
+ ## Quick start
21
+
22
+ ```ts
23
+ // app.config.ts
24
+ import { mailPlugin } from '@voltro/plugin-mail'
25
+
26
+ export default {
27
+ type: 'api' as const,
28
+ name: 'myApi',
29
+ plugins: [
30
+ mailPlugin({
31
+ provider: 'resend', // reads RESEND_API_KEY
32
+ from: 'Acme <hello@acme.com>',
33
+ // suppression: 'postgres', // multi-node bounce/complaint list
34
+ // allowlist: ['me@acme.com'], // dev-only: don't email real users
35
+ }),
36
+ ],
37
+ }
38
+ ```
39
+
40
+ In any handler, pull the service off the plugin's services layer:
41
+
42
+ ```ts
43
+ import { MailService } from '@voltro/plugin-mail'
44
+ import { Effect } from 'effect'
45
+
46
+ export default (input: { email: string }) =>
47
+ Effect.gen(function* () {
48
+ const mail = yield* MailService
49
+ yield* mail.send({ to: input.email, subject: 'Hi', html: '<p>welcome</p>' })
50
+ return { sent: true }
51
+ })
52
+ ```
53
+
54
+ ## Attachments
55
+
56
+ Both raw and template messages take an `attachments` list — `MailAttachment` is `{ filename, content, contentType? }` where `content` is base64. It maps to each transport's native attachment shape (Resend / Postmark / SendGrid / SMTP):
57
+
58
+ ```ts
59
+ yield* mail.send({
60
+ to: user.email,
61
+ subject: 'Your invoice',
62
+ html: '<p>Attached.</p>',
63
+ attachments: [{ filename: 'invoice.pdf', content: pdfBase64, contentType: 'application/pdf' }],
64
+ })
65
+ ```
66
+
67
+ ## Providers
68
+
69
+ One `MailProvider` contract, several adapters. `provider` is a string (built
70
+ from env) or a `MailProvider` object (the exported factories / bring-your-own).
71
+
72
+ | Provider | `provider` | Transport | Key (env) |
73
+ |---|---|---|---|
74
+ | Resend | `'resend'` | REST (`fetch`) | `RESEND_API_KEY` |
75
+ | Postmark | `'postmark'` | REST (`fetch`) | `POSTMARK_SERVER_TOKEN` |
76
+ | SendGrid | `'sendgrid'` | REST (`fetch`) | `SENDGRID_API_KEY` |
77
+ | SES | `'ses'` | SESv2 REST (HttpClient, SigV4) | `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN?` |
78
+ | Mailgun | `'mailgun'` | REST (HttpClient, form) | `MAILGUN_API_KEY`, `MAILGUN_DOMAIN`, `MAILGUN_BASE_URL?` |
79
+ | SMTP | `'smtp'` | nodemailer | `SMTP_URL` or `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS` |
80
+ | console | `'console'` | logs to stdout | — (zero-config default) |
81
+ | memory | `'memory'` | in-process buffer | — (tests: `readMailBuffer()`) |
82
+
83
+ ```ts
84
+ // string + env (default: MAIL_PROVIDER env, else 'console')
85
+ mailPlugin({ provider: 'postmark', from: '…' })
86
+
87
+ // explicit factory — full control / DI
88
+ import { resendProvider } from '@voltro/plugin-mail'
89
+ mailPlugin({ provider: resendProvider({ apiKey: process.env.RESEND_API_KEY! }), from: '…' })
90
+ ```
91
+
92
+ Resend / Postmark / SendGrid use `fetch`; SES + Mailgun go through the
93
+ framework-native `@effect/platform` HttpClient (SES is SigV4-signed with
94
+ `node:crypto` — no `@aws-sdk`); SMTP uses `nodemailer` (optional dep, loaded
95
+ only when used). None pull a vendor SDK. Policy — default `from`, transient
96
+ retry, suppression, allowlist, scheduling, batching, idempotency — lives in the
97
+ service, not the adapters. A failed send fails with a typed `MailError` whose
98
+ `transient` flag (429 / 5xx / network) drove the retry.
99
+
100
+ **SES + Mailgun setup.** SES resolves from `AWS_REGION` +
101
+ `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (+ optional `AWS_SESSION_TOKEN`
102
+ for STS creds); the endpoint is derived from the region. Mailgun resolves from
103
+ `MAILGUN_API_KEY` (sent as the HTTP basic-auth password for user `api`) +
104
+ `MAILGUN_DOMAIN`, with `MAILGUN_BASE_URL=https://api.eu.mailgun.net` for the EU
105
+ region. Secrets are read from env / options and never logged.
106
+
107
+ ## Templates — `*.email.tsx`
108
+
109
+ Author emails as React-Email components. `defineEmail` pairs a name + an
110
+ effect/Schema for the props + the subject line + the render function:
111
+
112
+ ```tsx
113
+ // apps/api/emails/welcome.email.tsx
114
+ import { defineEmail } from '@voltro/plugin-mail'
115
+ import { Html, Head, Body, Text, Button } from '@react-email/components'
116
+ import { Schema } from 'effect'
117
+
118
+ export const welcome = defineEmail({
119
+ name: 'welcome',
120
+ props: Schema.Struct({ name: Schema.String }),
121
+ preview: { name: 'Mario' }, // dashboard preview pre-fill
122
+ subject: (p) => `Welcome, ${p.name}!`,
123
+ render: (p) => (
124
+ <Html>
125
+ <Head />
126
+ <Body>
127
+ <Text>Hi {p.name},</Text>
128
+ <Button href="https://acme.com/dashboard">Open dashboard</Button>
129
+ </Body>
130
+ </Html>
131
+ ),
132
+ })
133
+ ```
134
+
135
+ Send it by name — props are validated against the Schema, the subject is
136
+ derived, and the component is rendered to **HTML + plain-text**:
137
+
138
+ ```ts
139
+ yield* mail.send({ to: user.email, template: 'welcome', props: { name: user.name } })
140
+ ```
141
+
142
+ **Auto-discovery:** `voltro dev` walks `*.email.tsx` files and registers their
143
+ `defineEmail(...)` exports automatically — you don't list them in
144
+ `app.config.ts`. (To register by hand, e.g. in a test, pass
145
+ `mailPlugin({ templates: [welcome] })`; explicit templates win over discovered
146
+ ones.)
147
+
148
+ **Locales:** add a `locale` and the matching variant is picked, falling back to
149
+ the base template:
150
+
151
+ ```tsx
152
+ export const welcomeDe = defineEmail({ name: 'welcome', locale: 'de', /* … */ })
153
+ ```
154
+
155
+ ```ts
156
+ yield* mail.send({ to, template: 'welcome', props, locale: 'de' }) // → welcome.de, else welcome
157
+ ```
158
+
159
+ Rendered output is cached by `(template, locale, props)`. `react` +
160
+ `@react-email/render` are optional peers — apps that never send a template
161
+ never load them.
162
+
163
+ ## Suppression — bounces & complaints
164
+
165
+ Bounced or complained addresses land on a per-tenant suppression list; the send
166
+ path silently drops suppressed recipients (and skips the send entirely if none
167
+ remain). Backend is `'memory'` (default, single-node) or `'postgres'` (own pool
168
+ from the same `DB_*`/`PG_*` env; creates `_voltro_mail_suppression`). Both fail
169
+ OPEN — a suppression-db blip never blocks legitimate mail.
170
+
171
+ ```ts
172
+ mailPlugin({ provider: 'resend', from: '…', suppression: 'postgres' })
173
+ ```
174
+
175
+ Manage it from a handler — the `MailService` exposes the list directly:
176
+
177
+ ```ts
178
+ const mail = yield* MailService
179
+ yield* mail.suppress(tenantId, 'dead@x.com', 'manual')
180
+ yield* mail.unsuppress(tenantId, 'recovered@x.com')
181
+ const blocked = yield* mail.isSuppressed(tenantId, 'dead@x.com')
182
+ ```
183
+
184
+ Pass `tenantId` on a message to scope the check; omit it for app-global
185
+ suppression:
186
+
187
+ ```ts
188
+ yield* mail.send({ to: user.email, tenantId: user.tenantId, subject: '…', html: '…' })
189
+ ```
190
+
191
+ ### Bounce / complaint webhooks
192
+
193
+ Providers POST delivery events; `parseMailEvent` normalises each provider's
194
+ shape and `handleMailEvents` applies them — bounces + complaints auto-suppress.
195
+ Mount it from a `*.webhook.tsx` incoming query (signature-verified by
196
+ `@voltro/plugin-webhooks`):
197
+
198
+ ```tsx
199
+ // apps/api/webhooks/resend.webhook.tsx
200
+ import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
201
+ import { genericProvider } from '@voltro/plugin-webhooks/providers'
202
+ import { handleMailEvents, memorySuppressionStore } from '@voltro/plugin-mail'
203
+ import { Effect, Schema } from 'effect'
204
+
205
+ // Use the SAME suppression store the mail plugin uses (export it from a
206
+ // shared module, or use the postgres store which is process-global).
207
+ const suppression = memorySuppressionStore()
208
+
209
+ export default defineIncomingWebhook({
210
+ id: 'resend',
211
+ provider: genericProvider(), // verifies the signing secret
212
+ payload: Schema.Any,
213
+ handler: async (ctx) =>
214
+ Effect.runPromise(handleMailEvents('resend', ctx.body, suppression, ctx.headers['x-tenant'] ?? null)),
215
+ })
216
+ ```
217
+
218
+ - `parseMailEvent(provider, payload)` → normalised `MailEvent[]`
219
+ (`delivered | bounced | complained | opened | clicked`) for `'resend'`,
220
+ `'postmark'`, and `'sendgrid'` (SendGrid posts an array).
221
+ - `handleMailEvents(provider, payload, store, tenantId?)` parses **and**
222
+ suppresses bounced/complained addresses in one call, returning the events
223
+ for logging/auditing.
224
+
225
+ ## Send-time scheduling
226
+
227
+ Pass `scheduledAt: Date` to deliver later. Providers that schedule natively —
228
+ Resend (`scheduled_at`), SendGrid (`send_at`), Mailgun (`o:deliverytime`) —
229
+ get it on the wire. For the rest (`console` / `memory` / `smtp` / `ses`) the
230
+ service **holds** the message and a background flush loop delivers it once the
231
+ due time passes:
232
+
233
+ ```ts
234
+ yield* mail.send({
235
+ to: user.email,
236
+ subject: 'Trial reminder',
237
+ html: '<p>Your trial ends tomorrow.</p>',
238
+ scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
239
+ })
240
+ ```
241
+
242
+ A held send returns `{ id: 'scheduled', provider: 'scheduled' }` right away; a
243
+ `scheduledAt` in the past sends immediately. The hold store is in-memory by
244
+ default (single-node) — pass a durable `ScheduleStore` via
245
+ `mailPlugin({ schedule })` for cross-replica, restart-surviving schedules, or
246
+ use a workflow step (below) for the crash-proof path.
247
+
248
+ ## Bulk / batch send
249
+
250
+ `sendBatch` sends N **personalized** messages (each with its own subject,
251
+ body, and template vars) in one provider call where the transport supports it
252
+ (Resend `/emails/batch`, SendGrid N `personalizations`), falling back to a
253
+ looped `send` otherwise. Allowlist, suppression, default-`from`, scheduling and
254
+ idempotency apply per entry:
255
+
256
+ ```ts
257
+ const mail = yield* MailService
258
+ yield* mail.sendBatch([
259
+ { to: 'ana@x.com', template: 'welcome', props: { name: 'Ana' } },
260
+ { to: 'bob@x.com', template: 'welcome', props: { name: 'Bob' } },
261
+ ])
262
+ ```
263
+
264
+ It returns one `SendResult` per input, in order.
265
+
266
+ ## Idempotency — webhook-retry double-send guard
267
+
268
+ Pass `idempotencyKey` to guard against an at-least-once webhook / retry
269
+ double-send **outside** a workflow step. The first send with a given key (per
270
+ tenant) delivers and records its result; a replay with the same key is a no-op
271
+ that returns the recorded `SendResult` — one delivery:
272
+
273
+ ```ts
274
+ yield* mail.send({ to: user.email, subject: 'Receipt', html, idempotencyKey: webhookEvent.id })
275
+ ```
276
+
277
+ The reservation is an atomic `INSERT … ON CONFLICT DO NOTHING`, so exactly one
278
+ replica wins the key fleet-wide. Unlike suppression this store fails **closed**
279
+ — a dedup-DB blip skips the send rather than risk a double. In-memory by
280
+ default; set `mailPlugin({ idempotency: 'postgres' })` for cross-replica dedup.
281
+
282
+ ## Durable delivery — via workflows, not a queue
283
+
284
+ There is **no separate mail queue or worker**. Durable execution in Voltro is
285
+ `@effect/workflow` (the framework forbids parallel job runners). For a send
286
+ that must survive a crash / retry from where it died, call `mail.send` inside a
287
+ workflow `step` — the step result is journaled and replayed on resume:
288
+
289
+ ```tsx
290
+ // apps/api/workflows/onboarding.workflow.tsx
291
+ import { workflow, step } from '@voltro/workflow'
292
+ import { MailService } from '@voltro/plugin-mail'
293
+ import { Effect, Schema } from 'effect'
294
+
295
+ export const Onboard = workflow({
296
+ name: 'onboarding',
297
+ payload: { email: Schema.String, name: Schema.String },
298
+ success: Schema.Struct({ ok: Schema.Boolean }),
299
+ })
300
+
301
+ const buildExecute = () => ({ email, name }: { email: string; name: string }) =>
302
+ Effect.gen(function* () {
303
+ const mail = yield* MailService
304
+ yield* step({
305
+ name: 'send-welcome',
306
+ success: Schema.Struct({ id: Schema.String, provider: Schema.String }),
307
+ execute: mail.send({ to: email, template: 'welcome', props: { name } }),
308
+ })
309
+ return { ok: true }
310
+ })
311
+
312
+ export default buildExecute
313
+ ```
314
+
315
+ A plain `mail.send` (outside a workflow) retries transient failures in-process
316
+ but does not survive a crash. Use the workflow step when durability matters
317
+ (onboarding, billing receipts); a bare `send` is right for best-effort
318
+ notifications.
319
+
320
+ ## Dev safety — allowlist
321
+
322
+ Outside production, set an allowlist so a staging run can't email real users.
323
+ Recipients not on the list are dropped (logged); production bypasses it.
324
+
325
+ ```ts
326
+ mailPlugin({ provider: 'resend', from: '…', allowlist: ['me@acme.com'] })
327
+ // or env: MAIL_ALLOWLIST=me@acme.com,qa@acme.com
328
+ ```
329
+
330
+ ## See also
331
+
332
+ - [Webhooks](/docs/plugins/webhooks) — mounting the provider bounce/complaint query
333
+ - [Rate limiting](/docs/plugins/ratelimit) — the sibling interceptor plugin
334
+ - [Multi-tenancy](/docs/multi-tenancy) — where per-tenant suppression scoping comes from
@@ -0,0 +1,65 @@
1
+ # Moderation
2
+
3
+ > Moderate user content before it commits — keyword denylist or AI provider, block or flag via rpc interceptor, plus an in-handler redact helper.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/moderation.md -->
10
+ ## Moderation
11
+
12
+ _Moderate user content before it commits — keyword denylist or AI provider, block or flag via rpc interceptor, plus an in-handler redact helper._
13
+
14
+ `@voltro/plugin-moderation` screens user-submitted content. A rule matches an rpc tag + fields; the configured provider classifies the text; the interceptor **blocks** (typed `ContentRejected`, the write never commits) or **flags** (annotates, lets it through). Because the interceptor can't rewrite input, **redact** is an in-handler helper.
15
+
16
+ ## Wiring
17
+
18
+ ```ts
19
+ // app.config.ts
20
+ import { moderationPlugin, keywordProvider, aiProvider } from '@voltro/plugin-moderation'
21
+
22
+ export default {
23
+ type: 'api' as const,
24
+ name: 'api',
25
+ plugins: [
26
+ moderationPlugin({
27
+ provider: keywordProvider(['spam', 'scam', 'http://badsite']), // or aiProvider() — uses @voltro/ai
28
+ rules: [
29
+ { match: 'posts.create', fields: ['title', 'body'], action: 'block' },
30
+ { match: /^comments\./, fields: ['text'], action: 'flag' },
31
+ ],
32
+ }),
33
+ ],
34
+ }
35
+ ```
36
+
37
+ ## Providers
38
+
39
+ - **`keywordProvider(terms)`** — pure denylist; zero deps, deterministic, instant. Best for a known set of banned terms / URLs.
40
+ - **`aiProvider(opts?)`** — lazy `@voltro/ai` classification (`@voltro/ai` is an optional dep). **Fails open**: a provider error resolves to `CLEAN` so an AI outage can't block all writes. Use for nuanced toxicity/abuse detection.
41
+ - Custom — any function with the provider signature `(text) => Effect.Effect<ModerationVerdict>` plugs into the same `provider` slot. The verdict is `{ flagged, categories?, score?, reason? }`.
42
+
43
+ ## Block vs flag vs redact
44
+
45
+ - **`block`** — interceptor fails the call with typed `ContentRejected` (`{ tag, categories, reason }`, merged into the wire-error union). The write never commits.
46
+ - **`flag`** — lets the write through; surfaces the classification for downstream review (e.g. a moderation queue).
47
+ - **Redact** — the interceptor can't mutate input, so there is no `redact` action. Instead, call the in-handler `moderate(text)` helper: it **classifies** the text against the configured provider (returning a `ModerationVerdict` — it does NOT return redacted text) and the handler decides what to store:
48
+
49
+ ```ts
50
+ import { moderate } from '@voltro/plugin-moderation'
51
+
52
+ export default (input: { body: string }, ctx) => Effect.gen(function* () {
53
+ const verdict = yield* moderate(input.body) // classifies via the configured provider
54
+ const body = verdict.flagged ? '[redacted]' : input.body
55
+ return yield* Effect.tryPromise(() => ctx.store.insert('posts', { body }))
56
+ })
57
+ ```
58
+
59
+ ## Dashboard panel
60
+
61
+ Both dashboards ship a **Moderation** panel (api apps): block/flag counts + the **review queue** of flagged content (tag, categories, reason, subject), each pending item with **Confirm** (real violation) / **Dismiss** (false positive) actions. Resolving gates on the `canModerateContent` capability. Backed by `/_voltro/inspect/plugins/moderation/{flagged,resolve}`.
62
+
63
+ ## Permissions
64
+
65
+ `rpc:intercept:mutation` + `rpc:intercept:action` (moderation gates both writes and external-I/O actions) + `inspect:read` (dashboard panel).