@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
@@ -12,18 +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/env": "0.11.4",
18
- "@voltro/plugin-multitenancy": "0.11.4",
19
- "@voltro/plugin-storage": "0.11.4",
20
- "@voltro/protocol": "0.11.4",
21
- "@voltro/runtime": "0.11.4",
22
- "@voltro/sql-mysql": "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-storage": "0.13.0",
20
+ "@voltro/protocol": "0.13.0",
21
+ "@voltro/runtime": "0.13.0",
22
+ "@voltro/sql-mysql": "0.13.0",
23
23
  "effect": "^3.21.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.11.4",
26
+ "@voltro/testing": "0.13.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }
@@ -10,17 +10,17 @@
10
10
  "test": "voltro test"
11
11
  },
12
12
  "dependencies": {
13
- "@voltro/cli": "0.11.4",
14
- "@voltro/database": "0.11.4",
15
- "@voltro/env": "0.11.4",
16
- "@voltro/plugin-multitenancy": "0.11.4",
17
- "@voltro/plugin-storage": "0.11.4",
18
- "@voltro/protocol": "0.11.4",
19
- "@voltro/runtime": "0.11.4",
13
+ "@voltro/cli": "0.13.0",
14
+ "@voltro/database": "0.13.0",
15
+ "@voltro/env": "0.13.0",
16
+ "@voltro/plugin-multitenancy": "0.13.0",
17
+ "@voltro/plugin-storage": "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.11.4",
23
+ "@voltro/testing": "0.13.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.11.4",
15
- "@voltro/database": "0.11.4",
16
- "@voltro/env": "0.11.4",
17
- "@voltro/plugin-governance": "0.11.4",
18
- "@voltro/plugin-multitenancy": "0.11.4",
19
- "@voltro/protocol": "0.11.4",
20
- "@voltro/runtime": "0.11.4",
14
+ "@voltro/cli": "0.13.0",
15
+ "@voltro/database": "0.13.0",
16
+ "@voltro/env": "0.13.0",
17
+ "@voltro/plugin-governance": "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.11.4",
24
+ "@voltro/testing": "0.13.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -71,7 +71,12 @@ describe('authors.withBooks', () => {
71
71
  const rows = await ctx.store.query(authorsWithBooksExecute({}, ctx).descriptor)
72
72
 
73
73
  expect(rows).toHaveLength(1)
74
- const ada = rows[0] as { id: string; name: string; books: ReadonlyArray<{ title: string }> }
74
+ // Narrow BEFORE describing the shape: under `noUncheckedIndexedAccess`
75
+ // `rows[0]` is `Row | undefined`, and a cast that spans that gap is the one
76
+ // TypeScript refuses outright ("neither type sufficiently overlaps").
77
+ const first = rows[0]
78
+ if (first === undefined) throw new Error('expected one author row')
79
+ const ada = first as unknown as { id: string; name: string; books: ReadonlyArray<{ title: string }> }
75
80
  expect(ada.name).toBe('Ada')
76
81
  expect(ada.books.map((b) => b.title).sort()).toEqual(['Analytical Engine', 'Notes'])
77
82
  })
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.11.4",
15
- "@voltro/database": "0.11.4",
16
- "@voltro/env": "0.11.4",
17
- "@voltro/plugin-multitenancy": "0.11.4",
18
- "@voltro/protocol": "0.11.4",
19
- "@voltro/runtime": "0.11.4",
20
- "@voltro/workflow": "0.11.4",
14
+ "@voltro/cli": "0.13.0",
15
+ "@voltro/database": "0.13.0",
16
+ "@voltro/env": "0.13.0",
17
+ "@voltro/plugin-multitenancy": "0.13.0",
18
+ "@voltro/protocol": "0.13.0",
19
+ "@voltro/runtime": "0.13.0",
20
+ "@voltro/workflow": "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,18 +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/env": "0.11.4",
18
- "@voltro/plugin-flags": "0.11.4",
19
- "@voltro/plugin-multitenancy": "0.11.4",
20
- "@voltro/protocol": "0.11.4",
21
- "@voltro/runtime": "0.11.4",
22
- "@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-flags": "0.13.0",
19
+ "@voltro/plugin-multitenancy": "0.13.0",
20
+ "@voltro/protocol": "0.13.0",
21
+ "@voltro/runtime": "0.13.0",
22
+ "@voltro/sql-postgres": "0.13.0",
23
23
  "effect": "^3.21.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.11.4",
26
+ "@voltro/testing": "0.13.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }
@@ -44,5 +44,4 @@ export default {
44
44
  retention: [{ table: 'profiles', ttlMs: 365 * 86_400_000, action: 'delete' }],
45
45
  }),
46
46
  ],
47
- env,
48
47
  }
@@ -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-audit": "0.11.4",
19
- "@voltro/plugin-governance": "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-audit": "0.13.0",
19
+ "@voltro/plugin-governance": "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
  }
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.11.4",
15
- "@voltro/database": "0.11.4",
16
- "@voltro/env": "0.11.4",
17
- "@voltro/kv": "0.11.4",
18
- "@voltro/plugin-multitenancy": "0.11.4",
19
- "@voltro/protocol": "0.11.4",
20
- "@voltro/runtime": "0.11.4",
14
+ "@voltro/cli": "0.13.0",
15
+ "@voltro/database": "0.13.0",
16
+ "@voltro/env": "0.13.0",
17
+ "@voltro/kv": "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.11.4",
24
+ "@voltro/testing": "0.13.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -38,7 +38,7 @@ describe('sync.pull (durable cursor + idempotency markers)', () => {
38
38
  const rows = await ctx.store.query(database.syncedEvents.descriptor)
39
39
  expect(rows).toHaveLength(3)
40
40
  expect(rows.every((r) => r['tenantId'] === 'acme')).toBe(true)
41
- expect((rows.map((r) => r['externalId']) as ReadonlyArray<string>).sort()).toEqual([
41
+ expect((rows.map((r) => r['externalId']) as string[]).sort()).toEqual([
42
42
  'ext-1',
43
43
  'ext-2',
44
44
  'ext-3',
@@ -63,7 +63,7 @@ describe('sync.pull (durable cursor + idempotency markers)', () => {
63
63
 
64
64
  const rows = await ctx.store.query(database.syncedEvents.descriptor)
65
65
  expect(rows).toHaveLength(4) // 2 + 2, no duplicates
66
- expect((rows.map((r) => r['sequence']) as ReadonlyArray<number>).sort((a, b) => a - b)).toEqual([
66
+ expect((rows.map((r) => r['sequence']) as number[]).sort((a, b) => a - b)).toEqual([
67
67
  1, 2, 3, 4,
68
68
  ])
69
69
  })
@@ -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-moderation": "0.11.4",
19
- "@voltro/plugin-multitenancy": "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-moderation": "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
  },
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/plugin-multitenancy": "0.11.4",
18
- "@voltro/plugin-prometheus": "0.11.4",
19
- "@voltro/plugin-sentry": "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/plugin-multitenancy": "0.13.0",
18
+ "@voltro/plugin-prometheus": "0.13.0",
19
+ "@voltro/plugin-sentry": "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-ratelimit": "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-ratelimit": "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
  }
@@ -13,19 +13,39 @@ pnpm --filter @{{projectName}}/{{appName}} dev
13
13
 
14
14
  ## What this shows
15
15
 
16
- - **Roles → scopes, as config** — `app.config.ts` declares a role map
17
- (`viewer notes:read`, `editor notes:read + notes:write`,
18
- `admin admin:full`). An rpc interceptor resolves each caller's roles to
19
- scopes and stamps them on the subject.
20
- - **Guard with `permission()`** — `notes.create` calls
21
- `permission(ctx, 'notes:write')`, the Effect-native guard. It fails typed
22
- `ScopeError` when the caller lacks the scope; `admin:full` is a blanket bypass.
23
- Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw),
24
- `can(ctx, scope)` (boolean).
25
- - **Where roles come from** the shipped `resolveRoles` is a DEMO that maps
26
- the request's **tenant** to a role so you can try each with the `x-tenant`
27
- header. Production reads the caller's real roles: the default resolver reads
28
- `subject.metadata.roles` (set by your auth strategy), or do a DB lookup.
16
+ - **Roles → scopes, as config** — `authz.ts` declares the role map; an rpc
17
+ interceptor resolves each caller's roles to scopes and publishes them to the
18
+ framework's effective-scope seam. That map is also the app's **declared scope
19
+ vocabulary**: `voltro check` fails on a guard requiring a scope no role grants.
20
+ - **Declarative guards** (`notes.create`, `notes.list`) — `guards: [{ scope }]`
21
+ on the descriptor. Enforced before the executor and before the transaction,
22
+ and the only form `voltro check` can inspect. **Prefer this.**
23
+ - **Resource-scoped guards** (`teams.rename`) `guards: [{ scope, resource }]`
24
+ plus `resolveResourceRoles`, so "owner" means owner *of that team*.
25
+ - **In-handler guards** (`notes.delete`)`can()` / `permission()` for the
26
+ decision a descriptor cannot make, because it depends on the loaded row.
27
+ - **Where roles come from** — the shipped `resolveRoles` is a DEMO mapping the
28
+ request's **tenant** to a role, so you can try each with an `x-tenant` header.
29
+ Production reads the caller's real roles (`subject.metadata.roles`, or a DB
30
+ lookup).
31
+
32
+ ## Which guard form?
33
+
34
+ Put in `guards:` everything decidable from the **subject** and the **input**.
35
+ Reach for `permission()` / `can()` only for what needs loaded data.
36
+
37
+ The difference is not style. A declarative guard lands in the capability
38
+ manifest, so `voltro check` catches a scope no role grants — a bug that is
39
+ otherwise invisible, because the procedure simply becomes uncallable by
40
+ everyone, forever, with no error anywhere. An in-handler call is invisible to
41
+ that check; a typo there is a branch that is silently never taken.
42
+
43
+ ## Failure postures
44
+
45
+ | Resolver | On throw / reject |
46
+ | --- | --- |
47
+ | `resolveRoles` / `resolvePermissions` | logs, degrades to the subject's **own** scopes — never grants |
48
+ | `resolveResourceRoles` | **denies** (fail-closed) |
29
49
 
30
50
  ## Try it
31
51
 
@@ -41,6 +61,25 @@ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json
41
61
  # → { ok:false, error:{ _tag:"ScopeError", required:"notes:write" } }
42
62
  ```
43
63
 
64
+ ```bash
65
+ voltro check --offline # no running app needed — usable as a CI gate
66
+ ```
67
+
68
+ ## Tests
69
+
70
+ `tests/authz.test.ts` drives the **real** plugin — `makeTestContext({ plugins })`
71
+ composes the actual interceptor chain and `invoke` enforces the descriptor's
72
+ guards with the same function the serve pipeline calls.
73
+
74
+ Do not hand-set `subject.scopes` to the value you expect. That tests the guard
75
+ while assuming the resolution that produces it, and the resolution is the half
76
+ that actually breaks (a renamed role, a throwing resolver, a scope no role
77
+ grants) — all of which pass a pre-stamped test.
78
+
79
+ ```bash
80
+ pnpm --filter @{{projectName}}/{{appName}} test
81
+ ```
82
+
44
83
  ## On the web side
45
84
 
46
85
  ```tsx
@@ -54,7 +93,7 @@ const canWrite = useCan('notes:write')
54
93
  The UI gate ships in `@voltro/client`, not in the plugin: scopes are a framework
55
94
  concept, so gating a button must not require an rbac dependency.
56
95
 
57
- `useCan` is a UI affordance only — the server `permission()` guard is the real
96
+ `useCan` is a UI affordance only — the server-side guard is the real
58
97
  enforcement. Never trust the client.
59
98
 
60
99
  ## Persisted roles
@@ -1,26 +1,31 @@
1
1
  // RBAC backend for the {{projectName}} project.
2
2
  //
3
3
  // `rbacPlugin` compiles a role→scope map; an rpc interceptor resolves each
4
- // caller's ROLES to SCOPES and stamps them on the subject, so in-handler
5
- // guards like `permission(ctx, 'notes:write')` (Effect-native, fails typed
6
- // `Forbidden`) just work. The `admin:full` scope is a blanket bypass.
4
+ // caller's ROLES to SCOPES and publishes them to the framework's effective-
5
+ // scope seam. From there BOTH authorization forms see them:
7
6
  //
8
- // Config-onlyno tables, no infra.
7
+ // declarative `guards:` on a descriptor enforced by the framework in the
8
+ // dispatch spine, before the executor and (for a mutation) before the
9
+ // transaction opens. PREFER THIS. It is the only form `voltro check` can
10
+ // inspect, so a guard requiring a scope no role grants is caught
11
+ // statically instead of becoming a permanently uncallable procedure.
12
+ // • in-handler `permission()` / `can()` — for authz that needs LOADED data a
13
+ // descriptor guard cannot see (see `notes.delete`).
14
+ //
15
+ // The `admin:full` scope is a blanket bypass for both.
16
+ //
17
+ // The role map itself lives in `authz.ts` so the tests can build the real
18
+ // plugin from the real roles.
19
+ //
20
+ // Config-only — no external infra.
9
21
  import { defineEnv, envVar } from '@voltro/env'
10
22
  import { rbacPlugin } from '@voltro/plugin-rbac'
23
+ import { demoRolesForTenant, roles, rolesOnTeam } from './authz'
11
24
 
12
25
  export const env = defineEnv({
13
26
  LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
14
27
  })
15
28
 
16
- // role → the scopes it grants. Scopes are `domain:action`; `admin:full` is the
17
- // framework's blanket bypass (passes every permission check).
18
- const roles = {
19
- viewer: ['notes:read'],
20
- editor: ['notes:read', 'notes:write'],
21
- admin: ['admin:full'],
22
- }
23
-
24
29
  export default {
25
30
  type: 'api' as const,
26
31
  name: '{{capProjectName}}{{capAppName}}',
@@ -28,20 +33,13 @@ export default {
28
33
  plugins: [
29
34
  rbacPlugin({
30
35
  roles,
31
- // DEMO resolver — assigns a role by the request's tenant so you can try
32
- // each role via the `x-tenant` header with zero auth setup. PRODUCTION
33
- // reads the caller's REAL roles: the default resolver reads
34
- // `subject.metadata.roles` (set by your auth strategy), or do a DB
35
- // lookup here (`subject.id` / `subject.tenantId` roles). Delete this
36
- // whole `resolveRoles` to fall back to the metadata default.
37
- resolveRoles: (subject) => {
38
- const demo: Record<string, ReadonlyArray<string>> = {
39
- acme: ['admin'],
40
- editors: ['editor'],
41
- readers: ['viewer'],
42
- }
43
- return demo[subject.tenantId ?? ''] ?? ['viewer']
44
- },
36
+ resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
37
+ // PER-RESOURCE roles what makes `guards: [{ scope, resource }]` mean
38
+ // "owner OF THIS TEAM" instead of "owner globally". Consulted only for
39
+ // the gap: a globally-held scope (or `admin:full`) passes without ever
40
+ // calling this. Fail-CLOSED if it throws or rejects the guard DENIES,
41
+ // it does not fall back to the global answer.
42
+ resolveResourceRoles: (subject, resource) => rolesOnTeam(subject.id, resource),
45
43
  }),
46
44
  ],
47
45
  env,
@@ -0,0 +1,76 @@
1
+ // The app's authorization vocabulary, in ONE place.
2
+ //
3
+ // Split out of `app.config.ts` so the tests can build the real plugin from the
4
+ // real role map. A test that redeclares the roles it is testing proves only
5
+ // that its own copy is self-consistent — the copy drifts, the test stays
6
+ // green, and the drift is exactly the thing worth catching.
7
+
8
+ /**
9
+ * role → the scopes it grants. Scopes are `domain:action`; `'*'` expands to
10
+ * the framework's blanket bypass (`admin:full`), which passes every check.
11
+ *
12
+ * This map is ALSO the app's declared scope vocabulary: `rbacPlugin` publishes
13
+ * the union of it, and `voltro check` errors on any descriptor guard requiring
14
+ * a scope that appears nowhere here. So: add a scope to a guard → add it to a
15
+ * role, or the build tells you the procedure is uncallable.
16
+ *
17
+ * (`notes:purge`, used by `notes.delete` via `can()`, is deliberately absent —
18
+ * only the wildcard `admin` role reaches it. See that handler for why that is
19
+ * a lesson rather than an oversight.)
20
+ */
21
+ export const roles = {
22
+ viewer: ['notes:read'],
23
+ editor: ['notes:read', 'notes:write'],
24
+ owner: ['notes:read', 'notes:write', 'notes:delete', 'teams:rename'],
25
+ admin: ['*'],
26
+ }
27
+
28
+ /**
29
+ * DEMO membership table — which roles a caller holds ON A SPECIFIC team.
30
+ * PRODUCTION does a DB read (a `memberships` lookup keyed by `subject.id` +
31
+ * the resource id); this literal keeps the template infra-free.
32
+ */
33
+ export const teamMemberships: Record<string, Record<string, ReadonlyArray<string>>> = {
34
+ // subjectId → teamId → the roles held on THAT team
35
+ u_alice: { team_core: ['owner'], team_marketing: ['viewer'] },
36
+ u_bob: { team_marketing: ['owner'] },
37
+ }
38
+
39
+ /**
40
+ * Roles the caller holds on ONE resource. `Subject.id` is `string | null`
41
+ * (an anonymous caller has no id), and an anonymous caller is a member of
42
+ * nothing — so the null case returns `[]` rather than being coerced into a
43
+ * lookup key that could collide.
44
+ */
45
+ export const rolesOnTeam = (
46
+ subjectId: string | null | undefined,
47
+ teamId: string,
48
+ ): ReadonlyArray<string> =>
49
+ subjectId == null ? [] : teamMemberships[subjectId]?.[teamId] ?? []
50
+
51
+ /**
52
+ * DEMO role resolver — assigns a role by the request's TENANT so every role is
53
+ * reachable with an `x-tenant` header and zero auth setup.
54
+ *
55
+ * PRODUCTION reads the caller's REAL roles: the default resolver reads
56
+ * `subject.metadata.roles` (set by your auth strategy), or do a DB lookup here
57
+ * (`subject.id` / `subject.tenantId` → roles). Delete this and omit
58
+ * `resolveRoles` entirely to fall back to the metadata default.
59
+ *
60
+ * Know the failure posture whatever you put here: if this throws or rejects,
61
+ * rbac logs and degrades to the subject's OWN scopes. It never grants on
62
+ * failure — but it also does not deny a caller whose api key already carried
63
+ * valid scopes.
64
+ */
65
+ export const demoRolesForTenant = (
66
+ // `Subject.tenantId` is `string | null` — an anonymous caller has no tenant.
67
+ tenantId: string | null | undefined,
68
+ ): ReadonlyArray<string> => {
69
+ const demo: Record<string, ReadonlyArray<string>> = {
70
+ acme: ['admin'],
71
+ owners: ['owner'],
72
+ editors: ['editor'],
73
+ readers: ['viewer'],
74
+ }
75
+ return demo[tenantId ?? ''] ?? ['viewer']
76
+ }
@@ -1,7 +1,9 @@
1
- // Schema for the {{projectName}} RBAC backend — a tiny `notes` domain to
2
- // guard. `actors` + `tenants` are the framework core tables.
1
+ // Schema for the {{projectName}} RBAC backend — a `notes` domain guarded by
2
+ // GLOBAL scopes, plus a `teams` domain guarded PER RESOURCE. `actors` +
3
+ // `tenants` are the framework core tables.
3
4
 
4
5
  import {
6
+ boolean,
5
7
  databaseHandle,
6
8
  id,
7
9
  table,
@@ -25,13 +27,28 @@ export const tenants = table('tenants', {
25
27
  })
26
28
 
27
29
  export const notes = table('notes', {
28
- id: id({ prefix: 'note' }),
29
- title: text(),
30
- body: text(),
30
+ id: id({ prefix: 'note' }),
31
+ title: text(),
32
+ body: text(),
33
+ // `notes.delete` demonstrates authz that a descriptor guard CANNOT express:
34
+ // whether a note may be hard-deleted depends on this column, which is only
35
+ // known after the row is loaded.
36
+ archived: boolean().default(false),
37
+ })
38
+ .with(tenant())
39
+ .reactive()
40
+
41
+ // Guarded per-resource rather than globally: holding `teams:rename` on
42
+ // `team_core` must not let you rename `team_marketing`. The grant comes from
43
+ // `resolveResourceRoles` in app.config.ts.
44
+ export const teams = table('teams', {
45
+ id: id({ prefix: 'team' }),
46
+ name: text(),
31
47
  })
32
48
  .with(tenant())
33
49
  .reactive()
34
50
 
35
51
  export type Note = InferRow<typeof notes>
52
+ export type Team = InferRow<typeof teams>
36
53
 
37
- export const database = databaseHandle({ actors, tenants, notes })
54
+ export const database = databaseHandle({ actors, tenants, notes, teams })
@@ -1,13 +1,15 @@
1
+ // No authz in here — deliberately. The descriptor's `guards: [{ scope:
2
+ // 'notes:write' }]` already refused an unscoped caller before this executor
3
+ // was reached. Re-checking the same scope by hand would be a second copy of
4
+ // the rule that can drift from the first, and the one `voltro check` reads is
5
+ // the descriptor's.
6
+
1
7
  import { EffectStore } from '@voltro/runtime'
2
8
  import type { AppContext } from '@voltro/runtime'
3
- import { permission } from '@voltro/plugin-rbac'
4
9
  import { Effect } from 'effect'
5
10
 
6
- const execute = (input: { title: string; body: string }, ctx: AppContext) =>
11
+ const execute = (input: { title: string; body: string }, _ctx: AppContext) =>
7
12
  Effect.gen(function* () {
8
- // RBAC guard — passes for editor/admin, fails typed ScopeError for viewer.
9
- yield* permission(ctx, 'notes:write')
10
-
11
13
  const store = yield* EffectStore
12
14
  // tenant() auto-fills tenantId from the subject — no need to pass it.
13
15
  const row = yield* store.insert('notes', { title: input.title, body: input.body })