Website: https://jramm.github.io/clickwrap/
A self-hosted legal signed document service — for provable acceptance of versioned agreements (terms of service, data processing agreements, privacy policies, or any contract document you need your users to accept and you need to prove they did) and for archiving documents that were signed externally (e.g. counter-signed offers) as immutable per-customer evidence.
For the acceptance side it supports two modes side by side:
- Active click-consent — the user explicitly checks a box / clicks "I agree". The exact consent text shown is versioned and stored as part of the evidence.
- Passive tacit acceptance — a new version is considered accepted unless the user objects within an objection period. Crucially, that period only starts on provable access (an e-mail delivery confirmation, or a confirmed in-app popup display), never on publish alone.
Downstream applications ask a single compliance-gate API — "is this customer allowed in?" —
and get a compliant: true|false answer with per-document detail. Everything is backed by an
append-only evidence chain (who accepted what version, when, from which IP/user-agent, against
which exact consent text and document hash).
Document types (e.g. terms, dpa) and audiences (e.g. customer, partner) are declared in a
configuration file (config/legal-entities.json, JSON) that is the single source of truth and
is reconciled into the store at every boot — so the legal-entity state is reproducible and
consistent, and the admin UI lists them read-only (see
docs/PERSISTENCE.md §Legal-entities configuration). A document type can be
marked external: external types skip the whole
clickwrap machinery and instead accept externally-signed PDFs uploaded per customer — a pure
evidence archive that never touches the compliance gate. E-mail delivery, file storage and admin auth are plugins discovered from installed npm
packages (Postmark/SMTP/no-op, in-memory/local-disk storage, Google SSO/static token/SuperTokens
built in — see docs/PLUGINS.md), and an admin web UI is included for
legal/operations staff.
License: Apache-2.0.
- Versioned agreement documents — one document per (type × audience), with an immutable
version history (DRAFT → PUBLISHED → RETIRED). Each version carries a PDF, a
versionLabel, a change summary, a content hash, and — for active mode — the exact consent text. - Two acceptance modes —
ACTIVE(click-consent with an absolute acceptance deadlinehardDeadlineAt: every customer must accept by that calendar date or is blocked, independent of access) andPASSIVE(tacit acceptance with a per-customer objection period from delivery). - Externally-signed documents — document types flagged
externalskip versions/publish/gate; instead already-signed PDFs (e.g. counter-signed offers) are uploaded per customer via the admin or integration API as immutable, append-only evidence (with a host-computed content hash, signer, reference and signature date). They appear in the customer history but are never part of the compliance gate. - PASSIVE deadlines start on provable access only — the objection period begins when access is
proven (e-mail
Deliverywebhook, delivery polling, or a confirmed popup display), not when a version is published; a PASSIVE customer who never accessed is never tacit-booked. ACTIVE uses an absolute hard deadline stamped at rollout — it applies to all customers regardless of access. - Compliance-gate API —
GET /customers/:id/compliancereturns a single boolean plus per-document detail; the intended integration point for portals and tools. - Append-only evidence chain — acceptances, objections and notification events are immutable (enforced by DB privileges in the Prisma driver), with corrections modelled as new rows.
- Config-driven audiences & document types — declared in
config/legal-entities.jsonand reconciled into the store at every boot (create/update + delete-only-if-unused); no code change or enum migration to add a new agreement kind, and no divergence between environments. The admin surface for these is read-only. - Managed e-mail templates, per document type — rollout notification, reminder and
acceptance-confirmation mails are rendered from admin-managed templates, selectable per document
type (so
termsanddpacan use different wording). Templates are authored in the admin UI with the Unlayer drag-and-drop editor (design JSON + exported HTML stored) and support{{placeholders}}({{firstName}}/{{lastName}}/{{companyName}}/{{customerName}}, document details,{{acceptedAt}}, a permanent acceptance link, the public PDF link{{documentPdfUrl}}, app name). The three built-in default templates (one per kind) ship as real, editable rows and are used whenever a document type has no assignment. Seedocs/API.md §2a. Trade-off: the template editor iframe loads from Unlayer's CDN, so authoring needs internet access and a third-party (free-tier) service; sending and rendering do not — the stored HTML is self-contained. - Acceptance-confirmation e-mails with the signed PDF attached — every real acceptance
(
ACTIVE_CONSENTvia portal/link/admin, andTACITbooked by the sweeper — never bulkIMPORT) triggers a confirmation mail rendered from the per-document-typeACCEPTANCE_CONFIRMATIONtemplate, carrying the accepted document as a PDF attachment. Delivery is best-effort and never fails the acceptance itself. Seedocs/INTEGRATION.md §6b. - Permanent acceptance links in mails — the
{{acceptanceLink}}placeholder resolves to a per-customer, non-expiring (but revocable) hosted-acceptance link so notification/reminder mails never go stale; only the token's hash is stored at rest. Seedocs/INTEGRATION.md §6a. - Plugin architecture — e-mail delivery, file storage, admin auth and the hosted acceptance
page are plugins, auto-discovered from installed npm packages (built-ins:
postmark/smtp/noop,memory/local/s3,google-sso/static-token/supertokens) and activated explicitly via env. Third parties ship their own provider as an npm package — seedocs/PLUGINS.md. - Scheduled effectiveness ("publish now, effective later") — one or more versions may be
published with a future
validFrom(several future revisions can be scheduled simultaneously — they surface as theupcomingVersions[]array): the rollout happens immediately, so acceptance can be collected in advance (popup and hosted page mark such items asupcoming), while the current version stays the compliance baseline until the flip at the nearestvalidFrom. The hourly activation sweep then retires the predecessor and supersedes its open states. Deadlines of a not-yet-effective version are anchored atmax(notifiedAt + period, validFrom)— recipients always get the full objection/grace window and nothing blocks or is tacitly booked before the version is in force. - Stable public document URLs —
GET /documents/<type>/<audience>/latest.pdf(no auth) 302-redirects to the currently effective published PDF; the URL is deterministic from the document keys, so a link rendered into an offer stays valid across future publishes. - Background jobs — hourly activation sweep (scheduled-effectiveness flip at
validFrom) and deadline sweeper (tacit acceptance / hard block on expiry), daily reminders (7 and 2 days before a deadline), and Postmark fallback delivery polling. - Hosted acceptance page — the server itself serves a mobile-first acceptance page under
/accept/<token>: an admin mints a link in the UI ("copy acceptance link" in the agreements section of the customer detail page) and sends it directly to the person who has to accept — no portal integration required. The link token is a capability (only its SHA-256 is stored, expiry/revocation supported); the signer's identity is self-declared (typed name + e-mail) and recorded as such in the evidence chain. Rendering the page counts as provable access, so deadlines start exactly like with the popup. - Legal event / audit log — a single normalized, chronological (newest-first), paginated,
filterable Events list (
GET /admin/events) backed by a dedicated, append-onlyEventtable the core writes on each successful action (dual-write viaEventRecorder, alongside the unchanged evidence/audit stores) so a legal user can trace, for the whole system or one customer, which e-mails were sent/delivered/bounced to whom & when, who accessed/viewed what, when agreements were accepted/objected, and every admin/system action. Filter by customer, date range, category (COMMUNICATION / ACCESS / CONSENT / ADMINISTRATION), document type or version. Every state-changing action produces an event — including the automatic (cron/webhook) transitions: passive/tacit acceptance and deadline expiry (deadline sweeper), scheduled version activation / retirement and block carry-over (activation sweeper), and e-mail delivery/bounce (provider webhook). The full event-type catalogue (grouped by category, with which are system/cron-driven) is in docs/API.md. - Admin web UI — React + Google SSO front end for managing documents, versions, rollouts,
the per-version acceptance dashboard, per-customer history, the legal event log, manual
(back-dated) recording, acceptance links, and the dynamic categories. See
admin-ui/. - Two persistence drivers — an in-memory driver (no database, starts instantly, for dev/demo/tests) and a PostgreSQL driver via Prisma.
The backend is a NestJS application built around a pure domain core with a ports-and-adapters boundary:
HTTP controllers (agreements, consent, compliance, admin, webhooks)
│
application services (use-cases: publish, accept, object, sweep, remind, …)
│
┌──────────────────────────────────┴──────────────────────────────────┐
│ pure domain (src/domain/) │
│ state machine · consent rules · compliance rules · clock │
│ no NestJS / no Prisma imports · time only via an injected Clock │
└──────────────────────────────────┬──────────────────────────────────┘
│ ports (repository interfaces)
┌───────────────┴───────────────┐
in-memory driver Prisma / PostgreSQL driver
(src/persistence/inmemory) (src/persistence/prisma)
src/domain/— pure transition functions (state machine, consent/compliance rules); no framework imports; all time comes from an injectedClock.- Ports — repository interfaces in
src/domain/ports.ts. Both persistence drivers implement the same ports; the driver is chosen at boot viaREPOSITORY_DRIVER. - Plugin SDK & registry —
src/plugin-sdk/defines the plugin contracts (e-mail provider, file storage, admin auth, acceptance page);src/plugins/registry/discovers plugins in the installed dependencies (package.json"clickwrap"manifest) andCLICKWRAP_PLUGIN_PATHS. Built-ins live insrc/plugins/builtins/and use the same mechanism. Activation is explicit:EMAIL_PROVIDER,FILE_STORAGE,ADMIN_AUTH.
See docs/API.md for the HTTP API, docs/INTEGRATION.md
for the integrator guide (service-to-service surface), docs/PLUGINS.md for
the plugin author guide, and docs/PERSISTENCE.md
for the schema and database details.
Requirements: Node.js 26+, pnpm (for the non-Docker paths). Commands below
assume pnpm on your PATH. Or skip the toolchain entirely and use Docker — see step 2.
pnpm install
cp .env.example .env # defaults are fine: REPOSITORY_DRIVER=inmemory, EMAIL_PROVIDER=noop
pnpm start:dev # http://localhost:3000 (.env is loaded via dotenv)The in-memory driver starts without Postgres and keeps nothing across restarts — ideal for a first look, demos, and the test suite.
Load a small example dataset (a few documents, versions and customers). The audiences
customer/partner and document types terms/dpa are not created by the seed — they come from
config/legal-entities.json, reconciled at boot:
pnpm seed-exampleThe simplest way to run everything locally — needs Docker:
docker compose up --buildThis starts Postgres, a one-shot migrate step (prisma db push + the partial unique index), and
the combined image — one container that serves the API and the admin UI:
- Admin UI → http://localhost:3000/ui — log in with the
ADMIN_API_TOKEN(defaultdev-admin-token). A bare http://localhost:3000/ redirects to/ui. - API + hosted acceptance page → http://localhost:3000 (=
PUBLIC_BASE_URL); Swagger at/docs/adminand/docs/integration.
What's in the container? The combined image runs a single Node process: the backend serves the API + hosted acceptance page at the root, and the admin-ui SPA under
/ui(NestJSServeStaticModule, enabled bySERVE_ADMIN_UI=true). The SPA is built withbase=/uiso its client routes (/customers,/documents, …) never collide with the backend controllers of the same name. The tokens/secrets indocker-compose.ymlare dev-only throwaways; never reuse them.
docker compose up -d postgres # only the Postgres service (user/password/db: clickwrap)
DATABASE_URL=postgresql://clickwrap:clickwrap@localhost:5432/clickwrap \
pnpm prisma migrate dev --name init
# Apply the post-migration SQL (partial unique index + append-only REVOKEs).
# Run this after every migrate deploy. app_role = the app runtime role (locally: clickwrap).
psql "postgresql://clickwrap:clickwrap@localhost:5432/clickwrap" \
-v app_role=clickwrap -f prisma/partial-indexes.sql
REPOSITORY_DRIVER=prisma \
DATABASE_URL=postgresql://clickwrap:clickwrap@localhost:5432/clickwrap \
pnpm start:devpartial-indexes.sql ships the two things Prisma cannot express declaratively: the partial unique
index enforcing "exactly one effective acceptance per (customer, version)", and append-only
enforcement (REVOKE UPDATE, DELETE) on the evidence tables. See
docs/PERSISTENCE.md, including the note on separating the migration/owner
role from the app runtime role in staging/production.
All configuration is via environment variables (see .env.example):
| Variable | Default | Description |
|---|---|---|
REPOSITORY_DRIVER |
inmemory |
inmemory (no DB, nothing persists) or prisma (PostgreSQL). |
DATABASE_URL |
– | PostgreSQL connection string; required for REPOSITORY_DRIVER=prisma. |
LEGAL_ENTITIES_CONFIG |
config/legal-entities.json |
Path to the JSON config declaring audiences + document types (reconciled at boot). A missing/malformed file fails the boot. |
PORT |
3000 |
HTTP port. |
CLICKWRAP_PLUGIN_PATHS |
– | Comma-separated local plugin directories, loaded by path (see docs/PLUGINS.md). |
CLICKWRAP_PLUGIN_DIR |
/app/plugins (in the images) |
Comma-separated directories scanned for drop-in plugin subdirs — mount a volume here to add plugins to a published image with no rebuild. |
EMAIL_PROVIDER |
noop |
E-mail delivery plugin key. Built-ins: postmark, smtp, noop. |
EMAIL_FROM |
– | Sender address; required for postmark and smtp (no hardcoded fallback). |
POSTMARK_API_TOKEN |
– | Postmark server token; empty = no real sending, fake provider refs. |
POSTMARK_WEBHOOK_TOKEN |
– | Token header expected on POST /webhooks/postmark (403 on mismatch). |
SMTP_HOST |
localhost |
SMTP host (EMAIL_PROVIDER=smtp). |
SMTP_PORT |
587 |
SMTP port. |
SMTP_SECURE |
false |
true = implicit TLS (port 465). |
SMTP_USER |
– | SMTP username; empty = no auth. |
SMTP_PASS |
– | SMTP password. |
FILE_STORAGE |
memory |
File-storage plugin key. Built-ins: memory (nothing persists), local (server disk, single-node). |
FILE_STORAGE_LOCAL_DIR |
./data/files |
Blob directory for FILE_STORAGE=local (created recursively). |
FILE_STORAGE_LOCAL_SECRET |
– | Required for FILE_STORAGE=local: HMAC secret for the time-limited /files links. |
PUBLIC_BASE_URL |
http://localhost:${PORT} |
Absolute public base URL of this service, used for /files links and hosted acceptance links (/accept/<token>). Defaults to localhost for dev (startup warning) — set it in production. |
ADMIN_AUTH |
google-sso,static-token |
Ordered admin-auth plugin keys; the first strategy returning an identity wins. |
ADMIN_API_TOKEN |
change-me |
static-token strategy (x-admin-token, dev/CI); empty = disabled. |
SERVICE_API_TOKEN |
change-me |
Service-to-service token (x-service-token) for the /customers/** API. |
GOOGLE_CLIENT_ID |
– | google-sso strategy: OAuth 2.0 client ID; empty = Bearer path disabled. Served to the UI via GET /admin/auth/methods — a frontend-side VITE_GOOGLE_CLIENT_ID is obsolete. |
ADMIN_ALLOWED_DOMAIN |
– | Required for Google SSO: admin e-mail must end in @<domain>. |
ADMIN_ALLOWED_EMAILS |
– | Optional comma-separated exact allowlist, in addition to the domain. |
SUPERTOKENS_JWKS_URL |
– | supertokens strategy: JWKS endpoint of the SuperTokens core; required when active. |
SUPERTOKENS_ISSUER |
– | Optional issuer check for SuperTokens access tokens. |
SUPERTOKENS_LOGIN_URL |
– | Optional login URL advertised as the oidc-redirect method; empty = verify-only. |
ADMIN_SUPERTOKENS_ROLE |
admin |
Role required in the SuperTokens st-role claim. |
ADMIN_UI_ORIGINS |
http://localhost:5173,http://localhost:4173 |
Comma-separated CORS origins of the admin UI (vite dev + preview ports); empty = CORS off (a bootstrap warning is logged). |
SWEEPER_ENABLED |
true |
Kill switch for the background sweeper. |
OPENAPI_DOCS_ENABLED |
false |
true = serve Swagger UIs at /docs/admin and /docs/integration. |
The server, the seed script and pnpm openapi load .env automatically (dotenv).
pnpm openapi regenerates the two committed OpenAPI 3 documents at the repo root:
openapi.admin.json (admin surface, source for the admin-UI client) and
openapi.integration.json (service-to-service surface, see
docs/INTEGRATION.md). Re-run it whenever controllers or DTOs change and
commit the result.
E-mail delivery, file storage and admin auth are plugins. They are auto-discovered from the
installed dependencies (any package whose package.json carries a
"clickwrap": { "kind", "key" } manifest) plus local CLICKWRAP_PLUGIN_PATHS directories, and
activated explicitly via EMAIL_PROVIDER, FILE_STORAGE and ADMIN_AUTH. Built-ins register
through the exact same mechanism. A third party ships a provider as their own npm package —
implement the SDK interface, pnpm add it, set the env var. The full author guide (manifest,
definePlugin, kind interfaces, login-method flows, local development, publishing) is in
docs/PLUGINS.md; the SDK contracts live in
src/plugin-sdk/.
E-mail providers without delivery tracking (smtp, noop) can send but not confirm delivery; in
those modes the PASSIVE objection period starts exclusively via the in-app popup access confirmation
(POST /customers/:id/notifications). ACTIVE blocking is unaffected — its hard deadline is absolute
and independent of access.
/admin/**— document & version management, publish/rollout, the per-version acceptance dashboard, per-customer history, the legal event log (GET /admin/events), manual recording, deadline/block admin actions, and the read-only audiences / document-types list routes (GET /admin/audiences,GET /admin/document-types— these are managed viaconfig/legal-entities.json; there are no create/update/delete routes). Auth: the activeADMIN_AUTHstrategies (default: Google SSO Bearer token or thex-admin-tokendev fallback).GET /admin/auth/methodsis the unauthenticated login-method discovery for the admin UI login page./customers/:id/**— the service-to-service surface for downstream tools: the compliance gate, pending agreements (popup content), and recording acceptances / objections / notifications. Auth:x-service-token+ forwarded context headers./accept/:token— the hosted acceptance page (server-rendered HTML + its JSON acceptance endpoint). No service token: the capability link token in the URL is the authentication; invalid/expired/revoked tokens render a uniform 404./documents/:type/:audience/latest.pdf— public, unauthenticated 302 redirect to the currently effective published PDF of that document (stable URL for offers/templates); every miss is a uniform 404./webhooks/postmark— delivery/bounce webhook (only mounted whenEMAIL_PROVIDER=postmark).
Full request/response shapes and the complete error-code table are in
docs/API.md.
A React admin web interface lives in admin-ui/ (Vite + React + TypeScript + MUI).
It authenticates via Google SSO and drives the /admin/** API: per-version acceptance dashboard,
document/version management with PDF upload and publish, a customers list with per-row compliance
indicator and document-type / audience / compliance-status filters that narrow the list to the
assigned/role-matching customers (fully replacing the former
global Overview page), per-customer history with expandable
evidence, a filterable legal Events log (/events, with a per-customer activity section on the
detail page), manual
acceptance, deadline extension / block suspension, reminders, and a read-only view of the
audiences and document types (managed via config/legal-entities.json). See
admin-ui/README.md for setup.
# Backend
pnpm test # Jest unit suite (uses the in-memory driver; no DB needed)
pnpm lint # tsc --noEmit
pnpm build # tsc production build
# Admin UI
cd admin-ui && pnpm test && pnpm buildThe Prisma repository tests (*.prisma.spec.ts) are excluded from the default unit run and require
a real Postgres instance; see docs/PERSISTENCE.md for how to run them.
- No cross-repo transaction. Recording an acceptance writes the evidence row and the state transition as two separate repo calls; the invariants are protected independently (conditional state update, partial unique index, idempotency store) but there is no single unit of work yet.
- Service-to-service auth is a shared-secret seam. The
/customers/**API trusts a staticSERVICE_API_TOKENplus forwarded context headers; production deployments should move this to mTLS/JWT. - The hosted acceptance page rate limit is in-memory (per link token, 20 requests/minute, single node). Multi-node deployments need a shared limiter; link revocation currently has no admin endpoint (revoke via DB / repo).
- CSV export and customer self-service endpoints are not implemented.
The multi-stage Dockerfile produces three images (each a build --target); the release
workflow publishes all three to ghcr.io on a v* tag:
| Image | Target | What it is |
|---|---|---|
ghcr.io/jramm/clickwrap |
backend |
API + hosted acceptance page only. |
ghcr.io/jramm/clickwrap-admin-ui |
admin-ui |
nginx + admin SPA, reverse-proxies /api to a separate backend (upstream via CLICKWRAP_BACKEND; SPA built VITE_API_URL=/api). |
ghcr.io/jramm/clickwrap-combined |
combined |
backend and admin UI in one container — the backend serves the SPA under /ui and redirects / → /ui. |
The combined image is the simplest way to run everything behind one hostname:
docker build --target combined -t clickwrap-combined . # or: docker build . (combined is the default)
docker run -p 3000:3000 \
-e DATABASE_URL=postgresql://clickwrap:clickwrap@db:5432/clickwrap \
-e REPOSITORY_DRIVER=prisma \
-e ADMIN_AUTH=google-sso,static-token \
-e GOOGLE_CLIENT_ID=... -e ADMIN_ALLOWED_DOMAIN=example.com \
-e SERVICE_API_TOKEN=... -e ACCEPTANCE_LINK_SECRET=... \
-e PUBLIC_BASE_URL=https://clickwrap.example.com \
-e EMAIL_PROVIDER=postmark -e POSTMARK_API_TOKEN=... -e EMAIL_FROM=legal@example.com \
ghcr.io/jramm/clickwrap-combined:latestThe API + hosted acceptance page are at the root; the admin UI is at /ui (and / redirects there).
SERVE_ADMIN_UI=true is baked into the combined image. Apply prisma db push/migrations plus
prisma/partial-indexes.sql against the database before the first start (see Quickstart).
Prefer separate deployments? Run the backend image and put the admin-ui image (or any static
host) in front of it — set the admin-ui image's CLICKWRAP_BACKEND to the backend's URL.
docker compose up --build runs the combined image locally (Postgres + one-shot migrate +
the combined container). Admin UI at http://localhost:3000/ui (a bare / redirects there); API +
hosted acceptance page + Swagger at http://localhost:3000.
GET /health is a cheap liveness check (no I/O); GET /health/ready is readiness — it pings the
database with the prisma driver (503 when the DB is down) and is a no-op with inmemory. Wire
these into your orchestrator's livenessProbe / readinessProbe (see the compose clickwrap
healthcheck for an example).
CI (GitHub Actions) runs on every push/PR: backend lint/unit/build, Prisma integration tests against a real Postgres 16 service container (verifying the partial-unique-index and atomic-transition guarantees), OpenAPI/kubb drift checks, and the admin-ui suite.
Tag a version to publish: git tag v0.1.0 && git push --tags — the release workflow builds and
pushes ghcr.io/jramm/clickwrap (version + latest) and creates a GitHub release with the two OpenAPI
specs attached.
See CONTRIBUTING.md for dev setup, the TDD workflow and conventions
(CONVENTIONS.md), and PR expectations. Security issues: see
SECURITY.md.
Apache License 2.0 — see LICENSE and NOTICE.
Copyright 2026 jramm.