@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,324 @@
1
+ # Audit
2
+
3
+ > Mutation audit log — sinks (console/memory/custom), include/exclude filters, the audit() mixin, testing with the memory buffer.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/audit.md -->
10
+ ## Audit
11
+
12
+ _Mutation audit log — sinks (console/memory/custom), include/exclude filters, the audit() mixin, testing with the memory buffer._
13
+
14
+ `@voltro/plugin-audit` has two independent surfaces, picked à la carte:
15
+
16
+ 1. **The `auditPlugin({ sink })` mutation interceptor** — records every mutation invocation (tag, subject, input, outcome, duration) to a configurable sink.
17
+ 2. **The `audit()` schema mixin** — adds `createdAt`/`updatedAt`/`createdBy`/`updatedBy` columns, auto-stamped by the runtime from the request subject.
18
+
19
+ **Status:** ✓ shipped.
20
+
21
+ ## Installing
22
+
23
+ ```ts
24
+ // app.config.ts
25
+ import { auditPlugin } from '@voltro/plugin-audit'
26
+
27
+ export default {
28
+ type: 'api' as const,
29
+ plugins: [
30
+ auditPlugin({ sink: 'console' }),
31
+ ],
32
+ }
33
+ ```
34
+
35
+ ## What's recorded
36
+
37
+ The interceptor records **mutation invocations only** — not queries, not actions, not AI/agent calls. (For AI token counts and cost, read the `usage` returned on every `@voltro/ai` call — see [Cost tracking](/docs/ai/cost-tracking).) One `AuditEvent` per outcome:
38
+
39
+ ```ts
40
+ interface AuditEvent {
41
+ readonly ts: number // start time (epoch ms)
42
+ readonly tag: string // rpc tag, e.g. 'orders.create'
43
+ readonly subject: Subject // resolved caller (carries tenantId)
44
+ readonly traceId: string
45
+ readonly input: unknown // the mutation's validated input
46
+ readonly outcome:
47
+ | { readonly kind: 'ok'; readonly value: unknown; readonly durationMs: number }
48
+ | { readonly kind: 'error'; readonly error: unknown; readonly durationMs: number }
49
+ }
50
+ ```
51
+
52
+ A failure records the underlying `Effect.fail` value / thrown error (not the whole `Cause` tree); the original mutation outcome always flows through untouched.
53
+
54
+ ## Sinks
55
+
56
+ `sink` is one of these shapes:
57
+
58
+ ```ts
59
+ auditPlugin({ sink: 'console' })
60
+ // default — one structured line per mutation via @voltro/logger
61
+ // (log.info on success, log.warn on error)
62
+
63
+ auditPlugin({ sink: 'memory' })
64
+ // last 1000 events in an in-process ring buffer — for tests
65
+
66
+ auditPlugin({ sink: 'datastore' })
67
+ // DURABLE + queryable — contributes the _voltro_audit_log table
68
+ // (extendSchema, under store:write) and appends one row per mutation
69
+
70
+ auditPlugin({
71
+ sink: async (event) => { /* ship to wherever */ },
72
+ })
73
+ // custom function: (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>
74
+ ```
75
+
76
+ `'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns.
77
+
78
+ The custom function is the escape hatch for persisting events anywhere the built-in table's schema doesn't fit — e.g. an `Effect.Effect<void>` sink that writes rows into your own audit table on top of `@effect/sql`. All return shapes are normalised by the interceptor. A sink that throws / rejects / dies is caught and swallowed, so a broken sink can never mask the mutation's real outcome.
79
+
80
+ ### `record` — which outcomes reach the sink
81
+
82
+ `include` / `exclude` filter by TAG, before the call runs. `record` filters by what happened, after:
83
+
84
+ ```ts
85
+ auditPlugin({ sink: 'datastore', record: 'errors' })
86
+ ```
87
+
88
+ - `'all'` (default) — every invocation.
89
+ - `'errors'` — refusals only: a denied guard, a revoked key, a rejected validation. This is the forensic core, and it pairs with [`@voltro/plugin-versioning`](/docs/plugins/versioning), which records the successful *writes* — so the two together still cover everything while this table stays small enough that retention is a footnote.
90
+ - a predicate — `(event) => boolean`, for anything else.
91
+
92
+ `'all'` is the default even though `'errors'` is often the right choice, because defaulting to errors would silently stop recording successes for every app that upgrades — and "what did this compromised account touch" is answered by successes. Shrinking the trail is a decision you make with your eyes open.
93
+
94
+ ### `redactInput` — what of the payload is kept
95
+
96
+ **The default is `'all'`: the payload is replaced by `{ __redacted: 'all' }`.** The row still proves a payload existed; it just does not carry it.
97
+
98
+ ```ts
99
+ auditPlugin({ sink: 'datastore', redactInput: 'none' }) // the raw input, verbatim
100
+ auditPlugin({ sink: 'datastore', redactInput: (e) => pick(e) }) // field-level control
101
+ ```
102
+
103
+ `input` is the raw mutation input, so an unredacted audit table is where a password change, an API key at issuance and a PAT land — the one place nobody thinks to look for a credential. Losing payload detail is visible the first time you read a row; leaking a credential is not visible at all.
104
+
105
+ **Why it is not driven by `.serverOnly()` / `.sensitive()`,** which is the obvious design: those markers live on TABLE COLUMNS, and this is a mutation's INPUT. A `changePassword({ oldPassword, newPassword })` has no column to consult, so a marker-driven default would cover exactly 0% of the case it exists for — while reading, to whoever configured it, like protection. (`.sensitive()` is also the [export axis](/docs/database/sensitivity), not "unsafe to log"; treating one as the other is the category error that page warns about.) Pass a function once you know your own inputs.
106
+
107
+ ### `redactSubject` — what of the CALLER is kept
108
+
109
+ **The default is `'metadata'`: `subject.metadata` is replaced by `{ __redacted: 'all' }`.** `type`, `id`, `tenantId` and `scopes` survive, which is everything the trail is actually read for.
110
+
111
+ ```ts
112
+ auditPlugin({ sink: 'datastore', redactSubject: 'none' }) // the subject verbatim
113
+ auditPlugin({ sink: 'datastore', redactSubject: (s) => pickSafe(s) }) // field-level control
114
+ ```
115
+
116
+ **This exists because the durable sink wrote a live credential.** A reporter found a working Jira Personal Access Token in plaintext in 12 of 23 rows of their `_voltro_audit_log`, and neither plugin involved was wrong on its own:
117
+
118
+ - [`plugin-atlassian`](/docs/plugins/atlassian)'s `credentialsResolver` took a `Subject` and nothing else, so an app doing per-user Atlassian auth had nowhere to put the caller's PAT except `subject.metadata`;
119
+ - this plugin serialised the Subject verbatim into a json column.
120
+
121
+ Two correct contracts disagreeing about what a Subject *is* — an identity, or a credential envelope — with nothing reconciling them.
122
+
123
+ The reasoning is `redactInput`'s, word for word, applied to the field it did not cover: `metadata` is not a table column either, so no schema marker protects it; it is app-controlled, so its contents cannot be reasoned about here; and the framework's own per-user-credential mechanism puts a credential in it.
124
+
125
+ `resolveScope` still sees the **live** subject, so a scope derived from `metadata` keeps working — redaction applies to what is stored, not to what the plugin can compute.
126
+
127
+ > **If you are upgrading, check the rows you already have.** A safer default does not un-leak a past row.
128
+ >
129
+ > ```sql
130
+ > SELECT count(*) FROM _voltro_audit_log WHERE subject::text ILIKE '%token%';
131
+ > ```
132
+ >
133
+ > Purge what you find and rotate the credentials — assume anything written to a log table has been read.
134
+
135
+ ### Reading it back — the correlation join
136
+
137
+ The trail is only useful if you can enter it by the questions an incident asks. Two entry points, matching the two indices:
138
+
139
+ ```ts
140
+ import { auditByTrace, auditBySubject } from '@voltro/plugin-audit'
141
+ import { historyByTrace } from '@voltro/plugin-versioning'
142
+
143
+ // What happened during ONE call — and what it changed.
144
+ const calls = await auditByTrace(ctx.store, traceId)
145
+ const changed = await historyByTrace(ctx.store, traceId, ctx.request.subject.tenantId)
146
+
147
+ // Every refusal by one actor, newest first.
148
+ const denied = await auditBySubject(ctx.store, actorId, { status: 'error', limit: 50 })
149
+ ```
150
+
151
+ `traceId` is the join key. [`plugin-versioning`](/docs/plugins/versioning) records *what changed*; this records *who called and whether they were refused*. Neither is complete alone, and before the join key existed they could not be read together at all.
152
+
153
+ `auditBySubject` takes `status` as a real argument rather than leaving you to filter in JS: the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it.
154
+
155
+ ### Retention, and the GDPR interaction
156
+
157
+ `sink: 'datastore'` registers its own retention: **365 days by default**, tunable with `VOLTRO_AUDIT_LOG_TTL_HOURS`, drained by the boot sweep. An append-only trail with no ceiling is the one that eventually takes the database down.
158
+
159
+ **Erasure is deliberately NOT registered for you.** Erasing a subject must not delete the record that they were refused four hundred times — that record *is* the evidence. The defensible treatment is to anonymise rather than delete, and it is a compliance decision your app makes explicitly:
160
+
161
+ ```ts
162
+ governancePlugin({
163
+ subjectScopes: [{ table: '_voltro_audit_log', subjectField: 'subjectId' }],
164
+ erasure: { mode: 'anonymize', anonymizeFields: ['subjectId', 'subject', 'input'] },
165
+ })
166
+ ```
167
+
168
+ **Not included (yet):** no built-in OpenTelemetry sink and no truncation options.
169
+
170
+ ## Scoping which mutations are recorded
171
+
172
+ `include` / `exclude` are `RegExp`s tested against the rpc tag:
173
+
174
+ ```ts
175
+ auditPlugin({
176
+ sink: 'console',
177
+ include: /^orders\./, // record ONLY these tags (default: all)
178
+ exclude: /^orders\.debug/, // skip these — overrides `include`
179
+ })
180
+ ```
181
+
182
+ Useful for keeping high-frequency mutations (presence pings, analytics events) out of the log.
183
+
184
+ ## The `audit()` schema mixin
185
+
186
+ Import from the browser-safe `/mixin` subpath in a `*.entity.ts` file and chain it with `.with(...)`:
187
+
188
+ ```ts
189
+ // database/notes.entity.ts
190
+ import { table, id, text } from '@voltro/database'
191
+ import { audit } from '@voltro/plugin-audit/mixin'
192
+
193
+ export const notes = table('notes', {
194
+ id: id(),
195
+ title: text(),
196
+ body: text(),
197
+ })
198
+ .with(audit()) // + createdAt / updatedAt / createdBy / updatedBy
199
+ ```
200
+
201
+ Mixin adds:
202
+
203
+ | Column | Type | Filled on |
204
+ | ----------- | ---------------- | ---------------------------- |
205
+ | `createdAt` | `Date` | insert |
206
+ | `updatedAt` | `Date` | insert + update |
207
+ | `createdBy` | `string \| null` | insert (→ `actors`) |
208
+ | `updatedBy` | `string \| null` | insert + update (→ `actors`) |
209
+
210
+ The `*By` columns are `reference`s to the app's `actors` table and are **nullable** — a system-seeded or pre-auth write (e.g. signup) leaves them unset. The runtime fills all four from the request subject automatically; an explicit value the caller passes is respected, not overwritten. `softDelete()`, `tenant()`, and `deactivation()` all transitively require `audit()`.
211
+
212
+ ## Asserting on emitted events in a test
213
+
214
+ ```ts
215
+ import { auditPlugin, readAuditBuffer, clearAuditBuffer } from '@voltro/plugin-audit'
216
+
217
+ beforeEach(() => clearAuditBuffer())
218
+
219
+ const plugin = auditPlugin({ sink: 'memory' })
220
+ // … drive a mutation through the plugin …
221
+ const events = readAuditBuffer() // ReadonlyArray<AuditEvent>, oldest → newest
222
+ ```
223
+
224
+ `readAuditBuffer()` returns a detached snapshot copy; the buffer caps at 1000 events (oldest evicted first).
225
+
226
+ ## Who acted — a snapshot, not a reference
227
+
228
+ Both `_voltro_audit_log` and `_voltro_row_history` carry
229
+ `actor json { id, type, displayName, email }`, resolved from the `actors` row at
230
+ WRITE time.
231
+
232
+ The reason is visible inside a single row: `data` on a history row is a full-row
233
+ snapshot — deliberately, so it survives what happens to its source — while
234
+ `changedBy` beside it is a foreign key that does not. One record, two
235
+ philosophies.
236
+
237
+ That matters because the right to be forgotten is one this framework grants:
238
+ `@voltro/plugin-governance`'s `governance.erase` (`delete | anonymize`) exists
239
+ for it. Without a snapshot, installing audit + versioning + governance together
240
+ makes the first two unreadable for exactly the subjects an investigation is
241
+ about. **Anonymisation is the worse half**: the join succeeds and returns
242
+ "Anonymised" for every entry that actor ever produced, retroactively rewriting
243
+ history that was correct when it was written.
244
+
245
+ `email` is read opportunistically — the framework's own `actors` carries
246
+ `id` / `kind` / `displayName`, and apps commonly extend it. Resolution is
247
+ best-effort and never fails the mutation it records, and absent stays absent: a
248
+ fabricated name is what this column exists to prevent.
249
+
250
+ `_voltro_audit_log.metadata` is yours to write — the noun a diff cannot contain.
251
+ "Anna removed Bernd from the Frontend sub-team" is one row-delete plus a
252
+ membership row, and no column-level detail reconstructs the sentence.
253
+
254
+ ## What is recorded
255
+
256
+ ```ts
257
+ auditPlugin({
258
+ // Actions record by default. Queries are opt-in — a read-heavy app would
259
+ // write one row per read and drown the trail it needs searchable.
260
+ recordQueries: true,
261
+ include: /^export\./,
262
+ })
263
+ ```
264
+
265
+ Mutations and actions record by default; both write. Queries are opt-in, for the
266
+ surfaces where the READ is the sensitive act — a GDPR export, a salary view —
267
+ usually together with `include` so it stays targeted.
268
+
269
+
270
+ ## Scoping the trail — `scope`
271
+
272
+ ```ts
273
+ auditPlugin({
274
+ // The app's own dimension — the framework does not know what a team is.
275
+ // From the subject when your session is team-shaped …
276
+ resolveScope: (ctx) => ({ teamId: ctx.subject.metadata?.teamId }),
277
+ })
278
+
279
+ auditPlugin({
280
+ // … or from the call's INPUT, which is where it usually lives.
281
+ resolveScope: (ctx) =>
282
+ typeof ctx.input?.teamId === 'string' ? { teamId: ctx.input.teamId } : undefined,
283
+ })
284
+
285
+ versioningPlugin({
286
+ // From the ROW here — that is what this plugin has.
287
+ resolveScope: (row) => ({ teamId: row.teamId }),
288
+ })
289
+ ```
290
+
291
+ **`input` is there because the subject-only version covered the wrong half.** A
292
+ reporter's users belong to MANY teams, so their session carries no "current
293
+ team" and cannot without inventing a concept their product does not have — a
294
+ mutation's team comes from its input or from the row it loads. Their API-key
295
+ subjects *do* carry a `teamId`, which made subject-only worse than nothing for
296
+ them: it would have populated for key-authenticated calls and been null for every
297
+ human one, so a filtered view would have looked like it worked.
298
+
299
+ The input here is **raw** — not what [`redactInput`](#redactinput--what-of-the-payload-is-kept)
300
+ will store. That is required (a scope derived from a redacted payload is not
301
+ derivable at all) and it is a hazard worth naming: whatever you return lands in
302
+ `scope`, which is *not* redacted. Return the dimension, never the payload.
303
+
304
+ `scope` is opaque json on both `_voltro_audit_log` and `_voltro_row_history`,
305
+ stored and returned verbatim and filtered on equality — the same column
306
+ `_voltro_webhook_targets` carries, for the same reason: `.with(tenant())` is one
307
+ level too coarse when a trail is per-team and a tenant has many teams.
308
+
309
+ **It is not `metadata`, and the distinction is load-bearing.** `metadata` is
310
+ documented as the app's free-form note — the noun a diff cannot contain. Filtering
311
+ on it means building a read path against a column whose contract says it is not
312
+ one. Two columns, two jobs.
313
+
314
+ Configure it on **both** plugins or half of every view is unfiltered. Neither
315
+ resolver can fail your write: an underivable scope is `null`, the same answer as
316
+ not configuring one.
317
+
318
+
319
+ ## Anti-patterns
320
+
321
+ - **Relying on the `'memory'` sink in production.** It is a per-process test buffer capped at 1000 events — not a durable store. Use `sink: 'datastore'` (or a custom function sink) instead.
322
+ - **A throwing sink as a validation gate.** Sink failures are deliberately swallowed; the sink can never veto or alter the mutation.
323
+ - **PII in mutation inputs.** The interceptor records the validated input verbatim — there is no built-in redactor. Keep secrets out of mutation inputs, or redact inside your custom sink before persisting.
324
+ - **Audit log as a queryable view of business state.** It's a write log. The actual current state lives in your normal tables.
@@ -0,0 +1,92 @@
1
+ # Auth0
2
+
3
+ > Auth0 AuthStrategy — verifies Auth0-issued JWTs via the tenant's JWKS (no client secret) and maps a namespaced custom claim → tenantId.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/auth-auth0.md -->
10
+ ## Auth0
11
+
12
+ _Auth0 AuthStrategy — verifies Auth0-issued JWTs via the tenant's JWKS (no client secret) and maps a namespaced custom claim → tenantId._
13
+
14
+ `@voltro/plugin-auth-auth0` integrates [Auth0](https://auth0.com): an `AuthStrategy` (conforming to `@voltro/protocol`) that verifies Auth0-issued JWTs against the tenant's **JWKS endpoint** (public-key verify — no client secret), maps a namespaced custom claim to the framework's `tenantId`, and composes with other strategies (your password cookie, an API key, another IdP). It's a thin specialization of the shared `jwtBearerStrategy` — it adds Auth0's defaults and performs no verification of its own. Verified claims arrive on the Subject as `metadata.provider === 'auth0'` + `metadata.claims`.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pnpm add @voltro/plugin-auth-auth0
20
+ ```
21
+
22
+ ## Wiring
23
+
24
+ Add `auth0Strategy(...)` to `auth.strategies` in your api's `app.config.ts`. The built-in signed-cookie password strategy always runs first; your strategies run after it, in order, until one `matched`s (or one `failed`s).
25
+
26
+ ```ts
27
+ // app.config.ts
28
+ import { auth0Strategy } from '@voltro/plugin-auth-auth0'
29
+
30
+ export default {
31
+ type: 'api' as const,
32
+ name: 'myApi',
33
+ store: 'postgres' as const,
34
+ auth: {
35
+ strategies: [
36
+ auth0Strategy({
37
+ domain: 'acme.us.auth0.com', // required — builds JWKS URL + issuer
38
+ audience: 'https://api.acme.com', // your API identifier
39
+ // tenantClaim: 'https://voltro.dev/tenant', // default namespaced URN
40
+ // defaultTenantId: 'public', // fallback for single-tenant setups
41
+ // scopesFromClaims: (c) => c.permissions ?? [], // Auth0 RBAC permissions → scopes
42
+ }),
43
+ ],
44
+ },
45
+ }
46
+ ```
47
+
48
+ `domain` is the only required option (protocol + trailing slash are stripped). From it: JWKS URL defaults to `https://<domain>/.well-known/jwks.json` and issuer to `https://<domain>/` — **the trailing slash matters for Auth0** (override `issuer` for a custom domain).
49
+
50
+ Tenant resolution maps from `tenantClaim` (default the namespaced URN `https://voltro.dev/tenant` — Auth0's rules require non-standard claims to live under a URN). Use a custom Auth0 Action to copy your app metadata into the namespaced claim, or point `tenantClaim` at an unnamespaced field that already exists in your token. Supply `tenantIdFromClaims(claims)` to derive it (returning `null` is a `failed` verdict). `scopesFromClaims` maps verified claims (the `permissions` array with RBAC enabled, or the space-delimited `scope` claim) onto `Subject.scopes`.
51
+
52
+ To accept your own password sessions AND Auth0 during a migration, build the chain explicitly with `composeAuthStrategies` (see [auth strategies](/docs/authentication/strategies)):
53
+
54
+ ```ts
55
+ import { composeAuthStrategies } from '@voltro/protocol'
56
+ import { voltroPasswordStrategy } from '@voltro/plugin-auth'
57
+ import { auth0Strategy } from '@voltro/plugin-auth-auth0'
58
+
59
+ const resolve = composeAuthStrategies([
60
+ voltroPasswordStrategy(),
61
+ auth0Strategy({ domain: process.env.AUTH0_DOMAIN!, audience: process.env.AUTH0_AUDIENCE! }),
62
+ ])
63
+ ```
64
+
65
+ ## Options
66
+
67
+ | Option | Default | Notes |
68
+ |---|---|---|
69
+ | `domain` | — (**required**) | e.g. `acme.us.auth0.com`. Builds JWKS URL + issuer. |
70
+ | `audience` | (off) | Your API identifier. String or array. |
71
+ | `jwksUrl` | `https://<domain>/.well-known/jwks.json` | Override. |
72
+ | `issuer` | `https://<domain>/` | Trailing slash matters; override for custom domains. |
73
+ | `cookieName` | `null` (header-only) | Auth0's hosted flow uses Bearer tokens. |
74
+ | `tenantClaim` | `'https://voltro.dev/tenant'` | Namespaced URN. |
75
+ | `tenantIdFromClaims` | (uses `tenantClaim`) | Derive `tenantId`; `null` → `failed`. |
76
+ | `defaultTenantId` | (none) | Fallback for single-tenant setups. |
77
+ | `scopesFromClaims` | (none) | Map claims → `Subject.scopes`. |
78
+
79
+ Algorithms are pinned to `['RS256']` (no alg-confusion).
80
+
81
+ ## Environment variables
82
+
83
+ None read directly — pass `domain` / `audience` explicitly (typically from your own `defineEnv`-declared vars).
84
+
85
+ ## Security
86
+
87
+ Server-only; fails closed — a forged, expired, or wrong-audience token, or one with no resolvable tenant, yields `failed`, never `matched`. JWKS public-key verify only; no Auth0 client secret is ever handled.
88
+
89
+ ## See also
90
+
91
+ - [External identity providers](/docs/authentication/external-idp)
92
+ - [Auth plugin](/docs/plugins/auth)
@@ -0,0 +1,90 @@
1
+ # Clerk
2
+
3
+ > Clerk AuthStrategy — verifies Clerk-issued __session JWTs via the Frontend API JWKS (no secret key) and maps org_id → tenantId.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/auth-clerk.md -->
10
+ ## Clerk
11
+
12
+ _Clerk AuthStrategy — verifies Clerk-issued __session JWTs via the Frontend API JWKS (no secret key) and maps org_id → tenantId._
13
+
14
+ `@voltro/plugin-auth-clerk` integrates [Clerk](https://clerk.com): an `AuthStrategy` (conforming to `@voltro/protocol`) that verifies Clerk-issued `__session` JWTs against the **Frontend API JWKS** (public-key verify — no secret key), maps Clerk's Organizations `org_id` to the framework's `tenantId`, and composes with other strategies (your password cookie, an API key, another IdP). It's a thin specialization of the shared `jwtBearerStrategy` — it adds Clerk's defaults and performs no verification of its own. Verified claims arrive on the Subject as `metadata.provider === 'clerk'` + `metadata.claims`.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pnpm add @voltro/plugin-auth-clerk
20
+ ```
21
+
22
+ ## Wiring
23
+
24
+ Add `clerkStrategy(...)` to `auth.strategies` in your api's `app.config.ts`. The built-in signed-cookie password strategy always runs first; your strategies run after it, in order, until one `matched`s (or one `failed`s).
25
+
26
+ ```ts
27
+ // app.config.ts
28
+ import { clerkStrategy } from '@voltro/plugin-auth-clerk'
29
+
30
+ export default {
31
+ type: 'api' as const,
32
+ name: 'myApi',
33
+ store: 'postgres' as const,
34
+ auth: {
35
+ strategies: [
36
+ clerkStrategy({
37
+ frontendApi: 'https://clerk.yourapp.com', // required — Clerk Frontend API host
38
+ // audience: '…', // ONLY with a Clerk JWT Template
39
+ // defaultTenantId: 'public', // fallback when no org_id claim
40
+ }),
41
+ ],
42
+ },
43
+ }
44
+ ```
45
+
46
+ `frontendApi` is the only required option (trailing slash is normalised). From it: JWKS URL defaults to `<frontendApi>/.well-known/jwks.json` and issuer to `<frontendApi>` (Clerk mirrors the host into the `iss` claim). The cookie defaults to `__session` (Clerk's hardcoded session cookie name; pass `null` for header-only).
47
+
48
+ Tenant resolution maps from `claims.org_id` (Clerk's "Organizations" feature), then `defaultTenantId`. Supply `tenantIdFromClaims(claims)` to derive it (returning `null` is a `failed` verdict). Clerk's default `__session` tokens carry no `aud` claim, so the audience check is off — only set `audience` when you've configured a Clerk **JWT Template** with one.
49
+
50
+ To accept your own password sessions AND Clerk during a migration, build the chain explicitly with `composeAuthStrategies` (see [auth strategies](/docs/authentication/strategies)):
51
+
52
+ ```ts
53
+ import { composeAuthStrategies } from '@voltro/protocol'
54
+ import { voltroPasswordStrategy } from '@voltro/plugin-auth'
55
+ import { clerkStrategy } from '@voltro/plugin-auth-clerk'
56
+
57
+ const resolve = composeAuthStrategies([
58
+ voltroPasswordStrategy(),
59
+ clerkStrategy({ frontendApi: process.env.CLERK_FRONTEND_API! }),
60
+ ])
61
+ ```
62
+
63
+ ## Options
64
+
65
+ | Option | Default | Notes |
66
+ |---|---|---|
67
+ | `frontendApi` | — (**required**) | Clerk Frontend API host. Builds JWKS URL + issuer. |
68
+ | `jwksUrl` | `<frontendApi>/.well-known/jwks.json` | Override. |
69
+ | `issuer` | `<frontendApi>` | Override. |
70
+ | `audience` | (off) | Only with a Clerk JWT Template that sets one. |
71
+ | `algorithms` | `['RS256', 'ES256']` | JWT signing-algorithm allowlist; override for a non-default Clerk config. |
72
+ | `cookieName` | `'__session'` | Clerk's hardcoded cookie; `null` for header-only. |
73
+ | `tenantIdFromClaims` | (uses `org_id`) | Derive `tenantId`; `null` → `failed`. |
74
+ | `defaultTenantId` | (none) | Fallback when no `org_id` claim. |
75
+ | `scopesFromClaims` | (none) | Map claims (`org_permissions`, or a JWT-Template `permissions`) → `Subject.scopes`. |
76
+
77
+ Algorithms default to `['RS256', 'ES256']` — pinned (not trusting the token's declared `alg`) to close alg-confusion; override via `algorithms`.
78
+
79
+ ## Environment variables
80
+
81
+ None read directly — pass `frontendApi` explicitly (typically from your own `defineEnv`-declared vars).
82
+
83
+ ## Security
84
+
85
+ Server-only; fails closed — a forged, expired, or wrong-audience token, or one with no resolvable tenant, yields `failed`, never `matched`. JWKS public-key verify only; no Clerk secret key is ever handled.
86
+
87
+ ## See also
88
+
89
+ - [External identity providers](/docs/authentication/external-idp)
90
+ - [Auth plugin](/docs/plugins/auth)
@@ -0,0 +1,88 @@
1
+ # Kinde
2
+
3
+ > Kinde AuthStrategy — verifies Kinde-issued JWTs via JWKS at the configured issuer (no client secret) and maps org_code → tenantId.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/auth-kinde.md -->
10
+ ## Kinde
11
+
12
+ _Kinde AuthStrategy — verifies Kinde-issued JWTs via JWKS at the configured issuer (no client secret) and maps org_code → tenantId._
13
+
14
+ `@voltro/plugin-auth-kinde` integrates [Kinde](https://kinde.com): an `AuthStrategy` (conforming to `@voltro/protocol`) that verifies Kinde-issued JWTs against the issuer's **JWKS endpoint** (public-key verify — no client secret), maps Kinde's `org_code` to the framework's `tenantId`, and composes with other strategies (your password cookie, an API key, another IdP). It's a thin specialization of the shared `jwtBearerStrategy` — it adds Kinde's defaults and performs no verification of its own. Verified claims arrive on the Subject as `metadata.provider === 'kinde'` + `metadata.claims`.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pnpm add @voltro/plugin-auth-kinde
20
+ ```
21
+
22
+ ## Wiring
23
+
24
+ Add `kindeStrategy(...)` to `auth.strategies` in your api's `app.config.ts`. The built-in signed-cookie password strategy always runs first; your strategies run after it, in order, until one `matched`s (or one `failed`s).
25
+
26
+ ```ts
27
+ // app.config.ts
28
+ import { kindeStrategy } from '@voltro/plugin-auth-kinde'
29
+
30
+ export default {
31
+ type: 'api' as const,
32
+ name: 'myApi',
33
+ store: 'postgres' as const,
34
+ auth: {
35
+ strategies: [
36
+ kindeStrategy({
37
+ issuer: 'https://yourcompany.kinde.com', // required
38
+ // audience: '…', // your Kinde API audience
39
+ // defaultTenantId: 'public', // fallback for single-tenant setups
40
+ // scopesFromClaims: (c) => c.permissions ?? [], // Kinde permissions → scopes
41
+ }),
42
+ ],
43
+ },
44
+ }
45
+ ```
46
+
47
+ `issuer` is the only required option (trailing slash is normalised). From it the JWKS URL defaults to `<issuer>/.well-known/jwks`. The cookie defaults to `kinde_access_token` (for apps that mirror the SDK's token into a cookie for SSR; pass `null` for header-only).
48
+
49
+ Tenant resolution maps from `claims.org_code`, falling back to `claims.org_codes[0]`, then `defaultTenantId`. Supply `tenantIdFromClaims(claims)` to derive it (returning `null` is a `failed` verdict). `scopesFromClaims` maps verified claims onto `Subject.scopes` — Kinde emits a `permissions` array and a `roles` list (e.g. `roles.map((r) => r.key)`).
50
+
51
+ To accept your own password sessions AND Kinde during a migration, build the chain explicitly with `composeAuthStrategies` (see [auth strategies](/docs/authentication/strategies)):
52
+
53
+ ```ts
54
+ import { composeAuthStrategies } from '@voltro/protocol'
55
+ import { voltroPasswordStrategy } from '@voltro/plugin-auth'
56
+ import { kindeStrategy } from '@voltro/plugin-auth-kinde'
57
+
58
+ const resolve = composeAuthStrategies([
59
+ voltroPasswordStrategy(),
60
+ kindeStrategy({ issuer: process.env.KINDE_ISSUER! }),
61
+ ])
62
+ ```
63
+
64
+ ## Options
65
+
66
+ | Option | Default | Notes |
67
+ |---|---|---|
68
+ | `issuer` | — (**required**) | e.g. `https://acme.kinde.com`. Builds JWKS URL. |
69
+ | `audience` | (off) | Your Kinde API audience. |
70
+ | `jwksUrl` | `<issuer>/.well-known/jwks` | Override. |
71
+ | `algorithms` | `['RS256']` | Allowlist; override for a non-default Kinde signing alg. |
72
+ | `cookieName` | `'kinde_access_token'` | `null` for header-only. |
73
+ | `tenantIdFromClaims` | (uses `org_code` → `org_codes[0]`) | Derive `tenantId`; `null` → `failed`. |
74
+ | `defaultTenantId` | (none) | Fallback for single-tenant setups. |
75
+ | `scopesFromClaims` | (none) | Map claims → `Subject.scopes`. |
76
+
77
+ ## Environment variables
78
+
79
+ None read directly — pass `issuer` / `audience` explicitly (typically from your own `defineEnv`-declared vars).
80
+
81
+ ## Security
82
+
83
+ Server-only; fails closed — a forged, expired, or wrong-audience token, or one with no resolvable tenant, yields `failed`, never `matched`. JWKS public-key verify only; no Kinde client secret is ever handled.
84
+
85
+ ## See also
86
+
87
+ - [External identity providers](/docs/authentication/external-idp)
88
+ - [Auth plugin](/docs/plugins/auth)