@voltro/cli 0.4.0 → 0.6.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 +68 -0
- package/bin/voltro.mjs +54 -2
- package/dist/apiBuild-Bk4JBt-i.js +2 -0
- package/dist/{apiBuild-CvtQeBMs.js → apiBuild-BxY44VGj.js} +20 -20
- package/dist/bin.js +2 -2
- package/dist/{commands-DhyBIs1O.js → commands-My_YlUJV.js} +2823 -2265
- package/dist/{dev-CQxbrpDz.js → dev-Bjq-nIvH.js} +2650 -2230
- package/dist/dev-D6b74iXI.js +2 -0
- package/dist/devActivity-1WtIVyHc.js +153 -0
- package/dist/devActivity.d.ts +46 -0
- package/dist/devActivity.js +2 -0
- package/dist/index.js +1 -1
- package/dist/serveCommand-C-XQ89NJ.js +1110 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +22 -17
- package/templates/AGENTS.core.md +119 -0
- package/templates/AGENTS.md +119 -0
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/ai.md +41 -5
- package/templates/agent-docs/authentication.md +238 -0
- package/templates/agent-docs/cli.md +142 -0
- package/templates/agent-docs/configuration.md +38 -0
- package/templates/agent-docs/data.md +718 -42
- package/templates/agent-docs/database/advancedqueries.md +21 -0
- package/templates/agent-docs/database/querying.md +166 -8
- package/templates/agent-docs/database/schema.md +6 -2
- package/templates/agent-docs/deployment.md +171 -0
- package/templates/agent-docs/internationalization.md +2 -0
- package/templates/agent-docs/plugins.md +4 -4
- package/templates/agent-docs/reference.md +90 -2
- package/templates/agent-docs/templates/apibackends.md +13 -2
- package/templates/agent-docs/testing.md +157 -3
- package/templates/agent-docs/workflows.md +11 -8
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/app.config.ts +20 -13
- 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/.env.example +10 -4
- 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/app.config.ts +21 -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/app.config.ts +15 -2
- 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/app.config.ts +20 -1
- package/templates/apps/api-webhooks/package.json +8 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/baselines/bare/.env.example +10 -3
- package/templates/baselines/compose/.env.example +10 -2
- package/templates/baselines/compose-mariadb/.env.example +10 -2
- package/templates/baselines/helm/.env.example +10 -3
- package/dist/apiBuild-DQBNqNZ8.js +0 -2
- package/dist/dev-DYjGqPGD.js +0 -2
- package/dist/serveCommand-BZzUJIyo.js +0 -1077
- package/templates/apps/api-auth/.env +0 -17
- package/templates/apps/api-data-advanced/.env +0 -17
- package/templates/apps/api-governance/.env +0 -4
- package/templates/apps/api-webhooks/.env +0 -6
|
@@ -1732,6 +1732,81 @@ export const todoPolicy = defineResourcePolicy({
|
|
|
1732
1732
|
})
|
|
1733
1733
|
```
|
|
1734
1734
|
|
|
1735
|
+
## Enforce it declaratively — `guards:`
|
|
1736
|
+
|
|
1737
|
+
`can` / `assertCan` above are the imperative form: you load the tuples and make
|
|
1738
|
+
the decision inside the handler. That works, and it is what you reach for when
|
|
1739
|
+
the check needs data you have already loaded.
|
|
1740
|
+
|
|
1741
|
+
For the ordinary case — "may this caller perform ACTION on the row this input
|
|
1742
|
+
names?" — declare it on the descriptor instead:
|
|
1743
|
+
|
|
1744
|
+
```ts
|
|
1745
|
+
export const todoUpdate = defineMutation({
|
|
1746
|
+
name: 'todos.update',
|
|
1747
|
+
input: Schema.Struct({ id: Schema.String, title: Schema.String }),
|
|
1748
|
+
output: Schema.Void,
|
|
1749
|
+
guards: [{ action: 'edit', resourceType: 'todo', resource: (input) => input.id }],
|
|
1750
|
+
})
|
|
1751
|
+
```
|
|
1752
|
+
|
|
1753
|
+
The framework resolves it BEFORE the executor runs — for a mutation, before the
|
|
1754
|
+
transaction opens — and fails with a typed `ScopeError` naming
|
|
1755
|
+
`<resourceType>:<action>`.
|
|
1756
|
+
|
|
1757
|
+
Why the declarative form is not just shorter: an in-handler check is one an
|
|
1758
|
+
author can forget, and a forgotten check is a silent hole rather than an error.
|
|
1759
|
+
The same is true of the older pattern of hand-maintaining a map from rpc tag to
|
|
1760
|
+
policy rule and installing it as an interceptor — that map is **fail-open by
|
|
1761
|
+
omission**: add an endpoint, forget the entry, and nothing anywhere tells you.
|
|
1762
|
+
A guard on the descriptor cannot be forgotten for an rpc that exists, because
|
|
1763
|
+
it is part of the rpc.
|
|
1764
|
+
|
|
1765
|
+
Scope guards and relationship guards live in the same array and ALL must pass:
|
|
1766
|
+
|
|
1767
|
+
```ts
|
|
1768
|
+
guards: [
|
|
1769
|
+
{ scope: 'todos:write' }, // may you edit todos at all
|
|
1770
|
+
{ action: 'edit', resourceType: 'todo', resource: (i) => i.id }, // may you edit THIS one
|
|
1771
|
+
]
|
|
1772
|
+
```
|
|
1773
|
+
|
|
1774
|
+
A guard answers **may you call this**. It cannot answer **which rows may you
|
|
1775
|
+
see** — a list has no single resource to name. For visibility that follows from a
|
|
1776
|
+
relationship ("tickets on teams I hold a role on"), declare a
|
|
1777
|
+
[row filter](/docs/authentication/row-level-security) instead; it AND-merges a
|
|
1778
|
+
subject-derived predicate into every read, so it narrows and never grants.
|
|
1779
|
+
|
|
1780
|
+
### Activate it: register a tuple source
|
|
1781
|
+
|
|
1782
|
+
A relationship guard needs to read the caller's relations. That comes from the
|
|
1783
|
+
registered **tuple source**:
|
|
1784
|
+
|
|
1785
|
+
```ts
|
|
1786
|
+
// app.config.ts or a *.startup.ts
|
|
1787
|
+
import { setTupleSource, loadResourceTuples } from '@voltro/runtime'
|
|
1788
|
+
|
|
1789
|
+
setTupleSource((req) =>
|
|
1790
|
+
loadResourceTuples(store, req.subjectId, req.resourceType, req.resourceId))
|
|
1791
|
+
```
|
|
1792
|
+
|
|
1793
|
+
`voltro dev` / `voltro serve` register exactly this default for you, reading
|
|
1794
|
+
`_voltro_rebac_tuples`. Register your own when your relationships already live
|
|
1795
|
+
in your own tables — a `teamMembers` row is a relation; you should not have to
|
|
1796
|
+
copy it into a framework table to authorize against it.
|
|
1797
|
+
|
|
1798
|
+
**Every unanswerable case denies.** No tuple source registered, no policy for
|
|
1799
|
+
that `resourceType`, an input that doesn't identify a resource, a tuple source
|
|
1800
|
+
that throws — each is a denial, not a pass. An authorization question nobody can
|
|
1801
|
+
answer is a refusal; treating it as a pass is how a policy layer ends up
|
|
1802
|
+
enforcing nothing while looking like it does.
|
|
1803
|
+
|
|
1804
|
+
### Guards are re-checked on every subscription delivery
|
|
1805
|
+
|
|
1806
|
+
A subscription is a long-lived grant. Its guards — scope and relationship alike
|
|
1807
|
+
— are re-evaluated before each delivery, so revoking a relation mid-session ends
|
|
1808
|
+
the stream with the typed error instead of continuing to push rows.
|
|
1809
|
+
|
|
1735
1810
|
## Decide — `can` / `assertCan`
|
|
1736
1811
|
|
|
1737
1812
|
`can(subject, action, resource, { policy, tuples })` is the decision; `assertCan`
|
|
@@ -1789,3 +1864,166 @@ Full API — including the batch `useResourceCans` for per-row gating — in
|
|
|
1789
1864
|
`rebacPolicyGraph()` returns every resource type, its actions, the relations each
|
|
1790
1865
|
grants, and the implication edges — the policy graph a dashboard or an AI agent
|
|
1791
1866
|
reads to reason about authority without grepping the code.
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
---
|
|
1871
|
+
|
|
1872
|
+
<!-- source: en/authentication/row-level-security.md -->
|
|
1873
|
+
## Row-level security
|
|
1874
|
+
|
|
1875
|
+
_setRowFilter — a subject-derived predicate AND-merged into every read, so relational visibility ("rows on teams I hold a role on") is declared once instead of hand-written into every list handler and every subscription._
|
|
1876
|
+
|
|
1877
|
+
Reads already scope themselves by tenant and by soft-delete, and a descriptor's
|
|
1878
|
+
[`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards)
|
|
1879
|
+
decide whether you may call a procedure **at all**. Neither of those says which
|
|
1880
|
+
**rows** you may see.
|
|
1881
|
+
|
|
1882
|
+
That gap matters as soon as visibility is *relational* — "tickets on teams I hold
|
|
1883
|
+
a role on". Without a row filter, that predicate has to be hand-written into
|
|
1884
|
+
every list handler and every subscription, and a filter you have to remember is a
|
|
1885
|
+
filter you only have to forget once.
|
|
1886
|
+
|
|
1887
|
+
`setRowFilter` declares it once. The framework AND-merges the resulting predicate
|
|
1888
|
+
into every read.
|
|
1889
|
+
|
|
1890
|
+
## The two phases
|
|
1891
|
+
|
|
1892
|
+
```ts no-check
|
|
1893
|
+
setRowFilter({
|
|
1894
|
+
load: (subject) => Effect<Ctx>, // ASYNC, once per request
|
|
1895
|
+
predicate: (ctx, table) => Predicate | undefined, // PURE + SYNC, per read
|
|
1896
|
+
})
|
|
1897
|
+
```
|
|
1898
|
+
|
|
1899
|
+
- **`load`** resolves everything the predicates need — the memberships, the role
|
|
1900
|
+
rows, the project ids — **once per request**. It may read the store. This is
|
|
1901
|
+
the expensive half.
|
|
1902
|
+
- **`predicate`** derives the filter for one table from what `load` already
|
|
1903
|
+
fetched. It runs on **every read**, so it must be pure and synchronous. Return
|
|
1904
|
+
`undefined` for a table this filter does not constrain — which is most tables.
|
|
1905
|
+
|
|
1906
|
+
### Why two halves rather than one function
|
|
1907
|
+
|
|
1908
|
+
A single async `(subject, table) => Promise<Predicate>` would be simpler to
|
|
1909
|
+
declare and much worse to run. Every read on the hot path would await, and the
|
|
1910
|
+
obvious implementation would re-query the membership tables **once per query** —
|
|
1911
|
+
so a handler that reads five tables pays five membership lookups.
|
|
1912
|
+
|
|
1913
|
+
Splitting the phases makes the per-read cost a map lookup and makes the
|
|
1914
|
+
per-request cost explicit and visible: one load, reused.
|
|
1915
|
+
|
|
1916
|
+
## A worked example
|
|
1917
|
+
|
|
1918
|
+
The motivating shape — rows on teams the caller holds a role on:
|
|
1919
|
+
|
|
1920
|
+
```ts no-check
|
|
1921
|
+
// apps/api/rls.startup.ts
|
|
1922
|
+
import { Effect } from 'effect'
|
|
1923
|
+
import { eq, inSet } from '@voltro/database'
|
|
1924
|
+
import { setRowFilter } from '@voltro/runtime'
|
|
1925
|
+
import { database } from './database/index'
|
|
1926
|
+
|
|
1927
|
+
setRowFilter({
|
|
1928
|
+
// ASYNC — once per request. Read your own tables here.
|
|
1929
|
+
load: (subject) =>
|
|
1930
|
+
Effect.promise(async () => {
|
|
1931
|
+
const rows = await database.teamMembers
|
|
1932
|
+
.where(eq('userId', subject.id ?? ''))
|
|
1933
|
+
.all()
|
|
1934
|
+
return rows.map((row) => row.teamId)
|
|
1935
|
+
}),
|
|
1936
|
+
|
|
1937
|
+
// PURE + SYNC — runs on every read.
|
|
1938
|
+
predicate: (teamIds: ReadonlyArray<string>, table: string) =>
|
|
1939
|
+
table === 'tickets' ? inSet('teamId', teamIds) : undefined,
|
|
1940
|
+
})
|
|
1941
|
+
```
|
|
1942
|
+
|
|
1943
|
+
Register it at boot — `app.config.ts` or a `*.startup.ts`. It is process-global
|
|
1944
|
+
and last-write-wins. `Ctx` is whatever your `load` returns; the framework never
|
|
1945
|
+
inspects it.
|
|
1946
|
+
|
|
1947
|
+
With that registered, an ordinary list query needs no filter of its own:
|
|
1948
|
+
|
|
1949
|
+
```ts no-check
|
|
1950
|
+
// apps/api/queries/tickets.list.query.server.ts
|
|
1951
|
+
export default () => database.tickets.orderBy('createdAt', 'desc')
|
|
1952
|
+
```
|
|
1953
|
+
|
|
1954
|
+
A caller with no memberships gets zero rows. Nothing in the handler says so.
|
|
1955
|
+
|
|
1956
|
+
## It can only narrow, never grant
|
|
1957
|
+
|
|
1958
|
+
The predicate is **AND-merged** onto whatever the handler already asked for — it
|
|
1959
|
+
never replaces it. A row filter cannot widen a query, so it can never become an
|
|
1960
|
+
accidental grant:
|
|
1961
|
+
|
|
1962
|
+
```ts no-check
|
|
1963
|
+
// the handler asks for one ticket; the filter still applies
|
|
1964
|
+
database.tickets.where(eq('id', 'ticket-42'))
|
|
1965
|
+
// → id = 'ticket-42' AND teamId IN (…the caller's teams)
|
|
1966
|
+
```
|
|
1967
|
+
|
|
1968
|
+
## Both read paths are filtered
|
|
1969
|
+
|
|
1970
|
+
The filter applies to descriptor reads **and** to the fluent builder. A filter
|
|
1971
|
+
present on only one read path is not a filter, it is a detour:
|
|
1972
|
+
|
|
1973
|
+
```ts no-check
|
|
1974
|
+
await ctx.store.query(tickets.descriptor) // filtered
|
|
1975
|
+
await ctx.store.select('tickets').all() // filtered
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
## A failing `load` denies
|
|
1979
|
+
|
|
1980
|
+
If `load` fails, every constrained read is **refused** rather than silently
|
|
1981
|
+
running unfiltered. A row filter that degrades to "no filter" under load failure
|
|
1982
|
+
is worse than no filter at all, because the system keeps serving and nothing
|
|
1983
|
+
looks wrong.
|
|
1984
|
+
|
|
1985
|
+
The refusal is expressed as a predicate that matches nothing, not as an error —
|
|
1986
|
+
so a failure surfaces as an **empty result** rather than a 500 on every page. The
|
|
1987
|
+
underlying cause is reported separately, so it cannot pass unnoticed.
|
|
1988
|
+
|
|
1989
|
+
## What does *not* bypass it
|
|
1990
|
+
|
|
1991
|
+
| | Bypasses the row filter? |
|
|
1992
|
+
|---|---|
|
|
1993
|
+
| `.unscoped()` / `crossTenant` | **No** |
|
|
1994
|
+
| a `system` subject | Yes |
|
|
1995
|
+
|
|
1996
|
+
`.unscoped()` and `crossTenant` exist for legitimate cross-tenant admin reads.
|
|
1997
|
+
They opt out of **tenant isolation**, not out of **authorization** — letting them
|
|
1998
|
+
also drop row visibility would turn an isolation opt-out into an authorization
|
|
1999
|
+
one, which is exactly the silent widening this feature exists to prevent.
|
|
2000
|
+
|
|
2001
|
+
Only a `system` subject bypasses, because a system subject is the framework
|
|
2002
|
+
acting as itself — janitor sweeps, migrations, the scheduler — rather than on
|
|
2003
|
+
behalf of a user. That bypass is deliberately the narrow, explicit one.
|
|
2004
|
+
|
|
2005
|
+
Apps that register no filter pay nothing.
|
|
2006
|
+
|
|
2007
|
+
## Subscriptions re-resolve it
|
|
2008
|
+
|
|
2009
|
+
A subscription is the one read path that stays open for hours, so it is the one
|
|
2010
|
+
where a stale filter would matter most. Before every delivery the runtime
|
|
2011
|
+
re-derives the read from the **unfiltered base descriptor** and re-applies the
|
|
2012
|
+
freshly resolved filter.
|
|
2013
|
+
|
|
2014
|
+
A membership that ends mid-subscription therefore stops serving rows — the
|
|
2015
|
+
caller's open ticket list drops the rows they can no longer see, without a
|
|
2016
|
+
refresh and without the subscription having to be torn down.
|
|
2017
|
+
|
|
2018
|
+
## Row filters vs. guards
|
|
2019
|
+
|
|
2020
|
+
They answer different questions, and a complete policy usually wants both:
|
|
2021
|
+
|
|
2022
|
+
| | Question | Failure |
|
|
2023
|
+
|---|---|---|
|
|
2024
|
+
| [`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards) | May you call this procedure? | typed `ScopeError`, before the executor runs |
|
|
2025
|
+
| `setRowFilter` | Which rows may you see? | the rows are simply absent |
|
|
2026
|
+
|
|
2027
|
+
A guard is the right tool for "may this caller edit *this* ticket". A row filter
|
|
2028
|
+
is the right tool for "which tickets appear in the list at all" — a question a
|
|
2029
|
+
guard cannot answer, because there is no single resource to name.
|
|
@@ -30,6 +30,7 @@ The dispatcher routes `voltro <command> [args]` to the matching subcommand and p
|
|
|
30
30
|
| Ops / infra | `cache` (`status` / `flush` / `invalidate`), `add` (`redis`), `baseline` (`list` / `status` / `set`), `schedule-manifest`, [`storage`](/docs/plugins/storage) (`doctor` / `cors`) |
|
|
31
31
|
| AI / data | `embeddings backfill <table> --text <field> --vector <field>` — (re)embed rows the `vectorEmbedding()` mixin missed (pre-existing rows / a model change); `--dry-run` to preview |
|
|
32
32
|
| [Inspect & debug](/docs/cli/inspect) | `inspect`, `logs`, `traces`, `workflows`, `cluster`, `check` |
|
|
33
|
+
| [Health & surface](/docs/cli/build-and-start) | [`doctor`](/docs/cli/build-and-start) — serve preflight + the hand-roll detector (names the shipped primitive at the spot you're rebuilding it); [`capabilities`](/docs/cli/build-and-start) (`--json`) — the export surface read from your installed `@voltro/*`, so it can be verified instead of recalled |
|
|
33
34
|
| Harness | `test`, `e2e` |
|
|
34
35
|
| Cloud | `cloud` (`login` / `whoami` / `projects` / `env` / `import`) |
|
|
35
36
|
| Secrets | `secret` (`generate [purpose]` — the right var+format per secret; `generate` alone → a generic secret; `list`) |
|
|
@@ -739,6 +740,147 @@ Docker step) and prints the exact remedy: add a `voltro build .` step before
|
|
|
739
740
|
`voltro serve .`. Drop it into your image build right after `voltro build` to
|
|
740
741
|
guarantee the artefact is present before the image ships.
|
|
741
742
|
|
|
743
|
+
### The hand-roll detector
|
|
744
|
+
|
|
745
|
+
`voltro doctor` also scans your source for shapes the framework already has a
|
|
746
|
+
primitive for, and names the primitive at the spot the hand-roll lives. This is
|
|
747
|
+
**advisory and never blocking** — it prints, it does not fail your build.
|
|
748
|
+
|
|
749
|
+
```bash
|
|
750
|
+
voltro doctor .
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
```text
|
|
754
|
+
• Shipped primitives you may be hand-rolling:
|
|
755
|
+
[server]
|
|
756
|
+
hand-written not-found branch on rows[0] — 12 file(s): queries/team.get.ts, …
|
|
757
|
+
→ .one() — fails with the typed NoRowFound on zero rows AND on more than one
|
|
758
|
+
[client]
|
|
759
|
+
per-field useState + a submit flag (hand-rolled form) — 4 file(s): src/create-dialog.tsx, …
|
|
760
|
+
→ useFormBinding — fields + validation from the mutation input Schema
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
It covers both halves of the stack:
|
|
764
|
+
|
|
765
|
+
| Scope | It notices | Reach for |
|
|
766
|
+
|---|---|---|
|
|
767
|
+
| server | `if (!rows[0]) throw …` | `.one()` / `.first()` |
|
|
768
|
+
| server | 3+ sequential `store.query` in one handler | `relations()` + `.with()` — or `Effect.all` |
|
|
769
|
+
| server | `Effect.promise(() => ctx.store.…)` | `yield* EffectStore` |
|
|
770
|
+
| server | `requireScope(...)` at the top of an executor | `guards:` on the descriptor |
|
|
771
|
+
| server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
|
|
772
|
+
| server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
|
|
773
|
+
| server | `hasMore` + `limit + 1` | `paginateById` |
|
|
774
|
+
| server | `.getTime()` / `.toISOString()` mapping a row on the way out | `rowSchema(table)` as the descriptor's `output` |
|
|
775
|
+
| client | per-field `useState` + a submit flag | `useFormBinding` |
|
|
776
|
+
| client | a table with local sort/filter state | `useDataTable` |
|
|
777
|
+
| client | `FileReader` / `readAsDataURL` | `useUpload` |
|
|
778
|
+
| client | `setTimeout` debounce in a `useEffect` | `useDebounced` |
|
|
779
|
+
| client | `useMemo` fanning in several subscriptions | `useDerived` |
|
|
780
|
+
| client | a local Next.js compat shim | the native `@voltro/web` exports |
|
|
781
|
+
| client | a hand-rolled presence heartbeat | `@voltro/plugin-presence` |
|
|
782
|
+
|
|
783
|
+
The rules are deliberately conservative — a detector that cries wolf trains you
|
|
784
|
+
to ignore it. A column that already carries `.encrypted()`, or a handler that
|
|
785
|
+
already uses `.one()`, stays silent.
|
|
786
|
+
|
|
787
|
+
Two of them are worth spelling out, because their advice is not one-line:
|
|
788
|
+
|
|
789
|
+
**The credential-column rule skips names that aren't credentials.** A name ending
|
|
790
|
+
in `Id` / `_id`, a name ending in `Hash` / `_hash`, and a name beginning with
|
|
791
|
+
`vault` are all left alone:
|
|
792
|
+
|
|
793
|
+
| Column | Why it's skipped |
|
|
794
|
+
|---|---|
|
|
795
|
+
| `jiraSecretId`, `token_id` | an IDENTIFIER of a secret held elsewhere, not the secret |
|
|
796
|
+
| `apiKeyHash`, `password_hash` | the hash IS the protection — encrypting it is nonsense, and it breaks the column as a unique lookup key |
|
|
797
|
+
| `vaultToken` | a HANDLE into a secret store, naming a secret held elsewhere |
|
|
798
|
+
|
|
799
|
+
The suffix tests use a camelCase / underscore boundary on purpose: a blind
|
|
800
|
+
`/id$/i` would also swallow `apiKeyValid`, while `tokenIdentifier` — which ends
|
|
801
|
+
in neither — must still fire.
|
|
802
|
+
|
|
803
|
+
**The sequential-reads rule names TWO levers, and the criterion for choosing.**
|
|
804
|
+
Both shapes chain later reads off earlier results, so no text-level heuristic can
|
|
805
|
+
split them — you make the call:
|
|
806
|
+
|
|
807
|
+
- The reads are a **parent → child walk on ONE key** → declare `relations()` in a
|
|
808
|
+
`*.relations.ts` and collapse them into `.with({ … })`: one JSON-aggregate
|
|
809
|
+
query, on every dialect.
|
|
810
|
+
- The reads **collect ids from SEVERAL sources** (JSON-array references, a
|
|
811
|
+
junction carrying extra columns, JS-side sorting) → keep the assembly and run
|
|
812
|
+
the independent LEADING reads under `Effect.all`. Same queries, same results,
|
|
813
|
+
only concurrent — zero parity risk.
|
|
814
|
+
|
|
815
|
+
The second case is the common one. Measured on a real 74-hit codebase, about two
|
|
816
|
+
handlers were clean full-parity `relations()` conversions and the other ~72 were
|
|
817
|
+
multi-source assemblies where `.with()` covers only part of the work or subtly
|
|
818
|
+
changes behaviour. Prescribing `relations()` for all of them would be wrong ~97%
|
|
819
|
+
of the time — and advice that is usually wrong trains you to ignore the finding.
|
|
820
|
+
|
|
821
|
+
The human view shows the first three file paths per finding and says how many it
|
|
822
|
+
withheld. Those paths are the actionable part — a count you cannot turn back
|
|
823
|
+
into a work list tells you the size of the problem, not how to fix it — and the
|
|
824
|
+
matching rule lives inside the CLI, so you cannot re-derive the list with your
|
|
825
|
+
own grep. `--json` prints the complete scan, nothing elided, with no preflight
|
|
826
|
+
output mixed in:
|
|
827
|
+
|
|
828
|
+
```bash
|
|
829
|
+
voltro doctor . --json # the complete scan: every file path, machine-readable
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
```json
|
|
833
|
+
{
|
|
834
|
+
"root": "/app/api",
|
|
835
|
+
"scannedFiles": 214,
|
|
836
|
+
"scannedDirs": ["queries", "mutations", "database"],
|
|
837
|
+
"findings": [
|
|
838
|
+
{
|
|
839
|
+
"id": "row-not-found",
|
|
840
|
+
"scope": "server",
|
|
841
|
+
"smell": "hand-written not-found branch on rows[0]",
|
|
842
|
+
"use": ".one() — fails with the typed NoRowFound on zero rows AND on more than one",
|
|
843
|
+
"files": ["queries/team.get.ts", "queries/user.get.ts", "…"]
|
|
844
|
+
}
|
|
845
|
+
]
|
|
846
|
+
}
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
That is the form to hand an agent, or to pipe into a script that works the list
|
|
850
|
+
file by file.
|
|
851
|
+
|
|
852
|
+
## `voltro capabilities` — what the framework actually exports
|
|
853
|
+
|
|
854
|
+
Asked "what does this framework export", a language model will produce a
|
|
855
|
+
confident answer whether or not it knows. This command replaces that guess with
|
|
856
|
+
a reading of the `.d.ts` files in your own `node_modules`:
|
|
857
|
+
|
|
858
|
+
```bash
|
|
859
|
+
voltro capabilities # human summary, grouped by package
|
|
860
|
+
voltro capabilities --json # the full machine-readable surface
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
```text
|
|
864
|
+
voltro capabilities — 2517 exported symbols across 36 packages
|
|
865
|
+
@voltro/runtime@0.4.0 — 9 primitives, 41 values, 118 types
|
|
866
|
+
defineAggregate, defineReaction, defineSubscriber, defineResourcePolicy, …
|
|
867
|
+
@voltro/client@0.4.0 — 47 hooks, 12 components, 60 types
|
|
868
|
+
useSubscription, useMutation, useFormBinding, useDataTable, useUpload, …
|
|
869
|
+
|
|
870
|
+
* 8 primitive(s)/hook(s) appear nowhere in this project's agent guide:
|
|
871
|
+
@voltro/plugin-mail: defineEmail
|
|
872
|
+
…
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
Every symbol reported was read out of an installed package a moment ago, so an
|
|
876
|
+
agent can **verify** the surface instead of recalling it. The `--json` form is
|
|
877
|
+
stable and locale-independent — the same tree produces byte-identical output on
|
|
878
|
+
every machine, so you can diff it across upgrades.
|
|
879
|
+
|
|
880
|
+
Symbols marked `*` ship but appear nowhere in this project's seeded `AGENTS.md`
|
|
881
|
+
/ `CLAUDE.md`. Refresh the guide with `voltro agents-md --force`, or read that
|
|
882
|
+
package's README.
|
|
883
|
+
|
|
742
884
|
## Anti-patterns
|
|
743
885
|
|
|
744
886
|
- **Running `voltro start` against a directory without `dist/`.** It exits 1 with a clear `no built dist found — run voltro build first` (checked against `.framework/dist/index.html` before any heavy work). Run `voltro build` first.
|
|
@@ -91,6 +91,44 @@ build-time constant, still works.)
|
|
|
91
91
|
| `envVar.boolean(opts)` | `boolean` | `true/1/yes/on` ↔ `false/0/no/off/""` |
|
|
92
92
|
| `envVar.url(opts)` | `string` | validated with `URL` |
|
|
93
93
|
| `envVar.enum([…] as const, opts)` | literal union | must be one of the values |
|
|
94
|
+
| `envVar.secret(opts)` | `string` | server-only, required, length floor |
|
|
95
|
+
|
|
96
|
+
### `envVar.secret` — for values that must not be guessable
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
env: defineEnv({
|
|
100
|
+
// Ours to invent → `voltro dev` mints one per project.
|
|
101
|
+
VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url' }),
|
|
102
|
+
// Someone else's to issue → must be fetched, never invented.
|
|
103
|
+
STRIPE_SECRET_KEY: envVar.secret({ minLength: 20 }),
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
It differs from `envVar.string({ access: 'secret' })` in three ways, each
|
|
108
|
+
closing a specific failure:
|
|
109
|
+
|
|
110
|
+
- **`access` is forced to `'secret'`** — a value with a length floor is never
|
|
111
|
+
something you meant to bundle into a browser.
|
|
112
|
+
- **There is no `default`.** A default secret is not a secret: every
|
|
113
|
+
deployment that forgot to set the variable would share it.
|
|
114
|
+
- **A length floor** (default 32). Presence alone does not catch the real
|
|
115
|
+
failure mode — a variable that is *set* but reads `change-me` signs forgeable
|
|
116
|
+
cookies while looking completely healthy.
|
|
117
|
+
|
|
118
|
+
**`generate` opts a variable into per-project minting.** On first `voltro dev`,
|
|
119
|
+
any declared-but-unset mintable secret is written to a gitignored `.env.local`
|
|
120
|
+
and the boot continues. That is why no Voltro template ships a secret value: a
|
|
121
|
+
placeholder in a template is a signing key published to everyone who downloads
|
|
122
|
+
it, and it passes every check you could write.
|
|
123
|
+
|
|
124
|
+
Leave `generate` off for anything a third party issues. A WorkOS API key is
|
|
125
|
+
just as secret and just as required, but inventing one produces a value that
|
|
126
|
+
merely *looks* right and authenticates nobody — better that the boot gate fails
|
|
127
|
+
and a human fetches the real one.
|
|
128
|
+
|
|
129
|
+
Minting is **development only**. `voltro serve`, `build` and `start` never mint:
|
|
130
|
+
in production a missing secret is a boot failure, which is the whole point.
|
|
131
|
+
Generate deployment values with `voltro secret generate <purpose>`.
|
|
94
132
|
|
|
95
133
|
Every builder takes `{ access, optional?, default?, description?, example? }`.
|
|
96
134
|
Use `default` for a fallback (the value type stays present, `A`); use
|