useCapabilityManifest
Read the api's capability manifest (procedures + tables + schemas) from the browser, without codegen.
What does this api actually expose? useCapabilityManifest answers that at
runtime: a one-shot fetch of /_voltro/inspect/manifest — the same inspect
surface useProvenance rides. It is
a structural lookup, not a live subscription, so it does not re-fetch on data
changes.
import { useCapabilityManifest } from '@voltro/client'
const { manifest, loading, error } = useCapabilityManifest('app')The manifest carries:
| Field | Contents |
|---|---|
procedures |
Every procedure's tag, kind, input/output Schema, the source table a query reads, the targets ({ table, op }) a mutation writes, and the guards it declares. |
tables |
The user tables — name, columns (type, nullable, refersTo, enum, and the three exposure axes), pkColumn, editable, and whether the table is reactive. Framework _voltro_* tables are flagged framework. |
workflows |
The discovered workflow names. |
widgets |
The registered widget ids. |
scopes |
Every scope the installed plugins declare. Empty means "not declared" — never "no scopes exist". |
version |
The manifest format version. |
Deriving an admin surface
deriveEntityAdmins(manifest) is the pure projection the admin
template is built on. It joins each user table to the
procedures that read and write it, so a back-office binds to tags that
actually exist instead of guessing them from a naming convention:
import { useCapabilityManifest, deriveEntityAdmins } from '@voltro/client'
const { manifest } = useCapabilityManifest('app')
const entities = manifest ? deriveEntityAdmins(manifest) : []
// each: { table, columns, serverOnlyColumns, sensitiveColumns, reactive,
// pkColumn?, editable,
// list, create, update, delete } // each an { tag?, guards? }An action's tag is undefined when the app exposes no procedure for that
operation — render that affordance read-only rather than binding to a tag that
does not resolve.
Why derive at runtime instead of generating an admin
A generated back-office snapshots the answer at codegen time. The moment you edit
guards: on a mutation the generated gate is stale — and stale in the silent
direction: it renders a control the server now refuses, or hides one that would
work. The manifest is served by the running app, so a derived surface cannot
drift from it. Customisation is not the tradeoff it looks like: the derived spec
is plain data, and the component that maps over it is your own template code.
Gating on the access the api declares
Each action carries guards — the procedure's own guards: / openAccess:
declaration, the same data the server enforces. useAccessDecision turns it into
a decision against the scopes you fed
<PermissionProvider>:
import { useAccessDecision, requiredScopes } from '@voltro/client'
const decision = useAccessDecision(entity.create.guards) // 'allowed' | 'denied' | 'unknown'
{entity.create.tag && decision !== 'denied' ? <AutoForm … /> : null}
{decision === 'denied' ? <p>Requires {requiredScopes(entity.create.guards).join(', ')}</p> : null}The decision is three-valued, and unknown is the important one. A guard that
carries a resource extractor is answered per row by the server, and a browser
holding only the subject's global scopes cannot pre-compute it. Both ways of
collapsing that gap are bugs:
| Collapse | What ships |
|---|---|
unknown → denied |
Every affordance disappears for callers whose authority is per-resource — the multi-tenant case, where subjects are minted with no global scopes. A total outage wearing a permission check's clothes. |
unknown → allowed |
A control that always errors. |
So show it and let the server answer: it is the authorization boundary, and it
replies with a typed ScopeError. decideAccess(guards, scopes) is the pure form
if you need it outside React.
guards: undefined means the procedure declared neither guards: nor
openAccess: — undecided, not open. That is refused outright under
security.defaultDeny, so it is reported as unknown rather than guessed.
The three exposure axes in a derived UI
The manifest's columns carry all three schema markers, and they are orthogonal —
deriveEntityAdmins treats each one differently, and so must you:
| Marker | What it says | What the admin does |
|---|---|---|
.serverOnly() |
Never crosses any wire; the runtime refuses a mutation input that sets it | Excluded from columns; listed in serverOnlyColumns so the UI can say why it is absent |
.encrypted() |
Ciphertext at rest | Kept. Your procedures read it decrypted — hiding it is a category error |
.sensitive(class) |
Personal data; the export-masking axis | Kept, and listed in sensitiveColumns so a bulk export masks it |
pkColumn is the column row-keyed actions must target — do not hard-code id. When
a table has no single primary key, editable is false and no row can be addressed
for update or delete.
The manifest GET is bearer-gated wherever it runs — /_voltro/inspect/* is
fail-closed, so no configured VOLTRO_INSPECT_TOKEN means 401, not "everyone".
Under voltro dev that is handled for you (the dev server mints a token and its
proxy attaches it server-side). An admin UI pointed at a deployed api has to
supply the token itself — a deployment concern, not something this hook
handles.