@voltro/cli 0.11.3 → 0.12.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 +436 -0
- package/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/{apiBuild-zFDv0u8y.js → apiBuild-GefjNI5d.js} +10 -9
- package/dist/apiBuild-s7swuEU5.js +2 -0
- package/dist/appModuleLoader-C9r9mxZt.js +19 -0
- package/dist/bin.js +24 -6
- package/dist/cliArgs-qdZSElM3.js +28 -0
- package/dist/{commands-BDGYrBhK.js → commands-BmNi1RgZ.js} +2580 -2493
- package/dist/dbCommand-Bt6BCG7g.js +2 -0
- package/dist/dbCommand-DNrkb58g.js +862 -0
- package/dist/dev-YHbHmeXt.js +3 -0
- package/dist/{dev-CGt0PP1f.js → dev-d0LjACxE.js} +1591 -2332
- package/dist/{devActivity-1WtIVyHc.js → devActivity-BhIu6ncs.js} +7 -1
- package/dist/devActivity.js +1 -1
- package/dist/index.d.ts +22 -0
- package/dist/index.js +3 -3
- package/dist/inspect-DcZ04OME.js +2 -0
- package/dist/inspect-Dwx0_tUj.js +921 -0
- package/dist/inspectMetrics-uEfIS1vp.js +3399 -0
- package/dist/manifestBuild-D1MzJAiQ.js +2 -0
- package/dist/manifestBuild-P9yuCY2d.js +39 -0
- package/dist/seedRunner-DZmhoqNt.js +821 -0
- package/dist/serveCommand-B2-YV9TU.js +1112 -0
- package/dist/serveEntry.js +4 -4
- package/dist/{start-Crl39M38.js → start-DKcQd52a.js} +348 -337
- package/dist/startEntry.js +2 -2
- package/dist/startup.js +1 -1
- package/dist/{startupRunner-DhlX9nqd.js → startupRunner-DPGFchOa.js} +19 -33
- package/package.json +17 -17
- package/templates/AGENTS.md +1 -0
- package/templates/agent-docs/_index.md +1 -0
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/cli.md +266 -16
- package/templates/agent-docs/data.md +143 -1
- package/templates/agent-docs/database/misc.md +32 -0
- package/templates/agent-docs/database/querying.md +14 -1
- package/templates/agent-docs/internationalization.md +3 -8
- package/templates/agent-docs/reference.md +35 -0
- package/templates/agent-docs/scheduling.md +35 -1
- package/templates/agent-docs/security.md +48 -0
- package/templates/agent-docs/templates/apibackends.md +90 -17
- package/templates/agent-docs/whats-new.md +177 -0
- package/templates/agent-docs/workflows.md +31 -0
- 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-storage/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-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/README.md +3 -1
- package/templates/apps/api-webhooks/package.json +8 -8
- 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-blank/package.json +7 -7
- 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-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/dist/apiBuild-BmPdmhgi.js +0 -2
- package/dist/dev-D_PGP9Kx.js +0 -2
- package/dist/inspectMetrics-SRtv8KDy.js +0 -4247
- package/dist/serveCommand-p6e5Ahgx.js +0 -1070
|
@@ -691,6 +691,14 @@ column — and required otherwise. `upsertRow`'s `conflictColumns` are constrain
|
|
|
691
691
|
to the table's own columns, so a typo is a compile error too. The string-keyed
|
|
692
692
|
`ctx.store.insert` / `upsert` still work unchanged; the typed seam is opt-in.
|
|
693
693
|
|
|
694
|
+
> **If you adopt it, check your tooling for the old spelling.** `insertRow`
|
|
695
|
+
> replaces `store.insert('<tableName>', …)`, so anything that matches on that
|
|
696
|
+
> string stops matching — a lint rule, a codemod, an architecture test. One team
|
|
697
|
+
> had a test asserting every mutation writes an audit log; it matched
|
|
698
|
+
> `store.insert('auditLogs'`, and migrating to `insertRow` silently blinded it.
|
|
699
|
+
> Green suite, gap reintroduced. Grep for the old spelling before you migrate,
|
|
700
|
+
> not after.
|
|
701
|
+
|
|
694
702
|
## Partial updates: `ctx.store.applyDefined`
|
|
695
703
|
|
|
696
704
|
A partial-update mutation should set only the fields the caller actually sent — not overwrite an omitted field with `undefined`. Instead of hand-writing `if (input.x !== undefined) patch.x = input.x` per field, use `ctx.store.applyDefined(input, keys)`:
|
|
@@ -1579,6 +1587,61 @@ This is the Stripe-style contract — the **client** opts in by sending the head
|
|
|
1579
1587
|
- **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
|
|
1580
1588
|
- **Atomic claim, non-atomic completion.** Two concurrent same-key requests resolve to exactly one execution (the `UNIQUE(scope,key)` insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to `completed` leaves the key in-flight (a retry `409`s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.
|
|
1581
1589
|
|
|
1590
|
+
## Projecting an existing procedure — `publicApi`
|
|
1591
|
+
|
|
1592
|
+
You often want to *offer* an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with `publicApi` and the framework mounts ONE HTTP route that runs the **same** handler, under the same guards:
|
|
1593
|
+
|
|
1594
|
+
```ts
|
|
1595
|
+
// queries/absenceRequests.list.query.ts
|
|
1596
|
+
export default defineQuery({
|
|
1597
|
+
name: 'absenceRequests.list',
|
|
1598
|
+
input: Schema.Struct({ status: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number) }),
|
|
1599
|
+
output: Schema.Array(AbsenceRequest),
|
|
1600
|
+
guards: [requireScope('absences:read')],
|
|
1601
|
+
publicApi: {}, // → GET /v1/absenceRequests/list?status=open&limit=20
|
|
1602
|
+
})
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
- **Method** derives from the kind: query → `GET`, mutation / action → `POST` (override with `method`).
|
|
1606
|
+
- **Path** derives from the tag: `/<version>/<tag-as-path>` (override with `path`; set `version`).
|
|
1607
|
+
- **Input binding** follows the method: for `GET` the descriptor's `input` schema is bound to the **query string**, otherwise to the JSON **body**. So filter and pagination fields are plain URL params — no separate input shape.
|
|
1608
|
+
- **Relations** need nothing extra: eager loading is resolved server-side by the executor, so `include` works identically over HTTP.
|
|
1609
|
+
- **Authorization is the same code** as the WebSocket path — the declarative `guards:`, the row filter, and tenant scoping all run before the handler. A procedure that denies on the socket denies here.
|
|
1610
|
+
- Also available per endpoint: `scopes` (extra API-key scopes), `rateLimit`, `idempotent`.
|
|
1611
|
+
|
|
1612
|
+
This pairs with [`crud.list`](/docs/data/crud): `filter` / `paginate` / `sort` / `include` on the executor plus `publicApi: {}` on the descriptor is a complete, filtered, paginated REST list endpoint in one declaration.
|
|
1613
|
+
|
|
1614
|
+
Use `publicApi` when the procedure already exists and the derived URL is fine; use `defineRestRoute` when you need a hand-shaped URL, path params, or a response that isn't the procedure's output.
|
|
1615
|
+
|
|
1616
|
+
### Live updates over HTTP — `stream: 'sse'`
|
|
1617
|
+
|
|
1618
|
+
A third party that can't open your WebSocket can still follow changes: `stream: 'sse'` on a QUERY projects it as Server-Sent Events — the initial `snapshot`, then a `delta` per change, until the client disconnects.
|
|
1619
|
+
|
|
1620
|
+
```ts
|
|
1621
|
+
export default defineQuery({
|
|
1622
|
+
name: 'orders.live',
|
|
1623
|
+
input: Schema.Struct({ status: Schema.optional(Schema.String) }),
|
|
1624
|
+
output: Schema.Array(Order),
|
|
1625
|
+
guards: [requireScope('orders:read')],
|
|
1626
|
+
publicApi: { stream: 'sse' }, // → GET /v1/orders/live?status=open (text/event-stream)
|
|
1627
|
+
})
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
```js
|
|
1631
|
+
// any EventSource client — no Voltro SDK needed
|
|
1632
|
+
const es = new EventSource('/v1/orders/live?status=open')
|
|
1633
|
+
es.addEventListener('snapshot', (e) => setRows(JSON.parse(e.data).data))
|
|
1634
|
+
es.addEventListener('delta', (e) => applyDelta(JSON.parse(e.data)))
|
|
1635
|
+
```
|
|
1636
|
+
|
|
1637
|
+
Each event's `_tag` becomes the SSE `event:` name, so a client listens per kind instead of switching on a payload field. The framing handles the details that bite otherwise: embedded newlines are split across `data:` lines (a raw `\n` would truncate the event), a `retry:` hint is sent, and a keep-alive comment goes out every 15s so proxies don't drop an idle stream.
|
|
1638
|
+
|
|
1639
|
+
Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the row filter and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
|
|
1640
|
+
|
|
1641
|
+
`stream: 'sse'` on a mutation or action is ignored: there is nothing to subscribe to.
|
|
1642
|
+
|
|
1643
|
+
For a hand-written `defineRestRoute`, the same machinery is available directly — return `sse((emit) => unsubscribe)` from the handler and frame events with `sseFrame(event, data)` (both from `@voltro/protocol/rest`).
|
|
1644
|
+
|
|
1582
1645
|
## REST route vs Action
|
|
1583
1646
|
|
|
1584
1647
|
Both are unary request/response. Pick by transport + audience:
|
|
@@ -1981,7 +2044,7 @@ export default defineQuery({
|
|
|
1981
2044
|
## What the defaults bake in
|
|
1982
2045
|
|
|
1983
2046
|
- **Tenant scope.** `crud.list` and `crud.getById` read through `ctx.store`, which auto-scopes a `tenant()` table. They never call `.unscoped()`, so a cross-tenant read is impossible through them — `payslips.list` cannot return another tenant's rows.
|
|
1984
|
-
- **Redaction.**
|
|
2047
|
+
- **Redaction.** A column a generated read must never ship — a credential, a token hash, a salary — is stripped from every returned row (reads and the row a `create` / `update` echoes). Two sources: a column marked [`.serverOnly()`](/docs/database/sensitivity) is stripped **automatically** (declare the exposure policy once at the schema and every crud read respects it — the single-source form), plus the per-call `redact: [...]` option for anything not worth a schema marker. Declare the same omission in the descriptor's `output` schema so the column never reaches the client at all; the helper is the runtime guarantee that it doesn't, whatever the schema says.
|
|
1985
2048
|
- **`getById` returns `null`, never throws.** A reactive getter that throws takes its shared-WebSocket siblings down with it. `crud.getById` resolves `null` for an absent row.
|
|
1986
2049
|
|
|
1987
2050
|
## The helpers
|
|
@@ -1993,9 +2056,88 @@ export default defineQuery({
|
|
|
1993
2056
|
| `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row |
|
|
1994
2057
|
| `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null` |
|
|
1995
2058
|
| `crud.remove(table)` | delete `input.id`; returns `{ deleted }` |
|
|
2059
|
+
| `crud.count(table, { filter? })` | `COUNT(*)` of the filtered, tenant-scoped set — the total for page-based UIs |
|
|
1996
2060
|
|
|
1997
2061
|
`redactColumns(rows, cols)` is exported standalone for a hand-written handler that isn't plain CRUD but still needs to redact declaratively.
|
|
1998
2062
|
|
|
2063
|
+
## `crud.list` read ergonomics — filter, sort, paginate, include
|
|
2064
|
+
|
|
2065
|
+
A generated list isn't limited to "all rows". `crud.list` takes the ergonomics every real list view needs — all optional and additive on top of `redact`:
|
|
2066
|
+
|
|
2067
|
+
```ts
|
|
2068
|
+
export default crud.list('absenceRequests', {
|
|
2069
|
+
filter: (input) => ({ employeeId: input.employeeId, status: input.status }), // → WHERE
|
|
2070
|
+
paginate: true, // ?page=3&pageSize=20 (or ?limit=20&offset=40)
|
|
2071
|
+
sort: [{ column: 'createdAt', direction: 'desc' }], // multi-column
|
|
2072
|
+
include: { employee: { with: { team: true } } }, // eager relations, nested filter/sort
|
|
2073
|
+
redact: ['internalNote'],
|
|
2074
|
+
})
|
|
2075
|
+
```
|
|
2076
|
+
|
|
2077
|
+
- **`filter`** maps the request input to a `WHERE` — return a column→value map; an `undefined` field is ignored (an absent filter param is a no-op). Applied through the tenant-scoped `.where`.
|
|
2078
|
+
- **`paginate: true`** accepts BOTH paging styles, so a caller uses whichever its UI thinks in: **`page`** (1-based) + **`pageSize`** (default 100), or **`limit`** / **`offset`** (defaults 100 / 0). `page` wins when both are sent, and a `page` below 1 clamps to the first page rather than producing a negative offset. For the total a page-based UI needs ("page 3 of 12"), pair it with `crud.count` — see below.
|
|
2079
|
+
- **`maxPageSize`** caps how many rows ONE request may ask for (default **1000**). The page size is caller-controlled, so without a cap `?limit=1000000` is a one-request read of the whole table — and with `publicApi` that caller is anyone who can reach the URL. Values above the cap are clamped, not rejected; raise it deliberately for an export-style endpoint.
|
|
2080
|
+
- **`sort`** is a multi-column `orderBy`, applied in order.
|
|
2081
|
+
- **`include`** is the SAME spec [`.with(...)`](/docs/database/relations) takes, so nested relations and per-branch `where` / `orderBy` / `limit` (nested filtering and sort) all work. `getById` takes `include` too.
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
Pass `crud.count` the **same** `filter` as the list (share the option object) so the total and the pages can't disagree about which rows they mean. It ignores paging fields on the input — it counts the whole filtered set, not the current page:
|
|
2085
|
+
|
|
2086
|
+
```ts
|
|
2087
|
+
// the total, for rendering "page 3 of 12" — SAME filter as the list
|
|
2088
|
+
export default crud.count('absenceRequests', {
|
|
2089
|
+
filter: (input) => ({ employeeId: input.employeeId, status: input.status }),
|
|
2090
|
+
})
|
|
2091
|
+
```
|
|
2092
|
+
|
|
2093
|
+
Declare the filter / pagination fields in the descriptor's `input` schema so the client can pass them; the executor reads them off `input`.
|
|
2094
|
+
|
|
2095
|
+
**You don't need a `toView` projection layer** — the `output` schema already shapes the wire result. `crud.list` returns full rows, and on encode Effect **strips every column the output schema doesn't declare** (a tight `Schema.Struct({ id, name })` ships only `id` + `name`, whatever else the row holds), and a `timestampMs` field normalizes a `Date` to epoch ms. So select fields by naming them in `output`, and normalize dates with the wire field schemas — no per-table view function. (Renames are expressible via a Schema transform if you need them.)
|
|
2096
|
+
|
|
2097
|
+
### `columns` — don't even READ what you drop
|
|
2098
|
+
|
|
2099
|
+
The output schema stops a column reaching the client; `columns` stops it being read at all. Use it when a table carries something wide that a list view never shows — a long text body, a big `json()` blob:
|
|
2100
|
+
|
|
2101
|
+
```ts
|
|
2102
|
+
export default crud.list('articles', { columns: ['id', 'title', 'createdAt'] })
|
|
2103
|
+
// the large `body` is never SELECTed, transferred from the DB, or decoded
|
|
2104
|
+
```
|
|
2105
|
+
|
|
2106
|
+
`.serverOnly()` columns are removed from the projection automatically — they're stripped from the response anyway, so reading them is pure waste.
|
|
2107
|
+
|
|
2108
|
+
**Trap:** an eager `include` branch joins on a foreign key, so a projection that omits that FK breaks the relation. Keep the FK in `columns` when you also pass `include`.
|
|
2109
|
+
|
|
2110
|
+
### `scope` — keep per-subject narrowing when you adopt the helper
|
|
2111
|
+
|
|
2112
|
+
`filter` builds the WHERE from the request; `scope` builds it from the **caller**:
|
|
2113
|
+
|
|
2114
|
+
```ts
|
|
2115
|
+
crud.list('timeEntries', {
|
|
2116
|
+
filter: (input) => ({ status: input.status }), // what the caller ASKED for
|
|
2117
|
+
scope: (ctx) => ({ ownerId: ctx.request.subject.id }), // what it MAY SEE
|
|
2118
|
+
})
|
|
2119
|
+
```
|
|
2120
|
+
|
|
2121
|
+
A WHERE built only from `input` can express *the rows the caller asked for*, never
|
|
2122
|
+
*the rows the caller may see*.
|
|
2123
|
+
|
|
2124
|
+
`scope` is merged **last**, so a request field of the same name can never widen it —
|
|
2125
|
+
`?ownerId=someone-else` is simply overridden. That ordering is why the two are
|
|
2126
|
+
separate options rather than one: only one of them is a security boundary, and kept
|
|
2127
|
+
apart, *"does this list declare a `scope`?"* is a question a reviewer — or a future
|
|
2128
|
+
boot audit — can actually ask. Folded into `filter`, it becomes *"does this filter
|
|
2129
|
+
happen to read ctx somewhere in its body?"*, which nothing can check. Tenant scope still applies automatically; anything
|
|
2130
|
+
narrower — owner, team, role — does not. So replacing a hand-written handler that
|
|
2131
|
+
carried such a narrowing with `crud.list` **widens the result set, silently and
|
|
2132
|
+
without an error**. One app lost exactly that across eight list views.
|
|
2133
|
+
|
|
2134
|
+
Use the same filter for `crud.count`, or the total contradicts the pages —
|
|
2135
|
+
"showing 10 of 4000" on a page holding ten rows.
|
|
2136
|
+
|
|
2137
|
+
The reason this is an option rather than a reason to leave: hand-writing the query
|
|
2138
|
+
to get the narrowing also forfeits `serverOnly` stripping and the page-size clamp.
|
|
2139
|
+
A narrowing requirement should not cost you the safety rails.
|
|
2140
|
+
|
|
1999
2141
|
## What they deliberately don't do — authorization
|
|
2000
2142
|
|
|
2001
2143
|
A guard runs *before* the executor, so gating lives on the **descriptor**, not the handler — an executor cannot gate itself. Keep every write descriptor guarded:
|
|
@@ -225,6 +225,38 @@ Precedence, exactly:
|
|
|
225
225
|
3. **`.safe()`** on an encrypted column suppresses the implied secret and copies
|
|
226
226
|
it verbatim — say this only when you have genuinely reviewed the plaintext.
|
|
227
227
|
|
|
228
|
+
## `.serverOnly()` — never to a client
|
|
229
|
+
|
|
230
|
+
A THIRD, independent axis. `.sensitive()` / `.safe()` are about **data export
|
|
231
|
+
masking**; `.encrypted()` is about **storage at rest**; `.serverOnly()` is about
|
|
232
|
+
**wire exposure** — a column marked `.serverOnly()` is read normally by server
|
|
233
|
+
code but must NEVER be serialized to a client:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
keyHash: text().serverOnly(), // an auth middleware verifies it; a client never sees it
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The three are orthogonal — a column can carry any combination:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
keyHash: text().serverOnly(), // a hash you never ship (not secret at rest — it IS the digest)
|
|
243
|
+
recoveryNote: text().encrypted(), // encrypted at rest, but the owner may read it → not serverOnly
|
|
244
|
+
apiToken: text().encrypted().serverOnly(), // secret at rest AND never to a client
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Enforcement: the [`crud.*` read helpers](/docs/data/crud) strip `.serverOnly()`
|
|
248
|
+
columns from every returned row **automatically** — you declare the exposure
|
|
249
|
+
policy once at the schema and can't forget it on a handler. For a hand-written
|
|
250
|
+
query, omit the column from the `output` schema (and don't put it in the returned
|
|
251
|
+
object) — and `voltro dev` **warns at boot**, naming the query and column, if a
|
|
252
|
+
wire-reachable query's output declares a `.serverOnly()` column of its `source`
|
|
253
|
+
table, so a secret can't ship on the wire by omission-mistake.
|
|
254
|
+
|
|
255
|
+
Distinct from `.encrypted()` on purpose: encryption at rest says nothing about
|
|
256
|
+
who may receive the plaintext — a private note you decrypt *for its owner* is a
|
|
257
|
+
valid case, so treating "encrypted" as "never to a client" would be wrong. State
|
|
258
|
+
the exposure policy explicitly.
|
|
259
|
+
|
|
228
260
|
## Worked example
|
|
229
261
|
|
|
230
262
|
```ts
|
|
@@ -1058,7 +1058,20 @@ const orgIds = await ctx.store.links('org_memberships', { userId: user.id }).lis
|
|
|
1058
1058
|
|
|
1059
1059
|
`set(targetIds)` writes only the **difference**: the missing links are inserted, the surplus deleted, and links that are already correct are left untouched — so a reactive consumer sees a change only for what actually changed, and it returns `{ added, removed }`. `add` and `remove` read first and act only on the genuine delta, so both are idempotent.
|
|
1060
1060
|
|
|
1061
|
-
The **target column** is the junction's *other* `reference()` column — the one the anchor doesn't name. A junction with anything but exactly two reference columns is refused (write it by hand with `insertMany` / `deleteMany`). The writes go through the normal store path, so a junction that carries `tenant()` / `audit()` gets those columns stamped as usual.
|
|
1061
|
+
The **target column** is the junction's *other* `reference()` column — the one the anchor doesn't name. A junction with anything but exactly two reference columns is refused (write it by hand with `insertMany` / `deleteMany`). The writes go through the normal store path, so a junction that carries `tenant()` / `audit()` gets those columns stamped as usual.
|
|
1062
|
+
|
|
1063
|
+
`set` / `add` / `remove` manage only the two FK columns. For a junction that carries **per-row payload** — a membership `role`, a `capacity` value — use `setRows`, which diffs on the (source, target) pair AND updates the payload:
|
|
1064
|
+
|
|
1065
|
+
```ts
|
|
1066
|
+
await ctx.store.links('team_capacities', { teamId: team.id }).setRows([
|
|
1067
|
+
{ projectId: 'p1', capacity: 40 },
|
|
1068
|
+
{ projectId: 'p2', capacity: 20 },
|
|
1069
|
+
])
|
|
1070
|
+
// added rows inserted with payload, removed deleted, a surviving row whose payload
|
|
1071
|
+
// CHANGED is updated, an unchanged one is left untouched → { added, removed, updated }
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
Only rows whose payload actually differs are written, so a reactive consumer sees a change exactly where the data changed — the diff-based replacement for a drop-and-reinsert on a data-carrying junction. Payload is compared by strict per-column equality (scalars like `capacity` / `role`).
|
|
1062
1075
|
|
|
1063
1076
|
## SQL shape
|
|
1064
1077
|
|
|
@@ -178,18 +178,13 @@ useT('does.not.exist') // ✗ compile error — not a catalog key
|
|
|
178
178
|
|
|
179
179
|
It's almost pure type refinement — the runtime is the same `useT` / `useTFn` / `<T>` (plus a `.dynamic` escape, below), only the signatures narrow to your catalog. Requires the base catalog to be `as const` (so its message strings survive as literal types).
|
|
180
180
|
|
|
181
|
-
**Scope.** Simple `{name}` and single-argument `{count, number}` placeholders are extracted and required.
|
|
182
|
-
|
|
183
|
-
To get that nested var **compiler-required**, split the plural into simple sibling keys and pick with a JS branch — each key is then a plain message where every var is top-level:
|
|
181
|
+
**Scope.** Simple `{name}` and single-argument `{count, number}` placeholders are extracted and required. A *nested* inline ICU message (`{count, plural, one {…} other {…}}` / `select`) is fully parsed: the **top-level arg** (`count`) AND a real var nested inside a branch are both required, while a branch's literal text is never mistaken for a var. So for `'{count, plural, one {# blocker in {discipline}} other {# blockers in {discipline}}}'`, `t('blockers', { count, discipline })` is required — omit `discipline` and it's a compile error, not a render-time throw:
|
|
184
182
|
|
|
185
183
|
```tsx
|
|
186
|
-
|
|
187
|
-
count === 1
|
|
188
|
-
? t('blocker.one', { count, discipline }) // ✓ both vars required
|
|
189
|
-
: t('blocker.other', { count, discipline })
|
|
184
|
+
t('blockers', { count: n, discipline }) // ✓ both required — discipline lives inside the branches
|
|
190
185
|
```
|
|
191
186
|
|
|
192
|
-
|
|
187
|
+
`<T>` gets a typed key with loose values, because its rich-text `<tag>` renderers can't be modelled by placeholder extraction.
|
|
193
188
|
|
|
194
189
|
### Runtime-computed keys — `t.dynamic`
|
|
195
190
|
|
|
@@ -985,6 +985,41 @@ export default (input: { prompt: string }) =>
|
|
|
985
985
|
})
|
|
986
986
|
```
|
|
987
987
|
|
|
988
|
+
### Writing a wrapper that PROVIDES a service
|
|
989
|
+
|
|
990
|
+
A helper that provides a service must be generic over `R` and **subtract** the tag
|
|
991
|
+
it provides, or it silently narrows what callers may pass it. The failure mode is
|
|
992
|
+
confusing because it shows up at the CALL site, not in the wrapper:
|
|
993
|
+
|
|
994
|
+
```ts
|
|
995
|
+
import { Context, Effect } from 'effect'
|
|
996
|
+
|
|
997
|
+
class Tenant extends Context.Tag('Tenant')<Tenant, { readonly id: string }>() {}
|
|
998
|
+
|
|
999
|
+
// WRONG — `R` defaults to `never`, so this only accepts effects that need
|
|
1000
|
+
// nothing else. Pass it an effect that also needs `Db` and it stops compiling.
|
|
1001
|
+
const withTenantBad = <A, E>(id: string, effect: Effect.Effect<A, E>) =>
|
|
1002
|
+
Effect.provideService(effect, Tenant, { id })
|
|
1003
|
+
|
|
1004
|
+
// RIGHT — generic over `R`, and the return type SUBTRACTS the tag it provided.
|
|
1005
|
+
const withTenant = <A, E, R>(
|
|
1006
|
+
id: string,
|
|
1007
|
+
effect: Effect.Effect<A, E, R>,
|
|
1008
|
+
): Effect.Effect<A, E, Exclude<R, Tenant>> =>
|
|
1009
|
+
Effect.provideService(effect, Tenant, { id })
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
`Exclude<R, Tenant>` is what makes the wrapper composable: the caller's remaining
|
|
1013
|
+
requirements pass through untouched, and only the tag you actually supplied
|
|
1014
|
+
disappears. Without it, the wrapper's own signature dictates the caller's entire
|
|
1015
|
+
requirement set.
|
|
1016
|
+
|
|
1017
|
+
The same rule applies to any callback the framework takes from you (an AI tool
|
|
1018
|
+
body, a media generator, a workflow step): declare its `R` as `unknown` rather
|
|
1019
|
+
than letting it default to `never`, then let the framework's own bridge discharge
|
|
1020
|
+
it. A callback typed `Effect<A, E, never>` cannot use ANY service, which is
|
|
1021
|
+
rarely what you meant.
|
|
1022
|
+
|
|
988
1023
|
## Context By Primitive
|
|
989
1024
|
|
|
990
1025
|
| Server file | Transaction | Typical store usage | External I/O | Returns |
|
|
@@ -208,7 +208,15 @@ timezone: 'Europe/Berlin' // 09:00 Berlin — DST handled by effect's Cron
|
|
|
208
208
|
|
|
209
209
|
## The handler context
|
|
210
210
|
|
|
211
|
-
The handler receives a `ScheduleContext` — the same `app` a mutation gets, plus firing metadata
|
|
211
|
+
The handler receives a `ScheduleContext` — the same `app` a mutation gets, plus firing metadata.
|
|
212
|
+
|
|
213
|
+
> **The API is identical; the subject is not.** `ctx.app.store` here is **not
|
|
214
|
+
> tenant-scoped** — a schedule runs as `system` with no tenant. Reads see every
|
|
215
|
+
> tenant's rows, and a write to a `tenant()` table fails with
|
|
216
|
+
> `TenantScopeViolation` unless you pass `tenantId` explicitly. See
|
|
217
|
+
> [below](#a-schedule-runs-as-the-system-subject--no-tenant). This sentence is
|
|
218
|
+
> here rather than only further down because "same shape as a mutation" is what
|
|
219
|
+
> sets the expectation that gets violated.
|
|
212
220
|
|
|
213
221
|
```tsx
|
|
214
222
|
handler: async (ctx) => {
|
|
@@ -250,6 +258,32 @@ Writes to a `tenant()` table need the same treatment: pass `tenantId`
|
|
|
250
258
|
explicitly, or the write fails with `TenantScopeViolation` rather than landing
|
|
251
259
|
somewhere arbitrary.
|
|
252
260
|
|
|
261
|
+
### `ctx.app.storeForTenant(id)` — the fan-out shortcut
|
|
262
|
+
|
|
263
|
+
Doing that by hand means every `.where('tenantId', …)` and every explicit
|
|
264
|
+
`tenantId:` is one forgotten call away from reading or writing across tenants.
|
|
265
|
+
`storeForTenant` hands you a store scoped to exactly one:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
handler: async (ctx) => {
|
|
269
|
+
for (const t of await ctx.app.store.select('tenants').all()) {
|
|
270
|
+
const scoped = ctx.app.storeForTenant(t.id)
|
|
271
|
+
await scoped.insert('digests', { body: summary }) // tenantId stamped, not passed
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Inside a REQUEST this is almost always the wrong tool — the subject already
|
|
277
|
+
carries a tenant, and reaching for another one is a cross-tenant access with
|
|
278
|
+
extra steps. It exists because the system subject has *no* tenant to infer.
|
|
279
|
+
|
|
280
|
+
One implementation detail worth knowing, because it is counter-intuitive: the
|
|
281
|
+
scoped store does **not** run as a `system` subject with a tenant attached. A
|
|
282
|
+
system subject carries `tenantId: null` by construction and the tenant mixin
|
|
283
|
+
special-cases it to skip scoping entirely — on a system subject, a null tenant
|
|
284
|
+
means *all* tenants. So the scoped view runs as a `serviceAccount` bound to that
|
|
285
|
+
one tenant, keeping the schedule's scopes.
|
|
286
|
+
|
|
253
287
|
This behaves identically under `voltro dev` and `voltro serve`. It did not
|
|
254
288
|
always — before 0.10.0, dev scoped schedules to `$TENANT` (default `acme`)
|
|
255
289
|
while production ran them unscoped, so the same cron read one tenant in
|
|
@@ -22,6 +22,54 @@ Please report suspected vulnerabilities **privately** — never in public issues
|
|
|
22
22
|
|
|
23
23
|
You'll get an **acknowledgement within 3 business days**, an assessment once we've reproduced the issue, and a **coordinated disclosure** timeline agreed with you — with credit in the release notes if you'd like it. We don't run a paid bug-bounty program yet, but we genuinely value responsible disclosure.
|
|
24
24
|
|
|
25
|
+
## Outbound HTTP is SSRF-guarded by default
|
|
26
|
+
|
|
27
|
+
The `HttpClient` your handlers `yield*` refuses internal targets:
|
|
28
|
+
|
|
29
|
+
- loopback, RFC-1918, CGNAT and link-local addresses — including the
|
|
30
|
+
`169.254.169.254` cloud-metadata endpoint
|
|
31
|
+
- the hostnames `localhost`, `*.internal`, `*.local`
|
|
32
|
+
- any non-`http(s)` scheme
|
|
33
|
+
|
|
34
|
+
**Every redirect hop is revalidated, not just the URL you passed.** A public URL
|
|
35
|
+
that `302`s to the metadata endpoint is the actual attack; checking only the
|
|
36
|
+
initial target catches none of it.
|
|
37
|
+
|
|
38
|
+
This matters because a caller-supplied URL is ordinary product surface — a
|
|
39
|
+
scraper, a webhook-registration form, an importer, a "test this connection"
|
|
40
|
+
button. Those reach `HttpClient` with whatever the user typed.
|
|
41
|
+
|
|
42
|
+
### Allowing a target on purpose
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// app.config.ts
|
|
46
|
+
export default defineApiConfig({
|
|
47
|
+
http: { allowHosts: ['*.svc.cluster.local', 'billing.internal'] },
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
An entry may be an exact host, a `*.suffix` wildcard (which does **not** match the
|
|
52
|
+
apex — one that did would silently widen your exception), or `host:port` when only
|
|
53
|
+
one port should be reachable.
|
|
54
|
+
|
|
55
|
+
There is no boolean off-switch, deliberately: *"we call one internal service"* and
|
|
56
|
+
*"we do not check URLs"* are different postures, and a boolean cannot tell them
|
|
57
|
+
apart six months later.
|
|
58
|
+
|
|
59
|
+
### In tests, allow the stub — don't mock the guard
|
|
60
|
+
|
|
61
|
+
Use the same `allowHosts` to reach a local stub server (`['127.0.0.1:8787']`).
|
|
62
|
+
That keeps the test on the real guarded code path with a narrow exception. Mocking
|
|
63
|
+
the guard away instead means the production path is never exercised — which is
|
|
64
|
+
exactly how this framework's own webhook delivery once ended up behind a
|
|
65
|
+
`NODE_ENV` check.
|
|
66
|
+
|
|
67
|
+
### What this does NOT cover
|
|
68
|
+
|
|
69
|
+
DNS is not resolved. A public hostname that *resolves* to a private address (DNS
|
|
70
|
+
rebinding) still passes. That vector needs network-layer egress control; it is
|
|
71
|
+
stated here rather than silently implied.
|
|
72
|
+
|
|
25
73
|
## Supply-chain assurance
|
|
26
74
|
|
|
27
75
|
Every release passes automated supply-chain gates in CI before a single package is published:
|
|
@@ -2615,9 +2615,9 @@ done
|
|
|
2615
2615
|
<!-- source: en/templates/api-rbac.md -->
|
|
2616
2616
|
## API · RBAC
|
|
2617
2617
|
|
|
2618
|
-
_Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, and
|
|
2618
|
+
_Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, declarative descriptor guards (statically checked by voltro check), resource-scoped guards, and the in-handler permission()/can() guards. admin:full bypasses. Config-only, zero infra._
|
|
2619
2619
|
|
|
2620
|
-
Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map and an rpc interceptor resolves each caller's **roles**
|
|
2620
|
+
Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map, and an rpc interceptor resolves each caller's **roles** into **scopes** published to the framework's effective-scope seam. From there both authorization forms see them. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
|
|
2621
2621
|
|
|
2622
2622
|
## Scaffold
|
|
2623
2623
|
|
|
@@ -2627,40 +2627,99 @@ voltro create-project acme --api=api-rbac
|
|
|
2627
2627
|
|
|
2628
2628
|
## Roles as config
|
|
2629
2629
|
|
|
2630
|
-
|
|
2631
|
-
// app.config.ts
|
|
2632
|
-
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
2630
|
+
The role map is the app's **declared scope vocabulary**, not just a lookup table: `rbacPlugin` publishes the union of it, and [`voltro check`](/docs/cli/overview) fails on any descriptor guard requiring a scope that appears nowhere in it.
|
|
2633
2631
|
|
|
2634
|
-
|
|
2632
|
+
```ts
|
|
2633
|
+
// authz.ts — split out of app.config.ts so tests build the real plugin
|
|
2634
|
+
export const roles = {
|
|
2635
2635
|
viewer: ['notes:read'],
|
|
2636
2636
|
editor: ['notes:read', 'notes:write'],
|
|
2637
|
-
|
|
2637
|
+
owner: ['notes:read', 'notes:write', 'notes:delete', 'teams:rename'],
|
|
2638
|
+
admin: ['*'], // wildcard → admin:full blanket bypass
|
|
2638
2639
|
}
|
|
2640
|
+
```
|
|
2641
|
+
|
|
2642
|
+
```ts
|
|
2643
|
+
// app.config.ts
|
|
2644
|
+
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
2645
|
+
import { demoRolesForTenant, roles, rolesOnTeam } from './authz'
|
|
2639
2646
|
|
|
2640
2647
|
rbacPlugin({
|
|
2641
2648
|
roles,
|
|
2642
2649
|
// PRODUCTION: the default resolver reads subject.metadata.roles (set by your
|
|
2643
2650
|
// auth strategy), or do a DB lookup. The shipped DEMO maps tenant → role so
|
|
2644
|
-
// you can try each via the x-tenant header with zero auth setup
|
|
2645
|
-
resolveRoles: (subject) =>
|
|
2646
|
-
|
|
2651
|
+
// you can try each via the x-tenant header with zero auth setup.
|
|
2652
|
+
resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
|
|
2653
|
+
// PER-RESOURCE roles — see "Resource-scoped guards" below.
|
|
2654
|
+
resolveResourceRoles: (subject, resource) => rolesOnTeam(subject.id, resource),
|
|
2647
2655
|
})
|
|
2648
2656
|
```
|
|
2649
2657
|
|
|
2650
|
-
##
|
|
2658
|
+
## Two guard forms — prefer the declarative one
|
|
2651
2659
|
|
|
2652
2660
|
```ts
|
|
2653
|
-
// notes.create.mutation.
|
|
2654
|
-
|
|
2661
|
+
// notes.create.mutation.ts — DECLARATIVE, on the descriptor
|
|
2662
|
+
export const createNote = defineMutation({
|
|
2663
|
+
name: 'notes.create',
|
|
2664
|
+
target: { table: 'notes', op: 'insert' },
|
|
2665
|
+
guards: [{ scope: 'notes:write' }],
|
|
2666
|
+
input: Schema.Struct({ title: Schema.NonEmptyString, body: Schema.String }),
|
|
2667
|
+
output: Schema.Struct({ id: Schema.String, title: Schema.String }),
|
|
2668
|
+
})
|
|
2669
|
+
```
|
|
2670
|
+
|
|
2671
|
+
The framework enforces this in the dispatch spine — before the executor, and before a mutation's transaction opens — so an unauthorized call never touches the DB. Declaring `guards:` also merges `ScopeError` into the wire error union automatically, so no `error:` field is needed and the client still decodes the denial typed. Reads take guards too, re-checked on every delivery, so a revoked role stops a live subscription.
|
|
2655
2672
|
|
|
2673
|
+
The reason to prefer it is **static checkability**: a declarative guard lands in the capability manifest, so `voltro check` catches a scope no role grants. An in-handler call is invisible to that check.
|
|
2674
|
+
|
|
2675
|
+
```ts
|
|
2676
|
+
// notes.delete.mutation.server.ts — IN-HANDLER, for what needs the loaded row
|
|
2656
2677
|
export default (input, ctx) => Effect.gen(function* () {
|
|
2657
|
-
yield* permission(ctx, 'notes:write') // fails typed ScopeError for viewer
|
|
2658
2678
|
const store = yield* EffectStore
|
|
2659
|
-
|
|
2679
|
+
const rows = yield* Effect.promise(() =>
|
|
2680
|
+
ctx.store.query(database.notes.where(eq('id', input.id)).limit(1).descriptor))
|
|
2681
|
+
const note = rows[0]
|
|
2682
|
+
if (note === undefined) return yield* Effect.fail(new NoteNotFound({ id: input.id }))
|
|
2683
|
+
|
|
2684
|
+
// Only decidable AFTER the row is loaded — no descriptor guard can express it.
|
|
2685
|
+
const hard = note['archived'] === true || can(ctx, 'notes:purge')
|
|
2686
|
+
if (hard) { yield* store.delete('notes', input.id); return { id: input.id, mode: 'hard' } }
|
|
2687
|
+
yield* store.update('notes', input.id, { archived: true })
|
|
2688
|
+
return { id: input.id, mode: 'soft' }
|
|
2660
2689
|
})
|
|
2661
2690
|
```
|
|
2662
2691
|
|
|
2663
|
-
|
|
2692
|
+
Rule of thumb: put in `guards:` everything decidable from the **subject** and the **input**; reach for `permission()` / `can()` only for what needs loaded data. Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean, branches instead of refusing).
|
|
2693
|
+
|
|
2694
|
+
## Resource-scoped guards
|
|
2695
|
+
|
|
2696
|
+
`guards: [{ scope, resource }]` makes a grant mean "owner **of this team**" instead of "owner globally".
|
|
2697
|
+
|
|
2698
|
+
```ts
|
|
2699
|
+
// teams.rename.mutation.ts
|
|
2700
|
+
guards: [{ scope: 'teams:rename', resource: (input: { teamId: string }) => input.teamId }],
|
|
2701
|
+
```
|
|
2702
|
+
|
|
2703
|
+
The `resource` extractor pulls the id out of the decoded input; rbac's `resolveResourceRoles` returns the roles the caller holds on that id, compiled through the same role map. A **globally** held scope (or `admin:full`) satisfies the guard without the resolver ever being called — only the gap falls through. That fall-through is **fail-closed**: a resolver that throws or rejects denies, it does not degrade to the global answer.
|
|
2704
|
+
|
|
2705
|
+
## Failure postures, deliberately different
|
|
2706
|
+
|
|
2707
|
+
| Resolver | On throw / reject |
|
|
2708
|
+
| --- | --- |
|
|
2709
|
+
| `resolveRoles` / `resolvePermissions` | logs, degrades to the subject's **own** scopes — never grants, but an api key with valid scopes still works |
|
|
2710
|
+
| `resolveResourceRoles` | **denies** (fail-closed) |
|
|
2711
|
+
|
|
2712
|
+
Both hold for a synchronous throw as well as a rejected promise.
|
|
2713
|
+
|
|
2714
|
+
## Static check
|
|
2715
|
+
|
|
2716
|
+
```bash
|
|
2717
|
+
voltro check --offline # no running app needed — usable as a CI gate
|
|
2718
|
+
```
|
|
2719
|
+
|
|
2720
|
+
A guard requiring `notes:wirte` when the roles grant `notes:write` is not merely misconfigured: that procedure is permanently, silently uncallable by everyone. `voltro check` reports it as `rbac/unknown-scope` and exits non-zero.
|
|
2721
|
+
|
|
2722
|
+
The registry stays **dormant** when a custom `resolvePermissions` is configured — that resolver merges scopes from outside the role map by design, so publishing the map alone would flag correct code.
|
|
2664
2723
|
|
|
2665
2724
|
## Try it
|
|
2666
2725
|
|
|
@@ -2676,6 +2735,18 @@ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json
|
|
|
2676
2735
|
# → { ok:false, error:{ _tag:"ScopeError", required:"notes:write" } }
|
|
2677
2736
|
```
|
|
2678
2737
|
|
|
2738
|
+
## Testing authorization
|
|
2739
|
+
|
|
2740
|
+
The shipped `tests/authz.test.ts` drives the **real** plugin: `makeTestContext({ plugins: [rbacPlugin({ roles, … })] })` composes the actual interceptor chain, and `invoke` enforces the descriptor's guards with the same function the serve pipeline calls.
|
|
2741
|
+
|
|
2742
|
+
```ts
|
|
2743
|
+
const ctx = makeTestContext({ subject, store: mockStore({ notes: [] }), plugins: [plugin()] })
|
|
2744
|
+
await expect(invoke(createNote, createNoteHandler, { title: 'x', body: '' }, ctx))
|
|
2745
|
+
.rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:write' })
|
|
2746
|
+
```
|
|
2747
|
+
|
|
2748
|
+
Do **not** hand-set `subject.scopes` to the value you expect. That tests the guard while assuming the resolution which produces it — and the resolution is the half that actually breaks (a renamed role, a throwing resolver, a scope no role grants). Those all pass a pre-stamped test and fail a real one.
|
|
2749
|
+
|
|
2679
2750
|
## On the web side
|
|
2680
2751
|
|
|
2681
2752
|
```tsx
|
|
@@ -2686,7 +2757,7 @@ const canWrite = useCan('notes:write')
|
|
|
2686
2757
|
{canWrite && <NewNoteButton />} // hide affordances the user can't use
|
|
2687
2758
|
```
|
|
2688
2759
|
|
|
2689
|
-
The UI gate ships in [`@voltro/client`](/docs/ui/client-utilities/use-can), not in the plugin: scopes are a framework concept, so gating a button must not require an rbac dependency. `useCan` is a UI affordance only — the server
|
|
2760
|
+
The UI gate ships in [`@voltro/client`](/docs/ui/client-utilities/use-can), not in the plugin: scopes are a framework concept, so gating a button must not require an rbac dependency. `useCan` is a UI affordance only — the server guard is the enforcement.
|
|
2690
2761
|
|
|
2691
2762
|
## Persisted roles
|
|
2692
2763
|
|
|
@@ -2695,6 +2766,8 @@ Set `tables: true` to store assignments in `roles` / `userRoles` tables and reso
|
|
|
2695
2766
|
## Anti-patterns
|
|
2696
2767
|
|
|
2697
2768
|
- **Returning scopes from `resolveRoles`.** It returns role SLUGS (`['editor']`); the `roles` map turns those into scopes. Returning scopes directly bypasses the map.
|
|
2769
|
+
- **Re-checking a descriptor guard inside its own executor.** Two copies of one rule, and the copy `voltro check` reads is the descriptor's.
|
|
2770
|
+
- **Reaching for `permission()` when `guards:` would do.** It works, but it opts that procedure out of the static check.
|
|
2698
2771
|
- **Scopes ≠ entitlements.** RBAC answers "may you call this"; billing entitlements (see [`api-saas`](/docs/templates/api-saas)) answer "do you have quota left". A procedure can need both.
|
|
2699
2772
|
|
|
2700
2773
|
|