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

Voltro API scaffold (template: **api-governance**) — data governance with
`@voltro/plugin-governance`: field encryption, GDPR, consent, retention.

## Boot

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

**No 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`.

## What this shows

- **Field encryption** — `profiles.ssn` is `text().encrypted().serverOnly()`.
  You pass plaintext; the store middleware encrypts it (AES-256-GCM) on write
  and decrypts on read, so handlers always see plaintext while the column holds
  an opaque `enc:v1:…` string at rest. `governancePlugin({ fieldEncryption: true })`
  registers the cipher from `VOLTRO_FIELD_ENCRYPTION_KEY`. Boot fails loud if an
  `.encrypted()` column exists but no cipher is registered.
- **…and why the second marker is there.** `.encrypted()` is about bytes AT
  REST; it says nothing about the wire, and the runtime decrypts for the
  handler — so an encrypted column reaches a client exactly like any other
  unless it is also `.serverOnly()`. `profiles.get` therefore returns the last
  four digits it derived server-side, which proves the cipher ran without
  publishing the value.
- **Every procedure declares an access decision.** `guards: [{ scope }]` or
  `openAccess: '<why>'`, or the app refuses to boot (`security.defaultDeny`).
  Both procedures here are `openAccess` with the reason in the file — this
  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. Add an identity (see `api-auth` / `api-rbac`), then the guard.
- **GDPR** — admin-gated `governance.export` / `governance.erase` walk the
  declared `subjectScopes` (`[{ table: 'profiles', subjectField: 'id' }]`) to
  bundle or erase everything belonging to a subject.
- **Consent ledger** — `governance.consent` records a grant; `governance.hasConsent`
  checks it. `GovernanceService` exposes the same in-handler.
- **Retention** — `retention: [{ table, ttlMs, action }]` runs a periodic
  sweep that deletes (or anonymises) rows past their TTL.

## Try it

```bash
# create a profile, then read it back — the cipher round-trips, the value doesn't:
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 `.serverOnly()`.

# record consent (a mutation):
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"governance.consent","input":{"purpose":"marketing","granted":true}}'
# → { ok:true, result:{ ok:true } }
```

`governance.hasConsent` is a reactive QUERY (not invoke-able) — subscribe to it
on the web, or call `GovernanceService.hasConsent(subjectId, purpose)` in a handler.

`governance.export` / `governance.erase` are admin-gated — call them as a
subject with the `admin:full` scope (see [`api-rbac`](https://voltro.cloud/docs/templates/api-rbac)).

## Rules

- **Don't encrypt what you filter on.** An `.encrypted()` column is ciphertext
  on disk — you can't `WHERE`/`ORDER BY` it in 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 needs re-encrypting.
- **`.encrypted()` is not `.serverOnly()`.** Three orthogonal markers, three
  questions: `.encrypted()` = encrypted at rest, `.serverOnly()` = may it leave
  the server at all, `.sensitive()`/`.safe()` = may it appear in a
  `voltro data export`. Using one to answer another's question is the mistake —
  say both when you mean both.
