@voltro/cli 0.17.0 → 0.19.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 +423 -0
- package/bin/voltro.mjs +17 -1
- package/dist/apiBuild-9NXH53Sd.js +2 -0
- package/dist/{apiBuild-ChdlLqGv.js → apiBuild-CY5pEwwq.js} +2 -2
- package/dist/bin.js +2 -2
- package/dist/{commands-DYQbv-DG.js → commands-BvvoQL0v.js} +2126 -1720
- package/dist/{dev-DB7pbLob.js → dev-B6rfgKfo.js} +1513 -1418
- package/dist/{dev-BCmoJfBm.js → dev-C_E1XxNF.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-DWh56Qas.js → inspectMetrics-D1DmLeJs.js} +507 -494
- package/dist/serveCommand-vPF5ucXC.js +1136 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-ksY0wMZG.js → start-Clvz4IJb.js} +1 -1
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +60 -6
- package/templates/AGENTS.md +61 -7
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/authentication.md +57 -0
- package/templates/agent-docs/cli.md +9 -5
- package/templates/agent-docs/database/misc.md +26 -3
- package/templates/agent-docs/database/querying.md +8 -2
- package/templates/agent-docs/introduction.md +15 -1
- package/templates/agent-docs/routing.md +2 -0
- package/templates/agent-docs/schema-driven-ui.md +6 -3
- package/templates/agent-docs/whats-new.md +121 -31
- 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/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-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-D5WB119b.js +0 -2
- package/dist/serveCommand-DO6qr5Ok.js +0 -1129
package/dist/serveEntry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Q as e } from "./inspectMetrics-
|
|
1
|
+
import { Q as e } from "./inspectMetrics-D1DmLeJs.js";
|
|
2
2
|
import { c as t } from "./seedRunner-D6eu-u5U.js";
|
|
3
3
|
import { r as n } from "./appModuleLoader-C9r9mxZt.js";
|
|
4
|
-
import { t as r } from "./serveCommand-
|
|
4
|
+
import { t as r } from "./serveCommand-vPF5ucXC.js";
|
|
5
5
|
export { e as loadDotEnv, n as registerAppModules, t as registerDriver, r as runServe };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as e, B as t, C as n, E as r, G as i, I as a, K as o, N as s, O as c, U as l, W as ee, X as u, Y as d, _ as f, a as p, b as m, c as h, et as g, f as _, g as te, h as v, i as ne, it as y, j as b, k as x, m as S, nt as C, o as re, p as ie, q as w, r as ae, rt as T, s as E, st as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-
|
|
1
|
+
import { A as e, B as t, C as n, E as r, G as i, I as a, K as o, N as s, O as c, U as l, W as ee, X as u, Y as d, _ as f, a as p, b as m, c as h, et as g, f as _, g as te, h as v, i as ne, it as y, j as b, k as x, m as S, nt as C, o as re, p as ie, q as w, r as ae, rt as T, s as E, st as D, t as O, tt as k, ut as A, v as j, w as oe, y as M } from "./inspectMetrics-D1DmLeJs.js";
|
|
2
2
|
import { D as se, E as ce, T as le, a as N, p as ue, w as de } from "./inspect-Dwx0_tUj.js";
|
|
3
3
|
import { t as fe } from "./bootTiming-BdyP9nYw.js";
|
|
4
4
|
import { dirname as pe, extname as P, join as F, resolve as I } from "node:path";
|
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { Q as e } from "./inspectMetrics-
|
|
2
|
-
import { t } from "./start-
|
|
1
|
+
import { Q as e } from "./inspectMetrics-D1DmLeJs.js";
|
|
2
|
+
import { t } from "./start-Clvz4IJb.js";
|
|
3
3
|
export { e as loadDotEnv, t as runStartCommand };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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.19.0",
|
|
66
|
+
"@voltro/cache": "0.19.0",
|
|
67
|
+
"@voltro/data-transfer": "0.19.0",
|
|
68
|
+
"@voltro/database": "0.19.0",
|
|
69
|
+
"@voltro/env": "0.19.0",
|
|
70
|
+
"@voltro/kv": "0.19.0",
|
|
71
|
+
"@voltro/logger": "0.19.0",
|
|
72
|
+
"@voltro/plugin-auth": "0.19.0",
|
|
73
|
+
"@voltro/plugin-broadcast": "0.19.0",
|
|
74
|
+
"@voltro/plugin-mail": "0.19.0",
|
|
75
|
+
"@voltro/plugin-storage": "0.19.0",
|
|
76
|
+
"@voltro/plugin-webhooks": "0.19.0",
|
|
77
|
+
"@voltro/protocol": "0.19.0",
|
|
78
|
+
"@voltro/runtime": "0.19.0",
|
|
79
|
+
"@voltro/serverless": "0.19.0",
|
|
80
|
+
"@voltro/workflow": "0.19.0",
|
|
81
81
|
"chokidar": "^5.0.0",
|
|
82
82
|
"ioredis": "^5.11.1",
|
|
83
83
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.core.md
CHANGED
|
@@ -103,30 +103,41 @@ line below replaces something real apps write by hand hundreds of times.
|
|
|
103
103
|
in-handler for checks that need LOADED data. A hand-written `requireScope(...)`
|
|
104
104
|
at the top of every executor — or a hand-kept map from rpc tag to policy rule,
|
|
105
105
|
which is fail-open by omission — is what `guards:` exists to delete.
|
|
106
|
-
6. **
|
|
106
|
+
6. **Which ROWS may the caller see** (not: may they make the call)? →
|
|
107
|
+
**`setRowFilter`** — a predicate derived from the Subject, AND-merged into
|
|
108
|
+
EVERY read of that table, including every subscription delivery. `guards:`
|
|
109
|
+
and this answer different questions and the pair is easy to conflate:
|
|
110
|
+
- `guards:` → *may I call this procedure?* → a typed `ScopeError`.
|
|
111
|
+
- `setRowFilter` → *which rows may I see?* → the rows are simply absent.
|
|
112
|
+
A `WHERE ownerId = me` in the list handler covers the query and **NOT** the
|
|
113
|
+
stream, which is the failure this deletes. Register it from a
|
|
114
|
+
`*.startup.tsx`; its `load` must read through an UNFILTERED store (the boot
|
|
115
|
+
store / the `database` handle) — reading through the filtered one applies the
|
|
116
|
+
filter to itself and blows the stack. Depth → **`authentication`**.
|
|
117
|
+
7. **Storing a token, secret, or credential in a column?** → **`.encrypted()`**
|
|
107
118
|
on the column. Boot fails loudly if no cipher is configured, so an
|
|
108
119
|
`.encrypted()` column can never silently persist plaintext.
|
|
109
|
-
|
|
120
|
+
8. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
|
|
110
121
|
its failures land on the typed error channel. `Effect.promise(() =>
|
|
111
122
|
ctx.store.query(…))` throws that channel away and turns a store failure into
|
|
112
123
|
a defect.
|
|
113
|
-
|
|
124
|
+
9. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
|
|
114
125
|
direction?)`** (or **`paginateById`**, its `id`-column shorthand). Not a
|
|
115
126
|
hand-rolled limit+1 / slice / `hasMore` triple. `direction` flips the
|
|
116
127
|
COMPARISON as well as the sort — a `desc` feed pages with `<`.
|
|
117
|
-
|
|
128
|
+
10. **Assembling data whose SHAPE depends on the data** (a tree walk where each
|
|
118
129
|
level's ids come from the level above)? → **`ctx.load` / `ctx.loadMany`** —
|
|
119
130
|
same-tick reads of one table coalesce into one `WHERE id IN (...)`, so the
|
|
120
131
|
walk costs one query per LEVEL. Use `relations()` + `.with()` whenever the
|
|
121
132
|
shape IS static; this is the fallback, not the default.
|
|
122
|
-
|
|
133
|
+
11. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
|
|
123
134
|
payment)? → **`ctx.outbox.enqueue(effect, payload)`** + a
|
|
124
135
|
**`defineOutboxHandler`** in a `*.outbox.ts`. The enqueue writes through the
|
|
125
136
|
mutation's TRANSACTION, so the intent commits with the write or not at all;
|
|
126
137
|
delivery happens after commit, with backoff and a dead-letter. Do NOT call
|
|
127
138
|
the remote from the mutation (not transactional), and do not hand-build a
|
|
128
139
|
deliveries table + drain cron — that IS this primitive.
|
|
129
|
-
|
|
140
|
+
12. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
|
|
130
141
|
grant)? → **`defineConnection({ id, kind: 'oauth2' | 'pat' })`** in a
|
|
131
142
|
`*.connection.ts`, then **`ctx.connections.get(id)`**. One declaration yields
|
|
132
143
|
the encrypted per-user token store, the OAuth authorize/callback pair,
|
|
@@ -140,6 +151,7 @@ line below replaces something real apps write by hand hundreds of times.
|
|
|
140
151
|
| `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
|
|
141
152
|
| 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
|
|
142
153
|
| a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
|
|
154
|
+
| `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
|
|
143
155
|
| a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
|
|
144
156
|
| `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
|
|
145
157
|
| a token/secret column written as plain text | `.encrypted()` |
|
|
@@ -449,6 +461,48 @@ aggregations, FTS, vectors/RAG, migrations, dialect parity → the **`database/*
|
|
|
449
461
|
docs (see index). Async-vs-Effect handlers, `EffectStore`, typed store errors →
|
|
450
462
|
**`data.md`**.
|
|
451
463
|
|
|
464
|
+
## What may cross the wire (three ORTHOGONAL markers — do not substitute one for another)
|
|
465
|
+
|
|
466
|
+
A column is not "protected" or "unprotected". Three separate questions get three
|
|
467
|
+
separate markers, and using one to answer another's question is the mistake:
|
|
468
|
+
|
|
469
|
+
| Marker | Answers | Enforced by |
|
|
470
|
+
|---|---|---|
|
|
471
|
+
| `.serverOnly()` | may this value leave the server AT ALL? | **`voltro serve` fails to boot; `voltro doctor` exits non-zero.** `voltro dev` only warns |
|
|
472
|
+
| `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
|
|
473
|
+
| `.encrypted()` | is it encrypted AT REST? | the store's codec |
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
export const users = table('users', {
|
|
477
|
+
id: id(),
|
|
478
|
+
email: text().sensitive('pii'), // exportable only through a profile
|
|
479
|
+
pinHash: text().serverOnly(), // never reaches a client, ever
|
|
480
|
+
ssn: text().encrypted().serverOnly(), // both — they are not the same claim
|
|
481
|
+
})
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
- **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
|
|
485
|
+
query that declares such a column in its OUTPUT does not reach production:
|
|
486
|
+
`voltro serve` refuses to boot and `voltro doctor` exits non-zero, so the
|
|
487
|
+
failure happens at deploy time rather than in a bundle. `voltro dev` only
|
|
488
|
+
WARNS — mid-edit a refused boot is worse than the bug — so do not read a green
|
|
489
|
+
dev boot as a clean audit. `VOLTRO_SERVER_ONLY=strict` makes dev fail too;
|
|
490
|
+
put `voltro doctor` in CI if you want one gate that covers both.
|
|
491
|
+
- **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
|
|
492
|
+
encrypted; the runtime decrypts them for a handler, so an encrypted column
|
|
493
|
+
flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
|
|
494
|
+
Reading `.encrypted()` as "safe to expose" is a category error, and a plausible
|
|
495
|
+
one — say both when you mean both.
|
|
496
|
+
- **Masking is fail-closed**: an unclassified column blocks the export rather
|
|
497
|
+
than passing through, so `.sensitive()` / `.safe()` is a decision you make once
|
|
498
|
+
per column, not a filter you remember to apply.
|
|
499
|
+
- `crud.list` / `getById` / `create` / `update` are **redacted by construction** —
|
|
500
|
+
they strip `serverOnly` columns for you. Hand-rolling the same CRUD is where
|
|
501
|
+
that stripping gets forgotten.
|
|
502
|
+
|
|
503
|
+
Depth (classes, profiles, the export flow) → **`database/sensitivity`**; the
|
|
504
|
+
redacted CRUD surface → **`data/crud`**.
|
|
505
|
+
|
|
452
506
|
## Naming / RPC tags
|
|
453
507
|
|
|
454
508
|
- **camelCase** for vars, files, schemas, and rpc tags. The tag IS the wire
|
package/templates/AGENTS.md
CHANGED
|
@@ -103,30 +103,41 @@ line below replaces something real apps write by hand hundreds of times.
|
|
|
103
103
|
in-handler for checks that need LOADED data. A hand-written `requireScope(...)`
|
|
104
104
|
at the top of every executor — or a hand-kept map from rpc tag to policy rule,
|
|
105
105
|
which is fail-open by omission — is what `guards:` exists to delete.
|
|
106
|
-
6. **
|
|
106
|
+
6. **Which ROWS may the caller see** (not: may they make the call)? →
|
|
107
|
+
**`setRowFilter`** — a predicate derived from the Subject, AND-merged into
|
|
108
|
+
EVERY read of that table, including every subscription delivery. `guards:`
|
|
109
|
+
and this answer different questions and the pair is easy to conflate:
|
|
110
|
+
- `guards:` → *may I call this procedure?* → a typed `ScopeError`.
|
|
111
|
+
- `setRowFilter` → *which rows may I see?* → the rows are simply absent.
|
|
112
|
+
A `WHERE ownerId = me` in the list handler covers the query and **NOT** the
|
|
113
|
+
stream, which is the failure this deletes. Register it from a
|
|
114
|
+
`*.startup.tsx`; its `load` must read through an UNFILTERED store (the boot
|
|
115
|
+
store / the `database` handle) — reading through the filtered one applies the
|
|
116
|
+
filter to itself and blows the stack. Depth → **`authentication`**.
|
|
117
|
+
7. **Storing a token, secret, or credential in a column?** → **`.encrypted()`**
|
|
107
118
|
on the column. Boot fails loudly if no cipher is configured, so an
|
|
108
119
|
`.encrypted()` column can never silently persist plaintext.
|
|
109
|
-
|
|
120
|
+
8. **Writing an Effect-form handler?** → `const store = yield* EffectStore` —
|
|
110
121
|
its failures land on the typed error channel. `Effect.promise(() =>
|
|
111
122
|
ctx.store.query(…))` throws that channel away and turns a store failure into
|
|
112
123
|
a defect.
|
|
113
|
-
|
|
124
|
+
9. **Cursor pagination?** → **`paginateBy(descriptor, column, cursor, limit,
|
|
114
125
|
direction?)`** (or **`paginateById`**, its `id`-column shorthand). Not a
|
|
115
126
|
hand-rolled limit+1 / slice / `hasMore` triple. `direction` flips the
|
|
116
127
|
COMPARISON as well as the sort — a `desc` feed pages with `<`.
|
|
117
|
-
|
|
128
|
+
10. **Assembling data whose SHAPE depends on the data** (a tree walk where each
|
|
118
129
|
level's ids come from the level above)? → **`ctx.load` / `ctx.loadMany`** —
|
|
119
130
|
same-tick reads of one table coalesce into one `WHERE id IN (...)`, so the
|
|
120
131
|
walk costs one query per LEVEL. Use `relations()` + `.with()` whenever the
|
|
121
132
|
shape IS static; this is the fallback, not the default.
|
|
122
|
-
|
|
133
|
+
11. **A mutation must cause an EXTERNAL side effect** (webhook, Jira sync,
|
|
123
134
|
payment)? → **`ctx.outbox.enqueue(effect, payload)`** + a
|
|
124
135
|
**`defineOutboxHandler`** in a `*.outbox.ts`. The enqueue writes through the
|
|
125
136
|
mutation's TRANSACTION, so the intent commits with the write or not at all;
|
|
126
137
|
delivery happens after commit, with backoff and a dead-letter. Do NOT call
|
|
127
138
|
the remote from the mutation (not transactional), and do not hand-build a
|
|
128
139
|
deliveries table + drain cron — that IS this primitive.
|
|
129
|
-
|
|
140
|
+
12. **Calling a third party ON BEHALF OF A USER** (their Jira token, their Slack
|
|
130
141
|
grant)? → **`defineConnection({ id, kind: 'oauth2' | 'pat' })`** in a
|
|
131
142
|
`*.connection.ts`, then **`ctx.connections.get(id)`**. One declaration yields
|
|
132
143
|
the encrypted per-user token store, the OAuth authorize/callback pair,
|
|
@@ -140,6 +151,7 @@ line below replaces something real apps write by hand hundreds of times.
|
|
|
140
151
|
| `const rows = …; if (!rows[0]) throw new NotFound()` | `.one()` |
|
|
141
152
|
| 3+ sequential `store.query` to assemble related data | `relations()` + `.with()` |
|
|
142
153
|
| a notify/webhook helper called at the end of a mutation | `defineSubscriber` / `defineReaction` |
|
|
154
|
+
| `WHERE ownerId = me` in every list handler | `setRowFilter` (covers the SUBSCRIPTION too) |
|
|
143
155
|
| a counter recomputed by scanning rows on every read | `defineAggregate` (+ `incremental:`) |
|
|
144
156
|
| `requireScope(...)` as the first line of every executor | `guards:` on the descriptor |
|
|
145
157
|
| a token/secret column written as plain text | `.encrypted()` |
|
|
@@ -449,6 +461,48 @@ aggregations, FTS, vectors/RAG, migrations, dialect parity → the **`database/*
|
|
|
449
461
|
docs (see index). Async-vs-Effect handlers, `EffectStore`, typed store errors →
|
|
450
462
|
**`data.md`**.
|
|
451
463
|
|
|
464
|
+
## What may cross the wire (three ORTHOGONAL markers — do not substitute one for another)
|
|
465
|
+
|
|
466
|
+
A column is not "protected" or "unprotected". Three separate questions get three
|
|
467
|
+
separate markers, and using one to answer another's question is the mistake:
|
|
468
|
+
|
|
469
|
+
| Marker | Answers | Enforced by |
|
|
470
|
+
|---|---|---|
|
|
471
|
+
| `.serverOnly()` | may this value leave the server AT ALL? | **`voltro serve` fails to boot; `voltro doctor` exits non-zero.** `voltro dev` only warns |
|
|
472
|
+
| `.sensitive('class')` / `.safe()` | may it appear in a `voltro data export`? | the export's masking profile, **fail-closed** |
|
|
473
|
+
| `.encrypted()` | is it encrypted AT REST? | the store's codec |
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
export const users = table('users', {
|
|
477
|
+
id: id(),
|
|
478
|
+
email: text().sensitive('pii'), // exportable only through a profile
|
|
479
|
+
pinHash: text().serverOnly(), // never reaches a client, ever
|
|
480
|
+
ssn: text().encrypted().serverOnly(), // both — they are not the same claim
|
|
481
|
+
})
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
- **`.serverOnly()` is the one that decides "leak / no leak".** A wire-reachable
|
|
485
|
+
query that declares such a column in its OUTPUT does not reach production:
|
|
486
|
+
`voltro serve` refuses to boot and `voltro doctor` exits non-zero, so the
|
|
487
|
+
failure happens at deploy time rather than in a bundle. `voltro dev` only
|
|
488
|
+
WARNS — mid-edit a refused boot is worse than the bug — so do not read a green
|
|
489
|
+
dev boot as a clean audit. `VOLTRO_SERVER_ONLY=strict` makes dev fail too;
|
|
490
|
+
put `voltro doctor` in CI if you want one gate that covers both.
|
|
491
|
+
- **`.encrypted()` is NOT an exposure marker.** It says the bytes at rest are
|
|
492
|
+
encrypted; the runtime decrypts them for a handler, so an encrypted column
|
|
493
|
+
flows to the client exactly like any other unless it is ALSO `.serverOnly()`.
|
|
494
|
+
Reading `.encrypted()` as "safe to expose" is a category error, and a plausible
|
|
495
|
+
one — say both when you mean both.
|
|
496
|
+
- **Masking is fail-closed**: an unclassified column blocks the export rather
|
|
497
|
+
than passing through, so `.sensitive()` / `.safe()` is a decision you make once
|
|
498
|
+
per column, not a filter you remember to apply.
|
|
499
|
+
- `crud.list` / `getById` / `create` / `update` are **redacted by construction** —
|
|
500
|
+
they strip `serverOnly` columns for you. Hand-rolling the same CRUD is where
|
|
501
|
+
that stripping gets forgotten.
|
|
502
|
+
|
|
503
|
+
Depth (classes, profiles, the export flow) → **`database/sensitivity`**; the
|
|
504
|
+
redacted CRUD surface → **`data/crud`**.
|
|
505
|
+
|
|
452
506
|
## Naming / RPC tags
|
|
453
507
|
|
|
454
508
|
- **camelCase** for vars, files, schemas, and rpc tags. The tag IS the wire
|
|
@@ -541,7 +595,7 @@ each plugin's own README.
|
|
|
541
595
|
|
|
542
596
|
| Topic | Open | Summary |
|
|
543
597
|
|---|---|---|
|
|
544
|
-
| **What's new in 0.
|
|
598
|
+
| **What's new in 0.19.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. |
|
|
545
599
|
| 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. |
|
|
546
600
|
| 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. |
|
|
547
601
|
| 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,7 +9,7 @@ each plugin's own README.
|
|
|
9
9
|
|
|
10
10
|
| Topic | Open | Summary |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| **What's new in 0.
|
|
12
|
+
| **What's new in 0.19.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. |
|
|
13
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. |
|
|
14
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. |
|
|
15
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. |
|
|
@@ -569,6 +569,63 @@ Read users, sessions, keys. A strategy that runs domain writes while deciding
|
|
|
569
569
|
who the caller is has the two jobs the wrong way round; nothing in the type
|
|
570
570
|
stops you, and it is still wrong.
|
|
571
571
|
|
|
572
|
+
#### What the boot store carries, and what it does not
|
|
573
|
+
|
|
574
|
+
The line is **everything that does not need a Subject** — not "less than
|
|
575
|
+
`ctx.store`":
|
|
576
|
+
|
|
577
|
+
| | Boot store (`input.store`, `req.store`) | Request store (`ctx.store`) |
|
|
578
|
+
|---|---|---|
|
|
579
|
+
| `.encrypted()` columns decrypt / encrypt | ✓ | ✓ |
|
|
580
|
+
| Array columns round-trip on non-native dialects | ✓ | ✓ |
|
|
581
|
+
| Tenant scope | — | ✓ |
|
|
582
|
+
| Soft-delete filter | — | ✓ |
|
|
583
|
+
| Audit-column stamping | — | ✓ |
|
|
584
|
+
| Row-level security | — | ✓ |
|
|
585
|
+
|
|
586
|
+
The right-hand four need a resolved Subject, and a strategy runs *before* one
|
|
587
|
+
exists — so a read of tenant-owned rows here must derive and apply that scope
|
|
588
|
+
itself. The first two do not, and getting them wrong is silent: a `.encrypted()`
|
|
589
|
+
column read raw hands back the string `enc:v1:…`, which compares, concatenates,
|
|
590
|
+
renders and logs perfectly well, and simply never matches the token you compare
|
|
591
|
+
it to.
|
|
592
|
+
|
|
593
|
+
**The soft-delete row is the one to read twice if you are porting raw SQL onto
|
|
594
|
+
this store.** A read here behaves the way your SQL did and returns tombstones —
|
|
595
|
+
the store does *not* start appending `deletedAt IS NULL` behind you. So a lookup
|
|
596
|
+
that must see a soft-deleted row (a login that revives a returning user, say)
|
|
597
|
+
needs no opt-out and no `.withDeleted()`; that opt-out belongs to `ctx.store`,
|
|
598
|
+
which *does* apply the filter. Assuming the filter is present is the more
|
|
599
|
+
expensive mistake of the two: it turns a working login into a "not found →
|
|
600
|
+
insert → unique violation on email", and nothing about the code says so.
|
|
601
|
+
|
|
602
|
+
This is also what changes when you move a read **off** hand-written SQL and onto
|
|
603
|
+
the store. Raw SQL sees ciphertext and you decrypt it yourself — `decryptField`
|
|
604
|
+
from `@voltro/runtime` is the escape hatch for exactly that. Through either
|
|
605
|
+
store you get plaintext, so a hand-rolled `decryptField` on the way out will now
|
|
606
|
+
be handed a plaintext value; `decryptField` passes a non-ciphertext value
|
|
607
|
+
through unchanged, so the double call is harmless, but the manual step is no
|
|
608
|
+
longer doing anything.
|
|
609
|
+
|
|
610
|
+
#### Reading a plugin's own tables
|
|
611
|
+
|
|
612
|
+
A plugin's tables are declared through `extendSchema` like any others, so they
|
|
613
|
+
are in the same registry and the same store reads them. A public route that
|
|
614
|
+
needs a row a plugin wrote — a storage reference for an avatar proxy, say —
|
|
615
|
+
reads it directly:
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
const [ref] = await req.store.query(
|
|
619
|
+
queryFor(storageObjects).where(eq('id', objectId)).descriptor,
|
|
620
|
+
)
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Two things to keep in mind. The table is the plugin's contract with itself, not
|
|
624
|
+
with you, so it can change shape in any release — pin the version if you depend
|
|
625
|
+
on it. And this store applies no tenant scope, so a route reading a
|
|
626
|
+
tenant-owned plugin table must filter by tenant itself, from something the
|
|
627
|
+
request proves rather than something it claims.
|
|
628
|
+
|
|
572
629
|
## Composing the chain
|
|
573
630
|
|
|
574
631
|
`composeAuthStrategies` turns an ordered list of strategies into a single resolver. First `matched` wins; first `failed` short-circuits to anonymous.
|
|
@@ -600,19 +600,23 @@ export default {
|
|
|
600
600
|
}
|
|
601
601
|
```
|
|
602
602
|
|
|
603
|
-
#### Inspect token
|
|
603
|
+
#### Inspect token
|
|
604
604
|
|
|
605
|
-
The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints
|
|
605
|
+
The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints, and that surface is **fail-closed everywhere**: with no `VOLTRO_INSPECT_TOKEN` configured, nobody is authorised — `voltro dev` included.
|
|
606
606
|
|
|
607
|
-
The
|
|
607
|
+
**Under `voltro dev` you configure nothing.** The dev server mints a token per project and its proxy attaches the `Authorization: Bearer` header server-side, on the `/_voltro/api/<name>` route the panels fetch through. The token stays in the dev server's process; the browser never holds it.
|
|
608
|
+
|
|
609
|
+
That is deliberate rather than convenient. A token compiled into the client bundle is a live credential published to everyone who loads the page, so there is no env-var channel for it — `voltro dev` and `voltro build` set vite's `envPrefix` to a sentinel precisely so nothing leaks through `import.meta.env`.
|
|
610
|
+
|
|
611
|
+
For an api the dev proxy does not front — a `voltro start` deploy with `VOLTRO_INSPECT_TOKEN` set, say — pass the token explicitly, and note that whatever you pass ships in the bundle:
|
|
608
612
|
|
|
609
613
|
```tsx
|
|
610
614
|
import { VoltroDevtools } from '@voltro/devtools'
|
|
611
615
|
|
|
612
|
-
<VoltroDevtools inspectToken={
|
|
616
|
+
<VoltroDevtools inspectToken={myToken} />
|
|
613
617
|
```
|
|
614
618
|
|
|
615
|
-
|
|
619
|
+
Without the prop the overlay sends no `Authorization` header of its own, which is correct: under `voltro dev` the proxy has already added one. (The indexes panel's live SSE stream can't carry a header at all; against an api reached without the proxy it falls back to token-carrying HTTP polling.)
|
|
616
620
|
|
|
617
621
|
#### Overriding the overlay's labels
|
|
618
622
|
|
|
@@ -248,9 +248,32 @@ Enforcement: the [`crud.*` read helpers](/docs/data/crud) strip `.serverOnly()`
|
|
|
248
248
|
columns from every returned row **automatically** — you declare the exposure
|
|
249
249
|
policy once at the schema and can't forget it on a handler. For a hand-written
|
|
250
250
|
query, omit the column from the `output` schema (and don't put it in the returned
|
|
251
|
-
object)
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
object).
|
|
252
|
+
|
|
253
|
+
A hand-written output is the case `crud.*` cannot cover, so an audit checks it:
|
|
254
|
+
a wire-reachable query whose `source` table carries a `.serverOnly()` column that
|
|
255
|
+
its `output` **declares**. What that costs is different per command, on purpose:
|
|
256
|
+
|
|
257
|
+
| Command | On a leak |
|
|
258
|
+
|---|---|
|
|
259
|
+
| `voltro serve` | **the boot fails** |
|
|
260
|
+
| `voltro doctor` | **exits non-zero** — put it in CI |
|
|
261
|
+
| `voltro dev` | warns, naming the query and column |
|
|
262
|
+
|
|
263
|
+
Dev only warns because a refused boot between two keystrokes is worse than the
|
|
264
|
+
bug; production is the opposite, so that is where the gate is. **Do not read a
|
|
265
|
+
green `voltro dev` boot as a clean audit** — the warning sits in the boot log
|
|
266
|
+
among everything else. `voltro doctor` is the check to automate.
|
|
267
|
+
|
|
268
|
+
`VOLTRO_SERVER_ONLY` moves the line in both directions: `strict` makes `voltro
|
|
269
|
+
dev` fail too, `warn` downgrades `voltro serve` to a warning, `off` silences it
|
|
270
|
+
entirely. The downgrades are documented rather than hidden because the
|
|
271
|
+
alternative to a stated escape hatch is deleting the marker — and a check whose
|
|
272
|
+
only way out is to disable it gets disabled.
|
|
273
|
+
|
|
274
|
+
(`voltro check` does **not** run this audit. It has a live-api mode that has no
|
|
275
|
+
access to your table definitions, and a rule that fires in one of its two modes
|
|
276
|
+
would be worse than one that fires in neither.)
|
|
254
277
|
|
|
255
278
|
Distinct from `.encrypted()` on purpose: encryption at rest says nothing about
|
|
256
279
|
who may receive the plaintext — a private note you decrypt *for its owner* is a
|
|
@@ -105,11 +105,17 @@ ctx.store.select('notes')
|
|
|
105
105
|
.where('col', '>', value)
|
|
106
106
|
.where('col', '>=', value)
|
|
107
107
|
.where('col', 'in', [a, b, c])
|
|
108
|
-
.where('col', '
|
|
109
|
-
.where('col', '
|
|
108
|
+
.where('col', 'contains', 'needle') // case-INsensitive substring (ILIKE '%…%')
|
|
109
|
+
.where('col', 'startsWith', 'awb_') // case-SENSITIVE prefix (LIKE 'awb\_%')
|
|
110
110
|
.where('col', 'fts', 'query string') // full-text fallback (LIKE-based here)
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
`contains` folds case because it is a search primitive — a human typing into a box means `hello` to find `Hello`. `startsWith` does not, because a prefix is a namespace: `awb_` and `AWB_` are two different key spaces, and quietly merging them is a bug. `startsWith` is also the only one of the two a database can answer from an index — `LIKE 'literal%'` is a btree range scan, `%…%` is not. `%` and `_` inside either value are escaped, so they match literally.
|
|
114
|
+
|
|
115
|
+
There is no `'like'`. It used to be here, and it was a lie: it mapped to `contains`, so `.where('path', 'like', '/api/%')` matched only rows literally containing the characters `/api/%` and the wildcard you wrote did nothing. An operator named after SQL's must honour your wildcards or not exist.
|
|
116
|
+
|
|
117
|
+
> **Writing an in-memory store for tests? Do not reimplement these.** `evaluatePredicate` is exported from `@voltro/database` and is the same function the memory store runs, so a test double built on it agrees with a real database by construction. Hand-rolling the switch is where a stub quietly diverges — a `case 'contains'` written as `String(a).includes(b)` is case-SENSITIVE where the real one folds case, and coerces a number where the real one returns `false`. That stub passes queries a live dialect fails, which is the worst direction for a test double to be wrong in.
|
|
118
|
+
|
|
113
119
|
These are the only operators the ergonomic `.where(col, op, value)` form accepts. For `IS NULL` / `NOT IN` / `IS NOT NULL`, pass a predicate built with the `@voltro/database` helpers:
|
|
114
120
|
|
|
115
121
|
```ts
|
|
@@ -473,7 +473,7 @@ If yes, the promise belongs in the name — you cannot see a contract before you
|
|
|
473
473
|
|
|
474
474
|
| Suffix | Promise | Enforced by |
|
|
475
475
|
|---|---|---|
|
|
476
|
-
| `*.component.tsx` | exactly one component
|
|
476
|
+
| `*.component.tsx` | exactly one component | `component/one-per-file`, `component/no-hook-export` |
|
|
477
477
|
| `*.component.ui.tsx` | one component, **reads only** | `ui/no-write`, `ui/orphaned`, `ui/unlinked` |
|
|
478
478
|
| `*.hook.ts` | exactly one `use*` hook (+ types) | `hook/one-per-file`, `hook/no-component-export` |
|
|
479
479
|
| `*.types.ts` | zero runtime exports | `types/runtime-export` |
|
|
@@ -483,6 +483,10 @@ If yes, the promise belongs in the name — you cannot see a contract before you
|
|
|
483
483
|
| `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
|
|
484
484
|
| `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
|
|
485
485
|
|
|
486
|
+
A `*.component.tsx` promises exactly ONE component. It does not promise to export nothing else: types, and plain module-local values a `const COLUMNS = […]` beside the table that renders them, are fine and always were. What the rule counts is components — a declaration that renders — so an object, an array, a string or a `new` beside your component is not a second one, and neither is `export default Card` next to `export const Card`.
|
|
487
|
+
|
|
488
|
+
The BOUNDARY rules (`internal/foreign-import`, `fixture/production-import`, `ui/unlinked`) are assertions about your import graph, so it is worth knowing which edges they follow: relative specifiers, your tsconfig `paths` aliases (read from the nearest `tsconfig.json`, so a per-app `@/*` works when you run `voltro doctor` at the repo root), `export … from` re-exports, and dynamic `import()`. A package import is a leaf — the walk stops at the edge of your app.
|
|
489
|
+
|
|
486
490
|
## `*.component.ui.tsx` — reads, never writes
|
|
487
491
|
|
|
488
492
|
```tsx
|
|
@@ -536,6 +540,16 @@ A component then wires it up with `useTracking(checkoutTracking, props, sink)`
|
|
|
536
540
|
|
|
537
541
|
The payoff is not tidiness. "What do we send to third parties" becomes a file listing instead of an archaeology project — which is the only form in which that question can be answered on demand when someone asks about personal data.
|
|
538
542
|
|
|
543
|
+
## `convention/missing-test` — why a shallow test is still worth writing
|
|
544
|
+
|
|
545
|
+
Every suffix that declares a runtime contract also expects a test beside it, named mechanically: `Card.component.tsx` → `Card.component.test.tsx`. It is an advisory, not an error.
|
|
546
|
+
|
|
547
|
+
The usual objection is that a per-component test at any real size is low value, and for *assertions* that is often true. That is not what the rule buys. What it buys is that something **mounts** the component — and a render loop, a crashing effect, a missing provider or a broken context is invisible until something does.
|
|
548
|
+
|
|
549
|
+
That is not hypothetical. One app adopting the taxonomy wrote 251 of these, deliberately shallow (it mounts, it performs no domain write, it renders no raw catalogue key). The first run found a page whose breadcrumb effect rebuilt a fresh array literal on every render — effect → context state → re-render → new literal, without end. That one test took 423 seconds and exhausted the heap. Ten sibling pages memoised; exactly one did not, and in a browser the screen had looked usable. After the fix the whole web suite went from 645 s to 57 s.
|
|
550
|
+
|
|
551
|
+
So write them shallow if you like. The mount is the point.
|
|
552
|
+
|
|
539
553
|
## What deliberately has NO suffix
|
|
540
554
|
|
|
541
555
|
A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in — less coverage for more cost. The shape rules above fire only on files that *declared* the contract, because declaring it is what makes the promise mean something.
|
|
@@ -262,6 +262,8 @@ No naming trick is needed to keep something out of the router: the absence of th
|
|
|
262
262
|
|
|
263
263
|
The canonical form is **no trailing slash** — always link with `<Link to="/about">`, not `<Link to="/about/">`.
|
|
264
264
|
|
|
265
|
+
`Link` forwards every prop it does not consume itself to the underlying `<a>`, `ref` included — so it drops straight into a polymorphic slot (`<Button component={Link} to={url}>`) without a wrapper.
|
|
266
|
+
|
|
265
267
|
The framework does NOT emit a trailing-slash redirect on its own. If you need `/about/` → `/about` normalisation (for SEO), configure a 301 redirect at your reverse proxy.
|
|
266
268
|
|
|
267
269
|
## What pages CAN'T do
|
|
@@ -903,9 +903,12 @@ that affordance read-only rather than binding to a tag that does not resolve. Th
|
|
|
903
903
|
[`useCan`](/docs/ui/client-utilities/use-can); map them to your app's real RBAC
|
|
904
904
|
scopes.
|
|
905
905
|
|
|
906
|
-
The
|
|
907
|
-
|
|
908
|
-
|
|
906
|
+
The manifest GET is bearer-gated wherever it runs — `/_voltro/inspect/*` is
|
|
907
|
+
fail-closed, so no configured `VOLTRO_INSPECT_TOKEN` means `401`, not "everyone".
|
|
908
|
+
Under `voltro dev` that is handled for you (the dev server mints a token and its
|
|
909
|
+
proxy attaches it server-side). An admin UI pointed at a deployed api has to
|
|
910
|
+
supply the token itself — a deployment concern, not something this hook
|
|
911
|
+
handles.
|
|
909
912
|
|
|
910
913
|
|
|
911
914
|
|