{"id":51317910,"url":"https://github.com/vllnt/convex-suppression","last_synced_at":"2026-07-01T09:31:17.207Z","repository":{"id":365269580,"uuid":"1268769529","full_name":"vllnt/convex-suppression","owner":"vllnt","description":"Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Convex component","archived":false,"fork":false,"pushed_at":"2026-06-16T15:30:47.000Z","size":110,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-16T17:12:36.565Z","etag":null,"topics":["convex","convex-component","gdpr","suppression","typescript","vllnt"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/@vllnt/convex-suppression","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vllnt.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":"bntvllnt"}},"created_at":"2026-06-13T23:13:51.000Z","updated_at":"2026-06-16T15:32:04.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/vllnt/convex-suppression","commit_stats":null,"previous_names":["vllnt/convex-suppression"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/vllnt/convex-suppression","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-suppression","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-suppression/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-suppression/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-suppression/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vllnt","download_url":"https://codeload.github.com/vllnt/convex-suppression/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-suppression/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35001648,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["convex","convex-component","gdpr","suppression","typescript","vllnt"],"created_at":"2026-07-01T09:31:16.194Z","updated_at":"2026-07-01T09:31:17.191Z","avatar_url":"https://github.com/vllnt.png","language":"TypeScript","funding_links":["https://github.com/sponsors/bntvllnt"],"categories":[],"sub_categories":[],"readme":"\u003c!-- Badges --\u003e\n[![convex-component](https://img.shields.io/badge/convex-component-EE342F.svg)](https://www.convex.dev/components)\n[![npm](https://img.shields.io/npm/v/@vllnt/convex-suppression.svg)](https://www.npmjs.com/package/@vllnt/convex-suppression)\n[![CI](https://github.com/vllnt/convex-suppression/actions/workflows/ci.yml/badge.svg)](https://github.com/vllnt/convex-suppression/actions/workflows/ci.yml)\n[![license](https://img.shields.io/npm/l/@vllnt/convex-suppression.svg)](./LICENSE)\n\n# @vllnt/convex-suppression\n\nThe do-not-contact suppression list + opt-in proof, as a Convex component.\n\n```ts\nconst dnc = new Suppression(components.suppression);\nawait dnc.suppress(ctx, contactHash, \"complaint\", { channel: \"email\" });\nconst canSend = await dnc.isEligible(ctx, contactHash, { channel: \"email\" });\n```\n\nThe GDPR opt-out / CAN-SPAM primitive: a global do-not-contact list keyed by an\nopaque **`contactHash`** (never raw PII, so it survives erasure) plus an opt-in\nproof ledger. Before every send the host asks `isEligible`; an unsubscribe / bounce\n/ complaint calls `suppress`; a double-opt-in confirmation calls `recordOptIn`.\nDomain-neutral — channels and reasons are the host's.\n\n## Features\n\n- **Hash-keyed, erasure-surviving** — stores only the opaque `contactHash`, never raw PII, so a tombstone outlives erasure of the subject.\n- **Channel-aware** — scope a suppression to one channel (`\"email\"`/`\"sms\"`/`\"push\"`), or omit `channel` for a global all-channel tombstone.\n- **One send gate** — `isEligible` answers `¬suppressed [∧ confirmed]` in one call; suppression always blocks, opt-in is per call.\n- **Opt-in proof ledger** — `recordOptIn` / `getOptInProof` store legal proof-of-consent per list, kept separate from any authz store.\n- **Idempotent** — re-suppressing / re-recording on its key tuple updates in place, so a replayed webhook never duplicates a row.\n- **Typed, opaque proof** — `Suppression\u003cTProof\u003e` with an optional `proofValidator` narrows the stored evidence at the boundary.\n- **Server-sourced time** — `createdAt`/`confirmedAt` are stamped from the server clock; a caller can never supply a timestamp.\n- **Mount-safe** — correct under multiple named `app.use` mounts (e.g. marketing vs. transactional lists), each an isolated sandbox.\n\n## Installation\n\n```bash\npnpm add @vllnt/convex-suppression\n```\n\nPeer dependency: `convex@^1.41.0`.\n\n## Usage\n\n```ts\n// convex/convex.config.ts\nimport { defineApp } from \"convex/server\";\nimport suppression from \"@vllnt/convex-suppression/convex.config\";\n\nconst app = defineApp();\napp.use(suppression);\nexport default app;\n```\n\n```ts\n// convex/email.ts — host owns auth + hashing; pass an opaque contactHash in.\nimport { components } from \"./_generated/api\";\nimport { mutation, query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { Suppression } from \"@vllnt/convex-suppression\";\n\nconst dnc = new Suppression\u003c{ ip: string }\u003e(components.suppression, {\n  proofValidator: v.object({ ip: v.string() }).parse,\n});\nconst hash = (email: string) =\u003e myHash(email.trim().toLowerCase()); // host's hashing + salt policy\n\n// The send gate — call before every send.\nexport const canEmail = query({\n  args: { email: v.string() },\n  handler: (ctx, { email }) =\u003e\n    dnc.isEligible(ctx, hash(email), { channel: \"email\", listKey: \"newsletter\", requireOptIn: true }),\n});\n\n// A provider webhook suppresses on complaint; a confirmed double opt-in records proof.\nexport const onComplaint = mutation({\n  args: { email: v.string() },\n  handler: (ctx, { email }) =\u003e dnc.suppress(ctx, hash(email), \"complaint\", { channel: \"email\" }),\n});\nexport const confirmOptIn = mutation({\n  args: { email: v.string(), ip: v.string() },\n  handler: (ctx, { email, ip }) =\u003e\n    dnc.recordOptIn(ctx, hash(email), { listKey: \"newsletter\", source: \"double-opt-in\", proof: { ip } }),\n});\n```\n\nClient options: `new Suppression(component, { proofValidator? })`. Omitting `channel`/`listKey` targets the global entry.\n\n## API Reference\n\n| Method | Kind | Result |\n|--------|------|--------|\n| `suppress(ctx, contactHash, reason, opts?)` | mutation | `null` (`reason`: `\"unsubscribe\" \\| \"bounce\" \\| \"complaint\" \\| \"manual\" \\| \"global\"`; `opts`: `{ channel? }`) |\n| `unsuppress(ctx, contactHash, channel?)` | mutation | `boolean` (`true` if an entry was removed) |\n| `recordOptIn(ctx, contactHash, opts)` | mutation | `null` (`opts`: `{ listKey?; source; proof? }`) |\n| `isSuppressed(ctx, contactHash, channel?)` | query | `SuppressionView \\| null` |\n| `getOptInProof(ctx, contactHash, listKey?)` | query | `OptInProofView \\| null` |\n| `isEligible(ctx, contactHash, opts?)` | query | `boolean` (`opts`: `{ channel?; listKey?; requireOptIn? }`) |\n\nFull reference: [docs/API.md](docs/API.md).\n\n## React\n\nBackend-only — no `./react` entry. The consumer surface is a server-side\n`isEligible` gate and webhook-driven `suppress` writes; a reactive do-not-contact\nbadge, if ever needed, is an ordinary `useQuery` over the host's re-exported\n`isSuppressed` ref.\n\n## Security\n\n- **Auth-agnostic** — the host authenticates the caller, decides who may suppress/query a hash, and passes an opaque `contactHash`; tables are sandboxed.\n- **Hash-keyed, never raw PII** — the host hashes and normalizes the contact and owns the salt policy; a suppression survives erasure of the subject.\n- **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.\n\nSee [docs/API.md](docs/API.md).\n\n## Testing\n\n```bash\npnpm test           # single run\npnpm test:coverage  # enforced 100% on covered files\n```\n\nTests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Author\n\nBuilt by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt)\n\nPart of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com)\n\nIf this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt).\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-suppression","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvllnt%2Fconvex-suppression","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-suppression/lists"}