pithy add consent --with-prerequisitesConsent requires auth. Both writes wear the same-origin gate auth publishes, and are held to auth’s rate limiter, so without auth they would refuse every visitor. --with-prerequisites composes auth first when the Worker does not have it yet.
Add email if you send marketing mail or track opens and clicks: it declares its own purposes and asks before either. Add audit to make the record tamper-evident, and controlplane for the management reads.
What lands in your repo
apps/<worker>/pithy.config.ts gains the registration. Email’s purposes are composed for you, so a block with no purposes of its own already records and gates email.
consent({
basePath: "/consent",
visitorWindowDays: 395,
purposes: [],
}),Declare your own purposes under your own domain, such as your app’s name. A purpose under a composed capability’s domain, email. included, is refused at startup, naming it.
consent({
purposes: [
{
id: "clinic.records",
summary: "Keep my health record so my care team can read it.",
noticeVersion: 1,
classes: ["phi"],
explicit: true,
minimumMethod: "typed",
writtenWithdrawal: true,
},
],
}),The summary is what the person reads on the settings screen. Write it for them. The reference has every field.
apps/<worker>/wrangler.jsonc holds the bindings consent reads.
| Binding | Type | What it is |
|---|---|---|
DB | d1 | Where the record, the documents and the merges live. The same app database |
AUTH_RATE_LIMITER | ratelimit | The per-IP limit on the two writes. Auth already declares it, so nothing new is provisioned |
CONSENT_EVIDENCE | r2 | Optional. Drawn signatures and providers’ certificates, each under its own hash |
CONSENT_EVIDENCE is needed only for drawn or provider. A project that takes clicks and typed names never binds it. A drawn grant without it is refused, consent/evidence_store_missing, naming the binding. pithy provision creates the bucket.
What runs
add runs that Worker’s dev migrations, creating three tables:
pithy_consent_records · pithy_consent_documents · pithy_consent_merges
The first is the record: one row per grant or withdrawal. The second keeps the exact text behind each signed grant, once per hash. The third records each visitor merged into an account. Triggers refuse an update of any fact and every delete on all three.
Encrypted names
The typed name and a representative’s name are encrypted fields, so the Worker needs PITHY_FIELD_KEYS. Run pithy secrets provision for each environment.
The config options
| Option | Default | What it decides |
|---|---|---|
purposes | [] | Your own purposes, beside the ones composed capabilities declare |
visitorWindowDays | 395 | How long an unmerged visitor’s grant counts, at most 3650. The device-id cookie lives as long |
basePath | /consent | Where the person’s routes and the control-plane reads mount. The scaffolded settings screens call under it |
rateLimiterBinding | AUTH_RATE_LIMITER | The rate limiting binding the two writes are held to, per client IP, under a consent: key of its own |
The settings screen
pithy ui add react --consentIt writes src/routes/pithy/consent.tsx: every purpose and where the person stands on it, with the controls to agree and to withdraw. pithy ui add react-native --consent writes the same screen into an Expo app, and its transport carries a signed-out phone’s visitor id, since a phone holds no cookie. Both follow the basePath you compose. Consent’s client projection is { enabled, basePath }: a Vite app reads it from virtual:pithy/consent, and a React Native app from its generated config, per environment. So with consent({ basePath: "/privacy" }), the React screen, the React Native one and the phone’s visitor header all call /privacy. pithy ui has the flag.
Check it worked
pithy doctor reports a consent line for every Worker that processes under consent. With consent composed it reads 2 purposes, recorded by consent ✓ for email’s two. Without it, the line names each purpose running with no consent record behind it.