Config options
| Option | Default | Meaning |
|---|---|---|
purposes | [] | Your own purposes, beside the ones composed capabilities declare. A purpose under a composed capability’s domain is refused at startup |
visitorWindowDays | 395 | How many days an unmerged visitor’s grant counts, 1 to 3650. Past it, hasConsent answers no. The device-id cookie lives as long |
basePath | /consent | Where every route mounts, the control-plane reads included. The manifest names the mounted path, and the client projection carries it to the scaffolded screens |
rateLimiterBinding | AUTH_RATE_LIMITER | The rate limiting binding both writes are held to, per client IP, under a consent: key of their own |
The client projection is { enabled, basePath }, and nothing else. It is declared as ConsentClientProjection in @pithy-sh/consent/src/client/projection. A Vite app reads it from virtual:pithy/consent, and a React Native app from its generated config, per environment. The purposes stay on the Worker: a screen reads them from GET {basePath}/purposes when it renders, so a purpose changes without a rebuild. A Worker composing no consent projects { enabled: false }.
Purpose fields
| Field | Default | Meaning |
|---|---|---|
id | required | <domain>.<name>, lowercase, at most 128 characters. A capability declares only under its own name; yours go under your own domain |
summary | required | One sentence written for the person, at most 500 characters. The settings screen shows it |
noticeVersion | required | A positive integer. Bump it when the notice changes, and every older grant answers no until renewed |
classes | [] | The data classes the purpose covers: pii, phi, or your own |
explicit | false | Whether the purpose needs explicit consent, GDPR Article 9 |
minimumMethod | click | The weakest method a grant may be given by: click, typed, drawn, provider, weakest first |
writtenWithdrawal | false | Whether a withdrawal must be at least at the grade of the grant it withdraws |
email declares two: email.marketing and email.tracking, both click. A purpose id appears once in the composed catalog, and two declarations of one id are refused.
Routes
Using Consent has the calls.
| Route | Who | Purpose |
|---|---|---|
GET /consent/purposes | anyone | Every purpose this Worker asks consent for |
GET /consent/me | anyone | Where the caller stands on each purpose, and every row behind it. No subject yet is subject: null |
GET /consent/me/documents/:hash | anyone | A signed document the caller’s own record names, as text/plain |
POST /consent/grants | anyone · same-origin · rate-limited | Give consent to one purpose. 201 |
POST /consent/withdrawals | anyone · same-origin · rate-limited | Withdraw consent from one purpose. 201 |
GET /consent/control-plane/purposes | control-plane · consent:read | Counts per purpose and notice version, the expired grants among them |
GET /consent/control-plane/expiring | control-plane · consent:read | The grants whose expiry has passed or falls within within days (default 30, 1 to 366), soonest first, paged by cursor and limit |
GET /consent/control-plane/subjects/:kind/:subjectId | control-plane · consent:read | One subject’s standing, rows and merges |
GET /consent/control-plane/documents/:hash | control-plane · consent:read | A signed document, byte for byte |
GET /consent/control-plane/evidence/:hash | control-plane · consent:read | A drawn signature or certificate, as an attachment |
“Anyone” is the signed-in account, or the visitor whose device-id cookie came with the request, or a signed-out phone’s visitor. A visitor’s first grant or withdrawal mints the id and sets the cookie. Nothing else sets it. A phone has no cookie and sends its own visitor id in x-pithy-consent-visitor instead, read only from a request with no cookie at all; such a request is exempt from the same-origin gate, because nothing in it is ambient. So is a bearer request, for the same reason. A phone’s id, once merged into an account, is no visitor again. Visitors has the rest.
The control-plane reads are a management client’s, reached through the control plane. Their operations are purposes.read, expiring.read, subjects.read, documents.read and evidence.read, so the MCP server offers them as consent_* tools. consent:read is a read, and there is no control-plane write.
The grant body
| Field | Required | Meaning |
|---|---|---|
purpose | yes | The purpose id |
noticeVersion | yes | The version the person was shown. It must be current |
method | yes | click, typed, drawn or provider |
surface | yes | Where the choice was made, a short lowercase name, at most 64 characters |
identity | no | none, session, verified_email, in_person or provider. Defaults to session for an account and none for a visitor |
document | for a signed grant | The exact rendered text, at most 256 KiB of characters |
typedName | for typed | At most 256 characters. Encrypted at rest |
drawn | for drawn | { image, type }: base64, at most 512 KiB, image/png or image/svg+xml |
provider | for provider | { name, envelopeId, certificate? }. The certificate is base64, at most 2 MiB |
expiresAt | no | An ISO-8601 instant in the future |
representative | no | { name, relationship }. The name is encrypted at rest |
A withdrawal takes the same fields but noticeVersion, expiresAt and representative.
Error codes
| Code | Status | Meaning |
|---|---|---|
consent/unknown_purpose | 404 | No composed capability or config declares that purpose |
consent/stale_notice | 409 | The grant names a notice version that is not the current one |
consent/method_below_minimum | 400 | The grant’s method is below the purpose’s minimumMethod |
consent/withdrawal_below_grant | 400 | The purpose requires a written withdrawal, and this one is below the grant’s method |
consent/evidence_missing | 400 | The signature, the envelope or the document the method needs is missing |
consent/expiry_passed | 400 | expiresAt is not in the future |
consent/document_not_found | 404 | No document or evidence is kept under that hash, or the caller’s record does not name it |
consent/evidence_store_missing | 500 | A drawn signature or certificate arrived, and no CONSENT_EVIDENCE bucket is bound |
Bindings
| Binding | Type | Notes |
|---|---|---|
DB | d1 | The app database |
AUTH_RATE_LIMITER | ratelimit | Auth’s, by default. rateLimiterBinding names another |
CONSENT_EVIDENCE | r2 | Optional. Needed only for drawn and provider. pithy provision creates it |
The typed and representative names are sealed under PITHY_FIELD_KEYS, which pithy secrets provision sets.
Tables
| Table | Key | Holds |
|---|---|---|
pithy_consent_records | UUID | One row per grant or withdrawal: subject kind and id, purpose, action, notice version, method, surface, identity, document hash, the typed name (encrypted), the drawn image’s hash and type, the provider, envelope id and certificate hash, expiry, the representative’s name (encrypted) and relationship, and when it was recorded |
pithy_consent_documents | the SHA-256 of the text | The exact text behind a signed grant, kept once, and when it was first kept |
pithy_consent_merges | UUID | One row per visitor merged into an account: the device id, the user id, and when |
All three are append-only by trigger. An update of any fact column and every delete abort. The one write allowed is the field-key rotator re-sealing an encrypted name. The tables are also declared retained, so a down migration never drops them while they hold rows.
A purge keeps them. Consent declares retain with its basis: the record is the proof of what the person agreed to and when they withdrew. An access request exports every row, the merged visitors’ rows and the merges, with the names opened. The drawn image is kept with the record, never purged with the account.
Audit actions
Recorded through audit when it is composed. Every row is one event, so the hash chain covers the record.
| Action | When |
|---|---|
consent/granted | A person gave consent to a purpose |
consent/withdrawn | A person withdrew consent from a purpose |
consent/merged | A visitor’s consent was carried into the account they signed in as |
A grant or withdrawal event carries the record’s id, the purpose, the notice version, the method, the surface and the document’s hash. A merge event carries the visitor’s id and the merge’s. None ever carries a name.
The core seam
| Name | Where | What it is |
|---|---|---|
composedConsent(capabilities, env) | @pithy-sh/core/src/consent/reader | The composed reader, or null with no consent composed |
hasConsent(subject, purpose) | the reader | true only for a current, unexpired, unwithdrawn grant at or above the minimum |
Capability.consentPurposes | @pithy-sh/core | The purposes a capability declares, under its own name |
Capability.consentWithdrawn | @pithy-sh/core | Called on the declaring capability once a withdrawal stands. Email suppresses the address for marketing here |
Capability.consentGranted | @pithy-sh/core | Called once a grant stands. Email lifts the withdrawal’s own suppression here |
A subject is { kind: "account" | "visitor", id }.
Peer capabilities
Required: auth. The writes wear its same-origin gate, which a bearer request and a phone’s cookie-less request pass, and its rate limiter.
Optional: email, which declares email.marketing and email.tracking and gates on them. audit, which records the three actions above in its chain. controlplane, which carries the five reads.
What is not here
No cookie banner. The settings screen is the one screen pithy ui add react --consent and pithy ui add react-native --consent write.
No e-signature provider integration. Your integration supplies the envelope and the certificate.
No refusal to write a classed field without consent. That is your call. The kit records and gates.