@codeluum/compliance
v0.1.1
Published
HTTP client for Codeluum compliance integration API (Node 18+)
Maintainers
Readme
@codeluum/compliance
Server-side HTTP client for the Codeluum compliance integration API — consent records, data-subject requests (DSR), audit events, and webhook verification for GDPR / NDPR (Nigeria Data Protection Act) workflows.
This is the server SDK. It authenticates with your secret key (sk_…) and must never run in a browser. For browser/React widgets (cookie banner, preference center, DSAR buttons) that use the publishable key (pk_…), use @codeluum/compliance-web.
- Zero runtime dependencies — built on the global
fetch(Node 18+). - ESM only (
"type": "module"). - Ships TypeScript types.
Installation
npm install @codeluum/complianceRequires Node.js ≥ 18 (uses global fetch and node:crypto).
Quick start
import { createCodeluumClient } from '@codeluum/compliance';
const codeluum = createCodeluumClient({
baseUrl: 'https://api.codeluum.com', // origin only, no trailing path
apiKey: process.env.CODELUUM_SECRET_KEY!, // sk_… — keep this server-side
});
// Record a consent decision (a write — withdrawal is `state: 'withdrawn'`)
await codeluum.consents.record({
subject: 'user_123',
purpose: 'newsletter',
state: 'granted',
basis: 'consent',
policyVersion: '2025-01',
evidence: { source: 'form', ip: req.ip, formId: 'signup' },
});
// Check current state before acting on it
if (await codeluum.consents.has({ subject: 'user_123', purpose: 'newsletter' })) {
await sendNewsletter('user_123');
}Keep the secret key secret.
apiKeygrants full server-side access to your compliance data. Load it from an environment variable or secrets manager — never commit it and never ship it to the browser.
Client configuration
createCodeluumClient(config) returns a CodeluumClient.
| Field | Type | Required | Description |
|---|---|---|---|
| baseUrl | string | ✅ | API origin only, e.g. https://api.codeluum.com (no trailing path; a trailing slash is trimmed). |
| apiKey | string | ✅ | Secret integration key sk_…. Sent as Authorization: Bearer <apiKey>. |
| fetchImpl | typeof fetch | | Override fetch (e.g. undici, or a mock in tests). Defaults to globalThis.fetch. |
All methods call …/compliance/integration/*, unwrap the { success, data } envelope, and return data. Non-2xx responses (or { success: false }) throw a CodeluumApiError.
Consent API — client.consents
Event-log-backed consent primitives. Every call appends to an immutable log; the "current state" is the projection of the latest event per (subject, purpose).
record(params): Promise<RecordConsentResult>
Append a consent event. Idempotent on idempotencyKey when provided.
const result = await codeluum.consents.record({
subject: 'user_123',
purpose: 'analytics',
state: 'granted', // 'granted' | 'withdrawn' | 'acknowledged'
basis: 'consent', // see ConsentBasis below
policyVersion: '2025-01',
dataFlowVersion: 3,
evidence: { source: 'preference_center' },
idempotencyKey: 'evt_abc123',
});
// → { id, subject, purpose, state, recordedAt }RecordConsentParams fields:
| Field | Type | Required | Notes |
|---|---|---|---|
| subject | string | ✅ | Stable subject identifier. |
| purpose | string | ✅ | Purpose id from your purpose registry. |
| state | ConsentState | ✅ | 'granted' \| 'withdrawn' \| 'acknowledged'. |
| basis | ConsentBasis | ✅ | Lawful basis — see below. |
| policyVersion | string | | Version of the policy the subject saw. |
| dataFlowVersion | number | | Version of the data-flow disclosure. |
| evidence | ConsentEvidence | | Capture context (source, ip, userAgent, referrer, formId, pageUrl, …). |
| idempotencyKey | string | | De-dupes retries. |
ConsentBasis: 'consent' | 'contract' | 'pre_contract' | 'legal_obligation' | 'legitimate_interest' | 'vital' | 'public_task' | 'employment_safeguard'.
get({ subject }): Promise<Record<string, ConsentProjectionEntry>>
Current state for every purpose the subject has touched, keyed by purpose id.
const states = await codeluum.consents.get({ subject: 'user_123' });
// → { newsletter: { state: 'granted', basis: 'consent', policyVersion, dataFlowVersion, evidence, asOf }, … }has({ subject, purpose }): Promise<boolean>
Convenience — true only when the latest state for that purpose is 'granted'.
await codeluum.consents.has({ subject: 'user_123', purpose: 'newsletter' });history(params): Promise<ConsentEvent[]>
Newest-first event log for a subject. Server-only — the browser SDK deliberately omits this (a publishable-key call shouldn't enumerate audit-grade history).
const events = await codeluum.consents.history({
subject: 'user_123',
since: '2025-01-01T00:00:00Z', // optional ISO timestamp
limit: 50,
offset: 0,
});Data-subject requests (DSR)
createDataSubjectRequest(params)
Open an export or delete request on behalf of a subject.
await codeluum.createDataSubjectRequest({
externalUserId: 'user_123',
type: 'export', // 'export' | 'delete'
idempotencyKey: 'dsr_001', // optional
});dsr.acknowledge(params): Promise<DsrAcknowledgeResult>
Call this from your webhook handler after you fulfil (or refuse) a dsar.requested event. Codeluum updates the DSR row and fires dsar.completed / dsar.failed for downstream subscribers; the failureReason surfaces on the data subject's status endpoint.
// On success:
await codeluum.dsr.acknowledge({
requestId: 'req_789',
status: 'completed',
fulfillment: { exportBundleUrl: 'https://…/bundle.zip', deletedRecordCount: 42 },
});
// On failure (failureReason is required):
await codeluum.dsr.acknowledge({
requestId: 'req_789',
status: 'failed',
failureReason: 'Subject not found in CRM',
});DsrFulfillment supports exportBundleUrl, deletedRecordCount, rectifiedFields, notes, plus any extra keys your audit exporter reads.
Webhook delivery acknowledgement
webhookDeliveries.acknowledge(params): Promise<AckDeliveryResult>
Generic acknowledgement for required-callback events other than DSR (e.g. consent.withdrawn, incident.reported). DSR uses dsr.acknowledge instead because its richer payload also resolves the DSR row.
await codeluum.webhookDeliveries.acknowledge({
deliveryId: 'dlv_456',
notes: 'Suppressed user in mailing platform', // optional, operator + audit-log visible
});Audit events
appendAudit(params)
Append a compliance audit event.
await codeluum.appendAudit({
action: 'data_exported',
resourceType: 'user',
resourceId: 'user_123',
actorExternalUserId: 'admin_7',
metadata: { format: 'json', records: 42 },
});Verifying inbound webhooks
Codeluum signs every webhook with HMAC-SHA256 over <unix-ts>.<rawBody>, sent in the Codeluum-Signature header as t=<unix>,v1=<hex>. Verify it against the raw request body (before JSON parsing).
import express from 'express';
import { verifyWebhookSignature } from '@codeluum/compliance';
const app = express();
// Use express.raw so the body is the exact bytes that were signed.
app.post('/webhooks/codeluum', express.raw({ type: 'application/json' }), (req, res) => {
const result = verifyWebhookSignature({
headers: req.headers,
rawBody: req.body, // string | Buffer
secret: process.env.CODELUUM_WEBHOOK_SECRET!,
toleranceSeconds: 300, // default 300; rejects stale timestamps
});
if (!result.isValid) {
// reason: 'missing' | 'malformed' | 'stale' | 'mismatch'
return res.status(400).json({ error: result.reason });
}
const event = result.event; // parsed JSON payload
// … handle event.type …
res.json({ received: true });
});verifyWebhookSignature returns { isValid: true, event } or { isValid: false, reason }:
| reason | Meaning |
|---|---|
| 'missing' | No Codeluum-Signature header. |
| 'malformed' | Header isn't t=…,v1=<hex>. |
| 'stale' | Timestamp drift exceeds toleranceSeconds. |
| 'mismatch' | HMAC mismatch (tampered payload or wrong secret). |
signWebhookPayload({ secret, body, now? }) produces the matching { timestamp, signature, header } — mainly a testing convenience; production code shouldn't need it.
Express middleware — requireConsent
Gate a route on a granted purpose. It's a thin coordinator over client.consents.has(...).
import { requireConsent } from '@codeluum/compliance';
app.post(
'/api/newsletter/subscribe',
requireConsent({ purpose: 'newsletter', client: codeluum }),
newsletterHandler
);Behaviour:
- 401
{ error: 'subject_unresolved' }— no subject id could be resolved from the request. - 403
{ error: 'consent_required' }— current state is not'granted'(withdrawn / acknowledged / never-recorded all fail). next()— granted.next(err)— an upstream lookup error (network/5xx) is deferred to your Express error middleware, not treated as a consent decision.
The subject defaults to req.user?.user_id. Override it for other auth shapes:
requireConsent({
purpose: 'newsletter',
client: codeluum,
subjectFrom: (req) => req.session?.userId ?? null, // return null/undefined → 401
});Error handling
Failed requests throw CodeluumApiError:
import { CodeluumApiError } from '@codeluum/compliance';
try {
await codeluum.consents.record(/* … */);
} catch (err) {
if (err instanceof CodeluumApiError) {
console.error(err.status, err.message, err.body);
} else {
throw err;
}
}CodeluumApiError carries status: number, message: string, and the raw body: unknown.
Exported types
CodeluumClient, CodeluumClientConfig, CodeluumApiError, ConsentState, ConsentBasis, ConsentEvidence, RecordConsentParams, RecordConsentResult, ConsentProjectionEntry, HistoryParams, ConsentEvent, CreateDataSubjectRequestParams, AppendAuditParams, DsrAcknowledgeParams, DsrAcknowledgeResult, DsrFulfillment, AckDeliveryParams, AckDeliveryResult, VerifyWebhookSignatureParams, VerifyResult, RequireConsentOptions.
License
MIT
