API · Governance
Data governance in one plugin with @voltro/plugin-governance — AES-256-GCM field encryption for .encrypted() columns (handler sees plaintext, ciphertext at rest), admin-gated GDPR governance.export/erase over declared subjectScopes, a consent ledger, and retention TTL sweeps. Memory store, zero infra.
Four compliance primitives, one plugin. @voltro/plugin-governance gives you field encryption (AES-256-GCM for .encrypted() columns — handlers see plaintext, the column holds enc:v1:… at rest), GDPR export/erase (admin-gated, walking declared subject scopes), a consent ledger, and retention sweeps (delete/anonymise rows past a TTL). The template boots zero-infra on a key voltro dev mints for your project — no key ships with it. Template id: api-governance.
Scaffold
voltro create-project acme --api=api-governanceNo encryption key ships with this template — a key committed to a template is a key published to everyone who downloads it. VOLTRO_FIELD_ENCRYPTION_KEY is declared with generate: 'hex', so voltro dev mints a unique one into a gitignored .env.local on first boot. Your deployment mints its own: voltro secret generate field-encryption.
Field encryption
// database/schema.ts
export const profiles = table('profiles', {
id: id(), name: text(), email: text(),
// TWO markers, two different questions — say both when you mean both.
ssn: text().encrypted().serverOnly(), // encrypted AT REST · and may never leave the server
}).with(audit())
// app.config.ts
governancePlugin({ fieldEncryption: true /* reads VOLTRO_FIELD_ENCRYPTION_KEY */ })You pass plaintext; the store middleware encrypts on write and decrypts on read — handlers never touch the ciphertext. Boot fails loud if an .encrypted() column exists but no cipher is registered.
.encrypted() is not an exposure marker, and reading it as one is a tempting category error: the runtime decrypts for the handler, so an encrypted column reaches a client exactly like any other unless it is also .serverOnly(). That is why the column above carries both, and why profiles.get returns the last four digits it derived server-side — proof the cipher ran, without publishing the value.
ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
-d '{"tag":"profiles.create","input":{"name":"Ada","email":"ada@acme.com","ssn":"123-45-6789"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
-d "{\"tag\":\"profiles.get\",\"input\":{\"id\":\"$ID\"}}"
# → { ok:true, result:{ …, ssnLast4:"6789" } }
# The handler read "123-45-6789" as plaintext (the cipher decrypted it) and
# published four digits, because the column is also `.serverOnly()`.GDPR, consent, retention
governancePlugin({
fieldEncryption: true,
subjectScopes: [{ table: 'profiles', subjectField: 'id' }], // GDPR walks these
retention: [{ table: 'profiles', ttlMs: 365 * 86_400_000, action: 'delete' }],
})- GDPR —
governance.export/governance.eraseare admin-gated (call as a subject withadmin:full— seeapi-rbac); they walksubjectScopesto bundle or erase everything belonging to a subject.GovernanceServiceexposes the same in-handler. - Consent —
governance.consent(a mutation) records a grant;governance.hasConsent(a reactive query) checks it — subscribe to it on the web, or callGovernanceService.hasConsent(...)in a handler. - Retention — the sweep deletes (or
anonymizes) rows whose age pastdateField(defaultcreatedAt) exceedsttlMs.
Rules
- Don't encrypt what you filter on. An
.encrypted()column is ciphertext on disk — noWHERE/ORDER BYin SQL. Encrypt fields you read back WHOLE (PII, tokens, notes). - The key is everything. GCM fails closed on a bad key — you get an error, never silent corruption. Back the key up; rotating it means re-encrypting.
- Three orthogonal markers, three questions.
.encrypted()= encrypted at rest ·.serverOnly()= may it leave the server at all ·.sensitive()/.safe()= may it appear in avoltro data export. Using one to answer another's question is the mistake. - Every procedure declares an access decision.
guards: [{ scope }]oropenAccess: '<why>', or the app refuses to boot (security.defaultDeny). Both procedures here areopenAccesswith the reason in the file: the template configures no auth strategy and no rbac, so every caller resolves to an anonymous Subject holding no scopes and a scope guard would deny 100% of traffic — an outage, not security. Add an identity (seeapi-auth/api-rbac), then the guard. - GDPR endpoints are admin-only by design. Don't remove the
admin:fullgate — an unauthenticated export/erase is a data-exfiltration / griefing hole.