@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,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)
|