consent
| Subpath | @noy-db/hub/consent |
| Factory | withConsent() |
| Layer | with-audit |
| Cluster | E — Time & Audit |
| LOC cost | ~194 (off-bundle when not opted in) |
What it does
Privacy regulations such as the GDPR require consent to be demonstrable: you must be able
to show what was done with personal data, when, by whom, and under which consent. The
consent service gives a vault (an encrypted, per-tenant data space — see
Core 01 — Vault and collections) exactly that: wrap any
block of work in vault.withConsent({ purpose, consentHash }, fn) and every get, put,
and delete inside the block appends one audit entry to the reserved _consent_audit
collection. Outside a scope, nothing is written — consent logging is opt-in per access,
by design.
Audit entries are encrypted like any other record, with their own data-encryption key, so the storage backend only ever sees ciphertext — it can prove entries exist without being able to read who accessed what. The whole service costs ~194 lines of code and is off-bundle when you don't opt in.
Prerequisites
- None —
withConsent()alone enables consent scopes and the audit log.
Opt in
Pass consentStrategy: withConsent() to createNoydb:
import { createNoydb } from '@noy-db/hub'
import { withConsent } from '@noy-db/hub/consent'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({
store: toMemory(), // any NoydbStore works
user: 'alice',
secret: 'correct horse battery staple',
consentStrategy: withConsent(),
})
const vault = await db.openVault('acme')
Usage
Log access under a consent scope
vault.withConsent(ctx, fn) runs fn with the consent context active and returns
whatever fn resolves to. Each per-record operation inside the scope writes one entry:
interface Invoice { amount: number; status: string }
const invoices = vault.collection<Invoice>('invoices')
await invoices.put('inv-1', { amount: 100, status: 'draft' }) // before the scope — not logged
const invoice = await vault.withConsent(
{ purpose: 'quarterly-review', consentHash: '7f3a8b9c' },
async () => {
await invoices.put('inv-2', { amount: 200, status: 'draft' })
return invoices.get('inv-1')
},
)
// → { amount: 100, status: 'draft' } (the callback's resolved value)
Query the audit log
vault.consentAudit(filter?) decrypts and returns the matching entries. Entries carry
ULID ids, so sorting by id is insertion-order stable:
const log = await vault.consentAudit({ purpose: 'quarterly-review' })
[
{
"id": "01JZGJ3V9GQ2N9W3H4X5T6K7R8",
"timestamp": "2026-07-08T09:14:03.412Z",
"actor": "alice",
"purpose": "quarterly-review",
"consentHash": "7f3a8b9c",
"op": "put",
"collection": "invoices",
"recordId": "inv-2"
},
{
"id": "01JZGJ3V9SQ0F2B7C8D9E0A1B2",
"timestamp": "2026-07-08T09:14:03.421Z",
"actor": "alice",
"purpose": "quarterly-review",
"consentHash": "7f3a8b9c",
"op": "get",
"collection": "invoices",
"recordId": "inv-1"
}
]
The filter fields compose; every field is optional:
await vault.consentAudit({ collection: 'invoices' })
// → only entries targeting the 'invoices' collection
await vault.consentAudit({ actor: 'alice', since: '2026-07-01T00:00:00Z' })
// → alice's entries at or after July 1
await vault.consentAudit()
// → every entry
Nothing is logged outside a scope
await invoices.get('inv-1') // no active scope
await vault.consentAudit()
// → the same entries as before — no new ones
The scope is restored when the callback exits, even if it throws: an error inside
withConsent propagates to the caller, and subsequent operations are unaudited again.
API reference
withConsent()
function withConsent(): ConsentStrategy
Builds the active consent strategy. Pass it as consentStrategy to createNoydb — this
is the only wiring step. Takes no options.
vault.withConsent(ctx, fn)
vault.withConsent<T>(ctx: ConsentContext, fn: () => Promise<T>): Promise<T>
Runs fn under a consent scope and returns its resolved value. Every audited operation
inside fn writes one entry to _consent_audit. The prior scope is restored on exit,
including when fn throws.
ConsentContext:
| Field | Type | Purpose |
|---|---|---|
purpose | string | What this access is for. Free-form — your compliance vocabulary. Carried in every entry and queryable via consentAudit({ purpose }). |
consentHash | string | Hex-encoded sha256 of the consent artefact the actor presented (a signed consent-banner click, an authorization PDF, an X-Consent-Hash header). Stored as-is in each entry. |
vault.consentAudit(filter?)
vault.consentAudit(filter?: ConsentAuditFilter): Promise<ConsentAuditEntry[]>
Decrypts and returns every audit entry matching the filter (all entries when omitted).
ConsentAuditFilter — all fields optional, combined with AND:
| Field | Type | Purpose |
|---|---|---|
since | string | Only entries at or after this ISO timestamp. |
until | string | Only entries at or before this ISO timestamp. |
collection | string | Only entries targeting this collection. |
actor | string | Only entries written by this actor (the vault user). |
purpose | string | Only entries with this purpose. |
ConsentAuditEntry — one record per audited operation:
| Field | Type | Meaning |
|---|---|---|
id | string | ULID — stable insertion-order sort key. |
timestamp | string | ISO timestamp of the operation. |
actor | string | The user the vault was opened with. |
purpose | string | From the active ConsentContext. |
consentHash | string | From the active ConsentContext. |
op | ConsentOp | Which operation ran (see below). |
collection | string | Target collection. |
recordId | string | Target record id — '*' for whole-collection sweeps (the find op). |
ConsentOp
type ConsentOp = 'get' | 'put' | 'delete' | 'reveal' | 'verify' | 'find'
get / put / delete come from ordinary per-record collection operations. reveal,
verify, and find are emitted by the classified service's
sealed-field operations; find is a digest-search sweep and records the '*' sentinel
as its recordId (never a real id — real ids are ULIDs).
Other exports
CONSENT_AUDIT_COLLECTION— the reserved collection name,'_consent_audit'.writeConsentEntry(adapter, vault, encrypted, entry, getDEK)/loadConsentEntries(adapter, vault, encrypted, getDEK, filter?)— the low-level functions the strategy delegates to. Application code uses the vault methods above; these exist for custom strategy implementations.- Types:
ConsentStrategy,ConsentContext,ConsentOp,ConsentAuditEntry,ConsentAuditFilter.
Behavior when NOT opted in
vault.withConsent(ctx, fn)still runsfnand returns its value — but no entries are written.vault.consentAudit()returns[].- None of the ~194 LOC ships in your bundle.
Limits & gotchas
- Single-slot scope per Vault instance. Two concurrent
withConsentcalls on the same vault stomp each other's context. Use separate Vault instances — or an externalAsyncLocalStorageshim — for per-flight scoping. - Bulk
list()reads are not logged. Entries are written by per-record operations (get,put,delete) and by classified-field operations (reveal,verify,find). Reading a whole collection withlist()writes no per-record entries; use per-recordget()inside the scope when each read must be provable. - The
consentHashis opaque to noy-db. The system stores only the hash; the consent record itself (the signed banner click, the authorization document) lives outside the vault. A regulator verifies a presented document against the logged hash at audit time — the vault never possesses the document. - The log is ciphertext at the store. Entries are encrypted with a dedicated
consent-audit key, separate from per-collection keys, so querying the log never unwraps
individual collection keys — and the storage backend can never read
purpose,actor, orconsentHash(see Core 02 — Enclave).
See also
- Services overview · Orientation map
- history — hash-chained change ledger; the vault's other audit primitive
- classified — sealed fields whose
reveal/verify/findoperations feed the consent log - periods — fiscal-period seals; pair
since/untilaudit filters with period boundaries - Core 02 — Enclave — how envelopes and per-collection keys work
- Proven by showcase
19-with-consent.