https://github.com/vllnt/convex-suppression
Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Convex component
https://github.com/vllnt/convex-suppression
convex convex-component gdpr suppression typescript vllnt
Last synced: 23 days ago
JSON representation
Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Convex component
- Host: GitHub
- URL: https://github.com/vllnt/convex-suppression
- Owner: vllnt
- License: mit
- Created: 2026-06-13T23:13:51.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-16T15:30:47.000Z (about 1 month ago)
- Last Synced: 2026-06-16T17:12:36.565Z (about 1 month ago)
- Topics: convex, convex-component, gdpr, suppression, typescript, vllnt
- Language: TypeScript
- Homepage: https://www.npmjs.com/package/@vllnt/convex-suppression
- Size: 107 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- Funding: .github/FUNDING.yml
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
[](https://www.convex.dev/components)
[](https://www.npmjs.com/package/@vllnt/convex-suppression)
[](https://github.com/vllnt/convex-suppression/actions/workflows/ci.yml)
[](./LICENSE)
# @vllnt/convex-suppression
The do-not-contact suppression list + opt-in proof, as a Convex component.
```ts
const dnc = new Suppression(components.suppression);
await dnc.suppress(ctx, contactHash, "complaint", { channel: "email" });
const canSend = await dnc.isEligible(ctx, contactHash, { channel: "email" });
```
The GDPR opt-out / CAN-SPAM primitive: a global do-not-contact list keyed by an
opaque **`contactHash`** (never raw PII, so it survives erasure) plus an opt-in
proof ledger. Before every send the host asks `isEligible`; an unsubscribe / bounce
/ complaint calls `suppress`; a double-opt-in confirmation calls `recordOptIn`.
Domain-neutral — channels and reasons are the host's.
## Features
- **Hash-keyed, erasure-surviving** — stores only the opaque `contactHash`, never raw PII, so a tombstone outlives erasure of the subject.
- **Channel-aware** — scope a suppression to one channel (`"email"`/`"sms"`/`"push"`), or omit `channel` for a global all-channel tombstone.
- **One send gate** — `isEligible` answers `¬suppressed [∧ confirmed]` in one call; suppression always blocks, opt-in is per call.
- **Opt-in proof ledger** — `recordOptIn` / `getOptInProof` store legal proof-of-consent per list, kept separate from any authz store.
- **Idempotent** — re-suppressing / re-recording on its key tuple updates in place, so a replayed webhook never duplicates a row.
- **Typed, opaque proof** — `Suppression` with an optional `proofValidator` narrows the stored evidence at the boundary.
- **Server-sourced time** — `createdAt`/`confirmedAt` are stamped from the server clock; a caller can never supply a timestamp.
- **Mount-safe** — correct under multiple named `app.use` mounts (e.g. marketing vs. transactional lists), each an isolated sandbox.
## Installation
```bash
pnpm add @vllnt/convex-suppression
```
Peer dependency: `convex@^1.41.0`.
## Usage
```ts
// convex/convex.config.ts
import { defineApp } from "convex/server";
import suppression from "@vllnt/convex-suppression/convex.config";
const app = defineApp();
app.use(suppression);
export default app;
```
```ts
// convex/email.ts — host owns auth + hashing; pass an opaque contactHash in.
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Suppression } from "@vllnt/convex-suppression";
const dnc = new Suppression<{ ip: string }>(components.suppression, {
proofValidator: v.object({ ip: v.string() }).parse,
});
const hash = (email: string) => myHash(email.trim().toLowerCase()); // host's hashing + salt policy
// The send gate — call before every send.
export const canEmail = query({
args: { email: v.string() },
handler: (ctx, { email }) =>
dnc.isEligible(ctx, hash(email), { channel: "email", listKey: "newsletter", requireOptIn: true }),
});
// A provider webhook suppresses on complaint; a confirmed double opt-in records proof.
export const onComplaint = mutation({
args: { email: v.string() },
handler: (ctx, { email }) => dnc.suppress(ctx, hash(email), "complaint", { channel: "email" }),
});
export const confirmOptIn = mutation({
args: { email: v.string(), ip: v.string() },
handler: (ctx, { email, ip }) =>
dnc.recordOptIn(ctx, hash(email), { listKey: "newsletter", source: "double-opt-in", proof: { ip } }),
});
```
Client options: `new Suppression(component, { proofValidator? })`. Omitting `channel`/`listKey` targets the global entry.
## API Reference
| Method | Kind | Result |
|--------|------|--------|
| `suppress(ctx, contactHash, reason, opts?)` | mutation | `null` (`reason`: `"unsubscribe" \| "bounce" \| "complaint" \| "manual" \| "global"`; `opts`: `{ channel? }`) |
| `unsuppress(ctx, contactHash, channel?)` | mutation | `boolean` (`true` if an entry was removed) |
| `recordOptIn(ctx, contactHash, opts)` | mutation | `null` (`opts`: `{ listKey?; source; proof? }`) |
| `isSuppressed(ctx, contactHash, channel?)` | query | `SuppressionView \| null` |
| `getOptInProof(ctx, contactHash, listKey?)` | query | `OptInProofView \| null` |
| `isEligible(ctx, contactHash, opts?)` | query | `boolean` (`opts`: `{ channel?; listKey?; requireOptIn? }`) |
Full reference: [docs/API.md](docs/API.md).
## React
Backend-only — no `./react` entry. The consumer surface is a server-side
`isEligible` gate and webhook-driven `suppress` writes; a reactive do-not-contact
badge, if ever needed, is an ordinary `useQuery` over the host's re-exported
`isSuppressed` ref.
## Security
- **Auth-agnostic** — the host authenticates the caller, decides who may suppress/query a hash, and passes an opaque `contactHash`; tables are sandboxed.
- **Hash-keyed, never raw PII** — the host hashes and normalizes the contact and owns the salt policy; a suppression survives erasure of the subject.
- **Server-sourced time** — `createdAt`/`confirmedAt` come from `Date.now()` in each handler, never the caller; the opt-in `proof` is opaque, narrowed by the host validator.
See [docs/API.md](docs/API.md).
## Testing
```bash
pnpm test # single run
pnpm test:coverage # enforced 100% on covered files
```
Tests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Author
Built by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt)
Part of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com)
If this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt).
## License
MIT — see [LICENSE](LICENSE).