stable

consent

Consent-audit logging for GDPR/PIPL-style compliance: wrap access in `vault.withConsent(...)` and every operation in scope appends one encrypted audit entry.
Subpath@noy-db/hub/consent
FactorywithConsent()
Layerwith-audit
ClusterE — 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

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:

FieldTypePurpose
purposestringWhat this access is for. Free-form — your compliance vocabulary. Carried in every entry and queryable via consentAudit({ purpose }).
consentHashstringHex-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:

FieldTypePurpose
sincestringOnly entries at or after this ISO timestamp.
untilstringOnly entries at or before this ISO timestamp.
collectionstringOnly entries targeting this collection.
actorstringOnly entries written by this actor (the vault user).
purposestringOnly entries with this purpose.

ConsentAuditEntry — one record per audited operation:

FieldTypeMeaning
idstringULID — stable insertion-order sort key.
timestampstringISO timestamp of the operation.
actorstringThe user the vault was opened with.
purposestringFrom the active ConsentContext.
consentHashstringFrom the active ConsentContext.
opConsentOpWhich operation ran (see below).
collectionstringTarget collection.
recordIdstringTarget 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 runs fn and 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 withConsent calls on the same vault stomp each other's context. Use separate Vault instances — or an external AsyncLocalStorage shim — 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 with list() writes no per-record entries; use per-record get() inside the scope when each read must be provable.
  • The consentHash is 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, or consentHash (see Core 02 — Enclave).

See also