@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
|
@@ -2600,6 +2600,38 @@ export const users = table('users', {
|
|
|
2600
2600
|
|
|
2601
2601
|
The planner folds the diff into one `ALTER TABLE users RENAME COLUMN firstName TO givenName`, classified `safe`. After the rename is applied in every env, the marker can be removed (covered in [rename-and-drop.md](./rename-and-drop.md)).
|
|
2602
2602
|
|
|
2603
|
+
## "the migration did not converge" (apply refuses to record a fingerprint)
|
|
2604
|
+
|
|
2605
|
+
```
|
|
2606
|
+
applyPlan: the migration did not converge. 31 operation(s) were executed without
|
|
2607
|
+
error, but re-planning against the live schema still finds 31:
|
|
2608
|
+
- alter-column-default todos.attachments
|
|
2609
|
+
…
|
|
2610
|
+
No fingerprint was recorded — recording one would make the next boot report
|
|
2611
|
+
"schema up to date" for a schema that was never applied.
|
|
2612
|
+
```
|
|
2613
|
+
|
|
2614
|
+
Every statement ran and the database accepted every one of them, and none of them
|
|
2615
|
+
changed anything. That is possible because DDL that changes nothing succeeds
|
|
2616
|
+
exactly as quietly as DDL that works — `ALTER COLUMN x TYPE text` on a column
|
|
2617
|
+
that is already `text` is a valid, successful no-op.
|
|
2618
|
+
|
|
2619
|
+
This message exists because the alternative is worse. Before the convergence
|
|
2620
|
+
check, such a plan reported `applied 31 op(s)`, recorded a fingerprint, and every
|
|
2621
|
+
later boot short-circuited on "schema up to date" — for a schema that had never
|
|
2622
|
+
been applied. One app ran that way for two releases. The apply now proves it
|
|
2623
|
+
worked before it records anything: the same planner, run against the database as
|
|
2624
|
+
it now is, must have nothing left to do.
|
|
2625
|
+
|
|
2626
|
+
**It is a framework bug, not a mistake in your schema.** The named operations
|
|
2627
|
+
emit DDL that does not take effect. Report the operation kinds plus the column
|
|
2628
|
+
types involved. In the meantime the schema is unchanged and safe — nothing was
|
|
2629
|
+
half-applied, and no fingerprint was written, so `voltro db plan` still shows you
|
|
2630
|
+
the truth.
|
|
2631
|
+
|
|
2632
|
+
If you need to move forward before a fix lands, apply the equivalent DDL by hand
|
|
2633
|
+
and re-run `voltro db plan` to confirm it converges.
|
|
2634
|
+
|
|
2603
2635
|
## "Schema fingerprint mismatch" (prod refuse)
|
|
2604
2636
|
|
|
2605
2637
|
Full error:
|
|
@@ -235,6 +235,37 @@ The `where` predicate is a regular [Predicate](/docs/database/query-builder#pred
|
|
|
235
235
|
AST — same shape `.where()` uses. Sub-queries via `inSubquery` /
|
|
236
236
|
`exists` are supported.
|
|
237
237
|
|
|
238
|
+
### Typed: `updateManyRow`
|
|
239
|
+
|
|
240
|
+
`updateMany` takes a string table name and an untyped patch, so a misspelled
|
|
241
|
+
column or a wrongly-typed value is only found by the database — or not at all,
|
|
242
|
+
if the column happens to exist. `updateManyRow` takes the TABLE OBJECT instead
|
|
243
|
+
and checks the patch against the row type:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { updateManyRow, eq } from '@voltro/database'
|
|
247
|
+
|
|
248
|
+
await updateManyRow(ctx.store, posts, { hidden: true }, {
|
|
249
|
+
where: eq('userId', bannedId),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
await updateManyRow(ctx.store, posts, { hiddne: true }, { where: … })
|
|
253
|
+
// ^^^^^^^ compile error: not a column
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
It is worth using rather than the string form, and the evidence is concrete: one
|
|
257
|
+
app migrating 29 `store.upsert` call sites to the typed `upsertRow` got 15 `tsc`
|
|
258
|
+
errors across 8 distinct defects that no test had caught — including seven
|
|
259
|
+
per-user mutations with no authentication check at all, which wrote
|
|
260
|
+
`ctx.request.subject.id` (typed `string | null`) into a NOT NULL column, so an
|
|
261
|
+
anonymous caller reached the database and got a raw statement failure instead of
|
|
262
|
+
a typed refusal.
|
|
263
|
+
|
|
264
|
+
The subtlest one is the most persuasive: a value spread from a plain object
|
|
265
|
+
literal widens to `string`, and a column's `.oneOf()` union rejects it even
|
|
266
|
+
though the value IS one of the members. Neither a reviewer nor a test would
|
|
267
|
+
plausibly find that; only the row type asks the question. (`as const` fixes it.)
|
|
268
|
+
|
|
238
269
|
### Reactivity
|
|
239
270
|
|
|
240
271
|
`updateMany` emits ONE ChangeEvent per affected row, so reactive
|
|
@@ -208,7 +208,15 @@ timezone: 'Europe/Berlin' // 09:00 Berlin — DST handled by effect's Cron
|
|
|
208
208
|
|
|
209
209
|
## The handler context
|
|
210
210
|
|
|
211
|
-
The handler receives a `ScheduleContext` — the same `app` a mutation gets, plus firing metadata
|
|
211
|
+
The handler receives a `ScheduleContext` — the same `app` a mutation gets, plus firing metadata.
|
|
212
|
+
|
|
213
|
+
> **The API is identical; the subject is not.** `ctx.app.store` here is **not
|
|
214
|
+
> tenant-scoped** — a schedule runs as `system` with no tenant. Reads see every
|
|
215
|
+
> tenant's rows, and a write to a `tenant()` table fails with
|
|
216
|
+
> `TenantScopeViolation` unless you pass `tenantId` explicitly. See
|
|
217
|
+
> [below](#a-schedule-runs-as-the-system-subject--no-tenant). This sentence is
|
|
218
|
+
> here rather than only further down because "same shape as a mutation" is what
|
|
219
|
+
> sets the expectation that gets violated.
|
|
212
220
|
|
|
213
221
|
```tsx
|
|
214
222
|
handler: async (ctx) => {
|
|
@@ -250,6 +258,32 @@ Writes to a `tenant()` table need the same treatment: pass `tenantId`
|
|
|
250
258
|
explicitly, or the write fails with `TenantScopeViolation` rather than landing
|
|
251
259
|
somewhere arbitrary.
|
|
252
260
|
|
|
261
|
+
### `ctx.app.storeForTenant(id)` — the fan-out shortcut
|
|
262
|
+
|
|
263
|
+
Doing that by hand means every `.where('tenantId', …)` and every explicit
|
|
264
|
+
`tenantId:` is one forgotten call away from reading or writing across tenants.
|
|
265
|
+
`storeForTenant` hands you a store scoped to exactly one:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
handler: async (ctx) => {
|
|
269
|
+
for (const t of await ctx.app.store.select('tenants').all()) {
|
|
270
|
+
const scoped = ctx.app.storeForTenant(t.id)
|
|
271
|
+
await scoped.insert('digests', { body: summary }) // tenantId stamped, not passed
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Inside a REQUEST this is almost always the wrong tool — the subject already
|
|
277
|
+
carries a tenant, and reaching for another one is a cross-tenant access with
|
|
278
|
+
extra steps. It exists because the system subject has *no* tenant to infer.
|
|
279
|
+
|
|
280
|
+
One implementation detail worth knowing, because it is counter-intuitive: the
|
|
281
|
+
scoped store does **not** run as a `system` subject with a tenant attached. A
|
|
282
|
+
system subject carries `tenantId: null` by construction and the tenant mixin
|
|
283
|
+
special-cases it to skip scoping entirely — on a system subject, a null tenant
|
|
284
|
+
means *all* tenants. So the scoped view runs as a `serviceAccount` bound to that
|
|
285
|
+
one tenant, keeping the schedule's scopes.
|
|
286
|
+
|
|
253
287
|
This behaves identically under `voltro dev` and `voltro serve`. It did not
|
|
254
288
|
always — before 0.10.0, dev scoped schedules to `$TENANT` (default `acme`)
|
|
255
289
|
while production ran them unscoped, so the same cron read one tenant in
|
|
@@ -22,6 +22,54 @@ Please report suspected vulnerabilities **privately** — never in public issues
|
|
|
22
22
|
|
|
23
23
|
You'll get an **acknowledgement within 3 business days**, an assessment once we've reproduced the issue, and a **coordinated disclosure** timeline agreed with you — with credit in the release notes if you'd like it. We don't run a paid bug-bounty program yet, but we genuinely value responsible disclosure.
|
|
24
24
|
|
|
25
|
+
## Outbound HTTP is SSRF-guarded by default
|
|
26
|
+
|
|
27
|
+
The `HttpClient` your handlers `yield*` refuses internal targets:
|
|
28
|
+
|
|
29
|
+
- loopback, RFC-1918, CGNAT and link-local addresses — including the
|
|
30
|
+
`169.254.169.254` cloud-metadata endpoint
|
|
31
|
+
- the hostnames `localhost`, `*.internal`, `*.local`
|
|
32
|
+
- any non-`http(s)` scheme
|
|
33
|
+
|
|
34
|
+
**Every redirect hop is revalidated, not just the URL you passed.** A public URL
|
|
35
|
+
that `302`s to the metadata endpoint is the actual attack; checking only the
|
|
36
|
+
initial target catches none of it.
|
|
37
|
+
|
|
38
|
+
This matters because a caller-supplied URL is ordinary product surface — a
|
|
39
|
+
scraper, a webhook-registration form, an importer, a "test this connection"
|
|
40
|
+
button. Those reach `HttpClient` with whatever the user typed.
|
|
41
|
+
|
|
42
|
+
### Allowing a target on purpose
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// app.config.ts
|
|
46
|
+
export default defineApiConfig({
|
|
47
|
+
http: { allowHosts: ['*.svc.cluster.local', 'billing.internal'] },
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
An entry may be an exact host, a `*.suffix` wildcard (which does **not** match the
|
|
52
|
+
apex — one that did would silently widen your exception), or `host:port` when only
|
|
53
|
+
one port should be reachable.
|
|
54
|
+
|
|
55
|
+
There is no boolean off-switch, deliberately: *"we call one internal service"* and
|
|
56
|
+
*"we do not check URLs"* are different postures, and a boolean cannot tell them
|
|
57
|
+
apart six months later.
|
|
58
|
+
|
|
59
|
+
### In tests, allow the stub — don't mock the guard
|
|
60
|
+
|
|
61
|
+
Use the same `allowHosts` to reach a local stub server (`['127.0.0.1:8787']`).
|
|
62
|
+
That keeps the test on the real guarded code path with a narrow exception. Mocking
|
|
63
|
+
the guard away instead means the production path is never exercised — which is
|
|
64
|
+
exactly how this framework's own webhook delivery once ended up behind a
|
|
65
|
+
`NODE_ENV` check.
|
|
66
|
+
|
|
67
|
+
### What this does NOT cover
|
|
68
|
+
|
|
69
|
+
DNS is not resolved. A public hostname that *resolves* to a private address (DNS
|
|
70
|
+
rebinding) still passes. That vector needs network-layer egress control; it is
|
|
71
|
+
stated here rather than silently implied.
|
|
72
|
+
|
|
25
73
|
## Supply-chain assurance
|
|
26
74
|
|
|
27
75
|
Every release passes automated supply-chain gates in CI before a single package is published:
|
|
@@ -2615,9 +2615,9 @@ done
|
|
|
2615
2615
|
<!-- source: en/templates/api-rbac.md -->
|
|
2616
2616
|
## API · RBAC
|
|
2617
2617
|
|
|
2618
|
-
_Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, and
|
|
2618
|
+
_Role-based access control with @voltro/plugin-rbac — a role→scope map, an interceptor that resolves the caller's roles to scopes, declarative descriptor guards (statically checked by voltro check), resource-scoped guards, and the in-handler permission()/can() guards. admin:full bypasses. Config-only, zero infra._
|
|
2619
2619
|
|
|
2620
|
-
Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map and an rpc interceptor resolves each caller's **roles**
|
|
2620
|
+
Roles → scopes → guards. `@voltro/plugin-rbac` compiles a role map, and an rpc interceptor resolves each caller's **roles** into **scopes** published to the framework's effective-scope seam. From there both authorization forms see them. The `admin:full` scope is a blanket bypass. Config-only, zero infra. Template id: **`api-rbac`**.
|
|
2621
2621
|
|
|
2622
2622
|
## Scaffold
|
|
2623
2623
|
|
|
@@ -2627,40 +2627,99 @@ voltro create-project acme --api=api-rbac
|
|
|
2627
2627
|
|
|
2628
2628
|
## Roles as config
|
|
2629
2629
|
|
|
2630
|
-
|
|
2631
|
-
// app.config.ts
|
|
2632
|
-
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
2630
|
+
The role map is the app's **declared scope vocabulary**, not just a lookup table: `rbacPlugin` publishes the union of it, and [`voltro check`](/docs/cli/overview) fails on any descriptor guard requiring a scope that appears nowhere in it.
|
|
2633
2631
|
|
|
2634
|
-
|
|
2632
|
+
```ts
|
|
2633
|
+
// authz.ts — split out of app.config.ts so tests build the real plugin
|
|
2634
|
+
export const roles = {
|
|
2635
2635
|
viewer: ['notes:read'],
|
|
2636
2636
|
editor: ['notes:read', 'notes:write'],
|
|
2637
|
-
|
|
2637
|
+
owner: ['notes:read', 'notes:write', 'notes:delete', 'teams:rename'],
|
|
2638
|
+
admin: ['*'], // wildcard → admin:full blanket bypass
|
|
2638
2639
|
}
|
|
2640
|
+
```
|
|
2641
|
+
|
|
2642
|
+
```ts
|
|
2643
|
+
// app.config.ts
|
|
2644
|
+
import { rbacPlugin } from '@voltro/plugin-rbac'
|
|
2645
|
+
import { demoRolesForTenant, roles, rolesOnTeam } from './authz'
|
|
2639
2646
|
|
|
2640
2647
|
rbacPlugin({
|
|
2641
2648
|
roles,
|
|
2642
2649
|
// PRODUCTION: the default resolver reads subject.metadata.roles (set by your
|
|
2643
2650
|
// auth strategy), or do a DB lookup. The shipped DEMO maps tenant → role so
|
|
2644
|
-
// you can try each via the x-tenant header with zero auth setup
|
|
2645
|
-
resolveRoles: (subject) =>
|
|
2646
|
-
|
|
2651
|
+
// you can try each via the x-tenant header with zero auth setup.
|
|
2652
|
+
resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
|
|
2653
|
+
// PER-RESOURCE roles — see "Resource-scoped guards" below.
|
|
2654
|
+
resolveResourceRoles: (subject, resource) => rolesOnTeam(subject.id, resource),
|
|
2647
2655
|
})
|
|
2648
2656
|
```
|
|
2649
2657
|
|
|
2650
|
-
##
|
|
2658
|
+
## Two guard forms — prefer the declarative one
|
|
2651
2659
|
|
|
2652
2660
|
```ts
|
|
2653
|
-
// notes.create.mutation.
|
|
2654
|
-
|
|
2661
|
+
// notes.create.mutation.ts — DECLARATIVE, on the descriptor
|
|
2662
|
+
export const createNote = defineMutation({
|
|
2663
|
+
name: 'notes.create',
|
|
2664
|
+
target: { table: 'notes', op: 'insert' },
|
|
2665
|
+
guards: [{ scope: 'notes:write' }],
|
|
2666
|
+
input: Schema.Struct({ title: Schema.NonEmptyString, body: Schema.String }),
|
|
2667
|
+
output: Schema.Struct({ id: Schema.String, title: Schema.String }),
|
|
2668
|
+
})
|
|
2669
|
+
```
|
|
2670
|
+
|
|
2671
|
+
The framework enforces this in the dispatch spine — before the executor, and before a mutation's transaction opens — so an unauthorized call never touches the DB. Declaring `guards:` also merges `ScopeError` into the wire error union automatically, so no `error:` field is needed and the client still decodes the denial typed. Reads take guards too, re-checked on every delivery, so a revoked role stops a live subscription.
|
|
2655
2672
|
|
|
2673
|
+
The reason to prefer it is **static checkability**: a declarative guard lands in the capability manifest, so `voltro check` catches a scope no role grants. An in-handler call is invisible to that check.
|
|
2674
|
+
|
|
2675
|
+
```ts
|
|
2676
|
+
// notes.delete.mutation.server.ts — IN-HANDLER, for what needs the loaded row
|
|
2656
2677
|
export default (input, ctx) => Effect.gen(function* () {
|
|
2657
|
-
yield* permission(ctx, 'notes:write') // fails typed ScopeError for viewer
|
|
2658
2678
|
const store = yield* EffectStore
|
|
2659
|
-
|
|
2679
|
+
const rows = yield* Effect.promise(() =>
|
|
2680
|
+
ctx.store.query(database.notes.where(eq('id', input.id)).limit(1).descriptor))
|
|
2681
|
+
const note = rows[0]
|
|
2682
|
+
if (note === undefined) return yield* Effect.fail(new NoteNotFound({ id: input.id }))
|
|
2683
|
+
|
|
2684
|
+
// Only decidable AFTER the row is loaded — no descriptor guard can express it.
|
|
2685
|
+
const hard = note['archived'] === true || can(ctx, 'notes:purge')
|
|
2686
|
+
if (hard) { yield* store.delete('notes', input.id); return { id: input.id, mode: 'hard' } }
|
|
2687
|
+
yield* store.update('notes', input.id, { archived: true })
|
|
2688
|
+
return { id: input.id, mode: 'soft' }
|
|
2660
2689
|
})
|
|
2661
2690
|
```
|
|
2662
2691
|
|
|
2663
|
-
|
|
2692
|
+
Rule of thumb: put in `guards:` everything decidable from the **subject** and the **input**; reach for `permission()` / `can()` only for what needs loaded data. Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw), `can(ctx, scope)` (boolean, branches instead of refusing).
|
|
2693
|
+
|
|
2694
|
+
## Resource-scoped guards
|
|
2695
|
+
|
|
2696
|
+
`guards: [{ scope, resource }]` makes a grant mean "owner **of this team**" instead of "owner globally".
|
|
2697
|
+
|
|
2698
|
+
```ts
|
|
2699
|
+
// teams.rename.mutation.ts
|
|
2700
|
+
guards: [{ scope: 'teams:rename', resource: (input: { teamId: string }) => input.teamId }],
|
|
2701
|
+
```
|
|
2702
|
+
|
|
2703
|
+
The `resource` extractor pulls the id out of the decoded input; rbac's `resolveResourceRoles` returns the roles the caller holds on that id, compiled through the same role map. A **globally** held scope (or `admin:full`) satisfies the guard without the resolver ever being called — only the gap falls through. That fall-through is **fail-closed**: a resolver that throws or rejects denies, it does not degrade to the global answer.
|
|
2704
|
+
|
|
2705
|
+
## Failure postures, deliberately different
|
|
2706
|
+
|
|
2707
|
+
| Resolver | On throw / reject |
|
|
2708
|
+
| --- | --- |
|
|
2709
|
+
| `resolveRoles` / `resolvePermissions` | logs, degrades to the subject's **own** scopes — never grants, but an api key with valid scopes still works |
|
|
2710
|
+
| `resolveResourceRoles` | **denies** (fail-closed) |
|
|
2711
|
+
|
|
2712
|
+
Both hold for a synchronous throw as well as a rejected promise.
|
|
2713
|
+
|
|
2714
|
+
## Static check
|
|
2715
|
+
|
|
2716
|
+
```bash
|
|
2717
|
+
voltro check --offline # no running app needed — usable as a CI gate
|
|
2718
|
+
```
|
|
2719
|
+
|
|
2720
|
+
A guard requiring `notes:wirte` when the roles grant `notes:write` is not merely misconfigured: that procedure is permanently, silently uncallable by everyone. `voltro check` reports it as `rbac/unknown-scope` and exits non-zero.
|
|
2721
|
+
|
|
2722
|
+
The registry stays **dormant** when a custom `resolvePermissions` is configured — that resolver merges scopes from outside the role map by design, so publishing the map alone would flag correct code.
|
|
2664
2723
|
|
|
2665
2724
|
## Try it
|
|
2666
2725
|
|
|
@@ -2676,6 +2735,18 @@ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json
|
|
|
2676
2735
|
# → { ok:false, error:{ _tag:"ScopeError", required:"notes:write" } }
|
|
2677
2736
|
```
|
|
2678
2737
|
|
|
2738
|
+
## Testing authorization
|
|
2739
|
+
|
|
2740
|
+
The shipped `tests/authz.test.ts` drives the **real** plugin: `makeTestContext({ plugins: [rbacPlugin({ roles, … })] })` composes the actual interceptor chain, and `invoke` enforces the descriptor's guards with the same function the serve pipeline calls.
|
|
2741
|
+
|
|
2742
|
+
```ts
|
|
2743
|
+
const ctx = makeTestContext({ subject, store: mockStore({ notes: [] }), plugins: [plugin()] })
|
|
2744
|
+
await expect(invoke(createNote, createNoteHandler, { title: 'x', body: '' }, ctx))
|
|
2745
|
+
.rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:write' })
|
|
2746
|
+
```
|
|
2747
|
+
|
|
2748
|
+
Do **not** hand-set `subject.scopes` to the value you expect. That tests the guard while assuming the resolution which produces it — and the resolution is the half that actually breaks (a renamed role, a throwing resolver, a scope no role grants). Those all pass a pre-stamped test and fail a real one.
|
|
2749
|
+
|
|
2679
2750
|
## On the web side
|
|
2680
2751
|
|
|
2681
2752
|
```tsx
|
|
@@ -2686,7 +2757,7 @@ const canWrite = useCan('notes:write')
|
|
|
2686
2757
|
{canWrite && <NewNoteButton />} // hide affordances the user can't use
|
|
2687
2758
|
```
|
|
2688
2759
|
|
|
2689
|
-
The UI gate ships in [`@voltro/client`](/docs/ui/client-utilities/use-can), not in the plugin: scopes are a framework concept, so gating a button must not require an rbac dependency. `useCan` is a UI affordance only — the server
|
|
2760
|
+
The UI gate ships in [`@voltro/client`](/docs/ui/client-utilities/use-can), not in the plugin: scopes are a framework concept, so gating a button must not require an rbac dependency. `useCan` is a UI affordance only — the server guard is the enforcement.
|
|
2690
2761
|
|
|
2691
2762
|
## Persisted roles
|
|
2692
2763
|
|
|
@@ -2695,6 +2766,8 @@ Set `tables: true` to store assignments in `roles` / `userRoles` tables and reso
|
|
|
2695
2766
|
## Anti-patterns
|
|
2696
2767
|
|
|
2697
2768
|
- **Returning scopes from `resolveRoles`.** It returns role SLUGS (`['editor']`); the `roles` map turns those into scopes. Returning scopes directly bypasses the map.
|
|
2769
|
+
- **Re-checking a descriptor guard inside its own executor.** Two copies of one rule, and the copy `voltro check` reads is the descriptor's.
|
|
2770
|
+
- **Reaching for `permission()` when `guards:` would do.** It works, but it opts that procedure out of the static check.
|
|
2698
2771
|
- **Scopes ≠ entitlements.** RBAC answers "may you call this"; billing entitlements (see [`api-saas`](/docs/templates/api-saas)) answer "do you have quota left". A procedure can need both.
|
|
2699
2772
|
|
|
2700
2773
|
|
|
@@ -66,6 +66,30 @@ voltro e2e # boot api + web siblings, run tests, tear down
|
|
|
66
66
|
|
|
67
67
|
_makeTestContext — the in-memory request ctx for handler and tool tests. The real mixin-wrapped store, mockStore seeding, subject/tenant re-scoping, and the deterministic clock / email / LLM mocks._
|
|
68
68
|
|
|
69
|
+
## tsconfig `paths` aliases
|
|
70
|
+
|
|
71
|
+
`voltro test` derives Vite's `resolve.alias` from your app's tsconfig
|
|
72
|
+
`compilerOptions.paths`, so an app that maps `@/* → ./src/*` can test modules
|
|
73
|
+
that import through it without any extra config:
|
|
74
|
+
|
|
75
|
+
```jsonc
|
|
76
|
+
// tsconfig.json
|
|
77
|
+
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { greeting } from '@/locales/en' // resolves under `voltro test`
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Before this, the first person to write a test for each aliased file discovered
|
|
85
|
+
`Cannot find package '@/locales/en' imported from src/lib/i18n.ts` — one file at
|
|
86
|
+
a time — and worked around it with a local `vitest.config.ts` restating what
|
|
87
|
+
tsconfig already said. The dev and build pipelines resolve these already (your
|
|
88
|
+
app runs), so the test runner disagreeing with them was a gap, not a policy.
|
|
89
|
+
|
|
90
|
+
A project-local `vitest.config.ts` still merges on top, so an app that already
|
|
91
|
+
wrote the workaround keeps working.
|
|
92
|
+
|
|
69
93
|
## `makeTestContext`
|
|
70
94
|
|
|
71
95
|
`makeTestContext(options?)` returns an **`AppContext`** — the exact `ctx` a mutation / query / action / tool executor receives at runtime — with the deterministic test doubles added on top. Because it *is* an `AppContext`, you pass it straight into a handler: `await myHandler(input, makeTestContext({ … }))`. The acting subject is at `ctx.request.subject`; `ctx.store` is the **real** mixin-wrapped store backed by an in-memory data store (so tenant auto-scoping, soft-delete filtering, audit auto-fill, and the fluent `select` / `update` / `delete` builders behave exactly as in production); `ctx.cache` is a real in-memory cache whose TTLs honour `ctx.clock`.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# What's new in 0.13.0
|
|
2
|
+
|
|
3
|
+
Read this FIRST when a task touches an area you have not worked in recently.
|
|
4
|
+
It is the cheapest way to notice that the framework grew the thing you were
|
|
5
|
+
about to hand-roll — the failure this section exists for is a team building a
|
|
6
|
+
workaround for something that shipped two versions ago.
|
|
7
|
+
|
|
8
|
+
BREAKING entries name a codemod; run `voltro update` to apply it.
|
|
9
|
+
|
|
10
|
+
### ⚠ BREAKING
|
|
11
|
+
|
|
12
|
+
- **@voltro/protocol, @voltro/plugin-scim, @voltro/plugin-prometheus** — SCIM was served UNAUTHENTICATED whenever its token was an empty string.
|
|
13
|
+
|
|
14
|
+
`checkBearer(headers, expected)` returned `true` when `expected` was unset or empty, documented as "no token configured = open; the caller decided not to gate this surface". Its one production caller had decided the opposite: `scimPlugin` declares `token: string`, and `scimPlugin({ token: process.env.SCIM_TOKEN ?? '' })` — the shape anyone writes — turned the gate off silently. The result was SCIM 2.0 Users and Groups readable with no credentials: a full directory dump plus the provisioning surface that can deactivate accounts. Likeliest exactly where it hurts, too: an env var set in production and missing in a preview environment.
|
|
15
|
+
|
|
16
|
+
`checkBearer` is now fail-closed by default, with the permissive behaviour available as an explicit `{ openWhenUnset: true }` — a two-argument helper cannot know its caller's intent, so it must not assume the permissive one. `@voltro/plugin-prometheus` passes it (its token is documented as optional), and `scimPlugin` now throws at construction — i.e. at boot — rather than answering the first anonymous request.
|
|
17
|
+
- **@voltro/database, @voltro/cli** — `voltro db apply` and boot auto-migrate could report success while applying nothing, and then record a fingerprint that made every later boot short-circuit on "schema up to date".
|
|
18
|
+
|
|
19
|
+
Reported from a live pod: `applied 31 op(s)` on every boot for two releases, with none of the 31 present in the database. Nothing was wrong with the transport, the lock or the transaction — the applier emitted statements that postgres accepted and that changed nothing. Two independent causes:
|
|
20
|
+
|
|
21
|
+
- A `ColumnSnapshot` carried no `vector` dimension / `array` element / `enum` name, so the applier's type renderers collapsed all three to `text`. A declared `vector(1536)` over a live `text` column planned an `alter-column-type` that emitted `ALTER COLUMN … TYPE text`. Valid, applied, no-op, re-planned forever. (Also meant an `add-column` for a vector, array, enum or PostGIS column created a plain `text` column.) - The default-clause renderers excluded ARRAYS, returning `null`, and the call site turned that into `SET DEFAULT NULL`. A declared `.default([])` on a `json()` column therefore never landed — thirty columns were stuck this way in the reporting schema.
|
|
22
|
+
|
|
23
|
+
Fixed: the snapshot carries the type parameters and the renderers delegate to `migrate.ts`'s canonical `sqlType`, so the applier and the CREATE-TABLE emitter cannot disagree; array defaults render (a real `text[]` literal on a native `array()` column, a jsonb literal otherwise); and a default the renderer cannot express now FAILS instead of degrading to `DEFAULT NULL`.
|
|
24
|
+
|
|
25
|
+
And the structural guard, which is the part that matters: **`applyPlan` re-plans against the live schema before it records a fingerprint, and refuses to record one if any operation remains.** DDL that changes nothing succeeds exactly as quietly as DDL that works, so the only evidence a plan applied is that the same planner has nothing left to do. `ApplyPlanCtx` gains a required `replan`; `AppliedMigration` gains `appliedOps` (what EXECUTED, not `plan.operations.length`), and the boot log quotes that.
|
|
26
|
+
- **@voltro/plugin-storage** — `storage.share`, `storage.revoke` and `storage.listGrants` performed no authorization at all.
|
|
27
|
+
|
|
28
|
+
Each took an object id straight off the wire and passed it to a service method that (correctly, for a trusted server-side API) checks nothing, with nothing in between. Any authenticated caller could grant themselves read or write on any object in the installation, revoke anyone else's grants, and enumerate who an object is shared with.
|
|
29
|
+
|
|
30
|
+
All three now require that the caller owns the object, or carries `admin:full`. A missing object and an unowned object report the same 403 — a 404 would let an unauthorized caller probe which ids exist. `GrantStore` gains `getById`, which `revoke` needs to resolve a grant id back to its object.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **@voltro/runtime, @voltro/database** — API keys carry app-owned `metadata` — the second ownership axis.
|
|
35
|
+
|
|
36
|
+
`tenantId` and `onBehalfOf` are the two relationships the framework models. Plenty of apps have a third that actually authorizes the key: a team, a project, an environment. `ApiKeyRecord` in `@voltro/protocol` has carried a `metadata` slot all along — its doc comment even names `teamId` as the example — but the SERVICE had nowhere to store it and nowhere to return it. So an app with a team axis could authenticate through the built-in strategy and still not authorize, and `apiKeys: true` was unusable for it. Reported as the one thing that stopped an otherwise complete adoption; their alternatives were a second table joined on the hot auth path, or smuggling `team:<id>` into `scopes`, where `hasScope` would then see a scope that is not a scope.
|
|
37
|
+
|
|
38
|
+
`IssueInput`, `ApiKeyRow` and `ResolvedApiKey` now carry it, stored as JSON on `_voltro_api_keys`, and it survives `rotate` — a rotated key is the same credential with a new secret, so dropping it would silently de-authorize every rotated key.
|
|
39
|
+
|
|
40
|
+
It is app data, never identity. The strategy merges it UNDER the framework's own claims: `provider` and the acting `userId` are written afterwards from `onBehalfOf` and always win, including when the answer is "none". A bag that could set `userId` would let whoever minted the key choose who the request is. Pinned end-to-end, not just at the protocol layer.
|
|
41
|
+
|
|
42
|
+
`PublicApiKey` also gains `createdBy` and `onBehalfOf`, so `service.list` can answer the two questions an admin actually asks about a shared credential. Neither is a secret — they are the accountability record, and omitting them hid them from the person responsible for the key.
|
|
43
|
+
- **@voltro/protocol, @voltro/cli** — A boot warning when two auth strategies claim the same bearer-token prefix.
|
|
44
|
+
|
|
45
|
+
The chain is first-match-wins, so a duplicate claim is not a harmless redundancy: whichever strategy runs first decides the Subject. An app that already has its own `sk_` keys and then sets `apiKeys: true` gets the framework strategy appended on the same prefix — resolving without the app's own team binding — and *which strategy answered* decides whether authorization works. Reported by an app that had to pin a test asserting it never enables the flag.
|
|
46
|
+
|
|
47
|
+
`AuthStrategy` gains an optional `claimsBearerPrefix`, set by `apiKeyStrategy` from its `prefix` option. Making the claim declarative is what makes the collision detectable at all — the same "only what is declared can be checked" argument the scope rules run on. Checked in `buildResolveSubject`, which both `voltro dev` and `voltro serve` call, so the two boot paths cannot drift.
|
|
48
|
+
|
|
49
|
+
A warning rather than a refusal: two strategies on one prefix can be deliberate (a migration window where old and new keys share a shape). What must not happen is that it goes unmentioned.
|
|
50
|
+
- **@voltro/protocol, @voltro/cli** — `auth.resolveScopes` — add scopes to an authenticated Subject from your own data, so ROLE-based authorization becomes declarable.
|
|
51
|
+
|
|
52
|
+
An app whose authorization is a database role (`requireCallerAdmin(ctx)` reading an `employees.role` column) is invisible to every static check the framework has: `voltro check`'s `rbac/unguarded-mutation` reports its writes as unguarded, and it is right to — nothing about the decision is declared. But the declarative alternative was unusable for exactly those apps: their subjects come from an external IdP's JWTs and 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.
|
|
53
|
+
|
|
54
|
+
Lifting the role into `subject.scopes` makes the SAME authorization declarable, visible in the manifest and checkable in CI. Deliberately narrow: the hook returns SCOPES, never a Subject — it cannot change `id` or `tenantId` (identity belongs to the auth strategy), and the result is unioned with the strategy's own scopes, so it can grant but never revoke. It runs per matched request, so cache the lookup yourself; the framework does not, because only the app knows how fast a role change must take effect. Wired identically in `voltro dev` and `voltro serve`.
|
|
55
|
+
- **@voltro/cli** — `voltro doctor` reports packages resolved at more than one version.
|
|
56
|
+
|
|
57
|
+
A consumer reported type errors inside the GENERATED `rpcGroup.generated.ts` — `Property '[TypeId]' is missing`, `typeof Never is not assignable to All`, an `Rpc<…, Stream<…>, …>` refused where `Any` was expected — and reasonably concluded the framework emits bad types, because the errors land in a file they cannot edit and did not write. That is the signature of two copies of `effect` in one install: Effect's types are nominal, so a Schema built by one copy is not the type the other expects.
|
|
58
|
+
|
|
59
|
+
It deserves its own check because the RUNTIME usually stays green — two instances only diverge where identity matters — so an app boots, serves and passes its tests while `tsc` is red, which sends people looking at the compiler instead of the dependency tree. The report names the versions, the paths, and the errors it explains. Only identity-sensitive packages count (`effect`, `@effect/*`, `@voltro/*`, react/react-dom); a duplicated string utility is wasteful, not a bug class.
|
|
60
|
+
- **@voltro/cli** — `voltro doctor` flags an executor that never names its own descriptor.
|
|
61
|
+
|
|
62
|
+
Descriptor/executor pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a complete, correct executor with no reference at all to the contract it implements. Those are exactly the files where a hand-written input drifts from the wire.
|
|
63
|
+
|
|
64
|
+
Reported after a 426-executor migration to `ExecutorInput<typeof descriptor>`: three files were skipped by the app's own codemod for a reason no reviewer would guess — they never imported their descriptor, so there was no `typeof` to point at. In the same codebase, six executors had written `boardPurpose: string` where their descriptor declared `Schema.Literal(...)`, discarding the contract at the executor boundary. Only imports of a SIBLING module clear the finding: an executor importing nothing but `@voltro/*` and `node:*` has still not named its contract.
|
|
65
|
+
- **@voltro/database** — `updateManyRow(store, table, patch, { where })` — the last untyped write is now typed against its table.
|
|
66
|
+
|
|
67
|
+
`insertRow` and `upsertRow` already were; `ctx.store.updateMany(table, row, { where })` still took a string table name and an untyped row literal. Worth closing because the typed versions were measured: migrating 29 `store.upsert` call sites to `upsertRow` produced 15 `tsc` errors across 8 distinct defects that no test had caught — including seven per-user mutations with no authentication check at all (they wrote `ctx.request.subject.id`, typed `string | null`, into a NOT NULL column, so an anonymous caller reached the database and got a raw statement failure instead of a typed refusal).
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- **@voltro/runtime** — A `cache:` declared on a query whose handler returns a COMPUTED value was silently ignored; it now says so.
|
|
72
|
+
|
|
73
|
+
The snapshot cache wraps the store read, and a computed query has none — its handler has already run by the time the binding is built. Caching one would mean wrapping the handler invocation, which is a different feature. Until that exists, the honest failure is a loud one: silently ignoring the config is how an author ends up believing a hot query is cached while every subscriber re-runs it. The data stays correct, so nothing else would ever tell them. Warned once per query name, not per subscribe.
|
|
74
|
+
- **@voltro/cli** — The minted `.env.local` is handed to the workspace's owner, and an unreadable env file explains itself.
|
|
75
|
+
|
|
76
|
+
A dev container running as root with the host workspace bind-mounted wrote `apps/api/.env.local` as `root:root 0600` INTO THE SHARED WORKSPACE. On the host, everything that loads env then died with EACCES — vitest, `voltro doctor`, the editor — and the developer could not even read the file, while the next container boot recreated it. Container-with-bind-mount is the ordinary dev shape, not an edge case.
|
|
77
|
+
|
|
78
|
+
`0600` stays (the file holds a real signing key), because loosening it to `0644` would make that key readable by every account on the machine for the far more common single-user case. Ownership was the wrong variable, so that is the one corrected: the mint chowns the file to whoever owns the directory, which root can do — exactly the case that needs it — and reports loudly when it cannot. A plain EACCES while loading an env file now names the owning uid, the mode and the current uid, because that pair IS the diagnosis and none of it appears in node's message.
|
|
79
|
+
- **@voltro/cli** — Four tooling fixes, all from downstream reports:
|
|
80
|
+
|
|
81
|
+
- **`voltro check --offline` crashed on any app that declares a workflow.** It built workflow entries as `{ name }` behind an `as never` while `InspectWorkflowEntry` is keyed by `tag`, so the manifest's sort read `undefined` and threw — surfacing as "could not assemble the graph from source" rather than the type error underneath. The cast is what let the two shapes disagree. - **`voltro check --offline` reported plugin tables as `dangling-source`.** It collected only the app's own `*.entity.ts` tables, so a query reading `_voltro_storage_refs` was an `error` — which sets the exit code, failing the CI gate the offline mode exists for. It now uses the same `assembleFrameworkTables` the migrator does. - **`voltro test` now derives `resolve.alias` from the app's tsconfig `paths`.** An app mapping `@/* → ./src/*` could not test any module importing through it (`Cannot find package '@/locales/en'`), and the workaround was a local `vitest.config.ts` restating what tsconfig already said. - **The `raw-fetch` doctor rule follows the import graph.** Keyed on filename conventions it caught 9 of 39 outbound calls on the reporting app; the other 30 were in `lib/*.ts` helpers only server code imports. A file reachable from a server-convention file and from nothing else is server code; one a page also imports is not, and stays unflagged.
|
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@effect/platform": "^0.96.1",
|
|
13
13
|
"@effect/rpc": "^0.75.1",
|
|
14
|
-
"@voltro/ai": "0.
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "0.
|
|
14
|
+
"@voltro/ai": "0.13.0",
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/database": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/protocol": "0.13.0",
|
|
19
|
+
"@voltro/runtime": "0.13.0",
|
|
20
20
|
"effect": "^3.21.2"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@voltro/testing": "0.
|
|
23
|
+
"@voltro/testing": "0.13.0",
|
|
24
24
|
"typescript": "^5.7.0",
|
|
25
25
|
"vitest": "^3.0.0"
|
|
26
26
|
}
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-auth": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
21
|
-
"@voltro/sql-postgres": "0.
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/database": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/plugin-auth": "0.13.0",
|
|
19
|
+
"@voltro/protocol": "0.13.0",
|
|
20
|
+
"@voltro/runtime": "0.13.0",
|
|
21
|
+
"@voltro/sql-postgres": "0.13.0",
|
|
22
22
|
"effect": "^3.21.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.13.0",
|
|
26
26
|
"typescript": "^5.7.0",
|
|
27
27
|
"vitest": "^3.0.0"
|
|
28
28
|
}
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/database": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.13.0",
|
|
19
|
+
"@voltro/protocol": "0.13.0",
|
|
20
|
+
"@voltro/runtime": "0.13.0",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.13.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-deactivation": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/database": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/plugin-deactivation": "0.13.0",
|
|
19
|
+
"@voltro/protocol": "0.13.0",
|
|
20
|
+
"@voltro/runtime": "0.13.0",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.13.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -15,7 +15,7 @@ import { database } from '../database/schema' // registers users / actors / tena
|
|
|
15
15
|
import createUser from '../mutations/users.create.mutation.server'
|
|
16
16
|
import deactivateUser from '../mutations/users.deactivate.mutation.server'
|
|
17
17
|
|
|
18
|
-
const subject = { type: 'user' as const, id: 'user_1', tenantId:
|
|
18
|
+
const subject = { type: 'user' as const, id: 'user_1', tenantId: 'acme' }
|
|
19
19
|
|
|
20
20
|
describe('users.create', () => {
|
|
21
21
|
it('inserts a user with an auto-injected id and no deactivation timestamp', async () => {
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@react-email/components": "^1.0.12",
|
|
14
14
|
"@react-email/render": "^1.4.0",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-mail": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/database": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/plugin-mail": "0.13.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.13.0",
|
|
20
|
+
"@voltro/protocol": "0.13.0",
|
|
21
|
+
"@voltro/runtime": "0.13.0",
|
|
22
22
|
"effect": "^3.21.2",
|
|
23
23
|
"react": "^19.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.13.0",
|
|
27
27
|
"typescript": "^5.7.0",
|
|
28
28
|
"vitest": "^3.0.0"
|
|
29
29
|
}
|