@voltro/cli 0.12.0 → 0.14.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 +378 -0
- package/dist/{apiBuild-GefjNI5d.js → apiBuild-370ITNRk.js} +3 -3
- package/dist/apiBuild-DatWP5hh.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-BmNi1RgZ.js → commands-D-teL_TM.js} +3756 -2539
- package/dist/dbCommand-B3CeMU3u.js +2 -0
- package/dist/{dbCommand-DNrkb58g.js → dbCommand-Dmi-vHfA.js} +72 -57
- package/dist/{dev-d0LjACxE.js → dev-CKyW_LAv.js} +1511 -1331
- package/dist/dev-CPKbckaN.js +3 -0
- package/dist/fileConventions-COg0ZytD.js +9 -0
- package/dist/frameworkTableAssembly-DCLhpHsF.js +601 -0
- package/dist/frameworkTableAssembly-DYzb2elg.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-uEfIS1vp.js → inspectMetrics-CyVMeofr.js} +962 -943
- package/dist/seedRunner-D6eu-u5U.js +229 -0
- package/dist/serveCommand-CU3Sw4ZM.js +1127 -0
- package/dist/serveEntry.js +3 -3
- package/dist/{start-DKcQd52a.js → start-D8ATvTzo.js} +246 -246
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +77 -3
- package/templates/AGENTS.md +78 -4
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +2 -2
- package/templates/agent-docs/authentication.md +39 -0
- package/templates/agent-docs/cli.md +114 -6
- package/templates/agent-docs/configuration.md +48 -0
- package/templates/agent-docs/data.md +193 -1
- package/templates/agent-docs/database/hosting.md +2 -2
- package/templates/agent-docs/database/migrations.md +88 -3
- package/templates/agent-docs/database/overview.md +11 -0
- package/templates/agent-docs/database/seedsdialects.md +53 -1
- package/templates/agent-docs/database/transactions.md +31 -0
- package/templates/agent-docs/internationalization.md +3 -3
- package/templates/agent-docs/introduction.md +133 -2
- package/templates/agent-docs/plugins.md +2 -1
- package/templates/agent-docs/reference.md +4 -4
- package/templates/agent-docs/routing.md +48 -43
- package/templates/agent-docs/schema-driven-ui.md +12 -2
- package/templates/agent-docs/templates/apibackends.md +1 -8
- package/templates/agent-docs/templates/appshells.md +24 -24
- package/templates/agent-docs/testing.md +40 -1
- package/templates/agent-docs/whats-new.md +51 -131
- package/templates/apps/api-ai/database/schema.ts +0 -2
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/database/schema.ts +0 -3
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
- 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/database/schema.ts +0 -3
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/database/schema.ts +0 -3
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
- package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
- 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/database/schema.ts +0 -3
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/database/schema.ts +0 -1
- 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/database/schema.ts +0 -1
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/database/schema.ts +0 -3
- 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/database/schema.ts +0 -2
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/database/schema.ts +0 -1
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/database/schema.ts +0 -1
- 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 +22 -7
- 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/database/schema.ts +0 -3
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/database/schema.ts +0 -1
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/database/schema.ts +0 -1
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/database/schema.ts +0 -1
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/database/schema.ts +0 -1
- package/templates/apps/api-webhooks/package.json +9 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/changelog/src/pages/[locale]/{[slug].tsx → [slug].page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
- package/templates/apps/changelog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
- package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
- package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
- package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].tsx → [slug].page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
- package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/dist/apiBuild-s7swuEU5.js +0 -2
- package/dist/dbCommand-Bt6BCG7g.js +0 -2
- package/dist/dev-YHbHmeXt.js +0 -3
- package/dist/seedRunner-DZmhoqNt.js +0 -821
- package/dist/serveCommand-B2-YV9TU.js +0 -1112
- package/templates/apps/api-rbac/tests/notes.create.test.ts +0 -80
- /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
- /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
- /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Schema for the {{projectName}} RBAC backend — a
|
|
2
|
-
//
|
|
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,26 @@ export const tenants = table('tenants', {
|
|
|
25
27
|
})
|
|
26
28
|
|
|
27
29
|
export const notes = table('notes', {
|
|
28
|
-
id:
|
|
29
|
-
title:
|
|
30
|
-
body:
|
|
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
|
+
|
|
40
|
+
// Guarded per-resource rather than globally: holding `teams:rename` on
|
|
41
|
+
// `team_core` must not let you rename `team_marketing`. The grant comes from
|
|
42
|
+
// `resolveResourceRoles` in app.config.ts.
|
|
43
|
+
export const teams = table('teams', {
|
|
44
|
+
id: id({ prefix: 'team' }),
|
|
45
|
+
name: text(),
|
|
31
46
|
})
|
|
32
47
|
.with(tenant())
|
|
33
|
-
.reactive()
|
|
34
48
|
|
|
35
49
|
export type Note = InferRow<typeof notes>
|
|
50
|
+
export type Team = InferRow<typeof teams>
|
|
36
51
|
|
|
37
|
-
export const database = databaseHandle({ actors, tenants, notes })
|
|
52
|
+
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 },
|
|
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 })
|
|
@@ -1,17 +1,25 @@
|
|
|
1
|
-
// Creates a note — requires the `notes:write` scope
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/plugin-rbac": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.14.0",
|
|
16
|
+
"@voltro/database": "0.14.0",
|
|
17
|
+
"@voltro/env": "0.14.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.14.0",
|
|
19
|
+
"@voltro/plugin-rbac": "0.14.0",
|
|
20
|
+
"@voltro/protocol": "0.14.0",
|
|
21
|
+
"@voltro/runtime": "0.14.0",
|
|
22
22
|
"effect": "^3.21.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.14.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
|
|
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
|
+
})
|
|
@@ -43,9 +43,6 @@ export const products = table('products', {
|
|
|
43
43
|
priceCents: integer(),
|
|
44
44
|
createdAt: timestamp().default('now'),
|
|
45
45
|
})
|
|
46
|
-
// Opt into the matcher engine so a future reactive WS query over the
|
|
47
|
-
// same table stays live. The REST routes don't need it, but it's free.
|
|
48
|
-
.reactive()
|
|
49
46
|
|
|
50
47
|
export type Product = InferRow<typeof products>
|
|
51
48
|
|
|
@@ -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/plugin-openapi": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "0.
|
|
20
|
-
"@voltro/sql-postgres": "0.
|
|
15
|
+
"@voltro/cli": "0.14.0",
|
|
16
|
+
"@voltro/database": "0.14.0",
|
|
17
|
+
"@voltro/plugin-openapi": "0.14.0",
|
|
18
|
+
"@voltro/protocol": "0.14.0",
|
|
19
|
+
"@voltro/runtime": "0.14.0",
|
|
20
|
+
"@voltro/sql-postgres": "0.14.0",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.14.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -43,7 +43,6 @@ export const projects = table('projects', {
|
|
|
43
43
|
// createdBy / updatedBy (auto-stamped). Creating a project is gated by the
|
|
44
44
|
// billing `projects` entitlement (see mutations/projects.create).
|
|
45
45
|
.with(tenant())
|
|
46
|
-
.reactive()
|
|
47
46
|
|
|
48
47
|
export type Project = InferRow<typeof projects>
|
|
49
48
|
|
|
@@ -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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/plugin-analytics-postgres": "0.
|
|
18
|
-
"@voltro/plugin-billing": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/plugin-notifications": "0.
|
|
21
|
-
"@voltro/plugin-presence": "0.
|
|
22
|
-
"@voltro/protocol": "0.
|
|
23
|
-
"@voltro/runtime": "0.
|
|
24
|
-
"@voltro/sql-postgres": "0.
|
|
15
|
+
"@voltro/cli": "0.14.0",
|
|
16
|
+
"@voltro/database": "0.14.0",
|
|
17
|
+
"@voltro/plugin-analytics-postgres": "0.14.0",
|
|
18
|
+
"@voltro/plugin-billing": "0.14.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.14.0",
|
|
20
|
+
"@voltro/plugin-notifications": "0.14.0",
|
|
21
|
+
"@voltro/plugin-presence": "0.14.0",
|
|
22
|
+
"@voltro/protocol": "0.14.0",
|
|
23
|
+
"@voltro/runtime": "0.14.0",
|
|
24
|
+
"@voltro/sql-postgres": "0.14.0",
|
|
25
25
|
"effect": "^3.21.2"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@voltro/testing": "0.
|
|
28
|
+
"@voltro/testing": "0.14.0",
|
|
29
29
|
"typescript": "^5.7.0",
|
|
30
30
|
"vitest": "^3.0.0"
|
|
31
31
|
}
|