# {{projectName}} / {{appName}}

Voltro API scaffold (template: **api-rbac**) — role-based access control with
`@voltro/plugin-rbac`.

## Boot

```bash
pnpm install
pnpm --filter @{{projectName}}/{{appName}} dev
# → http://localhost:4000  (store: memory — zero infra)
```

## What this shows

- **Roles → scopes, as config** — `authz.ts` declares the role map; an rpc
  interceptor resolves each caller's roles to scopes and publishes them to the
  framework's effective-scope seam. That map is also the app's **declared scope
  vocabulary**: `voltro check` fails on a guard requiring a scope no role grants.
- **Declarative guards** (`notes.create`, `notes.list`) — `guards: [{ scope }]`
  on the descriptor. Enforced before the executor and before the transaction,
  and the only form `voltro check` can inspect. **Prefer this.**
- **Resource-scoped guards** (`teams.rename`) — `guards: [{ scope, resource }]`
  plus `resolveResourceRoles`, so "owner" means owner *of that team*.
- **In-handler guards** (`notes.delete`) — `can()` / `permission()` for the
  decision a descriptor cannot make, because it depends on the loaded row.
- **Where roles come from** — the shipped `resolveRoles` is a DEMO mapping the
  request's **tenant** to a role, so you can try each with an `x-tenant` header.
  Production reads the caller's real roles (`subject.metadata.roles`, or a DB
  lookup).

## Which guard form?

Put in `guards:` everything decidable from the **subject** and the **input**.
Reach for `permission()` / `can()` only for what needs loaded data.

The difference is not style. A declarative guard lands in the capability
manifest, so `voltro check` catches a scope no role grants — a bug that is
otherwise invisible, because the procedure simply becomes uncallable by
everyone, forever, with no error anywhere. An in-handler call is invisible to
that check; a typo there is a branch that is silently never taken.

## Failure postures

| Resolver | On throw / reject |
| --- | --- |
| `resolveRoles` / `resolvePermissions` | logs, degrades to the subject's **own** scopes — never grants |
| `resolveResourceRoles` | **denies** (fail-closed) |

## Try it

```bash
# admin tenant (acme) → admin:full → notes:write passes:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: acme' \
  -d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
# → { ok:true, result:{ id, … } }

# readers tenant → viewer → notes:write missing → typed ScopeError:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' -H 'x-tenant: readers' \
  -d '{"tag":"notes.create","input":{"title":"hi","body":"x"}}'
# → { ok:false, error:{ _tag:"ScopeError", required:"notes:write" } }
```

```bash
voltro check --offline     # no running app needed — usable as a CI gate
```

## Tests

`tests/authz.test.ts` drives the **real** plugin — `makeTestContext({ plugins })`
composes the actual interceptor chain and `invoke` enforces the descriptor's
guards with the same function the serve pipeline calls.

Do not hand-set `subject.scopes` to the value you expect. That tests the guard
while assuming the resolution that produces it, and the resolution is the half
that actually breaks (a renamed role, a throwing resolver, a scope no role
grants) — all of which pass a pre-stamped test.

```bash
pnpm --filter @{{projectName}}/{{appName}} test
```

## On the web side

```tsx
import { PermissionProvider, useCan } from '@voltro/client'

<PermissionProvider scopes={session.scopes}>…</PermissionProvider>
const canWrite = useCan('notes:write')
{canWrite && <NewNoteButton />}     // hide affordances the user can't use
```

The UI gate ships in `@voltro/client`, not in the plugin: scopes are a framework
concept, so gating a button must not require an rbac dependency.

`useCan` is a UI affordance only — the server-side guard is the real
enforcement. Never trust the client.

## Persisted roles

Set `tables: true` to store role assignments in `roles` / `userRoles` tables
and resolve them from the DB (multitenant-aware). The shipped template stays
config-only.
