@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +436 -0
  2. package/THIRD-PARTY-NOTICES.md +4 -4
  3. package/dist/{apiBuild-zFDv0u8y.js → apiBuild-GefjNI5d.js} +10 -9
  4. package/dist/apiBuild-s7swuEU5.js +2 -0
  5. package/dist/appModuleLoader-C9r9mxZt.js +19 -0
  6. package/dist/bin.js +24 -6
  7. package/dist/cliArgs-qdZSElM3.js +28 -0
  8. package/dist/{commands-BDGYrBhK.js → commands-BmNi1RgZ.js} +2580 -2493
  9. package/dist/dbCommand-Bt6BCG7g.js +2 -0
  10. package/dist/dbCommand-DNrkb58g.js +862 -0
  11. package/dist/dev-YHbHmeXt.js +3 -0
  12. package/dist/{dev-CGt0PP1f.js → dev-d0LjACxE.js} +1591 -2332
  13. package/dist/{devActivity-1WtIVyHc.js → devActivity-BhIu6ncs.js} +7 -1
  14. package/dist/devActivity.js +1 -1
  15. package/dist/index.d.ts +22 -0
  16. package/dist/index.js +3 -3
  17. package/dist/inspect-DcZ04OME.js +2 -0
  18. package/dist/inspect-Dwx0_tUj.js +921 -0
  19. package/dist/inspectMetrics-uEfIS1vp.js +3399 -0
  20. package/dist/manifestBuild-D1MzJAiQ.js +2 -0
  21. package/dist/manifestBuild-P9yuCY2d.js +39 -0
  22. package/dist/seedRunner-DZmhoqNt.js +821 -0
  23. package/dist/serveCommand-B2-YV9TU.js +1112 -0
  24. package/dist/serveEntry.js +4 -4
  25. package/dist/{start-Crl39M38.js → start-DKcQd52a.js} +348 -337
  26. package/dist/startEntry.js +2 -2
  27. package/dist/startup.js +1 -1
  28. package/dist/{startupRunner-DhlX9nqd.js → startupRunner-DPGFchOa.js} +19 -33
  29. package/package.json +17 -17
  30. package/templates/AGENTS.md +1 -0
  31. package/templates/agent-docs/_index.md +1 -0
  32. package/templates/agent-docs/_manifest.json +1 -1
  33. package/templates/agent-docs/cli.md +266 -16
  34. package/templates/agent-docs/data.md +143 -1
  35. package/templates/agent-docs/database/misc.md +32 -0
  36. package/templates/agent-docs/database/querying.md +14 -1
  37. package/templates/agent-docs/internationalization.md +3 -8
  38. package/templates/agent-docs/reference.md +35 -0
  39. package/templates/agent-docs/scheduling.md +35 -1
  40. package/templates/agent-docs/security.md +48 -0
  41. package/templates/agent-docs/templates/apibackends.md +90 -17
  42. package/templates/agent-docs/whats-new.md +177 -0
  43. package/templates/agent-docs/workflows.md +31 -0
  44. package/templates/apps/api-ai/package.json +7 -7
  45. package/templates/apps/api-auth/package.json +8 -8
  46. package/templates/apps/api-backend/package.json +7 -7
  47. package/templates/apps/api-backend-deactivation/package.json +7 -7
  48. package/templates/apps/api-backend-mail/package.json +8 -8
  49. package/templates/apps/api-backend-mariadb/package.json +9 -9
  50. package/templates/apps/api-backend-storage/package.json +8 -8
  51. package/templates/apps/api-data-advanced/package.json +8 -8
  52. package/templates/apps/api-durable/package.json +8 -8
  53. package/templates/apps/api-feature-flags/package.json +9 -9
  54. package/templates/apps/api-governance/package.json +8 -8
  55. package/templates/apps/api-kv/package.json +8 -8
  56. package/templates/apps/api-moderation/package.json +8 -8
  57. package/templates/apps/api-observability/package.json +8 -8
  58. package/templates/apps/api-ratelimit/package.json +8 -8
  59. package/templates/apps/api-rbac/package.json +8 -8
  60. package/templates/apps/api-rest/package.json +7 -7
  61. package/templates/apps/api-saas/package.json +11 -11
  62. package/templates/apps/api-search/package.json +8 -8
  63. package/templates/apps/api-versioning/package.json +8 -8
  64. package/templates/apps/api-webhooks/README.md +3 -1
  65. package/templates/apps/api-webhooks/package.json +8 -8
  66. package/templates/apps/changelog/package.json +6 -6
  67. package/templates/apps/edge-functions/package.json +2 -2
  68. package/templates/apps/frontend-admin/package.json +8 -8
  69. package/templates/apps/frontend-app/package.json +8 -8
  70. package/templates/apps/frontend-blank/package.json +7 -7
  71. package/templates/apps/frontend-contact/package.json +7 -7
  72. package/templates/apps/frontend-dashboard/package.json +7 -7
  73. package/templates/apps/frontend-docs/package.json +7 -7
  74. package/templates/apps/frontend-i18n/package.json +6 -6
  75. package/templates/apps/frontend-landing/package.json +7 -7
  76. package/templates/apps/frontend-spa/package.json +7 -7
  77. package/templates/apps/frontend-ssr/package.json +7 -7
  78. package/templates/apps/frontend-ssr-api/package.json +8 -8
  79. package/templates/apps/frontend-static-blog/package.json +6 -6
  80. package/dist/apiBuild-BmPdmhgi.js +0 -2
  81. package/dist/dev-D_PGP9Kx.js +0 -2
  82. package/dist/inspectMetrics-SRtv8KDy.js +0 -4247
  83. 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.** `redact` names columns stripped from every returned row — a credential, a token hash, a salary that a generated read must never ship. It applies to reads and to the row a `create` / `update` echoes back. 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.
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. A junction with extra business columns (a membership `role`, a tag `order`) needs those set per row — `links` only manages the two FK columns, so insert those rows directly.
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. For a *nested* inline ICU message (`{count, plural, one {…} other {…}}` / `select`), the **top-level arg** (`count`) is required and a branch's inner text is NOT mistaken for a var so `t('duration', { count })` typechecks and renders. A REAL var nested inside a branch (`other {# blockers in {discipline}}`) is not collected, so it reads as *not required* rather than wrongly required; pass it via the loose values bag.
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
- // catalog: 'blocker.one': '{count} blocker in {discipline}', 'blocker.other': '{count} blockers in {discipline}'
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
- (The [`plural()` helper](/docs/i18n/formatting) only substitutes `{count}`, so it doesn't type or fill a second var — use it for count-only messages.) `<T>` gets a typed key with loose values, because its rich-text `<tag>` renderers can't be modelled by placeholder extraction.
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 an in-handler permission(ctx, 'notes:write') guard that fails typed ScopeError. admin:full bypasses. Config-only, zero infra; useCan() for web UI._
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** to **scopes**, stamped on the subject. Guard handlers with `permission(ctx, 'notes:write')`, the Effect-native guard that fails typed `ScopeError`. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
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
- ```ts
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
- const roles = {
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
- admin: ['admin:full'], // blanket bypass — passes every check
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
- ({ acme: ['admin'], editors: ['editor'], readers: ['viewer'] })[subject.tenantId ?? ''] ?? ['viewer'],
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
- ## Guard with `permission()`
2658
+ ## Two guard forms — prefer the declarative one
2651
2659
 
2652
2660
  ```ts
2653
- // notes.create.mutation.server.ts
2654
- import { permission } from '@voltro/plugin-rbac'
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
- return yield* store.insert('notes', { title: input.title, body: input.body })
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
- Declare `error: ScopeError` (from `@voltro/protocol`). Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean).
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 `permission()` guard is the enforcement.
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