@voltro/cli 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/dist/{apiBuild-GefjNI5d.js → apiBuild-B6_ehbem.js} +3 -3
- package/dist/apiBuild-Bzpb383i.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-BmNi1RgZ.js → commands-DYOuGlEC.js} +1668 -1371
- package/dist/dbCommand-ADD4mrTI.js +2 -0
- package/dist/{dbCommand-DNrkb58g.js → dbCommand-DIOJQwsq.js} +71 -57
- package/dist/{dev-d0LjACxE.js → dev-Fb9BmavX.js} +1483 -1414
- package/dist/dev-xdijqojc.js +3 -0
- package/dist/{seedRunner-DZmhoqNt.js → frameworkTableAssembly-CYlrafoK.js} +110 -331
- package/dist/frameworkTableAssembly-XvKO7wQt.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-uEfIS1vp.js → inspectMetrics-DOiiII34.js} +302 -293
- package/dist/seedRunner-D6eu-u5U.js +229 -0
- package/dist/{serveCommand-B2-YV9TU.js → serveCommand-CqYxzBZR.js} +368 -367
- package/dist/serveEntry.js +3 -3
- package/dist/{start-DKcQd52a.js → start-CjG_0xhR.js} +1 -1
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/authentication.md +36 -0
- package/templates/agent-docs/cli.md +78 -4
- package/templates/agent-docs/configuration.md +48 -0
- package/templates/agent-docs/database/migrations.md +32 -0
- package/templates/agent-docs/database/transactions.md +31 -0
- package/templates/agent-docs/testing.md +24 -0
- package/templates/agent-docs/whats-new.md +41 -137
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +1 -1
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-data-advanced/tests/queries.test.ts +6 -1
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/app.config.ts +0 -1
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-kv/tests/sync.test.ts +2 -2
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/README.md +53 -14
- package/templates/apps/api-rbac/app.config.ts +24 -26
- package/templates/apps/api-rbac/authz.ts +76 -0
- package/templates/apps/api-rbac/database/schema.ts +23 -6
- package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +7 -5
- package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +15 -8
- package/templates/apps/api-rbac/mutations/notes.delete.mutation.server.ts +54 -0
- package/templates/apps/api-rbac/mutations/notes.delete.mutation.ts +43 -0
- package/templates/apps/api-rbac/mutations/teams.rename.mutation.server.ts +19 -0
- package/templates/apps/api-rbac/mutations/teams.rename.mutation.ts +37 -0
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rbac/queries/notes.list.query.server.ts +12 -0
- package/templates/apps/api-rbac/queries/notes.list.query.ts +26 -0
- package/templates/apps/api-rbac/template.json +2 -2
- package/templates/apps/api-rbac/tests/authz.test.ts +233 -0
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/dist/apiBuild-s7swuEU5.js +0 -2
- package/dist/dbCommand-Bt6BCG7g.js +0 -2
- package/dist/dev-YHbHmeXt.js +0 -3
- package/templates/apps/api-rbac/tests/notes.create.test.ts +0 -80
|
@@ -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.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.
|
|
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
|
|
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.
|
|
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.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.
|
|
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.
|
|
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.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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/plugin-search": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/database": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/plugin-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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/plugin-versioning": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/database": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/plugin-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.
|
|
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.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/
|
|
18
|
-
"@voltro/plugin-
|
|
19
|
-
"@voltro/
|
|
20
|
-
"@voltro/
|
|
21
|
-
"@voltro/
|
|
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.
|
|
26
|
+
"@voltro/testing": "0.13.0",
|
|
26
27
|
"typescript": "^5.7.0",
|
|
27
28
|
"vitest": "^3.0.0"
|
|
28
29
|
}
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
"test": "voltro test"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@voltro/changelog": "0.
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/web": "0.
|
|
14
|
+
"@voltro/changelog": "0.13.0",
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/client": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/i18n": "0.13.0",
|
|
19
|
+
"@voltro/web": "0.13.0",
|
|
20
20
|
"react": "^19.0.0",
|
|
21
21
|
"react-dom": "^19.0.0"
|
|
22
22
|
},
|
|
@@ -11,18 +11,18 @@
|
|
|
11
11
|
"test": "voltro test"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@{{projectName}}/api": "0.
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/ui-shadcn": "0.
|
|
20
|
-
"@voltro/web": "0.
|
|
14
|
+
"@{{projectName}}/api": "0.13.0",
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/client": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/i18n": "0.13.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.13.0",
|
|
20
|
+
"@voltro/web": "0.13.0",
|
|
21
21
|
"react": "^19.0.0",
|
|
22
22
|
"react-dom": "^19.0.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/devtools": "0.
|
|
25
|
+
"@voltro/devtools": "0.13.0",
|
|
26
26
|
"@types/react": "^19.0.0",
|
|
27
27
|
"@types/react-dom": "^19.0.0",
|
|
28
28
|
"typescript": "^5.7.0",
|
|
@@ -11,18 +11,18 @@
|
|
|
11
11
|
"typecheck": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@{{projectName}}/api": "0.
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/ui-shadcn": "0.
|
|
20
|
-
"@voltro/web": "0.
|
|
14
|
+
"@{{projectName}}/api": "0.13.0",
|
|
15
|
+
"@voltro/cli": "0.13.0",
|
|
16
|
+
"@voltro/client": "0.13.0",
|
|
17
|
+
"@voltro/env": "0.13.0",
|
|
18
|
+
"@voltro/i18n": "0.13.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.13.0",
|
|
20
|
+
"@voltro/web": "0.13.0",
|
|
21
21
|
"react": "^19.0.0",
|
|
22
22
|
"react-dom": "^19.0.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/devtools": "0.
|
|
25
|
+
"@voltro/devtools": "0.13.0",
|
|
26
26
|
"@types/react": "^19.0.0",
|
|
27
27
|
"@types/react-dom": "^19.0.0",
|
|
28
28
|
"typescript": "^5.7.0",
|
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
"typecheck": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@voltro/cli": "0.
|
|
15
|
-
"@voltro/client": "0.
|
|
16
|
-
"@voltro/env": "0.
|
|
17
|
-
"@voltro/i18n": "0.
|
|
18
|
-
"@voltro/ui-shadcn": "0.
|
|
19
|
-
"@voltro/web": "0.
|
|
14
|
+
"@voltro/cli": "0.13.0",
|
|
15
|
+
"@voltro/client": "0.13.0",
|
|
16
|
+
"@voltro/env": "0.13.0",
|
|
17
|
+
"@voltro/i18n": "0.13.0",
|
|
18
|
+
"@voltro/ui-shadcn": "0.13.0",
|
|
19
|
+
"@voltro/web": "0.13.0",
|
|
20
20
|
"react": "^19.0.0",
|
|
21
21
|
"react-dom": "^19.0.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/devtools": "0.
|
|
24
|
+
"@voltro/devtools": "0.13.0",
|
|
25
25
|
"@types/react": "^19.0.0",
|
|
26
26
|
"@types/react-dom": "^19.0.0",
|
|
27
27
|
"typescript": "^5.7.0",
|