An append-only record of every grant and withdrawal, in your own D1. Purposes declared and versioned, signed consent kept with its exact document, visitors merged into their account at sign-in, and one question every capability asks before it processes under consent.
@pithy-sh/consent
pithy add consentDemonstrating consent where it is the legal basis, as GDPR Article 7 asks: specific to a purpose, informed by a particular notice, as easy to withdraw as to give, and recorded. Each purpose has a notice version, and bumping it makes every older grant answer no until the person agrees again.
Signed consent, where a checkbox is not enough. A purpose can demand a typed name, a drawn signature or an e-signature provider's completed envelope, and a grant below that is refused. The grant keeps the exact document by its hash, produced again byte for byte, with how identity was verified, an expiry, and a parent or guardian signing for someone else. A purpose can also require a withdrawal at the grant's own grade, as a HIPAA revocation must be in writing.
Visitors before they have an account. A visitor consents against an anonymous device id, held in a first-party cookie that is set only once they choose, and merged into their account when they sign in. A phone holds no cookie, so a signed-out app sends a visitor id of consent's own instead, never the device id auth registers.
Email that follows it. With consent composed, marketing mail goes only to a subject who agreed, and opens and clicks are tracked only for one who agreed to that. Transactional mail is never gated, and a withdrawal from marketing suppresses the address for marketing until the person agrees again.
What it sits on
Every grant and withdrawal is a row in pithy_consent_records, and database triggers refuse an update of any fact and every delete. So append-only is a property of the schema, not a promise in the code. A signed document is kept once per hash, and a drawn signature or a provider's certificate goes to an R2 bucket under its own hash.
Every row is also an audit event, so with audit composed its hash chain makes the record tamper-evident. The typed and representative names are encrypted at rest.
hasConsent is a seam in core, so a capability asks it without importing this package, and with no consent composed it gets no reader and behaves as it did before.
The honest bit
Whether a purpose needs consent at all, which jurisdiction applies, and whether to refuse to store a classed field without it are your calls. The kit records what was agreed to and gates what you told it to gate.
It ships one screen, the settings screen where a person reads and withdraws their consents. There is no cookie banner component, though a banner is a client of the same two routes.
There is no e-signature provider integration. Your own integration supplies the completed envelope and its certificate, and the kit keeps them. It verifies no age either: a representative is recorded as your flow declares them.
If you build it yourself
Storing that somebody ticked a box is one column. Showing an auditor what they agreed to, when, and that they have not withdrawn since is the work.
A boolean on a user row can be flipped, and the flip erases the evidence that it was ever otherwise. The record has to be every grant and every withdrawal, in order, in rows the database refuses to change.
Consent is to a particular notice. When the notice changes, every grant under the old one has to stop counting until the person agrees again, and a stale grant must never pass for a current one.
A signed authorization is only proof if you can produce exactly what was signed. That means keeping the rendered text by its hash, and the signature beside it, for as long as the record lives.
People choose on a marketing page before they sign up. Their choice has to be recorded against something that is not an account yet, without setting a cookie before they choose, and carried over when they sign in.
Marketing mail, tracking and your own processing each need the same yes or no, decided the same way. Scatter the check and the answers drift apart.
pithy add consentComposes with
Passwordless sessions, mobile and web both first-class. Magic link, email OTP, Google, Apple, GitHub, Facebook, Microsoft.
Every send is a durable job: scheduled, retried, tracked. No mail ever sent inline.
A queryable trail of every security-relevant action, attributed to the right actor.
Let a management client reach into your own Worker, with no data plane in between.
In the dashboard
Counts per purpose and notice version with the expired grants among them, the grants that have expired or expire within 30 days, one person's record, and the signed documents behind it are control-plane reads behind consent:read. On Enterprise, the dashboard's evidence pane draws them in its consent section and exports each as your Worker answered it. On any plan, they are there for a management client you write yourself. There is no management write: consent is the person's to give and to take back.
Everything else
Each one composes the same way. Nothing you skip costs you anything.
One command installs the package, wires its bindings into every environment your project declares, and runs its migrations.