@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/dist/{apiBuild-DHHuiwXF.js → apiBuild-B6_ehbem.js} +9 -8
  3. package/dist/apiBuild-Bzpb383i.js +2 -0
  4. package/dist/appModuleLoader-C9r9mxZt.js +19 -0
  5. package/dist/bin.js +21 -10
  6. package/dist/cliArgs-qdZSElM3.js +28 -0
  7. package/dist/{commands-BwbMqR-1.js → commands-DYOuGlEC.js} +2903 -2647
  8. package/dist/dbCommand-ADD4mrTI.js +2 -0
  9. package/dist/dbCommand-DIOJQwsq.js +876 -0
  10. package/dist/{dev-Cvojvb_J.js → dev-Fb9BmavX.js} +1697 -2411
  11. package/dist/dev-xdijqojc.js +3 -0
  12. package/dist/frameworkTableAssembly-CYlrafoK.js +600 -0
  13. package/dist/frameworkTableAssembly-XvKO7wQt.js +2 -0
  14. package/dist/index.d.ts +19 -0
  15. package/dist/index.js +3 -3
  16. package/dist/inspect-DcZ04OME.js +2 -0
  17. package/dist/inspect-Dwx0_tUj.js +921 -0
  18. package/dist/inspectMetrics-DOiiII34.js +3408 -0
  19. package/dist/manifestBuild-D1MzJAiQ.js +2 -0
  20. package/dist/manifestBuild-P9yuCY2d.js +39 -0
  21. package/dist/seedRunner-D6eu-u5U.js +229 -0
  22. package/dist/serveCommand-CqYxzBZR.js +1113 -0
  23. package/dist/serveEntry.js +4 -4
  24. package/dist/{start-DyJE6w1R.js → start-CjG_0xhR.js} +346 -343
  25. package/dist/startEntry.js +2 -2
  26. package/dist/startup.js +1 -1
  27. package/dist/{startupRunner-DhlX9nqd.js → startupRunner-DPGFchOa.js} +19 -33
  28. package/package.json +17 -17
  29. package/templates/AGENTS.md +1 -0
  30. package/templates/agent-docs/_index.md +1 -0
  31. package/templates/agent-docs/authentication.md +36 -0
  32. package/templates/agent-docs/cli.md +254 -14
  33. package/templates/agent-docs/configuration.md +48 -0
  34. package/templates/agent-docs/data.md +39 -0
  35. package/templates/agent-docs/database/migrations.md +32 -0
  36. package/templates/agent-docs/database/transactions.md +31 -0
  37. package/templates/agent-docs/scheduling.md +35 -1
  38. package/templates/agent-docs/security.md +48 -0
  39. package/templates/agent-docs/templates/apibackends.md +90 -17
  40. package/templates/agent-docs/testing.md +24 -0
  41. package/templates/agent-docs/whats-new.md +81 -0
  42. package/templates/apps/api-ai/package.json +7 -7
  43. package/templates/apps/api-auth/package.json +8 -8
  44. package/templates/apps/api-backend/package.json +7 -7
  45. package/templates/apps/api-backend-deactivation/package.json +7 -7
  46. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +1 -1
  47. package/templates/apps/api-backend-mail/package.json +8 -8
  48. package/templates/apps/api-backend-mariadb/package.json +9 -9
  49. package/templates/apps/api-backend-storage/package.json +8 -8
  50. package/templates/apps/api-data-advanced/package.json +8 -8
  51. package/templates/apps/api-data-advanced/tests/queries.test.ts +6 -1
  52. package/templates/apps/api-durable/package.json +8 -8
  53. package/templates/apps/api-feature-flags/package.json +9 -9
  54. package/templates/apps/api-governance/app.config.ts +0 -1
  55. package/templates/apps/api-governance/package.json +8 -8
  56. package/templates/apps/api-kv/package.json +8 -8
  57. package/templates/apps/api-kv/tests/sync.test.ts +2 -2
  58. package/templates/apps/api-moderation/package.json +8 -8
  59. package/templates/apps/api-observability/package.json +8 -8
  60. package/templates/apps/api-ratelimit/package.json +8 -8
  61. package/templates/apps/api-rbac/README.md +53 -14
  62. package/templates/apps/api-rbac/app.config.ts +24 -26
  63. package/templates/apps/api-rbac/authz.ts +76 -0
  64. package/templates/apps/api-rbac/database/schema.ts +23 -6
  65. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +7 -5
  66. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +15 -8
  67. package/templates/apps/api-rbac/mutations/notes.delete.mutation.server.ts +54 -0
  68. package/templates/apps/api-rbac/mutations/notes.delete.mutation.ts +43 -0
  69. package/templates/apps/api-rbac/mutations/teams.rename.mutation.server.ts +19 -0
  70. package/templates/apps/api-rbac/mutations/teams.rename.mutation.ts +37 -0
  71. package/templates/apps/api-rbac/package.json +8 -8
  72. package/templates/apps/api-rbac/queries/notes.list.query.server.ts +12 -0
  73. package/templates/apps/api-rbac/queries/notes.list.query.ts +26 -0
  74. package/templates/apps/api-rbac/template.json +2 -2
  75. package/templates/apps/api-rbac/tests/authz.test.ts +233 -0
  76. package/templates/apps/api-rest/package.json +7 -7
  77. package/templates/apps/api-saas/package.json +11 -11
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-versioning/package.json +8 -8
  80. package/templates/apps/api-webhooks/package.json +9 -8
  81. package/templates/apps/changelog/package.json +6 -6
  82. package/templates/apps/edge-functions/package.json +2 -2
  83. package/templates/apps/frontend-admin/package.json +8 -8
  84. package/templates/apps/frontend-app/package.json +8 -8
  85. package/templates/apps/frontend-blank/package.json +7 -7
  86. package/templates/apps/frontend-contact/package.json +7 -7
  87. package/templates/apps/frontend-dashboard/package.json +7 -7
  88. package/templates/apps/frontend-docs/package.json +7 -7
  89. package/templates/apps/frontend-i18n/package.json +6 -6
  90. package/templates/apps/frontend-landing/package.json +7 -7
  91. package/templates/apps/frontend-spa/package.json +7 -7
  92. package/templates/apps/frontend-ssr/package.json +7 -7
  93. package/templates/apps/frontend-ssr-api/package.json +8 -8
  94. package/templates/apps/frontend-static-blog/package.json +6 -6
  95. package/dist/apiBuild-D9MtlD02.js +0 -2
  96. package/dist/dev-BsBsIv93.js +0 -2
  97. package/dist/inspectMetrics-D4EJ8LTi.js +0 -4255
  98. package/dist/serveCommand-ARqSs73o.js +0 -1117
  99. package/templates/apps/api-rbac/tests/notes.create.test.ts +0 -80
@@ -1,17 +1,25 @@
1
- // Creates a note — requires the `notes:write` scope. The executor guards with
2
- // `permission(ctx, 'notes:write')`, the Effect-native RBAC guard that fails
3
- // typed `ScopeError` when the caller's resolved scopes don't include it.
4
- // Declaring `error: ScopeError` surfaces that to the client typed.
1
+ // Creates a note — requires the `notes:write` scope, declared DECLARATIVELY.
5
2
  //
6
- // `ScopeError` is the ONE framework-wide denial tag, from the browser-safe
7
- // `@voltro/protocol` (rbac guards and declarative `guards:` fail with it alike).
3
+ // This is the form to reach for by default. The framework enforces it in the
4
+ // dispatch spine: before the executor, and before the transaction opens, so an
5
+ // unauthorized call never touches the DB. It also lands in the capability
6
+ // manifest, which is what lets `voltro check` fail on a guard requiring a scope
7
+ // no role grants — an in-handler `permission()` call is invisible to that
8
+ // check. Compare `notes.delete`, which needs the in-handler form for a reason.
9
+ //
10
+ // Declaring `guards:` merges the framework-wide `ScopeError` into the
11
+ // descriptor's error union automatically, so the client receives the denial
12
+ // typed without an explicit `error:` field. `ScopeError` is the ONE denial tag
13
+ // — declarative guards and `permission()` both fail with it, so a client
14
+ // branches once.
8
15
 
9
- import { defineMutation, ScopeError } from '@voltro/protocol'
16
+ import { defineMutation } from '@voltro/protocol'
10
17
  import { Schema } from 'effect'
11
18
 
12
19
  export const createNote = defineMutation({
13
20
  name: 'notes.create',
14
21
  target: { table: 'notes', op: 'insert' },
22
+ guards: [{ scope: 'notes:write' }],
15
23
  input: Schema.Struct({
16
24
  title: Schema.NonEmptyString,
17
25
  body: Schema.String,
@@ -22,5 +30,4 @@ export const createNote = defineMutation({
22
30
  body: Schema.String,
23
31
  tenantId: Schema.String,
24
32
  }),
25
- error: ScopeError,
26
33
  })
@@ -0,0 +1,54 @@
1
+ // The authz that needs the ROW. `notes:delete` was already enforced by the
2
+ // descriptor guard; what is decided here could not have been:
3
+ //
4
+ // archived note → hard delete
5
+ // active note + notes:purge → hard delete
6
+ // active note, no purge → archive instead (soft)
7
+ //
8
+ // `can()` is the non-throwing form — it BRANCHES rather than refusing, which
9
+ // is the right shape when the caller is authorized to do *something* and the
10
+ // scope only selects which. Use `permission()` (Effect, typed failure) when
11
+ // the answer is "you may not do this at all".
12
+ //
13
+ // Note that `notes:purge` is granted by NO named role in app.config.ts — only
14
+ // the wildcard `admin` role reaches it. That is deliberate and worth
15
+ // understanding: scopes used in `can()` / `permission()` are invisible to
16
+ // `voltro check`, so they are NOT validated against the declared vocabulary.
17
+ // A typo here fails silently as a branch that is never taken; a typo in a
18
+ // descriptor `guards:` is caught before the app boots. One more reason to
19
+ // prefer the declarative form wherever the decision doesn't need loaded data.
20
+
21
+ import { EffectStore } from '@voltro/runtime'
22
+ import type { AppContext } from '@voltro/runtime'
23
+ import { can } from '@voltro/plugin-rbac'
24
+ import { eq } from '@voltro/database'
25
+ import { Effect } from 'effect'
26
+ import { database } from '../database/schema'
27
+ import { NoteNotFound } from './notes.delete.mutation'
28
+
29
+ const execute = (input: { id: string }, ctx: AppContext) =>
30
+ Effect.gen(function* () {
31
+ const store = yield* EffectStore
32
+
33
+ // tenant() scopes this read to the caller's tenant automatically, so a
34
+ // cross-tenant id simply does not resolve.
35
+ // `.where(...)` takes the predicate AST (`eq(...)`), not three arguments.
36
+ const rows = yield* Effect.promise(() =>
37
+ ctx.store.query(database.notes.where(eq('id', input.id)).limit(1).descriptor),
38
+ )
39
+ const note = rows[0]
40
+ if (note === undefined) {
41
+ return yield* Effect.fail(new NoteNotFound({ id: input.id }))
42
+ }
43
+
44
+ const hard = note['archived'] === true || can(ctx, 'notes:purge')
45
+ if (hard) {
46
+ yield* store.delete('notes', input.id)
47
+ return { id: input.id, mode: 'hard' as const }
48
+ }
49
+
50
+ yield* store.update('notes', input.id, { archived: true })
51
+ return { id: input.id, mode: 'soft' as const }
52
+ })
53
+
54
+ export default execute
@@ -0,0 +1,43 @@
1
+ // Deleting a note needs BOTH forms of authorization, which is why this file
2
+ // exists next to `notes.create`.
3
+ //
4
+ // • The declarative guard below is the coarse gate: you need `notes:delete`
5
+ // to be in this conversation at all. It is checked before the transaction.
6
+ // • The executor then makes a decision the descriptor CANNOT: a note that is
7
+ // already archived may be hard-deleted, an active one is only soft-
8
+ // deleted unless the caller also holds `notes:purge`. That depends on the
9
+ // loaded ROW, which no descriptor guard can see.
10
+ //
11
+ // The rule of thumb the two illustrate: put in `guards:` everything decidable
12
+ // from the SUBJECT and the INPUT; use `permission()` / `can()` only for what
13
+ // needs loaded data. Guards are statically checkable, in-handler calls are not.
14
+
15
+ import { defineMutation } from '@voltro/protocol'
16
+ import { Schema } from 'effect'
17
+
18
+ /**
19
+ * Lives HERE, in the descriptor file, because the web client imports this
20
+ * module value-level and must be able to construct/match the error. Keeping
21
+ * typed errors in a file with zero server imports is what stops a descriptor
22
+ * from dragging the database handle into the browser bundle.
23
+ */
24
+ export class NoteNotFound extends Schema.TaggedError<NoteNotFound>()('NoteNotFound', {
25
+ id: Schema.String,
26
+ }) {}
27
+
28
+ export const deleteNote = defineMutation({
29
+ name: 'notes.delete',
30
+ target: { table: 'notes', op: 'update' },
31
+ guards: [{ scope: 'notes:delete' }],
32
+ input: Schema.Struct({
33
+ id: Schema.NonEmptyString,
34
+ }),
35
+ output: Schema.Struct({
36
+ id: Schema.String,
37
+ // 'hard' when the row was removed, 'soft' when it was only archived.
38
+ mode: Schema.Literal('hard', 'soft'),
39
+ }),
40
+ // `ScopeError` is merged in automatically by `guards:` — only the app's own
41
+ // error needs declaring.
42
+ error: NoteNotFound,
43
+ })
@@ -0,0 +1,19 @@
1
+ // Authz-free again: by the time this runs, the framework has already asked
2
+ // `resolveResourceRoles` whether the caller holds `teams:rename` on THIS
3
+ // `teamId`. Re-checking membership here would duplicate the rule.
4
+
5
+ import { EffectStore } from '@voltro/runtime'
6
+ import type { AppContext } from '@voltro/runtime'
7
+ import { Effect } from 'effect'
8
+
9
+ const execute = (input: { teamId: string; name: string }, _ctx: AppContext) =>
10
+ Effect.gen(function* () {
11
+ const store = yield* EffectStore
12
+ const row = yield* store.update('teams', input.teamId, { name: input.name })
13
+ return {
14
+ id: input.teamId,
15
+ name: (row?.['name'] as string | undefined) ?? input.name,
16
+ }
17
+ })
18
+
19
+ export default execute
@@ -0,0 +1,37 @@
1
+ // A RESOURCE-SCOPED guard: "owner of THIS team", not "owner globally".
2
+ //
3
+ // The `resource` extractor pulls the resource id out of the decoded input; the
4
+ // framework then asks rbac's `resolveResourceRoles` (app.config.ts) which roles
5
+ // the caller holds on that specific id, compiles them through the SAME role
6
+ // map, and grants the scope only if the compiled set contains it.
7
+ //
8
+ // The order is worth knowing, because it decides how much your membership
9
+ // lookup gets hit: a GLOBALLY held scope (or `admin:full`) satisfies the guard
10
+ // without the resolver ever being called. Only the gap — "no global grant" —
11
+ // falls through to the per-resource question. And that fall-through is
12
+ // fail-CLOSED: a resolver that throws or rejects denies, it does not degrade
13
+ // to the global answer.
14
+ //
15
+ // Without this, an app has to choose between a global grant (too broad) and
16
+ // hand-rolled membership checks inside every handler (untestable, easy to
17
+ // forget, invisible to `voltro check`).
18
+
19
+ import { defineMutation } from '@voltro/protocol'
20
+ import { Schema } from 'effect'
21
+
22
+ export const renameTeam = defineMutation({
23
+ name: 'teams.rename',
24
+ target: { table: 'teams', op: 'update' },
25
+ guards: [{
26
+ scope: 'teams:rename',
27
+ resource: (input: { teamId: string }) => input.teamId,
28
+ }],
29
+ input: Schema.Struct({
30
+ teamId: Schema.NonEmptyString,
31
+ name: Schema.NonEmptyString,
32
+ }),
33
+ output: Schema.Struct({
34
+ id: Schema.String,
35
+ name: Schema.String,
36
+ }),
37
+ })
@@ -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.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/env": "0.11.4",
18
- "@voltro/plugin-multitenancy": "0.11.4",
19
- "@voltro/plugin-rbac": "0.11.4",
20
- "@voltro/protocol": "0.11.4",
21
- "@voltro/runtime": "0.11.4",
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/plugin-rbac": "0.13.0",
20
+ "@voltro/protocol": "0.13.0",
21
+ "@voltro/runtime": "0.13.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.11.4",
25
+ "@voltro/testing": "0.13.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -0,0 +1,12 @@
1
+ // The executor is authz-free on purpose: the descriptor's `guards:` already
2
+ // refused an unscoped caller before this ran. Tenant scoping is likewise
3
+ // automatic — `notes` carries `tenant()`, so the runtime AND-merges
4
+ // `eq('tenantId', subject.tenantId)` into the query.
5
+
6
+ import type { AppContext } from '@voltro/runtime'
7
+ import { database } from '../database/schema'
8
+
9
+ const route = (_input: Record<string, never>, _ctx: AppContext) =>
10
+ database.notes.orderBy('id', 'desc').limit(100)
11
+
12
+ export default route
@@ -0,0 +1,26 @@
1
+ // Reads are guarded too. A write-only authorization model is the quiet half
2
+ // of a leak: the data still goes out, just without anyone writing it.
3
+ //
4
+ // `guards:` on a QUERY is enforced when the subscription opens AND re-checked
5
+ // on every delivery — so a caller whose role is revoked mid-subscription stops
6
+ // receiving rows rather than keeping a live feed open on an expired grant.
7
+
8
+ import { defineQuery } from '@voltro/protocol'
9
+ import { Schema } from 'effect'
10
+
11
+ export const listNotes = defineQuery({
12
+ name: 'notes.list',
13
+ source: 'notes',
14
+ // Declarative — the framework enforces it, and `voltro check` can SEE it.
15
+ // No `error: ScopeError` needed: declaring `guards:` merges `ScopeError`
16
+ // into the descriptor's error union automatically.
17
+ guards: [{ scope: 'notes:read' }],
18
+ input: Schema.Struct({}),
19
+ output: Schema.Array(Schema.Struct({
20
+ id: Schema.String,
21
+ title: Schema.String,
22
+ body: Schema.String,
23
+ archived: Schema.Boolean,
24
+ tenantId: Schema.String,
25
+ })),
26
+ })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "api-rbac",
3
3
  "kind": "api",
4
- "summary": "Role-based access control — @voltro/plugin-rbac compiles a role→scope map and an interceptor resolves the caller's roles to scopes; guard handlers with permission(ctx, 'notes:write') (Effect-native, fails typed Forbidden). admin:full bypasses. Memory/config-only, zero infra; useCan() for web UI affordance.",
5
- "tags": ["api", "rbac", "authz", "roles", "permissions", "plugin"]
4
+ "summary": "Role-based access control — @voltro/plugin-rbac compiles a role→scope map and an interceptor resolves the caller's roles to scopes. Shows all three guard forms: declarative descriptor guards (statically checked by `voltro check`), resource-scoped guards (owner of THIS team), and in-handler permission()/can() for authz that needs the loaded row. Fails typed ScopeError; admin:full bypasses. Memory/config-only, zero infra; useCan() for web UI affordance.",
5
+ "tags": ["api", "rbac", "authz", "roles", "permissions", "scopes", "guards", "plugin"]
6
6
  }
@@ -0,0 +1,233 @@
1
+ // Authorization tests, driven through the REAL rbac plugin.
2
+ //
3
+ // The important detail is `makeTestContext({ plugins: [...] })` plus `invoke`:
4
+ // that composes this app's actual interceptor chain with
5
+ // `composeRpcInterceptors` and enforces the descriptor's `guards:` with the
6
+ // same `checkGuardsEffect` the serve pipeline calls. So these tests exercise
7
+ // the whole path — role slugs → compiled scopes → effective-scope seam →
8
+ // guard — rather than asserting against hand-set `subject.scopes`.
9
+ //
10
+ // That distinction is the point. Putting the expected scopes on the subject
11
+ // yourself tests the guard while ASSUMING the resolution that produces them,
12
+ // which is the half that actually breaks: a role renamed in `authz.ts`, a
13
+ // resolver that throws, a scope no role grants. Those all pass a test that
14
+ // pre-stamps its own scopes and fail these.
15
+ //
16
+ // Run with `voltro test` (vitest).
17
+
18
+ import { describe, it, expect, afterEach } from 'vitest'
19
+ import { Schema } from 'effect'
20
+ import { makeTestContext, mockStore, invoke } from '@voltro/testing'
21
+ import { rbacPlugin } from '@voltro/plugin-rbac'
22
+ import { mutationToRpc, ScopeError, setResourceScopeResolver, type Subject } from '@voltro/protocol'
23
+
24
+ import { roles, rolesOnTeam, demoRolesForTenant } from '../authz'
25
+ import { database } from '../database/schema' // registers notes / teams / actors / tenants
26
+ import { createNote } from '../mutations/notes.create.mutation'
27
+ import createNoteHandler from '../mutations/notes.create.mutation.server'
28
+ import { deleteNote, NoteNotFound } from '../mutations/notes.delete.mutation'
29
+ import deleteNoteHandler from '../mutations/notes.delete.mutation.server'
30
+ import { renameTeam } from '../mutations/teams.rename.mutation'
31
+ import renameTeamHandler from '../mutations/teams.rename.mutation.server'
32
+ import { listNotes } from '../queries/notes.list.query'
33
+
34
+ /** The app's real plugin, built from the app's real role map. */
35
+ const plugin = () =>
36
+ rbacPlugin({
37
+ roles,
38
+ resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
39
+ resolveResourceRoles: (subject, resource) => rolesOnTeam(subject.id, resource),
40
+ })
41
+
42
+ /** A caller in the tenant that the demo resolver maps to `role`. */
43
+ const caller = (id: string, tenantId: string): Subject =>
44
+ ({ type: 'user', id, tenantId, scopes: [] }) as Subject
45
+
46
+ const ctxFor = (subject: Subject, seed: Record<string, ReadonlyArray<Record<string, unknown>>> = {}) =>
47
+ makeTestContext({ subject, store: mockStore({ notes: [], teams: [], ...seed }), plugins: [plugin()] })
48
+
49
+ // `rbacPlugin` registers its resource resolver process-globally; clear it so
50
+ // cases stay isolated from each other.
51
+ afterEach(() => { setResourceScopeResolver(undefined) })
52
+
53
+ describe('notes.create — a global declarative guard', () => {
54
+ it('an editor holds notes:write, so the note is written', async () => {
55
+ const ctx = ctxFor(caller('u_ed', 'editors'))
56
+
57
+ const note = await invoke(createNote, createNoteHandler, { title: 'Ship it', body: 'x' }, ctx)
58
+
59
+ expect(note.title).toBe('Ship it')
60
+ expect(note.tenantId).toBe('editors') // tenant() auto-stamped from the subject
61
+ expect(note.id).toMatch(/^note_/)
62
+ })
63
+
64
+ it('a reader is refused with a typed ScopeError, and nothing is written', async () => {
65
+ const ctx = ctxFor(caller('u_read', 'readers'))
66
+
67
+ await expect(invoke(createNote, createNoteHandler, { title: 'nope', body: '' }, ctx))
68
+ .rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:write' })
69
+
70
+ // The refusal has to be a refusal — not an error thrown after the insert.
71
+ const rows = await ctx.store.query(database.notes.descriptor)
72
+ expect(rows).toHaveLength(0)
73
+ })
74
+
75
+ it('the admin wildcard role passes', async () => {
76
+ const ctx = ctxFor(caller('u_root', 'acme'))
77
+ await expect(invoke(createNote, createNoteHandler, { title: 'ok', body: '' }, ctx)).resolves.toBeDefined()
78
+ })
79
+ })
80
+
81
+ describe('notes.list — reads are guarded too', () => {
82
+ it('a caller with no role at all cannot subscribe', async () => {
83
+ // An unknown tenant falls to the resolver's `['viewer']` default, so use a
84
+ // subject the app grants nothing: the guard must still hold.
85
+ const ctx = makeTestContext({
86
+ subject: { type: 'user', id: 'u_x', tenantId: 'nobody', scopes: [] } as Subject,
87
+ store: mockStore({ notes: [] }),
88
+ plugins: [rbacPlugin({ roles, resolveRoles: () => [] })],
89
+ })
90
+
91
+ await expect(invoke(listNotes, async () => [], {}, ctx))
92
+ .rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:read' })
93
+ })
94
+
95
+ it('a viewer may read', async () => {
96
+ const ctx = ctxFor(caller('u_v', 'readers'))
97
+ await expect(invoke(listNotes, async () => [], {}, ctx)).resolves.toEqual([])
98
+ })
99
+ })
100
+
101
+ describe('notes.delete — the decision the descriptor cannot make', () => {
102
+ it('an editor lacks notes:delete entirely', async () => {
103
+ const ctx = ctxFor(caller('u_ed', 'editors'))
104
+
105
+ await expect(invoke(deleteNote, deleteNoteHandler, { id: 'note_1' }, ctx))
106
+ .rejects.toMatchObject({ _tag: 'ScopeError', required: 'notes:delete' })
107
+ })
108
+
109
+ it('an owner SOFT-deletes an active note (no notes:purge)', async () => {
110
+ const ctx = ctxFor(caller('u_own', 'owners'))
111
+ const row = await ctx.store.insert('notes', { title: 'live', body: '', archived: false })
112
+
113
+ const out = await invoke(deleteNote, deleteNoteHandler, { id: row['id'] as string }, ctx)
114
+
115
+ expect(out.mode).toBe('soft')
116
+ const rows = await ctx.store.query(database.notes.descriptor)
117
+ expect(rows).toHaveLength(1)
118
+ expect(rows[0]!['archived']).toBe(true)
119
+ })
120
+
121
+ it('an owner HARD-deletes an already-archived note', async () => {
122
+ const ctx = ctxFor(caller('u_own', 'owners'))
123
+ const row = await ctx.store.insert('notes', { title: 'old', body: '', archived: true })
124
+
125
+ const out = await invoke(deleteNote, deleteNoteHandler, { id: row['id'] as string }, ctx)
126
+
127
+ expect(out.mode).toBe('hard')
128
+ expect(await ctx.store.query(database.notes.descriptor)).toHaveLength(0)
129
+ })
130
+
131
+ it('admin holds notes:purge via the wildcard, so an active note is hard-deleted', async () => {
132
+ const ctx = ctxFor(caller('u_root', 'acme'))
133
+ const row = await ctx.store.insert('notes', { title: 'live', body: '', archived: false })
134
+
135
+ const out = await invoke(deleteNote, deleteNoteHandler, { id: row['id'] as string }, ctx)
136
+
137
+ expect(out.mode).toBe('hard')
138
+ })
139
+
140
+ it('a missing note fails with the app\'s own typed error, not a scope error', async () => {
141
+ const ctx = ctxFor(caller('u_own', 'owners'))
142
+
143
+ await expect(invoke(deleteNote, deleteNoteHandler, { id: 'note_gone' }, ctx))
144
+ .rejects.toBeInstanceOf(NoteNotFound)
145
+ })
146
+ })
147
+
148
+ describe('teams.rename — a resource-scoped guard', () => {
149
+ it('alice is owner OF team_core, so she may rename it', async () => {
150
+ const ctx = ctxFor(caller('u_alice', 'nobody'))
151
+ await ctx.store.insert('teams', { id: 'team_core', name: 'Core' })
152
+
153
+ const out = await invoke(renameTeam, renameTeamHandler, { teamId: 'team_core', name: 'Platform' }, ctx)
154
+
155
+ expect(out.name).toBe('Platform')
156
+ })
157
+
158
+ it('the SAME caller is refused on a team where she is only a viewer', async () => {
159
+ // The whole reason resource scoping exists: the grant must not generalize.
160
+ const ctx = ctxFor(caller('u_alice', 'nobody'))
161
+ await ctx.store.insert('teams', { id: 'team_marketing', name: 'Marketing' })
162
+
163
+ await expect(invoke(renameTeam, renameTeamHandler, { teamId: 'team_marketing', name: 'Growth' }, ctx))
164
+ .rejects.toMatchObject({ _tag: 'ScopeError', required: 'teams:rename' })
165
+ })
166
+
167
+ it('a caller with no membership at all is refused', async () => {
168
+ const ctx = ctxFor(caller('u_stranger', 'nobody'))
169
+ await ctx.store.insert('teams', { id: 'team_core', name: 'Core' })
170
+
171
+ await expect(invoke(renameTeam, renameTeamHandler, { teamId: 'team_core', name: 'Mine' }, ctx))
172
+ .rejects.toMatchObject({ _tag: 'ScopeError' })
173
+ })
174
+
175
+ it('a GLOBAL grant satisfies it without consulting memberships', async () => {
176
+ // `owners` tenant → the `owner` role → `teams:rename` globally. The
177
+ // membership table is never asked, which is why an admin does not need a
178
+ // row in it for every resource.
179
+ const ctx = ctxFor(caller('u_nobody_in_memberships', 'owners'))
180
+ await ctx.store.insert('teams', { id: 'team_new', name: 'New' })
181
+
182
+ await expect(invoke(renameTeam, renameTeamHandler, { teamId: 'team_new', name: 'Renamed' }, ctx))
183
+ .resolves.toMatchObject({ name: 'Renamed' })
184
+ })
185
+ })
186
+
187
+ describe('the declared scope vocabulary `voltro check` reads', () => {
188
+ it('every scope any descriptor guard requires is granted by some role', () => {
189
+ // This is the assertion `voltro check` makes at build time, kept here as
190
+ // well because it is cheap and it fails with the offending scope named.
191
+ // A guard requiring a scope no role grants is not "misconfigured" — that
192
+ // procedure is permanently, silently uncallable by everyone.
193
+ const declared = new Set(rbacPlugin({ roles }).declaredScopes ?? [])
194
+ const guardScopes = [createNote, deleteNote, renameTeam, listNotes]
195
+ .flatMap((d) => (d.guards ?? []).flatMap((g) => {
196
+ const scope = (g as { scope?: string | ReadonlyArray<string> }).scope
197
+ return scope === undefined ? [] : typeof scope === 'string' ? [scope] : [...scope]
198
+ }))
199
+
200
+ expect(guardScopes.length).toBeGreaterThan(0) // the check must not be vacuous
201
+ for (const scope of guardScopes) expect([...declared]).toContain(scope)
202
+ })
203
+
204
+ it('the wildcard is not published as a literal scope name', () => {
205
+ const declared = rbacPlugin({ roles }).declaredScopes ?? []
206
+ expect(declared).not.toContain('*')
207
+ expect(declared).not.toContain('admin:full')
208
+ })
209
+ })
210
+
211
+ describe('descriptor wire contracts', () => {
212
+ it('notes.create decodes a valid input and rejects a blank title', () => {
213
+ const decode = Schema.decodeUnknownSync(createNote.input)
214
+ expect(decode({ title: 'ok', body: 'b' })).toEqual({ title: 'ok', body: 'b' })
215
+ expect(() => decode({ title: '', body: '' })).toThrow()
216
+ })
217
+
218
+ it('a guarded descriptor carries ScopeError in the WIRE error union', () => {
219
+ // The merge happens in `mutationToRpc` — the same conversion both the
220
+ // server group and the generated client group go through — not on the raw
221
+ // descriptor object. Asserting it on `createNote.error` would test the
222
+ // wrong layer and pass for the wrong reason: that field is `Schema.Never`
223
+ // here, because this descriptor declares no error of its own.
224
+ //
225
+ // This is why none of these descriptors writes `error: ScopeError` by hand
226
+ // and a client can still branch on one denial tag.
227
+ const wire = mutationToRpc(createNote)
228
+ const denial = new ScopeError({ required: 'notes:write', message: 'nope' })
229
+ // `Rpc`'s `errorSchema` is typed as the broad `Schema.All`; narrow it to
230
+ // the context-free form `Schema.is` takes.
231
+ expect(Schema.is(wire.errorSchema as Schema.Schema.AnyNoContext)(denial)).toBe(true)
232
+ })
233
+ })
@@ -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.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/plugin-openapi": "0.11.4",
18
- "@voltro/protocol": "0.11.4",
19
- "@voltro/runtime": "0.11.4",
20
- "@voltro/sql-postgres": "0.11.4",
15
+ "@voltro/cli": "0.13.0",
16
+ "@voltro/database": "0.13.0",
17
+ "@voltro/plugin-openapi": "0.13.0",
18
+ "@voltro/protocol": "0.13.0",
19
+ "@voltro/runtime": "0.13.0",
20
+ "@voltro/sql-postgres": "0.13.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.11.4",
24
+ "@voltro/testing": "0.13.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -12,20 +12,20 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/plugin-analytics-postgres": "0.11.4",
18
- "@voltro/plugin-billing": "0.11.4",
19
- "@voltro/plugin-multitenancy": "0.11.4",
20
- "@voltro/plugin-notifications": "0.11.4",
21
- "@voltro/plugin-presence": "0.11.4",
22
- "@voltro/protocol": "0.11.4",
23
- "@voltro/runtime": "0.11.4",
24
- "@voltro/sql-postgres": "0.11.4",
15
+ "@voltro/cli": "0.13.0",
16
+ "@voltro/database": "0.13.0",
17
+ "@voltro/plugin-analytics-postgres": "0.13.0",
18
+ "@voltro/plugin-billing": "0.13.0",
19
+ "@voltro/plugin-multitenancy": "0.13.0",
20
+ "@voltro/plugin-notifications": "0.13.0",
21
+ "@voltro/plugin-presence": "0.13.0",
22
+ "@voltro/protocol": "0.13.0",
23
+ "@voltro/runtime": "0.13.0",
24
+ "@voltro/sql-postgres": "0.13.0",
25
25
  "effect": "^3.21.2"
26
26
  },
27
27
  "devDependencies": {
28
- "@voltro/testing": "0.11.4",
28
+ "@voltro/testing": "0.13.0",
29
29
  "typescript": "^5.7.0",
30
30
  "vitest": "^3.0.0"
31
31
  }
@@ -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.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/env": "0.11.4",
18
- "@voltro/plugin-multitenancy": "0.11.4",
19
- "@voltro/plugin-search": "0.11.4",
20
- "@voltro/protocol": "0.11.4",
21
- "@voltro/runtime": "0.11.4",
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/plugin-search": "0.13.0",
20
+ "@voltro/protocol": "0.13.0",
21
+ "@voltro/runtime": "0.13.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.11.4",
25
+ "@voltro/testing": "0.13.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -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.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/env": "0.11.4",
18
- "@voltro/plugin-multitenancy": "0.11.4",
19
- "@voltro/plugin-versioning": "0.11.4",
20
- "@voltro/protocol": "0.11.4",
21
- "@voltro/runtime": "0.11.4",
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/plugin-versioning": "0.13.0",
20
+ "@voltro/protocol": "0.13.0",
21
+ "@voltro/runtime": "0.13.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.11.4",
25
+ "@voltro/testing": "0.13.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -12,17 +12,18 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.11.4",
16
- "@voltro/database": "0.11.4",
17
- "@voltro/plugin-multitenancy": "0.11.4",
18
- "@voltro/plugin-webhooks": "0.11.4",
19
- "@voltro/protocol": "0.11.4",
20
- "@voltro/runtime": "0.11.4",
21
- "@voltro/sql-postgres": "0.11.4",
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/plugin-webhooks": "0.13.0",
20
+ "@voltro/protocol": "0.13.0",
21
+ "@voltro/runtime": "0.13.0",
22
+ "@voltro/sql-postgres": "0.13.0",
22
23
  "effect": "^3.21.2"
23
24
  },
24
25
  "devDependencies": {
25
- "@voltro/testing": "0.11.4",
26
+ "@voltro/testing": "0.13.0",
26
27
  "typescript": "^5.7.0",
27
28
  "vitest": "^3.0.0"
28
29
  }