Add Consent

pithy add consent --with-prerequisites

Consent 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.

BindingTypeWhat it is
DBd1Where the record, the documents and the merges live. The same app database
AUTH_RATE_LIMITERratelimitThe per-IP limit on the two writes. Auth already declares it, so nothing new is provisioned
CONSENT_EVIDENCEr2Optional. 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

OptionDefaultWhat it decides
purposes[]Your own purposes, beside the ones composed capabilities declare
visitorWindowDays395How long an unmerged visitor’s grant counts, at most 3650. The device-id cookie lives as long
basePath/consentWhere the person’s routes and the control-plane reads mount. The scaffolded settings screens call under it
rateLimiterBindingAUTH_RATE_LIMITERThe 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 --consent

It 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.

ESC