@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,270 @@
|
|
|
1
|
+
# RBAC
|
|
2
|
+
|
|
3
|
+
> Roles + permissions + the permission() handler guard. Roles compile to scopes.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/rbac.md -->
|
|
10
|
+
## RBAC
|
|
11
|
+
|
|
12
|
+
_Roles + permissions + the permission() handler guard. Roles compile to scopes._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-rbac` adds role-based access control to Voltro. It builds **on top of the existing scope system** in `@voltro/protocol`: a role is a named bundle of permission strings, and the plugin **compiles roles to scopes** — there is no parallel "role axis". The `permission()` handler guard rejects a call when the caller's resolved scope set doesn't include the required permission, reusing the same `hasScope` / `admin:full` machinery a raw API-key scope goes through.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// app.config.ts
|
|
20
|
+
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
type: 'api' as const,
|
|
24
|
+
name: 'myApi',
|
|
25
|
+
store: 'postgres' as const,
|
|
26
|
+
plugins: [
|
|
27
|
+
rbacPlugin({
|
|
28
|
+
// Declarative role → permission map. Each role lists the
|
|
29
|
+
// permission strings (== scope strings) it grants.
|
|
30
|
+
roles: {
|
|
31
|
+
viewer: ['notes:read', 'comments:read'],
|
|
32
|
+
editor: ['notes:read', 'notes:write', 'comments:read', 'comments:write'],
|
|
33
|
+
admin: ['*'], // wildcard — compiles to the admin:full bypass
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The plugin installs an interceptor that runs once per mutation / query / action, BEFORE the executor: it resolves the caller's role slugs, compiles them to a flat scope set, merges that with the subject's raw scopes, and stashes the result where `permission()` reads it.
|
|
41
|
+
|
|
42
|
+
## Roles compile to scopes
|
|
43
|
+
|
|
44
|
+
A role has no runtime identity once resolved — `compileRoles(['editor'], roleMap)` returns a flat permission-string set that IS a scope set. That set is checked through the same `hasScope` the framework uses for raw scopes, so a user with role `editor` and an API key minted with `scopes: ['notes:write']` are indistinguishable to `permission()`. The `'*'` wildcard in any granted role compiles to the `admin:full` blanket bypass.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { compileRoles } from '@voltro/plugin-rbac'
|
|
48
|
+
|
|
49
|
+
compileRoles(['viewer', 'editor'], {
|
|
50
|
+
viewer: ['notes:read'],
|
|
51
|
+
editor: ['notes:read', 'notes:write'],
|
|
52
|
+
})
|
|
53
|
+
// → ['notes:read', 'notes:write'] (deduped union)
|
|
54
|
+
|
|
55
|
+
compileRoles(['admin'], { admin: ['*'] })
|
|
56
|
+
// → ['admin:full'] (wildcard → blanket bypass)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Declarative `guards:` on the descriptor
|
|
60
|
+
|
|
61
|
+
The framework enforces authorization **declaratively** from the descriptor — you don't have to remember an in-handler check (forgetting one is a silent authz hole). Add `guards:` to `defineMutation` / `defineQuery` / `defineAction`; the framework runs it in the dispatch spine **before the executor** (for a mutation, **before the transaction even opens**), fails with a typed `ScopeError`, and **auto-merges `ScopeError` into the wire error union** so the client decodes the denial typed.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export const updateNote = defineMutation({
|
|
65
|
+
name: 'notes.update',
|
|
66
|
+
input: Schema.Struct({ id: Schema.String, teamId: Schema.String, title: Schema.String }),
|
|
67
|
+
output: Schema.Struct({ id: Schema.String }),
|
|
68
|
+
guards: [{ scope: 'notes:write' }], // ← enforced before the executor
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The check runs against the caller's **effective** scope set — raw subject scopes **∪** rbac role-derived scopes — so a role that grants `notes:write` satisfies the guard exactly like a raw API-key scope. `admin:full` bypasses every guard.
|
|
73
|
+
|
|
74
|
+
- **Multiple scopes, AND:** `guards: [{ scope: ['notes:write', 'team:member'] }]` (default `mode: 'all'`).
|
|
75
|
+
- **Multiple scopes, OR:** `guards: [{ scope: ['notes:write', 'notes:admin'], mode: 'any' }]`.
|
|
76
|
+
- **Several guards:** every entry must pass (AND across the array).
|
|
77
|
+
|
|
78
|
+
`guards:` is **browser-safe by construction**: it carries scope *strings* plus an optional **pure** `resource` extractor (`input → id`, the same discipline as `target.identify`) — never a server function, never a DB call. So a descriptor bundled value-level into the browser drags in nothing server-only.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
guards: [{ scope: 'roadmaps:write', resource: (input) => input.teamId }]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The `resource` extractor is passed to a resource-aware scope resolver for row/resource-scoped (ReBAC-style) checks. With **no** resolver registered it is advisory — the guard checks the subject's global scopes. Register one and the guard scopes to the resource (see next).
|
|
85
|
+
|
|
86
|
+
### Resource-scoped guards (multi-tenant / per-team)
|
|
87
|
+
|
|
88
|
+
A subject-global guard asks "does the caller hold scope `S` anywhere". For a multi-tenant app whose subjects are minted with `scopes: []` and whose real permissions are **per-resource** (owner in team A, viewer in team B), that's the wrong question — you need "does the caller hold `S` **on this resource**". Register a resolver and the `resource` extractor stops being advisory:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// rbac path — compile per-resource roles through the same `roles` map:
|
|
92
|
+
rbacPlugin({
|
|
93
|
+
roles: { owner: ['roadmaps:write'], viewer: ['roadmaps:read'] },
|
|
94
|
+
resolveResourceRoles: (subject, teamId) => db.rolesFor(subject.id, teamId), // → role slugs on THAT team
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// or, without rbac, register a resolver directly against your own tables:
|
|
98
|
+
import { setResourceScopeResolver } from '@voltro/protocol'
|
|
99
|
+
setResourceScopeResolver(({ subject, scope, resource }) =>
|
|
100
|
+
Effect.succeed(/* does subject hold `scope` on `resource`? */),
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The framework asks the resolver per request, **before the executor** (mutations: before the transaction opens). A globally-held scope or `admin:full` still short-circuits **without** a resolver call; only the gap falls through. A resolver error fails **closed** (denies). The denial is the same typed `ScopeError` a global guard raises.
|
|
105
|
+
|
|
106
|
+
## The `permission()` guard (in-handler)
|
|
107
|
+
|
|
108
|
+
For authz that depends on **loaded data** (row ownership, cross-field rules) — anything the declarative `guards:` can't express as a pure scope check — gate **inside the handler**, the same way `assertOwnTenant` / `requireScope` do.
|
|
109
|
+
|
|
110
|
+
Two forms, mirroring `scopes.ts`:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// Effect form — fails with typed ScopeError on the error channel.
|
|
114
|
+
import { permission } from '@voltro/plugin-rbac'
|
|
115
|
+
import { Effect } from 'effect'
|
|
116
|
+
|
|
117
|
+
export default (input: { title: string }, ctx: AppContext) =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
yield* permission(ctx, 'notes:write') // Effect<void, ScopeError>
|
|
120
|
+
// ... do the write ...
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// Sync form — throws ScopeError. For async (non-Effect) handlers.
|
|
126
|
+
import { assertPermission } from '@voltro/plugin-rbac'
|
|
127
|
+
|
|
128
|
+
const execute = async (input: { title: string }, ctx: AppContext) => {
|
|
129
|
+
assertPermission(ctx, 'notes:write') // throws ScopeError
|
|
130
|
+
return ctx.store.insert('notes', { title: input.title })
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- **Multiple permissions = ALL required (AND):** pass an array — `permission(ctx, ['notes:write', 'admin:bypass'])` — or call `permission()` twice.
|
|
135
|
+
- **OR semantics:** `anyPermission(ctx, ['notes:write', 'notes:admin'])` passes when the caller holds at least one.
|
|
136
|
+
- **`'*'` / `admin:full` bypass:** a caller whose resolved set contains `admin:full` (or a role mapped to `'*'`) passes every check.
|
|
137
|
+
- **Pure branch:** `can(ctx, 'notes:write')` returns a boolean without throwing — for in-handler branching.
|
|
138
|
+
|
|
139
|
+
These guards fail with protocol's `ScopeError` (`Schema.TaggedError`, `{ required: string, message: string }`) — the **one denial tag the whole framework uses**: declarative `guards:` raise it too, so a client that matches `_tag === 'ScopeError'` (or `errorTag(err)`) recognizes both a descriptor-guard denial and a `permission()` denial with a single branch. The plugin registers `ScopeError` via `errorSchemas` so a `permission()` rejection decodes typed even on a descriptor with no `guards:`; declare `error: ScopeError` (from `@voltro/protocol`) on the descriptor too if you want it explicit in the procedure's own wire union.
|
|
140
|
+
|
|
141
|
+
### `ctx.access` — the typed authorization slice
|
|
142
|
+
|
|
143
|
+
Every handler context carries `ctx.access`, the cast-free face of the caller's **effective** scope set (raw ∪ rbac roles). Reach for it instead of poking at `ctx.request.subject.scopes`:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
ctx.access.has('notes:write') // boolean
|
|
147
|
+
ctx.access.hasAny(['notes:write', 'notes:admin'])
|
|
148
|
+
yield* ctx.access.require('notes:write') // Effect<void, ScopeError>
|
|
149
|
+
ctx.access.scopes // the live effective set
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
It reads the same effective-scope seam the declarative `guards:` enforce, so `ctx.access.has(x)` and a `guards: [{ scope: x }]` entry can never disagree.
|
|
153
|
+
|
|
154
|
+
## Row-level checks
|
|
155
|
+
|
|
156
|
+
`permission()` covers the role/scope axis. Row-level rules ("can edit their OWN note") stay ordinary handler logic alongside it:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
export default (input: { noteId: string }, ctx: AppContext) =>
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
yield* permission(ctx, 'notes:write') // role/scope gate
|
|
162
|
+
const note = yield* /* load note by id */
|
|
163
|
+
if (note.authorId !== ctx.request.subject.id) {
|
|
164
|
+
return yield* Effect.fail(new ScopeError({ required: 'notes:write', message: 'not author' }))
|
|
165
|
+
}
|
|
166
|
+
// ... safe to write ...
|
|
167
|
+
})
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Where roles come from
|
|
171
|
+
|
|
172
|
+
By default the plugin reads role slugs from `subject.metadata.roles` (a `string[]`) — config-only, table-less. A project that assigns roles at runtime supplies its own resolver:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
rbacPlugin({
|
|
176
|
+
roles: { /* … */ },
|
|
177
|
+
// Resolve the caller's role slugs. Tenant-aware: filter by the
|
|
178
|
+
// ACTIVE tenant via subject.tenantId.
|
|
179
|
+
resolveRoles: async (subject) => {
|
|
180
|
+
const rows = await db.userRoles.findAll({
|
|
181
|
+
where: and(eq('userId', subject.id), eq('tenantId', subject.tenantId)),
|
|
182
|
+
})
|
|
183
|
+
return rows.map((r) => r.roleSlug)
|
|
184
|
+
},
|
|
185
|
+
// Optional extra raw permissions merged in (per-row ACLs, flags).
|
|
186
|
+
resolvePermissions: async (subject) => /* string[] */,
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The resolver runs once per request; the result is stashed on a per-request slot so `permission()` and `useCan` read a stable resolved set. A resolver failure degrades to the subject's raw scopes — it never silently grants.
|
|
191
|
+
|
|
192
|
+
## Tenant-aware roles
|
|
193
|
+
|
|
194
|
+
`subject.tenantId` is the **active** tenant (a single value, not a map). A tenant-aware resolver returns the roles that apply for `subject.tenantId`, so a user who is `editor` in tenant A but has no role in tenant B passes `permission()` under A and is denied (`ScopeError`) under B. The optional `userRoles` table (below) carries `(userId, tenantId, roleSlug)` for exactly this filter; `tenantId: null` means a global role across every tenant.
|
|
195
|
+
|
|
196
|
+
## Optional tables
|
|
197
|
+
|
|
198
|
+
Config-only projects run table-less. Projects that assign roles at runtime opt into the `roles` + `userRoles` tables via `extendSchema`:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
rbacPlugin({
|
|
202
|
+
roles: { /* … */ },
|
|
203
|
+
tables: true, // or { multitenant: true } for a FK to `tenants`
|
|
204
|
+
resolveRoles: /* DB resolver */,
|
|
205
|
+
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The tables emit on all six SQL dialects (postgres / mysql / mariadb / mssql / sqlite / turso) — `permissions` is a portable JSON column, never a postgres-only array. With `tables: true`, `userRoles.tenantId` is a plain nullable column; with `{ multitenant: true }` it's a nullable foreign key to the `tenants` table from `@voltro/plugin-multitenancy`.
|
|
209
|
+
|
|
210
|
+
### Assign / revoke — `userRoleStore`
|
|
211
|
+
|
|
212
|
+
You don't have to hand-write the `userRoles` CRUD or a DB resolver. `userRoleStore(dataStore)` gives tenant-aware `assign` / `revoke` / `rolesOf`, and `dataStoreRoleResolver(store)` adapts it into `resolveRoles`:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import { rbacPlugin, userRoleStore, dataStoreRoleResolver } from '@voltro/plugin-rbac'
|
|
216
|
+
|
|
217
|
+
const roleStore = userRoleStore(dataStore)
|
|
218
|
+
await roleStore.assign('user_123', 'editor', 't1') // idempotent grant
|
|
219
|
+
await roleStore.revoke('user_123', 'editor', 't1')
|
|
220
|
+
|
|
221
|
+
rbacPlugin({
|
|
222
|
+
roles: { /* … */ },
|
|
223
|
+
tables: { multitenant: true },
|
|
224
|
+
resolveRoles: dataStoreRoleResolver(roleStore), // scoped to subject.tenantId
|
|
225
|
+
})
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`rolesOf(userId, tenantId)` returns the tenant's assignments **plus** the global (`tenantId: null`) ones, so a global role grants everywhere.
|
|
229
|
+
|
|
230
|
+
## React side (web)
|
|
231
|
+
|
|
232
|
+
This plugin ships no client hook. The UI gate lives in
|
|
233
|
+
[`@voltro/client`](/docs/ui/client-utilities/use-can) — feed the caller's
|
|
234
|
+
resolved scopes to `<PermissionProvider>` and read them with `useCan`:
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import { PermissionProvider, useCan } from '@voltro/client'
|
|
238
|
+
|
|
239
|
+
// Feed the caller's resolved scopes once, near the app root:
|
|
240
|
+
// <PermissionProvider scopes={resolvedScopes}>…</PermissionProvider>
|
|
241
|
+
|
|
242
|
+
const Page = () => {
|
|
243
|
+
const canEdit = useCan('notes:write')
|
|
244
|
+
return (
|
|
245
|
+
<div>
|
|
246
|
+
<h1>My note</h1>
|
|
247
|
+
{canEdit ? <button>Edit</button> : null}
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
It sits there rather than here because scopes are a **framework** concept —
|
|
254
|
+
`@voltro/protocol` owns `ScopeError`, `guards: [{ scope }]` and `ctx.access`, and
|
|
255
|
+
rbac is only ONE way to produce them (an app can register its own
|
|
256
|
+
`setResourceScopeResolver` over its own tables and never install this plugin).
|
|
257
|
+
The hook CONSUMES scopes, which is generic; this plugin PRODUCES them, which is
|
|
258
|
+
rbac-specific. Gating a button must not require an rbac dependency.
|
|
259
|
+
|
|
260
|
+
What stays here is everything that IS rbac: the roles map, role→scope
|
|
261
|
+
compilation, the `permission()` server guard, the role tables, and
|
|
262
|
+
`resolveResourceRoles`.
|
|
263
|
+
|
|
264
|
+
**`useCan` is for hiding buttons, NOT authorization** — a client can lie about
|
|
265
|
+
its scopes. The server-side `permission()` guard is the real gate.
|
|
266
|
+
|
|
267
|
+
## See also
|
|
268
|
+
|
|
269
|
+
- [Multi-tenancy](/docs/multi-tenancy/overview) — tenant scoping IS authorisation
|
|
270
|
+
- [Authentication](/docs/authentication/overview) — RBAC sits on top of auth; roles compile to the subject's scopes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SCIM
|
|
2
|
+
|
|
3
|
+
> SCIM 2.0 provisioning — Users + Groups REST endpoints so an enterprise IdP (Okta, Entra, OneLogin) can create / update / deactivate users in your app.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/scim.md -->
|
|
10
|
+
## SCIM
|
|
11
|
+
|
|
12
|
+
_SCIM 2.0 provisioning — Users + Groups REST endpoints so an enterprise IdP (Okta, Entra, OneLogin) can create / update / deactivate users in your app._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-scim` exposes the SCIM 2.0 endpoints enterprise IdPs use to provision users into your app. When an admin adds someone to your app in Okta/Entra, the IdP POSTs to your `/scim/v2/Users` endpoint; when they remove them, it PATCHes `active: false`. This is a hard requirement in most enterprise procurement.
|
|
15
|
+
|
|
16
|
+
## Wiring
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// app.config.ts
|
|
20
|
+
import { scimPlugin } from '@voltro/plugin-scim'
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
type: 'api' as const, name: 'api',
|
|
24
|
+
plugins: [scimPlugin({ token: process.env.SCIM_TOKEN! })], // basePath defaults to /scim/v2
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Mounts `/scim/v2/Users` + `/scim/v2/Groups`, bearer-gated by `token` (paste it into the IdP's SCIM config). Contributes the `_voltro_scim_users` + `_voltro_scim_groups` tables.
|
|
29
|
+
|
|
30
|
+
## Endpoints
|
|
31
|
+
|
|
32
|
+
| Method + path | SCIM operation |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `GET /scim/v2/Users?filter=userName eq "x"` | list / find a user (`userName` / `externalId` eq filters, `startIndex`/`count` pagination) |
|
|
35
|
+
| `GET /scim/v2/Users/<id>` | read a user |
|
|
36
|
+
| `POST /scim/v2/Users` | create (provision) — duplicate `userName` answers `409 uniqueness` (DB-unique, IdP-retry-safe) |
|
|
37
|
+
| `PUT /scim/v2/Users/<id>` | replace |
|
|
38
|
+
| `PATCH /scim/v2/Users/<id>` | partial update — incl. the `active:false` **deactivation** IdPs send |
|
|
39
|
+
| `DELETE /scim/v2/Users/<id>` | delete |
|
|
40
|
+
| `GET`/`POST`/`DELETE /scim/v2/Groups` | list (`displayName` / `externalId` eq filters, paginated) / create / delete groups |
|
|
41
|
+
| `PUT`/`PATCH /scim/v2/Groups/<id>` | replace / **membership add-remove-replace** (how Okta/Entra push membership changes) |
|
|
42
|
+
| `GET /scim/v2/ServiceProviderConfig` · `/Schemas` · `/ResourceTypes` | the RFC 7644 §4 discovery trio IdPs probe at connector setup |
|
|
43
|
+
|
|
44
|
+
Requests + responses use the SCIM JSON envelope (schemas, `id`, `meta`, `ListResponse`); an unsupported `filter` is answered `400 invalidFilter` (never silently unfiltered). The pure mapping (`toScimUser`, `fromScimUser`, `applyScimPatch`, `applyScimGroupPatch`, `scimList`, `parseScimFilter`) is exported + unit-tested.
|
|
45
|
+
|
|
46
|
+
## Notes
|
|
47
|
+
|
|
48
|
+
- The bearer token is the trust boundary — keep it secret + rotate it via your IdP.
|
|
49
|
+
- **An empty token is refused at boot.** `scimPlugin({ token })` throws while
|
|
50
|
+
`app.config.ts` evaluates if the token is empty or whitespace, so a deployment
|
|
51
|
+
with an unset `SCIM_TOKEN` fails to start rather than coming up and answering
|
|
52
|
+
the first anonymous request. This exists because
|
|
53
|
+
`scimPlugin({ token: process.env.SCIM_TOKEN ?? '' })` — the shape everyone
|
|
54
|
+
writes — used to turn the gate off silently, leaving SCIM Users and Groups (a
|
|
55
|
+
full directory dump, plus the endpoints that deactivate accounts) readable with
|
|
56
|
+
no credentials. The miss is likeliest in a preview environment or a fresh
|
|
57
|
+
cluster where the variable was never copied over, which is exactly where a
|
|
58
|
+
boot failure is cheap and an open directory is not.
|
|
59
|
+
- `_voltro_scim_users` is the SCIM-side mirror; map it to your real `users` table in `onCreate`/sync logic if they differ.
|
|
60
|
+
|
|
61
|
+
## Permissions
|
|
62
|
+
|
|
63
|
+
`store:write` (the SCIM tables).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Search
|
|
2
|
+
|
|
3
|
+
> Keep an external search index (Typesense / Meilisearch / Algolia) in sync with your tables via the ChangeEvent tap, query it tenant-scoped through a typed action + hook.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/search.md -->
|
|
10
|
+
## Search
|
|
11
|
+
|
|
12
|
+
_Keep an external search index (Typesense / Meilisearch / Algolia) in sync with your tables via the ChangeEvent tap, query it tenant-scoped through a typed action + hook._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-search` mirrors your tables into an external search engine. It rides the **ChangeEvent tap** — every insert/update/delete on a configured table maps into the index automatically; a typed `search.query` action reads it back, tenant-scoped, with a `useSearch` hook on the client.
|
|
15
|
+
|
|
16
|
+
## Wiring
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// app.config.ts
|
|
20
|
+
import { searchPlugin } from '@voltro/plugin-search'
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
type: 'api' as const,
|
|
24
|
+
name: 'api',
|
|
25
|
+
plugins: [
|
|
26
|
+
searchPlugin({
|
|
27
|
+
// backend: defaults to memory (dev). Swap for production:
|
|
28
|
+
// backend: typesenseBackend({ url, apiKey }) | meilisearchBackend({ ... }) | algoliaBackend({ ... })
|
|
29
|
+
indexes: {
|
|
30
|
+
posts: {
|
|
31
|
+
index: 'posts',
|
|
32
|
+
tenantField: 'tenantId', // index documents carry the tenant → query scopes by it
|
|
33
|
+
map: (row) => ({ id: String(row.id), title: String(row.title), body: String(row.body) }),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How sync works
|
|
42
|
+
|
|
43
|
+
The plugin declares `onChangeEvent`. On every committed write to a configured table, `applyChange` maps the row via `map(row)` and upserts (insert/update) or removes (delete) the index document. No `*.subscribe.ts`, no manual indexing calls — the tap is the single sync path, and it runs under both `voltro dev` and `voltro serve`.
|
|
44
|
+
|
|
45
|
+
For pre-existing rows, `backfillIndex(backend, spec, rows)` indexes the rows you supply (call it from a `*.startup.tsx` or a one-off script) — or use the **Reindex** button / `POST /reindex` inspect endpoint, which reads the table's current rows for you.
|
|
46
|
+
|
|
47
|
+
## Querying
|
|
48
|
+
|
|
49
|
+
`search.query` is a typed **action** (not a streaming query — search results aren't reactive):
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { useSearch } from '@voltro/plugin-search/web'
|
|
53
|
+
|
|
54
|
+
const { results, facets, run, pending } = useSearch('posts')
|
|
55
|
+
run('voltro effect') // → results scoped to the caller's tenant
|
|
56
|
+
run('voltro effect', { limit: 20, offset: 20 }) // offset paging — page 2
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`results` are the hit documents; `facets` are the facet counts from the last run. The query is automatically filtered to the caller's `tenantId` (from the resolved Subject) against the index's `tenantField` — no cross-tenant leak.
|
|
60
|
+
|
|
61
|
+
`run(q, { limit, offset })` pages the results: `offset` skips leading hits, `limit` bounds the window. It maps to each engine's native paging (memory slice, Typesense/Meilisearch `offset`, Algolia `offset`+`length`).
|
|
62
|
+
|
|
63
|
+
## Query features
|
|
64
|
+
|
|
65
|
+
Every option is plain JSON and round-trips through the action's Schema:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { memoryBackend } from '@voltro/plugin-search'
|
|
69
|
+
|
|
70
|
+
const backend = memoryBackend()
|
|
71
|
+
const res = await backend.query('posts', {
|
|
72
|
+
q: 'kubernetis', // matches the typo below
|
|
73
|
+
limit: 20,
|
|
74
|
+
offset: 20, // offset paging
|
|
75
|
+
fuzziness: 'auto', // typo tolerance (or a number: max edits, 0 = exact)
|
|
76
|
+
filters: [
|
|
77
|
+
{ field: 'status', op: 'neq', value: 'draft' }, // negation
|
|
78
|
+
{ field: 'score', op: 'gte', value: 50 }, // numeric range
|
|
79
|
+
{ field: 'publishedAt', op: 'lt', value: '2026-06-01T00:00:00Z' }, // date range
|
|
80
|
+
{ field: 'tag', op: 'in', value: ['a', 'b'] }, // set membership
|
|
81
|
+
],
|
|
82
|
+
facets: ['status', 'tag'], // facet counts per field
|
|
83
|
+
highlight: { fields: ['title'], preTag: '<mark>', postTag: '</mark>' },
|
|
84
|
+
engineParams: { num_typos: 1 }, // escape hatch → forwarded verbatim to the engine
|
|
85
|
+
})
|
|
86
|
+
// res.hits: [{ doc, highlights? }, …] res.facets: { status: { open: 12, done: 3 }, … }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- **filters** — a list of clauses ANDed together, each `{ field, op, value }` with `op ∈ eq | neq | gt | gte | lt | lte | in | nin`. Range (`gt`/`lte`/…) and negation (`neq`/`nin`), not equality-only.
|
|
90
|
+
- **facets** — per-value counts for the named fields (over the full matched set, before paging).
|
|
91
|
+
- **highlight** — matched-term snippets per field, returned as `hit.highlights[field]`.
|
|
92
|
+
- **fuzziness** — a max edit distance (`0` = exact) or `'auto'`.
|
|
93
|
+
- **engineParams** — an escape-hatch bag forwarded verbatim into the underlying engine's search call. Ignored by the memory backend.
|
|
94
|
+
|
|
95
|
+
Native support degrades honestly: memory, Typesense, Meilisearch and Algolia all do filters/facets/highlighting; Typesense honors a numeric typo count (`num_typos` 0–2) while Meilisearch and Algolia only toggle typo tolerance on/off (`fuzziness: 0` disables it, other values keep their built-in tolerance). A backend op fails with a typed `SearchBackendError`.
|
|
96
|
+
|
|
97
|
+
## Backends
|
|
98
|
+
|
|
99
|
+
| Backend | Notes |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `memoryBackend` (default) | Fully in-process; dev + tests. Not for production scale. |
|
|
102
|
+
| `typesenseBackend` | Optional dep `typesense`. Lazy-loaded. |
|
|
103
|
+
| `meilisearchBackend` | Optional dep `meilisearch`. Lazy-loaded. |
|
|
104
|
+
| `algoliaBackend` | Optional dep `algoliasearch`. Lazy-loaded. |
|
|
105
|
+
|
|
106
|
+
A backend is the `SearchBackend` interface (`upsert` / `remove` / `query`) — bring your own (OpenSearch, Elastic, …).
|
|
107
|
+
|
|
108
|
+
## Dashboard panel
|
|
109
|
+
|
|
110
|
+
Both dashboards ship a **Search** panel (api apps): the configured indexes with per-index sync stats (docs synced/removed, last reindex) + the resolved backend, and a **Reindex** button per index that re-seeds it from the table's current rows (`backfillIndex`). Reindex gates on the `canReindexSearch` capability. Backed by `/_voltro/inspect/plugins/search/{indexes,reindex}`.
|
|
111
|
+
|
|
112
|
+
The sync stats behind this panel are **durable and aggregated across replicas**. They live in a framework-owned `_voltro_search_stats` table (contributed via `extendSchema.tables`; the plugin declares `store:write`), one row per `(index, replica)`, each bumped with an atomic compare-and-set. `/indexes` sums every replica's row and takes the most-recent reindex — so the counts are truthful under multiple instances and survive a restart (they no longer reset with the process, and no replica under-reports the fleet total). Zero-infra dev/tests use an in-process stats store; `bindDataStore` swaps in the durable one at boot.
|
|
113
|
+
|
|
114
|
+
## Permissions
|
|
115
|
+
|
|
116
|
+
`store:changes:read` (the ChangeEvent tap) + `store:write` (the durable `_voltro_search_stats` counters) + `inspect:read` (dashboard panel) + `network:outbound:<host>` (for a remote backend).
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Sentry
|
|
2
|
+
|
|
3
|
+
> Deep Sentry integration — errors correlated to the distributed trace, breadcrumbs from the log sink, and opt-in performance traces.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/sentry.md -->
|
|
10
|
+
## Sentry
|
|
11
|
+
|
|
12
|
+
_Deep Sentry integration — errors correlated to the distributed trace, breadcrumbs from the log sink, and opt-in performance traces._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-sentry` is the deep Sentry integration: every mutation/query/action failure becomes a Sentry issue **correlated to the active distributed trace** (trace_id + span_id) and carrying the request's recent log lines as breadcrumbs. Opt into performance traces and the framework's spans flow to Sentry as transactions.
|
|
15
|
+
|
|
16
|
+
Uses the official `@sentry/node` SDK in **OpenTelemetry-consumer mode** — it consumes the framework's existing tracer (it never becomes the global provider), so the in-app [Traces dashboard](/docs/observability/distributed-tracing) stays intact.
|
|
17
|
+
|
|
18
|
+
## Wiring
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// app.config.ts
|
|
22
|
+
import { sentryPlugin } from '@voltro/plugin-sentry'
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
type: 'api' as const,
|
|
26
|
+
name: 'api',
|
|
27
|
+
plugins: [
|
|
28
|
+
sentryPlugin({
|
|
29
|
+
// dsn defaults to SENTRY_DSN; without a valid DSN the plugin is a no-op.
|
|
30
|
+
environment: 'production', // or SENTRY_ENVIRONMENT
|
|
31
|
+
release: '1.4.2', // or SENTRY_RELEASE
|
|
32
|
+
traces: true, // route the framework's spans → Sentry (default off)
|
|
33
|
+
tracesSampleRate: 1.0,
|
|
34
|
+
profiling: true, // continuous CPU profiler via @sentry/profiling-node
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`@sentry/*` are optional, lazy-loaded dependencies — non-users pay nothing.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
| Option | Type | Default | Notes |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| `dsn` | `string` | `SENTRY_DSN` env | No valid DSN → the plugin is inert (no interceptors registered). |
|
|
47
|
+
| `environment` | `string` | `SENTRY_ENVIRONMENT` env | Sentry `environment` tag + `deployment.environment` resource attribute. |
|
|
48
|
+
| `release` | `string` | `SENTRY_RELEASE` env | Release-health + source-map matching; also `service.version`. |
|
|
49
|
+
| `traces` | `boolean` | `false` | Route the framework's OTel spans to Sentry as transactions. Off → errors + breadcrumbs only. |
|
|
50
|
+
| `tracesSampleRate` | `number` | `1.0` | Applied by the Sentry client (not a tracer sampler). Only relevant with `traces`. |
|
|
51
|
+
| `profiling` | `boolean` | `false` | Continuous CPU profiler via `@sentry/profiling-node` (lazy; warns if not installed). |
|
|
52
|
+
| `profilesSampleRate` | `number` | `1.0` | Only relevant with `profiling`. |
|
|
53
|
+
| `maxBreadcrumbs` | `number` | `50` | Per-trace breadcrumb cap. |
|
|
54
|
+
| `name` | `string` | — | Disambiguates multiple instances (`@voltro/plugin-sentry#<name>`). |
|
|
55
|
+
|
|
56
|
+
## How the deep correlation works
|
|
57
|
+
|
|
58
|
+
The framework is **Effect-native**: the active OTel span lives in Effect's fiber context, not the AsyncLocalStorage Sentry reads implicitly. So the plugin attaches trace context + breadcrumbs **explicitly** rather than relying on Sentry's ambient scope:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
interceptMutation/Query/Action
|
|
62
|
+
└─ on failure → Sentry.withScope(scope => {
|
|
63
|
+
scope.setContext('trace', { trace_id, span_id }) // ← from ctx.traceId / ctx.spanId
|
|
64
|
+
scope.setTags({ 'rpc.tag', 'rpc.kind', 'subject.type', 'tenant.id' })
|
|
65
|
+
breadcrumbs.forEach(b => scope.addBreadcrumb(b)) // ← the trace's log lines
|
|
66
|
+
Sentry.captureException(error)
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This guarantees the error links to its trace in Sentry **regardless** of the Effect/AsyncLocalStorage mismatch. Pure interruptions (client disconnects) are skipped.
|
|
71
|
+
|
|
72
|
+
### Breadcrumbs
|
|
73
|
+
|
|
74
|
+
The framework log sink feeds a bounded **per-trace** ring (capped per-trace + LRU + TTL), keyed on each log line's `traceId`. On an error the trace's breadcrumbs are attached to the event; traces that never error are reaped by the TTL. So a Sentry issue shows the exact log lines leading up to it — for that request only.
|
|
75
|
+
|
|
76
|
+
## Traces (opt-in `traces:true`)
|
|
77
|
+
|
|
78
|
+
The plugin contributes a `SentrySpanProcessor` to the framework's tracer (via the plugin observability surface — see [Observability › Routing traces to a vendor](/docs/observability/overview#routing-traces-to-a-vendor)). Every framework span becomes a Sentry transaction, sharing the same trace id the errors carry — so in Sentry you land on an error inside its full distributed trace.
|
|
79
|
+
|
|
80
|
+
We deliberately do **not** install a `SentrySampler` as the tracer's sampler: that would gate recording for the WHOLE tracer and blind the framework's own in-app Traces buffer. The framework keeps its always-on sampler; `tracesSampleRate` is applied by the Sentry client.
|
|
81
|
+
|
|
82
|
+
## Full-stack — browser → backend in ONE Sentry trace
|
|
83
|
+
|
|
84
|
+
The plugin ships a browser half at `@voltro/plugin-sentry/web` so a click in the UI and the server work it triggers appear as **one trace** in Sentry. Turn it on in the web app's `app.config.ts`:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// apps/web/app.config.ts
|
|
88
|
+
export default {
|
|
89
|
+
type: 'web' as const,
|
|
90
|
+
name: 'web',
|
|
91
|
+
apis: { app: { package: '@app/api' } },
|
|
92
|
+
sentry: {
|
|
93
|
+
dsn: 'https://<key>@oXXXX.ingest.sentry.io/XXXX', // public — safe in the browser bundle
|
|
94
|
+
environment: 'production',
|
|
95
|
+
release: '1.4.2', // MUST match the api's `release` for unified release health
|
|
96
|
+
tracesSampleRate: 1.0,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The framework's generated entry calls `initSentryBrowser()` before mount (no code to write). It needs `@voltro/plugin-sentry` as a dependency of the web app. Prefer the api `release` and the web `release` be identical.
|
|
102
|
+
|
|
103
|
+
What it does:
|
|
104
|
+
- **Inits `@sentry/react`** with `browserTracingIntegration` — page loads + client navigations become Sentry transactions, and unhandled errors / promise rejections are captured by the SDK's default global handlers.
|
|
105
|
+
- **Unifies the trace.** Every framework rpc call (`useMutation`/`useSubscription`/`useAction`) already wraps itself in an Effect client span and propagates that span's W3C `traceId`+`spanId` to the api (the api's `sentryPlugin({ traces:true })` turns the server spans into Sentry transactions under that same trace id). The browser half subscribes to the framework's client-trace bus and emits a browser-side Sentry span into the **same** trace, parented at the client span — so Sentry's trace view shows the browser hop and the server transactions in one tree.
|
|
106
|
+
|
|
107
|
+
> **Manual wiring** (no app.config field, e.g. an app that builds its own entry): call it yourself once at boot —
|
|
108
|
+
> ```ts
|
|
109
|
+
> import { initSentryBrowser } from '@voltro/plugin-sentry/web'
|
|
110
|
+
> await initSentryBrowser({ dsn: import.meta.env.VITE_SENTRY_DSN })
|
|
111
|
+
> ```
|
|
112
|
+
> It's a no-op on the server (SSR) and without a DSN.
|
|
113
|
+
|
|
114
|
+
The two halves are independent: the api plugin alone gives you server traces + errors; add the web half for the browser hop. For the FULL frontend→backend waterfall in Sentry, run **both** (`sentryPlugin({ traces:true })` on the api + `sentry: { dsn }` on the web app), with matching `release`.
|
|
115
|
+
|
|
116
|
+
## Source maps (browser stack traces)
|
|
117
|
+
|
|
118
|
+
The web bundle is minified, so browser errors arrive in Sentry with **minified** stack traces unless you upload source maps. The plugin does **not** upload them for you — it captures + correlates errors, but wiring the build to publish maps is a per-app build step (it needs your build output plus a Sentry auth token, neither of which the runtime plugin owns). Wire it once with Sentry's official tooling, keyed to the **same `release`** the plugin uses so the maps match the uploaded events:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
# after the web build, from the web app dir — uploads maps for one release
|
|
122
|
+
pnpm add -D @sentry/cli
|
|
123
|
+
SENTRY_AUTH_TOKEN=… npx sentry-cli sourcemaps upload \
|
|
124
|
+
--org <org> --project <project> \
|
|
125
|
+
--release "$SENTRY_RELEASE" ./dist
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Or add `@sentry/vite-plugin` to the web app's Vite config so the upload runs automatically on every production build. Either way, set `release` to the same value (e.g. the git SHA) on BOTH the api `sentryPlugin({ release })` and the web `sentry` field — the upload's `--release` must match or Sentry won't resolve the frames. Server stack traces are un-minified already (the api ships readable JS), so this is a browser-only concern.
|
|
129
|
+
|
|
130
|
+
## Catching errors — what's automatic, what's manual
|
|
131
|
+
|
|
132
|
+
Once the two halves are wired, most errors are captured **with no per-call code**:
|
|
133
|
+
|
|
134
|
+
| Error | Captured | How |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| rpc handler throw (mutation/query/action) | ✅ auto | server interceptor → Sentry, trace-correlated + breadcrumbs |
|
|
137
|
+
| Unhandled browser error / promise rejection | ✅ auto | `@sentry/react` global handlers (after `initSentryBrowser`) |
|
|
138
|
+
| **React render error** (any page, incl. catch-all `[...slug]`, `error.tsx` boundary) | ✅ auto | the framework's route ErrorBoundary publishes to a client-error bus → the Sentry web bridge captures it. React swallows boundary-caught errors before `window.onerror`, so this bridge is what makes them reach Sentry — tagged with the route + component stack. |
|
|
139
|
+
| **Loader failure** (page/layout `loader` reject) | ✅ auto | same route bus path |
|
|
140
|
+
| Manual try/catch (event handler, async effect, anywhere) | ✋ one call | `reportClientError(error, context?)` |
|
|
141
|
+
| **Every other server primitive** — REST route (`defineRestRoute`), aggregate, subscriber, schedule, workflow, webhook, startup | ✅ auto | each primitive's error path publishes to the framework's server-error bus → the plugin captures it, tagged `voltro.errorSource` + `voltro.name` (+ traceId for workflows) |
|
|
142
|
+
|
|
143
|
+
### Manual capture — `reportClientError`
|
|
144
|
+
|
|
145
|
+
Vendor-agnostic, ships with the framework (`@voltro/web` / re-exported from `@voltro/plugin-sentry/web`). Call it from anything the route boundary won't catch:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
import { reportClientError } from '@voltro/client'
|
|
149
|
+
|
|
150
|
+
const onPay = async () => {
|
|
151
|
+
try {
|
|
152
|
+
await pay.run({ amount })
|
|
153
|
+
} catch (err) {
|
|
154
|
+
reportClientError(err, { feature: 'checkout', amount }) // → Sentry, tagged
|
|
155
|
+
toast.error('Payment failed')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
It's a no-op when no reporter is subscribed (Sentry not initialised), so library code can call it unconditionally. The Sentry web bridge forwards it with `voltro.errorSource: manual` + your `context`.
|
|
161
|
+
|
|
162
|
+
### Catch-all / 404 routes
|
|
163
|
+
|
|
164
|
+
A catch-all page (`[...slug].tsx`) or a scoped `not-found.tsx` that *renders* fine produces no error — a 404 isn't an exception. But if such a page (or anything under it) **throws** while rendering or in its loader, the route ErrorBoundary catches it and it's auto-captured — no `error.tsx` wiring needed (though an `error.tsx` still gets to render your fallback UI; both happen).
|
|
165
|
+
|
|
166
|
+
### Server coverage — every primitive, not just rpc
|
|
167
|
+
|
|
168
|
+
With the plugin active, errors from **every** server primitive reach Sentry — no per-handler code:
|
|
169
|
+
|
|
170
|
+
- **rpc** (mutation / query / action, and the synthesized agent `send`/`messages`) — via the interceptors (richest context: subject, span id, breadcrumbs).
|
|
171
|
+
- **Everything else** — REST routes (`defineRestRoute`), aggregates, subscribers, schedules, workflows, webhooks, startups — via the framework's **server-error bus**: each primitive's existing error path calls `publishServerError({ error, source, name, … })`, and the plugin subscribes once and captures it (tagged with the source + name; workflows also carry their `traceId` so the failure correlates to its trace + breadcrumbs).
|
|
172
|
+
|
|
173
|
+
The bus is vendor-agnostic (`publishServerError` / `subscribeServerErrors` from `@voltro/protocol`) — the Sentry plugin is just the subscriber. Intentional control-flow throws (a REST handler's `throw { status: 4xx }`) are **not** reported — only unexpected 5xx-class failures.
|
|
174
|
+
|
|
175
|
+
## Troubleshooting
|
|
176
|
+
|
|
177
|
+
| Symptom | Cause / fix |
|
|
178
|
+
|---|---|
|
|
179
|
+
| Boot logs `sentry inactive — no valid DSN` | `dsn` / `SENTRY_DSN` is unset or malformed. The plugin registers no interceptors until a valid DSN is present. |
|
|
180
|
+
| Errors arrive in Sentry but **not linked to a trace** | The `trace` context is set explicitly from `ctx.traceId` — present on every rpc call. If it's missing, the error came from outside an rpc handler (e.g. a bare `console.error`), which the interceptor doesn't see. |
|
|
181
|
+
| Issue has **no breadcrumbs** | Breadcrumbs come from log lines carrying `fields.traceId`. Lines logged outside the request's trace (or after the trace was reaped by TTL) aren't attached. Raise `maxBreadcrumbs` if a trace logs heavily. |
|
|
182
|
+
| `traces: true` but **no transactions in Sentry** | Confirm `tracesSampleRate > 0`. Spans are recorded always-on by the framework; the Sentry client applies the sample rate when building transactions. |
|
|
183
|
+
| In-app Traces dashboard **went empty** after enabling Sentry | You contributed a sampler that drops spans. Don't — the framework's buffer is always-on by design (this plugin deliberately omits `SentrySampler`). |
|
|
184
|
+
| Profiling requested but no profiles | `@sentry/profiling-node` isn't installed; boot logs a warning. `pnpm add @sentry/profiling-node`. |
|
|
185
|
+
| Browser + server NOT in one trace | Run BOTH halves — `sentryPlugin({ traces:true })` on the api AND `sentry:{dsn}` on the web app. Errors-only on either side won't share a trace; you need `traces` on the api. |
|
|
186
|
+
| Web `sentry` set but nothing in Sentry | `@voltro/plugin-sentry` must be a dependency of the WEB app (the generated entry imports `@voltro/plugin-sentry/web`). Confirm `dsn` is set — without it the browser half is a no-op. |
|
|
187
|
+
|
|
188
|
+
## Notes
|
|
189
|
+
|
|
190
|
+
- No DSN → the plugin is inert (logs a warning at boot, registers no interceptors).
|
|
191
|
+
- A Sentry outage is **fail-soft**: a broken transport never masks the original handler error.
|
|
192
|
+
- Permissions: `rpc:intercept:{mutation,query,action}` + `network:outbound:<dsn-host>`.
|