@voltro/cli 0.11.4 → 0.13.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 +356 -0
- package/dist/{apiBuild-DHHuiwXF.js → apiBuild-B6_ehbem.js} +9 -8
- package/dist/apiBuild-Bzpb383i.js +2 -0
- package/dist/appModuleLoader-C9r9mxZt.js +19 -0
- package/dist/bin.js +21 -10
- package/dist/cliArgs-qdZSElM3.js +28 -0
- package/dist/{commands-BwbMqR-1.js → commands-DYOuGlEC.js} +2903 -2647
- package/dist/dbCommand-ADD4mrTI.js +2 -0
- package/dist/dbCommand-DIOJQwsq.js +876 -0
- package/dist/{dev-Cvojvb_J.js → dev-Fb9BmavX.js} +1697 -2411
- package/dist/dev-xdijqojc.js +3 -0
- package/dist/frameworkTableAssembly-CYlrafoK.js +600 -0
- package/dist/frameworkTableAssembly-XvKO7wQt.js +2 -0
- package/dist/index.d.ts +19 -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-DOiiII34.js +3408 -0
- package/dist/manifestBuild-D1MzJAiQ.js +2 -0
- package/dist/manifestBuild-P9yuCY2d.js +39 -0
- package/dist/seedRunner-D6eu-u5U.js +229 -0
- package/dist/serveCommand-CqYxzBZR.js +1113 -0
- package/dist/serveEntry.js +4 -4
- package/dist/{start-DyJE6w1R.js → start-CjG_0xhR.js} +346 -343
- 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/authentication.md +36 -0
- package/templates/agent-docs/cli.md +254 -14
- package/templates/agent-docs/configuration.md +48 -0
- package/templates/agent-docs/data.md +39 -0
- package/templates/agent-docs/database/migrations.md +32 -0
- package/templates/agent-docs/database/transactions.md +31 -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/testing.md +24 -0
- package/templates/agent-docs/whats-new.md +81 -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-deactivation/tests/users.deactivate.test.ts +1 -1
- 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-data-advanced/tests/queries.test.ts +6 -1
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/app.config.ts +0 -1
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-kv/tests/sync.test.ts +2 -2
- 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/README.md +53 -14
- package/templates/apps/api-rbac/app.config.ts +24 -26
- package/templates/apps/api-rbac/authz.ts +76 -0
- package/templates/apps/api-rbac/database/schema.ts +23 -6
- package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +7 -5
- package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +15 -8
- package/templates/apps/api-rbac/mutations/notes.delete.mutation.server.ts +54 -0
- package/templates/apps/api-rbac/mutations/notes.delete.mutation.ts +43 -0
- package/templates/apps/api-rbac/mutations/teams.rename.mutation.server.ts +19 -0
- package/templates/apps/api-rbac/mutations/teams.rename.mutation.ts +37 -0
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rbac/queries/notes.list.query.server.ts +12 -0
- package/templates/apps/api-rbac/queries/notes.list.query.ts +26 -0
- package/templates/apps/api-rbac/template.json +2 -2
- package/templates/apps/api-rbac/tests/authz.test.ts +233 -0
- 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/package.json +9 -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-D9MtlD02.js +0 -2
- package/dist/dev-BsBsIv93.js +0 -2
- package/dist/inspectMetrics-D4EJ8LTi.js +0 -4255
- package/dist/serveCommand-ARqSs73o.js +0 -1117
- package/templates/apps/api-rbac/tests/notes.create.test.ts +0 -80
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t } from "./start-
|
|
1
|
+
import { X as e } from "./inspectMetrics-DOiiII34.js";
|
|
2
|
+
import { t } from "./start-CjG_0xhR.js";
|
|
3
3
|
export { e as loadDotEnv, t as runStartCommand };
|
package/dist/startup.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as e, t } from "./startupRunner-
|
|
1
|
+
import { n as e, t } from "./startupRunner-DPGFchOa.js";
|
|
2
2
|
export { t as loadStartupFiles, e as runStartups };
|
|
@@ -1,44 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { n as e } from "./appModuleLoader-C9r9mxZt.js";
|
|
2
|
+
import { basename as t } from "node:path";
|
|
3
3
|
import { publishServerError as n } from "@voltro/protocol";
|
|
4
|
-
//#region src/
|
|
5
|
-
var r =
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if (n) return n;
|
|
10
|
-
let a = r.get(e);
|
|
11
|
-
if (a !== void 0) {
|
|
12
|
-
if (typeof a == "function") {
|
|
13
|
-
let t = await a();
|
|
14
|
-
return i.set(e, t), t;
|
|
15
|
-
}
|
|
16
|
-
return a;
|
|
17
|
-
}
|
|
18
|
-
return await import(t(e).href);
|
|
19
|
-
}, c = (e) => e.replace(/\.startup\.tsx?$/, ""), l = async (t, n) => {
|
|
20
|
-
let r = [];
|
|
21
|
-
for (let i of t) {
|
|
22
|
-
let t;
|
|
4
|
+
//#region src/startupRunner.ts
|
|
5
|
+
var r = (e) => e.replace(/\.startup\.tsx?$/, ""), i = async (n, i) => {
|
|
6
|
+
let a = [];
|
|
7
|
+
for (let o of n) {
|
|
8
|
+
let n;
|
|
23
9
|
try {
|
|
24
|
-
|
|
10
|
+
n = await e(o);
|
|
25
11
|
} catch (e) {
|
|
26
|
-
|
|
12
|
+
i.warn("startup: failed to import", { file: o }, e);
|
|
27
13
|
continue;
|
|
28
14
|
}
|
|
29
|
-
let
|
|
30
|
-
if (typeof
|
|
31
|
-
|
|
15
|
+
let s = n.default;
|
|
16
|
+
if (typeof s != "function") {
|
|
17
|
+
i.warn("startup: no default-export function", { file: o });
|
|
32
18
|
continue;
|
|
33
19
|
}
|
|
34
|
-
|
|
35
|
-
file:
|
|
36
|
-
id:
|
|
37
|
-
fn:
|
|
20
|
+
a.push({
|
|
21
|
+
file: o,
|
|
22
|
+
id: r(t(o)),
|
|
23
|
+
fn: s
|
|
38
24
|
});
|
|
39
25
|
}
|
|
40
|
-
return
|
|
41
|
-
},
|
|
26
|
+
return a;
|
|
27
|
+
}, a = async (e, t, r) => {
|
|
42
28
|
let i = [];
|
|
43
29
|
for (let a of e) {
|
|
44
30
|
let e = r.child(`startup:${a.id}`), o = {
|
|
@@ -82,4 +68,4 @@ var r = /* @__PURE__ */ new Map(), i = /* @__PURE__ */ new Map(), a = (e) => {
|
|
|
82
68
|
} };
|
|
83
69
|
};
|
|
84
70
|
//#endregion
|
|
85
|
-
export { a
|
|
71
|
+
export { a as n, i as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -62,22 +62,22 @@
|
|
|
62
62
|
"@effect/platform-node": "^0.107.0",
|
|
63
63
|
"@effect/sql": "^0.51.1",
|
|
64
64
|
"@effect/workflow": "^0.18.2",
|
|
65
|
-
"@voltro/ai": "0.
|
|
66
|
-
"@voltro/cache": "0.
|
|
67
|
-
"@voltro/data-transfer": "0.
|
|
68
|
-
"@voltro/database": "0.
|
|
69
|
-
"@voltro/env": "0.
|
|
70
|
-
"@voltro/kv": "0.
|
|
71
|
-
"@voltro/logger": "0.
|
|
72
|
-
"@voltro/plugin-auth": "0.
|
|
73
|
-
"@voltro/plugin-broadcast": "0.
|
|
74
|
-
"@voltro/plugin-mail": "0.
|
|
75
|
-
"@voltro/plugin-storage": "0.
|
|
76
|
-
"@voltro/plugin-webhooks": "0.
|
|
77
|
-
"@voltro/protocol": "0.
|
|
78
|
-
"@voltro/runtime": "0.
|
|
79
|
-
"@voltro/serverless": "0.
|
|
80
|
-
"@voltro/workflow": "0.
|
|
65
|
+
"@voltro/ai": "0.13.0",
|
|
66
|
+
"@voltro/cache": "0.13.0",
|
|
67
|
+
"@voltro/data-transfer": "0.13.0",
|
|
68
|
+
"@voltro/database": "0.13.0",
|
|
69
|
+
"@voltro/env": "0.13.0",
|
|
70
|
+
"@voltro/kv": "0.13.0",
|
|
71
|
+
"@voltro/logger": "0.13.0",
|
|
72
|
+
"@voltro/plugin-auth": "0.13.0",
|
|
73
|
+
"@voltro/plugin-broadcast": "0.13.0",
|
|
74
|
+
"@voltro/plugin-mail": "0.13.0",
|
|
75
|
+
"@voltro/plugin-storage": "0.13.0",
|
|
76
|
+
"@voltro/plugin-webhooks": "0.13.0",
|
|
77
|
+
"@voltro/protocol": "0.13.0",
|
|
78
|
+
"@voltro/runtime": "0.13.0",
|
|
79
|
+
"@voltro/serverless": "0.13.0",
|
|
80
|
+
"@voltro/workflow": "0.13.0",
|
|
81
81
|
"chokidar": "^5.0.0",
|
|
82
82
|
"ioredis": "^5.11.1",
|
|
83
83
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.md
CHANGED
|
@@ -452,6 +452,7 @@ each plugin's own README.
|
|
|
452
452
|
|
|
453
453
|
| Topic | Open | Summary |
|
|
454
454
|
|---|---|---|
|
|
455
|
+
| **What's new in 0.13.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
|
|
455
456
|
| AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
|
|
456
457
|
| Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
|
|
457
458
|
| Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
|
|
@@ -9,6 +9,7 @@ each plugin's own README.
|
|
|
9
9
|
|
|
10
10
|
| Topic | Open | Summary |
|
|
11
11
|
|---|---|---|
|
|
12
|
+
| **What's new in 0.13.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
|
|
12
13
|
| AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
|
|
13
14
|
| Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
|
|
14
15
|
| Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
|
|
@@ -563,6 +563,42 @@ const resolve = composeAuthStrategies(
|
|
|
563
563
|
|
|
564
564
|
Order matters: put the cheapest / most-common strategy first. When no strategy matches, the resolver returns an [anonymous Subject](/docs/authentication/subject) scoped to the `x-tenant` header (or a custom `fallback` you supply).
|
|
565
565
|
|
|
566
|
+
## Roles from your database — `auth.resolveScopes`
|
|
567
|
+
|
|
568
|
+
If your authorization is a database ROLE rather than a scope on the token, the framework cannot see it. `voltro check`'s `rbac/unguarded-mutation` reports every such write as unguarded — correctly, because nothing about the decision is declared — and the declarative alternative is unusable for you: subjects that come from an external IdP carry no scopes, so `requireScope('employee:admin')` would lock out every real user. One app measured 1566 findings it had no way to act on.
|
|
569
|
+
|
|
570
|
+
`resolveScopes` closes that. It runs after a strategy matches and adds scopes to the resolved Subject from whatever source you like:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
// app.config.ts
|
|
574
|
+
export default defineApiConfig({
|
|
575
|
+
auth: {
|
|
576
|
+
resolveScopes: async (subject) => {
|
|
577
|
+
const role = await roleCache.get(subject.id) // cache it — see below
|
|
578
|
+
return role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read']
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
})
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
The same authorization is now declarable on the descriptor:
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
export const payrollList = defineQuery({
|
|
588
|
+
name: 'payroll.list',
|
|
589
|
+
guards: [requireScope('employee:admin')], // visible in the manifest, checkable in CI
|
|
590
|
+
…
|
|
591
|
+
})
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
**Scopes only — never a Subject.** The hook cannot change `id` or `tenantId`: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface. The returned scopes are UNIONED with whatever the strategy already set, so a resolver can grant authority but never revoke it.
|
|
595
|
+
|
|
596
|
+
**It does not run for anonymous callers** — there is no identity to look a role up for.
|
|
597
|
+
|
|
598
|
+
**Cache it yourself.** It is on the request path. A `Map` keyed by subject id with a short TTL is usually enough. The framework deliberately does not cache for you, because only you know how quickly a role change has to take effect.
|
|
599
|
+
|
|
600
|
+
Wired identically under `voltro dev` and `voltro serve`.
|
|
601
|
+
|
|
566
602
|
## Wiring it into the app
|
|
567
603
|
|
|
568
604
|
The composed resolver becomes the runtime's `AuthMiddleware` — the per-request middleware that populates `SubjectService` so every handler can `yield* SubjectService` (or read `ctx.subject`). On a single-strategy password app you never touch this; the plugin wires `voltroPasswordStrategy` for you. You only assemble the chain explicitly when you add a second strategy:
|
|
@@ -105,8 +105,7 @@ pnpm dev # turbo adds the new app to its parallel boot
|
|
|
105
105
|
voltro db plan # diff declared schema vs live, color-coded
|
|
106
106
|
# review the plan
|
|
107
107
|
voltro db apply # execute it (dev)
|
|
108
|
-
#
|
|
109
|
-
voltro migrate
|
|
108
|
+
# `voltro migrate` is an alias of `db apply` — same differ, shorter name (CI / ops)
|
|
110
109
|
```
|
|
111
110
|
|
|
112
111
|
### "I want a clean rebuild"
|
|
@@ -130,19 +129,69 @@ voltro workflows list # recent workflow runs
|
|
|
130
129
|
|
|
131
130
|
### "Did my edit break a binding?"
|
|
132
131
|
|
|
133
|
-
`voltro check` runs blast-radius checks
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
`voltro check` runs blast-radius checks over your app's typed graph — dangling
|
|
133
|
+
`source` / `target` tables, scopes no role grants, unguarded mutations, broken
|
|
134
|
+
route bindings, orphan tables — at edit time instead of at runtime. `--json`
|
|
135
|
+
emits LLM-shaped diagnostics so a coding agent can fix-and-repeat; `--diff`
|
|
136
|
+
previews the blast radius of a removal BEFORE you apply it.
|
|
137
|
+
|
|
138
|
+
It prefers a running api (its manifest is ground truth, including live table
|
|
139
|
+
introspection). **With none reachable it assembles the same graph from source**,
|
|
140
|
+
so it works as a pre-commit hook or a CI gate without a second terminal —
|
|
141
|
+
`--offline` forces that path.
|
|
139
142
|
|
|
140
143
|
```bash
|
|
141
|
-
voltro check #
|
|
144
|
+
voltro check # running api if there is one, else from source
|
|
145
|
+
voltro check --offline # never contact a server — the CI form
|
|
146
|
+
voltro check --url https://api.example.com # a DEPLOYED app
|
|
142
147
|
voltro check --json # { ok, diagnostics: [{ rule, node, breaks, fix }] }
|
|
143
148
|
voltro check --diff removeTable:todos # what a proposed removal would break, before applying
|
|
144
149
|
```
|
|
145
150
|
|
|
151
|
+
`rbac/unknown-scope` needs a declared scope vocabulary to compare against —
|
|
152
|
+
`rbacPlugin({ roles })` publishes one automatically. It catches a guard
|
|
153
|
+
requiring a scope no role grants, which makes that procedure permanently and
|
|
154
|
+
silently uncallable. (With a custom `resolvePermissions` the vocabulary isn't
|
|
155
|
+
exhaustive, so the rule stays quiet rather than flagging correct code.)
|
|
156
|
+
|
|
157
|
+
#### Declared vs OBSERVED — reconciled against reality
|
|
158
|
+
|
|
159
|
+
A query's `source` and a mutation's `targets` are not documentation: the
|
|
160
|
+
framework routes optimistic patches and decides which subscriptions a write
|
|
161
|
+
invalidates from them. A wrong declaration is a live, user-visible bug that
|
|
162
|
+
nothing type-checks — the mutation succeeds, the write lands, and the wrong list
|
|
163
|
+
fails to update.
|
|
164
|
+
|
|
165
|
+
`voltro dev` records what each procedure ACTUALLY touched, into
|
|
166
|
+
`app.graph.observed.generated.json` (gitignored automatically). When that file is present,
|
|
167
|
+
`check` diffs it against the declarations:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
observed: 12/34 procedures exercised (35%)
|
|
171
|
+
mutation(orders.place) (api/orders/place.mutation.ts)
|
|
172
|
+
writes 'inventory' (update) but declares no target for it
|
|
173
|
+
subscriptions on that table are not invalidated by this mutation
|
|
174
|
+
fix: add { table: 'inventory', op: 'update' } to this procedure's targets
|
|
175
|
+
22 unexercised — no recording, so nothing is claimed about them
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Three things about that output are deliberate:
|
|
179
|
+
|
|
180
|
+
- **Coverage comes first.** Three findings at 8% coverage and three at 95% are
|
|
181
|
+
different claims. Hiding the denominator is how a check starts overstating
|
|
182
|
+
what it knows.
|
|
183
|
+
- **`unexercised` is a third state, not a finding.** A procedure no test and no
|
|
184
|
+
dev session ever ran has *no observation* — which is not the same as "touches
|
|
185
|
+
nothing". It is counted and never reported as a problem.
|
|
186
|
+
- **Observed diagnostics never fail the build.** An observation is evidence
|
|
187
|
+
about the runs that happened, not a proof about the ones that didn't, and
|
|
188
|
+
`check`'s exit code gates CI. They are always warnings.
|
|
189
|
+
|
|
190
|
+
No file → the section is skipped silently. This is not derived by parsing your
|
|
191
|
+
handlers: a static pass over code that reaches the store through shared helpers,
|
|
192
|
+
behind conditionals, has a long tail of both false positives and false negatives
|
|
193
|
+
— and a check that is *sometimes* wrong is one people stop reading.
|
|
194
|
+
|
|
146
195
|
The HTTP surface is reachable directly too — e.g. `curl -s localhost:4000/_voltro/inspect/rpc | jq` (there is no `/_voltro/inspect/queries` endpoint; it's `rpc` for procedures, `routes` for the web page tree, `subscriptions` for active subscribers).
|
|
147
196
|
|
|
148
197
|
## Where to read next
|
|
@@ -398,6 +447,25 @@ _voltro dev, codegen, agents-md — what runs during local development and the e
|
|
|
398
447
|
|
|
399
448
|
`voltro dev` is the day-to-day command. It runs different machinery for api vs web apps but the contract is the same: edit a file, the right thing happens.
|
|
400
449
|
|
|
450
|
+
### Running `voltro dev` in a container
|
|
451
|
+
|
|
452
|
+
If your dev pod runs as root with the host workspace bind-mounted, everything
|
|
453
|
+
the framework generates would otherwise land `root:root` inside your own tree —
|
|
454
|
+
and on the host `voltro build` then fails on its own output:
|
|
455
|
+
|
|
456
|
+
```
|
|
457
|
+
EACCES: permission denied, open '…/apps/display/.framework/index.html'
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`voltro dev` and `voltro build` hand their generated output (`.framework`,
|
|
461
|
+
`.env.local`, every `*.generated.*`) to whoever owns the app root, and warn
|
|
462
|
+
loudly when they cannot. Only generated state — the framework never takes
|
|
463
|
+
ownership of a file you wrote.
|
|
464
|
+
|
|
465
|
+
The cleaner fix is on your side and worth doing anyway: start the container as
|
|
466
|
+
the workspace owner, `docker run --user $(id -u):$(id -g)`. Then nothing needs
|
|
467
|
+
handing over at all.
|
|
468
|
+
|
|
401
469
|
## `voltro dev <appDir>`
|
|
402
470
|
|
|
403
471
|
```bash
|
|
@@ -798,6 +866,100 @@ Docker step) and prints the exact remedy: add a `voltro build .` step before
|
|
|
798
866
|
`voltro serve .`. Drop it into your image build right after `voltro build` to
|
|
799
867
|
guarantee the artefact is present before the image ships.
|
|
800
868
|
|
|
869
|
+
### The predicate-column check
|
|
870
|
+
|
|
871
|
+
`eq` / `isNull` / `inSet` are free functions, so the column name arrives as a bare
|
|
872
|
+
`string` and the builder cannot relate it to the table the predicate is attached
|
|
873
|
+
to:
|
|
874
|
+
|
|
875
|
+
```ts
|
|
876
|
+
database.teamAppointments.where(isNull('deletedAt'))
|
|
877
|
+
// ^ teamAppointments has no softDelete() mixin,
|
|
878
|
+
// so no `deletedAt` column exists. tsc: OK.
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
That type-checks — review and CI pass — and then fails at runtime as a bare SQL
|
|
882
|
+
error. `voltro doctor` checks every literal predicate column against the table's
|
|
883
|
+
declared columns and names both:
|
|
884
|
+
|
|
885
|
+
```
|
|
886
|
+
✗ predicate columns: 1 filter on a column that does not exist (214 checked)
|
|
887
|
+
api/appointments/rollforward.query.server.ts:31 'teamAppointments' has no column 'deletedAt'
|
|
888
|
+
columns: id, teamId, startsAt, createdAt, updatedAt
|
|
889
|
+
This type-checks today and fails at runtime as a bare SQL error.
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
Matching is on the AST, never on text, so a column name in a comment or an
|
|
893
|
+
unrelated string cannot trip it. A call site whose table cannot be resolved is
|
|
894
|
+
skipped silently — an unresolvable receiver is usually not a table at all.
|
|
895
|
+
|
|
896
|
+
The type-level fix (binding the predicate to the row, `where(c => isNull(c.x))`)
|
|
897
|
+
is the right end state and is planned separately. This check is the half that
|
|
898
|
+
works **retroactively**: it finds the bug in code that already exists, which a
|
|
899
|
+
type change never will.
|
|
900
|
+
|
|
901
|
+
The detector also flags **raw `fetch()` in server files**. The SSRF guard the
|
|
902
|
+
framework ships lives in the `HttpClient` handlers `yield*` — so it protects
|
|
903
|
+
exactly the apps that already adopted it, and misses the ones that never did.
|
|
904
|
+
Those are usually the same apps that secured least elsewhere, which is why the
|
|
905
|
+
absence is worth naming out loud rather than assuming the default did its job.
|
|
906
|
+
|
|
907
|
+
The rule follows the IMPORT GRAPH, not the filename. Server-convention files
|
|
908
|
+
(`*.server.ts`, `*.cron.ts`, `*.subscribe.ts`, …) are the starting points, and
|
|
909
|
+
any file reachable from them and from **nothing else** counts as server code
|
|
910
|
+
too. That matters: keyed on filenames alone the rule caught 9 of 39 outbound
|
|
911
|
+
calls on the app that reported it — the other 30 sat in `lib/*.ts` helpers
|
|
912
|
+
(payments, an AI provider, TTS) imported only from server executors. A
|
|
913
|
+
`lib/payments-mollie.ts` is not client code, and no file extension can say so.
|
|
914
|
+
|
|
915
|
+
A helper a page ALSO imports stays unflagged, and that is the property keeping
|
|
916
|
+
this rule useful: `fetch` is unremarkable in a browser component, and flagging it
|
|
917
|
+
there would make the rule noise that gets scrolled past — taking the real
|
|
918
|
+
findings with it. Relative imports and your tsconfig `paths` aliases are both
|
|
919
|
+
followed.
|
|
920
|
+
|
|
921
|
+
The detector also flags an **executor that never names its own descriptor**.
|
|
922
|
+
Pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a
|
|
923
|
+
complete, correct executor with no reference at all to the contract it
|
|
924
|
+
implements. Those are exactly the files where a hand-written input drifts from
|
|
925
|
+
the wire: in one reported codebase, six executors declared `boardPurpose: string`
|
|
926
|
+
where their own descriptor said `Schema.Literal(...)`, discarding the contract at
|
|
927
|
+
the executor boundary. Fix by importing the descriptor and typing the input as
|
|
928
|
+
`ExecutorInput<typeof descriptor>`. Only a SIBLING import clears the finding —
|
|
929
|
+
an executor importing nothing but `@voltro/*` and `node:*` has still not named
|
|
930
|
+
its contract.
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
### The `workflows.start` audit
|
|
934
|
+
|
|
935
|
+
`voltro doctor` also diffs every `workflows.start(name, payload)` call site
|
|
936
|
+
against the workflows the app actually registers:
|
|
937
|
+
|
|
938
|
+
```
|
|
939
|
+
✗ workflow starts: 13/14 call sites checked
|
|
940
|
+
api/crons/weekly.cron.ts:22 'sprint.report' payload is missing: teamId
|
|
941
|
+
api/crons/weekly.cron.ts:22 'sprint.report' payload has unknown field(s): scheduledAt
|
|
942
|
+
accepted: sinceIso, teamId
|
|
943
|
+
1 UNCHECKED (not verified — not a pass):
|
|
944
|
+
api/crons/digest.cron.ts:8 — payload spreads a value
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
**Why this exists even though `workflows.start` validates at runtime.** Runtime
|
|
948
|
+
validation fires on the next firing — which for a daily cron is hours, for a
|
|
949
|
+
weekly one is a week, and for a quarterly one is a quarter. And
|
|
950
|
+
`voltro inspect schedules --failing` cannot see a job that has *never fired*,
|
|
951
|
+
because its roll-up is built from recorded runs. A weekly workflow broken by a
|
|
952
|
+
refactor is invisible to both until it next runs.
|
|
953
|
+
|
|
954
|
+
**`UNCHECKED` is never folded into a pass.** A payload built with a spread or a
|
|
955
|
+
computed key can contribute any name, so its key set is unknowable here. Those
|
|
956
|
+
call sites are counted and listed rather than passed silently — `0 issues` must
|
|
957
|
+
not be readable as "all verified". The workflow **name** is checked regardless,
|
|
958
|
+
since a rename or a deletion is decidable whatever the payload looks like.
|
|
959
|
+
|
|
960
|
+
The required keys come from the live `payloadSchema`, the same source the runtime
|
|
961
|
+
validation reads, so the two cannot disagree about what a payload needs.
|
|
962
|
+
|
|
801
963
|
### The hand-roll detector
|
|
802
964
|
|
|
803
965
|
`voltro doctor` also scans your source for shapes the framework already has a
|
|
@@ -893,6 +1055,39 @@ matching rule lives inside the CLI, so you cannot re-derive the list with your
|
|
|
893
1055
|
own grep. `--json` prints the complete scan, nothing elided, with no preflight
|
|
894
1056
|
output mixed in:
|
|
895
1057
|
|
|
1058
|
+
### Duplicate package instances
|
|
1059
|
+
|
|
1060
|
+
`voltro doctor` also reports any identity-sensitive package resolved at more than
|
|
1061
|
+
one version — `effect`, `@effect/*`, `@voltro/*`, react/react-dom:
|
|
1062
|
+
|
|
1063
|
+
```
|
|
1064
|
+
• 1 package(s) resolved at more than one version:
|
|
1065
|
+
effect — 3.18.4, 3.21.0
|
|
1066
|
+
node_modules/effect
|
|
1067
|
+
../../node_modules/effect
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
This is worth its own check because of how it PRESENTS. Effect's types are
|
|
1071
|
+
nominal, so a `Schema` built by one copy is not the type the other expects, and
|
|
1072
|
+
the errors land in the GENERATED `rpcGroup.generated.ts` — a file you cannot edit
|
|
1073
|
+
and did not write:
|
|
1074
|
+
|
|
1075
|
+
```
|
|
1076
|
+
Property '[TypeId]' is missing in type … Schema<any, any, unknown>
|
|
1077
|
+
Type 'typeof Never' is not assignable to type 'All'
|
|
1078
|
+
Argument of type 'Rpc<…, Stream<…>, …>' is not assignable to 'Any'
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
Read cold, that says "the framework emits bad types". It says nothing about the
|
|
1082
|
+
dependency tree, which is where the problem is. And the RUNTIME usually stays
|
|
1083
|
+
green — two instances only diverge where identity matters — so the app boots,
|
|
1084
|
+
serves and passes its tests while `tsc` is red.
|
|
1085
|
+
|
|
1086
|
+
Fix it in the install, not the code: align the version across the workspace (a
|
|
1087
|
+
root `pnpm.overrides` / `resolutions` entry for `effect` is the blunt
|
|
1088
|
+
instrument), then reinstall. Do NOT add `@ts-nocheck` to the generated file — it
|
|
1089
|
+
is exactly where a genuine mistake in your own descriptors surfaces.
|
|
1090
|
+
|
|
896
1091
|
```bash
|
|
897
1092
|
voltro doctor . --json # the complete scan: every file path, machine-readable
|
|
898
1093
|
```
|
|
@@ -1007,16 +1202,18 @@ package's README.
|
|
|
1007
1202
|
<!-- source: en/cli/migrate.md -->
|
|
1008
1203
|
## Migrate
|
|
1009
1204
|
|
|
1010
|
-
_voltro migrate — the
|
|
1205
|
+
_voltro migrate — apply the declared schema through the declarative differ (an alias of voltro db apply)._
|
|
1206
|
+
|
|
1207
|
+
`voltro migrate` applies your declared schema (`*.entity.ts` / `*.schema.ts` / `schema.ts`) to the configured database. It is an **alias of [`voltro db apply`](#the-declarative-workflow)**: it diffs the declared schema against the live database and emits the ALTERs, so a changed column or a new index actually lands.
|
|
1011
1208
|
|
|
1012
|
-
|
|
1209
|
+
> Before 0.11.4 this command was a create-only apply (`CREATE TABLE IF NOT EXISTS`, no diffing), which meant a column or type change reported success having applied **nothing**. If you need that bootstrap-only behaviour for a brand-new database, it is now `voltro migrate --create-only`.
|
|
1013
1210
|
|
|
1014
1211
|
```bash
|
|
1015
1212
|
voltro migrate # apply the discovered schema to the configured store
|
|
1016
1213
|
voltro migrate apps/api # explicit app directory (defaults to cwd)
|
|
1017
1214
|
```
|
|
1018
1215
|
|
|
1019
|
-
|
|
1216
|
+
`voltro migrate` forwards its arguments to `voltro db apply`, so the same flags apply; `--create-only` selects the bootstrap-only emitter instead. The diff / plan / apply / drift / squash workflow lives under `voltro db` (see below).
|
|
1020
1217
|
|
|
1021
1218
|
For the deep dive on the schema DSL + day-to-day patterns, see [Database / Migrations](/docs/database/migrations).
|
|
1022
1219
|
|
|
@@ -1163,6 +1360,23 @@ has never run is not "failing".
|
|
|
1163
1360
|
Pair it with `voltro logs --level error`: a failing handler now logs at **error**
|
|
1164
1361
|
level, so the two surfaces agree.
|
|
1165
1362
|
|
|
1363
|
+
## Targeting a deployed app
|
|
1364
|
+
|
|
1365
|
+
Every command in this family resolves its target from the local runtime registry — the apps running on *this* machine. Pass `--url` to point one at a **deployed** app instead:
|
|
1366
|
+
|
|
1367
|
+
```bash
|
|
1368
|
+
voltro inspect app --url https://api.example.com --token "$TOKEN"
|
|
1369
|
+
voltro logs --tail 100 --url https://api.example.com
|
|
1370
|
+
voltro traces --errors --url https://api.example.com
|
|
1371
|
+
voltro check --url https://api.example.com
|
|
1372
|
+
```
|
|
1373
|
+
|
|
1374
|
+
`--token` (or `VOLTRO_INSPECT_TOKEN`) supplies the bearer; `VOLTRO_INSPECT_URL` sets a default target so you can drop the flag. Works for `inspect`, `logs`, `traces`, `workflows`, `cluster` and `check`.
|
|
1375
|
+
|
|
1376
|
+
## Securing the local surface
|
|
1377
|
+
|
|
1378
|
+
`voltro dev` mints a per-project `VOLTRO_INSPECT_TOKEN` into `.env.local`, so the inspect surface is authenticated from the first boot — the dev server listens on every interface, and without a token anyone on the same network could read your rows, schema and logs. You don't have to wire it anywhere: the CLI picks the token up from the runtime registry (so the commands work from any directory), and the dashboard's server-side proxy supplies it for same-machine targets. Setting `VOLTRO_INSPECT_TOKEN` yourself always wins.
|
|
1379
|
+
|
|
1166
1380
|
## The inspect HTTP surface
|
|
1167
1381
|
|
|
1168
1382
|
Every `voltro dev` / `voltro start` instance exposes a read-only introspection surface under `/_voltro/inspect/*`. The [Voltro Dashboard](/docs/observability/dashboard) consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with `curl` (the `voltro inspect` subcommands above are the thin wrapper over exactly these).
|
|
@@ -1293,6 +1507,30 @@ Runs Vitest with the framework's preset:
|
|
|
1293
1507
|
- `STORE=memory` by default for fast isolation.
|
|
1294
1508
|
- Plays well with `@voltro/testing` helpers (mock stores).
|
|
1295
1509
|
|
|
1510
|
+
### Every vitest flag is forwarded
|
|
1511
|
+
|
|
1512
|
+
Anything the command does not interpret itself goes straight to vitest — parsed
|
|
1513
|
+
by **vitest's own CLI parser**, not a list this wrapper maintains:
|
|
1514
|
+
|
|
1515
|
+
```bash
|
|
1516
|
+
voltro test --coverage
|
|
1517
|
+
voltro test --reporter=junit --outputFile=reports/junit.xml
|
|
1518
|
+
voltro test --coverage --reporter=junit --outputFile=reports/junit.xml
|
|
1519
|
+
```
|
|
1520
|
+
|
|
1521
|
+
That covers coverage numbers and a JUnit report for a merge-request widget, which
|
|
1522
|
+
is what most pipelines want beyond the exit code.
|
|
1523
|
+
|
|
1524
|
+
The framework keeps three decisions for itself and they win over a forwarded
|
|
1525
|
+
flag: the **root** (a positional that is an existing directory, which vitest
|
|
1526
|
+
would otherwise read as a filter), `--watch`, and `passWithNoTests` — an explicit
|
|
1527
|
+
filter that matches no file is an *error*, which vitest cannot decide because it
|
|
1528
|
+
does not know which positional was treated as a root.
|
|
1529
|
+
|
|
1530
|
+
An unrecognised flag is ignored rather than fatal, and a flag vitest cannot parse
|
|
1531
|
+
at all degrades to "run without the extra flags" with a warning instead of taking
|
|
1532
|
+
the run down.
|
|
1533
|
+
|
|
1296
1534
|
The actual test runner is Vitest; this command is a thin wrapper that injects the framework's config. You can run vitest directly if you prefer:
|
|
1297
1535
|
|
|
1298
1536
|
```bash
|
|
@@ -1332,7 +1570,9 @@ The framework's `voltro e2e` handles the lifecycle for you: it boots the api + w
|
|
|
1332
1570
|
|
|
1333
1571
|
## Securing the inspect surface
|
|
1334
1572
|
|
|
1335
|
-
The
|
|
1573
|
+
The surface is **fail-closed**: with no `VOLTRO_INSPECT_TOKEN` configured, every endpoint answers `401`. The absence of a secret is not consent — a check you were configured not to perform is a refusal, not a pass.
|
|
1574
|
+
|
|
1575
|
+
That is why `voltro dev` mints one for you (above) and why nothing mints outside dev: in production a missing secret must stay a boot-time decision rather than an invented value. So a production `voltro start` serves nothing here until you set the token yourself:
|
|
1336
1576
|
|
|
1337
1577
|
```bash
|
|
1338
1578
|
VOLTRO_INSPECT_TOKEN=$(openssl rand -hex 32)
|
|
@@ -1350,7 +1590,7 @@ Or disable the surface entirely: `VOLTRO_INSPECT=off`. The dashboard then can't
|
|
|
1350
1590
|
|
|
1351
1591
|
## Anti-patterns
|
|
1352
1592
|
|
|
1353
|
-
- **
|
|
1593
|
+
- **Assuming the surface is open because you didn't configure it.** It is the reverse: no token means `401`, not "everyone". If a production dashboard suddenly stops introspecting, the missing `VOLTRO_INSPECT_TOKEN` is the first thing to check — not a network problem.
|
|
1354
1594
|
- **`voltro test` against `STORE=postgres` by default.** Slower + flaky (test isolation harder). Use postgres only for integration tests that NEED it.
|
|
1355
1595
|
- **Skipping `voltro e2e` because "it's slow".** It catches integration bugs that unit tests miss. Run it in CI on every PR; locally for changes that touch queries.
|
|
1356
1596
|
|
|
@@ -326,6 +326,54 @@ const issued = await svc.issue({ tenantId, name: 'mobile app', scopes: ['read']
|
|
|
326
326
|
// show issued.token ONCE; later: svc.rotate(id), svc.revoke(id), svc.list(tenantId)
|
|
327
327
|
```
|
|
328
328
|
|
|
329
|
+
## The second ownership axis — `metadata`
|
|
330
|
+
|
|
331
|
+
`tenantId` and `onBehalfOf` are the two relationships the framework models. If
|
|
332
|
+
your keys also belong to something else — a team, a project, an environment —
|
|
333
|
+
and that binding is what authorizes them, store it in `metadata`:
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
const key = await keys.issue({
|
|
337
|
+
tenantId: ctx.request.subject.tenantId,
|
|
338
|
+
name: 'CI deploy',
|
|
339
|
+
createdBy: ctx.request.subject.id, // who minted it
|
|
340
|
+
onBehalfOf: null, // an ORG key: acts as no person
|
|
341
|
+
metadata: { teamId: 'team_7' }, // your axis
|
|
342
|
+
})
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
It comes straight back on resolve, so a guard needs no second query:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
const resolved = await keys.verify(token)
|
|
349
|
+
resolved?.metadata // { teamId: 'team_7' }
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
It survives `rotate` — a rotated key is the same credential with a new secret,
|
|
353
|
+
so dropping it would silently de-authorize every rotated key. And it reaches the
|
|
354
|
+
Subject as `metadata`, alongside the framework's own claims.
|
|
355
|
+
|
|
356
|
+
**It is app data, never identity.** The strategy merges your bag UNDER its own
|
|
357
|
+
claims: `provider`, and the acting `userId`, are written afterwards from
|
|
358
|
+
`onBehalfOf` and always win — including when the answer is "none". A bag that
|
|
359
|
+
could set `userId` would let whoever minted a key choose who the request is.
|
|
360
|
+
|
|
361
|
+
Before this slot existed, an app with a team axis could authenticate through the
|
|
362
|
+
built-in strategy and still not authorize, so `apiKeys: true` was unusable for
|
|
363
|
+
it. The alternatives people reached for were a second table joined on every auth
|
|
364
|
+
check, or `team:<id>` smuggled into `scopes` — where `hasScope` then sees a scope
|
|
365
|
+
that is not a scope.
|
|
366
|
+
|
|
367
|
+
## Two strategies, one prefix
|
|
368
|
+
|
|
369
|
+
If your app already runs its own key strategy on a prefix and you then enable
|
|
370
|
+
`apiKeys: true`, both claim the same shape. The chain is first-match-wins, so the
|
|
371
|
+
first one decides the Subject — and if they resolve to different authority,
|
|
372
|
+
*which strategy answered* decides whether authorization works.
|
|
373
|
+
|
|
374
|
+
`voltro dev` / `voltro serve` warn at boot when this happens. Give them distinct
|
|
375
|
+
prefixes (`apiKeys: { prefix: 'vk_' }`) or drop one.
|
|
376
|
+
|
|
329
377
|
## Security model
|
|
330
378
|
|
|
331
379
|
- **Hash-only storage.** A DB dump never exposes a usable key — only `sha256(token)`. Lose a token → rotate it (`rotate` revokes the old + issues a fresh one with the same scopes).
|
|
@@ -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)`:
|
|
@@ -2099,6 +2107,37 @@ export default crud.list('articles', { columns: ['id', 'title', 'createdAt'] })
|
|
|
2099
2107
|
|
|
2100
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`.
|
|
2101
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
|
+
|
|
2102
2141
|
## What they deliberately don't do — authorization
|
|
2103
2142
|
|
|
2104
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:
|