@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.
- package/CHANGELOG.md +163 -0
- package/dist/apiBuild-BOqL1dlg.js +2 -0
- package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
- package/dist/appGraph-CvQCte0z.js +2 -0
- package/dist/appGraph-gQ_6GkQQ.js +110 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-ClRvH_SN.js +2 -0
- package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
- package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
- package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
- package/dist/dbCommand-BwQ_Jl98.js +2 -0
- package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
- package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
- package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
- package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
- package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
- package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
- package/dist/serveEntry.js +2 -2
- package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +43 -43
- package/templates/agent-docs/_index.md +43 -43
- package/templates/agent-docs/_manifest.json +84 -42
- package/templates/agent-docs/cli.md +4 -2
- package/templates/agent-docs/database/migrations.md +14 -1
- package/templates/agent-docs/internationalization.md +54 -3
- package/templates/agent-docs/plugins/ai-flows.md +148 -0
- package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
- package/templates/agent-docs/plugins/atlassian.md +378 -0
- package/templates/agent-docs/plugins/audit.md +324 -0
- package/templates/agent-docs/plugins/auth-auth0.md +92 -0
- package/templates/agent-docs/plugins/auth-clerk.md +90 -0
- package/templates/agent-docs/plugins/auth-kinde.md +88 -0
- package/templates/agent-docs/plugins/auth-oidc.md +95 -0
- package/templates/agent-docs/plugins/auth-supabase.md +103 -0
- package/templates/agent-docs/plugins/auth-workos.md +105 -0
- package/templates/agent-docs/plugins/auth.md +227 -0
- package/templates/agent-docs/plugins/billing.md +324 -0
- package/templates/agent-docs/plugins/broadcast.md +144 -0
- package/templates/agent-docs/plugins/cdc-out.md +116 -0
- package/templates/agent-docs/plugins/clickhouse.md +91 -0
- package/templates/agent-docs/plugins/datadog.md +105 -0
- package/templates/agent-docs/plugins/deactivation.md +114 -0
- package/templates/agent-docs/plugins/duckdb.md +82 -0
- package/templates/agent-docs/plugins/flags.md +146 -0
- package/templates/agent-docs/plugins/governance.md +143 -0
- package/templates/agent-docs/plugins/licensing.md +74 -0
- package/templates/agent-docs/plugins/logship.md +56 -0
- package/templates/agent-docs/plugins/mail.md +334 -0
- package/templates/agent-docs/plugins/moderation.md +65 -0
- package/templates/agent-docs/plugins/multitenancy.md +99 -0
- package/templates/agent-docs/plugins/notifications.md +252 -0
- package/templates/agent-docs/plugins/openapi.md +94 -0
- package/templates/agent-docs/plugins/postgis.md +140 -0
- package/templates/agent-docs/plugins/posthog.md +91 -0
- package/templates/agent-docs/plugins/presence.md +115 -0
- package/templates/agent-docs/plugins/prometheus.md +124 -0
- package/templates/agent-docs/plugins/ratelimit.md +274 -0
- package/templates/agent-docs/plugins/rbac.md +270 -0
- package/templates/agent-docs/plugins/scim.md +63 -0
- package/templates/agent-docs/plugins/search.md +116 -0
- package/templates/agent-docs/plugins/sentry.md +192 -0
- package/templates/agent-docs/plugins/soft-delete.md +72 -0
- package/templates/agent-docs/plugins/sso-saml.md +132 -0
- package/templates/agent-docs/plugins/storage.md +709 -0
- package/templates/agent-docs/plugins/tinybird.md +92 -0
- package/templates/agent-docs/plugins/versioning.md +226 -0
- package/templates/agent-docs/plugins/webhooks.md +585 -0
- package/templates/agent-docs/security.md +12 -1
- package/templates/agent-docs/whats-new.md +81 -37
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +10 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +9 -9
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +2 -2
- package/dist/apiBuild-Ca6Sq5jy.js +0 -2
- package/dist/checkCommand-DHD0aBeR.js +0 -2
- package/dist/dbCommand-wfGi3FgG.js +0 -2
- 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).
|