@c15t/backend 2.1.0 → 2.2.0-canary-20260728085441

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +25 -12
  3. package/dist/{583.js → 261.js} +1 -1
  4. package/dist/{915.js → 49.js} +234 -133
  5. package/dist/{302.js → 744.js} +4 -3
  6. package/dist/cache.cjs +17 -13
  7. package/dist/cache.js +2 -2
  8. package/dist/core.cjs +255 -148
  9. package/dist/core.js +9 -8
  10. package/dist/db/adapters/drizzle.cjs +9 -5
  11. package/dist/db/adapters/kysely.cjs +9 -5
  12. package/dist/db/adapters/mongo.cjs +9 -5
  13. package/dist/db/adapters/prisma.cjs +9 -5
  14. package/dist/db/adapters/typeorm.cjs +9 -5
  15. package/dist/db/adapters.js +1 -0
  16. package/dist/db/migrator.cjs +12 -8
  17. package/dist/db/schema.cjs +24 -17
  18. package/dist/db/schema.js +2 -2
  19. package/dist/define-config.cjs +12 -8
  20. package/dist/edge.cjs +59 -18
  21. package/dist/edge.js +49 -12
  22. package/dist/router.cjs +261 -134
  23. package/dist/router.js +1 -1
  24. package/dist/types/index.cjs +9 -5
  25. package/dist-types/db/registry/index.d.ts +35 -35
  26. package/dist-types/db/registry/utils/generate-id.d.ts +32 -0
  27. package/dist-types/db/schema/1.0.0/index.d.ts +36 -1096
  28. package/dist-types/db/schema/2.0.0/consent-policy.d.ts +2 -2
  29. package/dist-types/db/schema/2.0.0/index.d.ts +16 -599
  30. package/dist-types/db/schema/index.d.ts +253 -2479
  31. package/dist-types/handlers/init/index.d.ts +1 -1
  32. package/dist-types/handlers/subject/consent-idempotency.d.ts +87 -0
  33. package/dist-types/handlers/subject/consent-time.d.ts +12 -0
  34. package/dist-types/handlers/subject/get.handler.d.ts +1 -3
  35. package/dist-types/handlers/subject/list.handler.d.ts +1 -3
  36. package/dist-types/handlers/subject/post.handler.d.ts +5 -1
  37. package/dist-types/middleware/cors/is-origin-trusted.d.ts +0 -11
  38. package/dist-types/middleware/openapi/config.d.ts +6 -6
  39. package/dist-types/version.d.ts +1 -1
  40. package/docs/README.md +15 -44
  41. package/docs/self-host/api/configuration.md +98 -0
  42. package/docs/{api → self-host/api}/endpoints.md +3 -2
  43. package/docs/{guides → self-host/guides}/caching.md +3 -1
  44. package/docs/{guides → self-host/guides}/database-setup.md +2 -6
  45. package/docs/{guides → self-host/guides}/edge-deployment.md +3 -2
  46. package/docs/{guides → self-host/guides}/framework-integration.md +1 -0
  47. package/docs/{guides → self-host/guides}/iab-tcf.md +6 -4
  48. package/docs/{guides → self-host/guides}/observability.md +1 -0
  49. package/docs/{guides → self-host/guides}/policy-packs.md +3 -1
  50. package/docs/{quickstart.md → self-host/quickstart.md} +10 -21
  51. package/package.json +24 -19
  52. package/readme.json +13 -7
  53. package/docs/api/configuration.md +0 -208
  54. /package/dist/{942.js → 292.js} +0 -0
package/AGENTS.md ADDED
@@ -0,0 +1,20 @@
1
+ # @c15t/backend
2
+
3
+ > Self-hosted c15t backend docs for configuration, APIs, storage, policy packs, and operations.
4
+
5
+ These docs ship inside the package so coding agents can read them offline. Open the topic file you need from the list below — paths are relative to this file.
6
+
7
+ ## Self Host
8
+
9
+ Run the c15t backend, configure storage, and operate consent infrastructure.
10
+
11
+ - [Configuration Reference](./docs/self-host/api/configuration.md): Complete reference for all c15t backend configuration options.
12
+ - [API Endpoints](./docs/self-host/api/endpoints.md): Full reference for every c15t consent backend endpoint.
13
+ - [Caching](./docs/self-host/guides/caching.md): Add a caching layer to your self-hosted c15t backend for production performance.
14
+ - [Database Setup](./docs/self-host/guides/database-setup.md): Configure a database adapter for your self-hosted c15t backend.
15
+ - [Edge Deployment](./docs/self-host/guides/edge-deployment.md): Run consent policy resolution at the edge for faster initial banner loads.
16
+ - [Framework Integration](./docs/self-host/guides/framework-integration.md): Mount the c15t consent backend in any JavaScript framework or runtime.
17
+ - [IAB TCF Support](./docs/self-host/guides/iab-tcf.md): Enable IAB Transparency and Consent Framework (TCF) support in your self-hosted backend.
18
+ - [Observability](./docs/self-host/guides/observability.md): Add logging, metrics, and tracing to your self-hosted c15t backend.
19
+ - [Policy Packs](./docs/self-host/guides/policy-packs.md): Configure regional policy packs on the backend and return deterministic runtime policy decisions from /init.
20
+ - [Quickstart](./docs/self-host/quickstart.md): Self-host the c15t consent management backend in your own infrastructure.
package/README.md CHANGED
@@ -9,25 +9,27 @@
9
9
 
10
10
  # @c15t/backend: Consent Management Backend
11
11
 
12
- [![GitHub stars](https://img.shields.io/github/stars/c15t/c15t?style=flat-square)](https://github.com/c15t/c15t)
13
- [![CI](https://img.shields.io/github/actions/workflow/status/c15t/c15t/ci.yml?style=flat-square)](https://github.com/c15t/c15t/actions/workflows/ci.yml)
14
- [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square)](https://github.com/c15t/c15t/blob/main/LICENSE.md)
15
- [![Discord](https://img.shields.io/discord/1312171102268690493?style=flat-square)](https://c15t.link/discord)
16
- [![npm version](https://img.shields.io/npm/v/%40c15t%2Fbackend?style=flat-square)](https://www.npmjs.com/package/@c15t/backend)
17
- [![Top Language](https://img.shields.io/github/languages/top/c15t/c15t?style=flat-square)](https://github.com/c15t/c15t)
18
- [![Last Commit](https://img.shields.io/github/last-commit/c15t/c15t?style=flat-square)](https://github.com/c15t/c15t/commits/main)
19
- [![Open Issues](https://img.shields.io/github/issues/c15t/c15t?style=flat-square)](https://github.com/c15t/c15t/issues)
12
+ <p>
13
+ <a href="https://www.npmjs.com/package/@c15t/backend"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/npm/%40c15t%2Fbackend.svg?variant=outline&mode=dark"><img src="https://shieldcn.dev/npm/%40c15t%2Fbackend.svg?variant=outline&mode=light" alt="Latest NPM Version"></picture></a>
14
+ <a href="https://github.com/c15t/c15t"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/c15t/c15t/stars.svg?variant=outline&mode=dark"><img src="https://shieldcn.dev/github/c15t/c15t/stars.svg?variant=outline&mode=light" alt="Stars"></picture></a>
15
+ <a href="https://github.com/c15t/c15t/blob/main/LICENSE.md"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/c15t/c15t/license.svg?variant=outline&mode=dark"><img src="https://shieldcn.dev/github/c15t/c15t/license.svg?variant=outline&mode=light" alt="License"></picture></a>
16
+ <a href="https://c15t.link/discord"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/discord/1312171102268690493.svg?variant=outline&mode=dark"><img src="https://shieldcn.dev/discord/1312171102268690493.svg?variant=outline&mode=light" alt="Discord"></picture></a>
17
+ <a href="https://skills.sh/c15t/skills/c15t"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/skills/c15t/skills/c15t.svg?variant=outline&mode=dark"><img src="https://shieldcn.dev/skills/c15t/skills/c15t.svg?variant=outline&mode=light" alt="Skills"></picture></a>
18
+ <a href="https://inth.com?utm_source=npm&utm_medium=readme&utm_campaign=oss_readme&utm_content=%40c15t%2Fbackend"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/badge/Made%20By-Inth-ffc803.svg?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAzOTMgNDAwIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTgyLjY2MiAwdjM2Ljg5NWgtNTkuMDMxdjgyLjczM2g1OS4wMzF2MzYuODkzSDI3LjQ4MnYtMzYuODkzaDU5LjAzVjM2Ljg5NWgtNTkuMDNWMHpNMzIxLjk0MSA4OS44NVYwaDM1LjM1NXYxNTYuNTIxaC0yNS43MTNsLTg2LjEzNy05MC4zNjR2OTAuMzY0aC0zNS4zNTVWMGgyNi4zNTV6Ii8%2BPHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzE4LjU3MSAxODUuNzE0aDc0LjI4NlY0MDBIMFYxODUuNzE0aDI3Mi44NTd2LTQ3LjE0M3ptLTI5MS4wOSAyOC45Njl2MzcuMTE4aDU4LjEzN3YxMTkuNjI4aDM2Ljg5NVYyNTEuODAxaDU4LjU4NHYtMzcuMTE4em0xODIuNjEuMjI0djE1Ni41MjJoMzYuODk0VjMxMy41OWg3My4zNDF2NTcuODM5aDM3LjExOFYyMTQuOTA3aC0zNy4xMTh2NjEuNzg4aC03My4zNDF2LTYxLjc4OHoiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg%3D%3D&color=ffc803&labelTextColor=000000&valueColor=000000&mode=dark"><img src="https://shieldcn.dev/badge/Made%20By-Inth-ffc803.svg?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAzOTMgNDAwIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTgyLjY2MiAwdjM2Ljg5NWgtNTkuMDMxdjgyLjczM2g1OS4wMzF2MzYuODkzSDI3LjQ4MnYtMzYuODkzaDU5LjAzVjM2Ljg5NWgtNTkuMDNWMHpNMzIxLjk0MSA4OS44NVYwaDM1LjM1NXYxNTYuNTIxaC0yNS43MTNsLTg2LjEzNy05MC4zNjR2OTAuMzY0aC0zNS4zNTVWMGgyNi4zNTV6Ii8%2BPHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzE4LjU3MSAxODUuNzE0aDc0LjI4NlY0MDBIMFYxODUuNzE0aDI3Mi44NTd2LTQ3LjE0M3ptLTI5MS4wOSAyOC45Njl2MzcuMTE4aDU4LjEzN3YxMTkuNjI4aDM2Ljg5NVYyNTEuODAxaDU4LjU4NHYtMzcuMTE4em0xODIuNjEuMjI0djE1Ni41MjJoMzYuODk0VjMxMy41OWg3My4zNDF2NTcuODM5aDM3LjExOFYyMTQuOTA3aC0zNy4xMTh2NjEuNzg4aC03My4zNDF2LTYxLjc4OHoiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg%3D%3D&color=ffc803&labelTextColor=000000&valueColor=000000&mode=light" alt="Made by Inth"></picture></a>
19
+ </p>
20
20
 
21
- Consent policy engine and API for c15t. Powers the cookie banner, consent manager, and preference center. Webhooks, audit logs, storage adapters. Self-host or use inth.com
21
+ Self-hostable consent management backend for c15t. Powers cookie banners, consent managers, preference centers, policy resolution, audit logs, and consent records.
22
22
 
23
23
  ## Key Features
24
24
 
25
- - Consent Management: Track and manage user consent preferences
26
- - Geo-Location: Identify user's location to show relevant consent preferences
25
+ - Self-hosted CMP backend: Run c15t on your own infrastructure with your own database
26
+ - Consent Management: Track and manage user consent preferences and consent records
27
+ - Geo-Location: Identify a user's location to show relevant consent preferences
27
28
  - Server-Side Translation: Reduce banner bundle size by translating consent preferences on the server
28
29
  - Audit Logging: Comprehensive logging of all consent-related actions
29
30
  - Domain Management: Handle multiple domains and subdomains
30
31
  - Policy Management: Version and manage consent policies
32
+ - IAB TCF 2.3 support: Configure GVL, CMP registration, and custom vendors
31
33
 
32
34
  ## Prerequisites
33
35
 
@@ -48,7 +50,18 @@ Configure your database adapter (supports Drizzle, Kysely, Prisma, TypeORM, and
48
50
 
49
51
  ## Documentation
50
52
 
51
- For further information, guides, and examples visit the [reference documentation](https://c15t.com/docs/self-host/v2).
53
+ For further information, guides, and examples visit the [reference documentation](https://c15t.com/docs/self-host/quickstart).
54
+
55
+ ## Hosted or Self-Hosted
56
+
57
+ Use [inth.com](https://inth.com) when you want a hosted c15t backend with no infrastructure to maintain. Use @c15t/backend when you need full control over hosting, database, CORS origins, policy configuration, audit logs, and deployment topology.
58
+
59
+ ## Related Packages
60
+
61
+ - [c15t](https://www.npmjs.com/package/c15t): Headless JavaScript consent engine
62
+ - [@c15t/react](https://www.npmjs.com/package/@c15t/react): React consent UI and headless hooks
63
+ - [@c15t/nextjs](https://www.npmjs.com/package/@c15t/nextjs): Next.js App Router and Pages Router integration
64
+ - [@c15t/node-sdk](https://www.npmjs.com/package/@c15t/node-sdk): Type-safe Node.js API client
52
65
 
53
66
  ## Support
54
67
 
@@ -2,7 +2,7 @@ import { inspectPolicies, resolvePolicyDecision, resolvePolicySync, validatePoli
2
2
  import { deepMergeTranslations, selectLanguage } from "@c15t/translations";
3
3
  import { baseTranslations } from "@c15t/translations/all";
4
4
  import { SignJWT, errors, jwtVerify } from "jose";
5
- import { createGVLResolver, getMetrics } from "./302.js";
5
+ import { createGVLResolver, getMetrics } from "./744.js";
6
6
  const DEFAULT_PROFILE = 'default';
7
7
  const warnedKeys = new Set();
8
8
  function isSupportedBaseLanguage(lang) {
@@ -1,13 +1,12 @@
1
1
  import { hashSha256Hex } from "@c15t/schema/types";
2
2
  import base_x from "base-x";
3
- import { checkConsentOutputSchema, checkConsentQuerySchema, getSubjectInputSchema, getSubjectOutputSchema, initOutputSchema, legalDocumentCurrentInputSchema, legalDocumentCurrentOutputSchema, legalDocumentCurrentParamsSchema, listSubjectsOutputSchema, listSubjectsQuerySchema, patchSubjectOutputSchema, postSubjectInputSchema, postSubjectOutputSchema, statusOutputSchema, subjectIdSchema } from "@c15t/schema";
3
+ import { checkConsentOutputSchema, checkConsentQuerySchema, getSubjectOutputSchema, getSubjectParamsSchema, getSubjectQuerySchema, initOutputSchema, isLegalDocumentType, legalDocumentCurrentInputSchema, legalDocumentCurrentOutputSchema, legalDocumentCurrentParamsSchema, listSubjectsOutputSchema, listSubjectsQuerySchema, patchSubjectInputSchema, patchSubjectOutputSchema, patchSubjectParamsSchema, postSubjectInputSchema, postSubjectOutputSchema, statusOutputSchema } from "@c15t/schema";
4
4
  import { Hono } from "hono";
5
5
  import { describeRoute, resolver, validator } from "hono-openapi";
6
6
  import { HTTPException } from "hono/http-exception";
7
7
  import { errors, jwtVerify } from "jose";
8
- import { extractErrorMessage, getMetrics, withDatabaseSpan } from "./302.js";
9
- import { getLocation, resolveInitPayload, policy_resolvePolicyDecision, verifyPolicySnapshotToken, getJurisdiction } from "./583.js";
10
- import * as __rspack_external_valibot from "valibot";
8
+ import { version, getMetrics, withDatabaseSpan, extractErrorMessage } from "./744.js";
9
+ import { getLocation, resolveInitPayload, policy_resolvePolicyDecision, verifyPolicySnapshotToken, getJurisdiction } from "./261.js";
11
10
  const prefixes = {
12
11
  auditLog: 'log',
13
12
  consent: 'cns',
@@ -18,11 +17,12 @@ const prefixes = {
18
17
  subject: 'sub'
19
18
  };
20
19
  const b58 = base_x('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
21
- function generateId(model) {
22
- const buf = crypto.getRandomValues(new Uint8Array(20));
23
- const prefix = prefixes[model];
24
- const EPOCH_TIMESTAMP = 1700000000000;
25
- const t = Date.now() - EPOCH_TIMESTAMP;
20
+ const generate_id_EPOCH_TIMESTAMP = 1700000000000;
21
+ const ID_BYTE_LENGTH = 20;
22
+ const TIMESTAMP_BYTE_LENGTH = 8;
23
+ function writeTimestamp(buf, timestamp) {
24
+ const offset = timestamp - generate_id_EPOCH_TIMESTAMP;
25
+ const t = Number.isFinite(offset) ? Math.max(0, offset) : 0;
26
26
  const high = Math.floor(t / 0x100000000);
27
27
  const low = t >>> 0;
28
28
  buf[0] = high >>> 24 & 255;
@@ -33,7 +33,21 @@ function generateId(model) {
33
33
  buf[5] = low >>> 16 & 255;
34
34
  buf[6] = low >>> 8 & 255;
35
35
  buf[7] = 255 & low;
36
- return `${prefix}_${b58.encode(buf)}`;
36
+ }
37
+ function generateId(model) {
38
+ const buf = crypto.getRandomValues(new Uint8Array(ID_BYTE_LENGTH));
39
+ writeTimestamp(buf, Date.now());
40
+ return `${prefixes[model]}_${b58.encode(buf)}`;
41
+ }
42
+ async function generateDeterministicId(model, timestamp, identity) {
43
+ const digest = await hashSha256Hex(JSON.stringify(identity));
44
+ const buf = new Uint8Array(ID_BYTE_LENGTH);
45
+ writeTimestamp(buf, timestamp);
46
+ for(let i = TIMESTAMP_BYTE_LENGTH; i < ID_BYTE_LENGTH; i++){
47
+ const offset = (i - TIMESTAMP_BYTE_LENGTH) * 2;
48
+ buf[i] = Number.parseInt(digest.slice(offset, offset + 2), 16);
49
+ }
50
+ return `${prefixes[model]}_${b58.encode(buf)}`;
37
51
  }
38
52
  async function generateUniqueId(db, model, ctx, options = {}) {
39
53
  const { maxRetries = 10, attempt = 0, baseDelay = 5 } = options;
@@ -709,7 +723,7 @@ const statusHandler = async (c)=>{
709
723
  try {
710
724
  await ctx.db.findFirst('subject', {});
711
725
  return c.json({
712
- version: "2.1.0",
726
+ version: version,
713
727
  timestamp: new Date(),
714
728
  client: clientInfo
715
729
  });
@@ -813,6 +827,7 @@ const getSubjectHandler = async (c)=>{
813
827
  });
814
828
  }
815
829
  };
830
+ const SUBJECT_ID_BATCH_SIZE = 500;
816
831
  const listSubjectsHandler = async (c)=>{
817
832
  const ctx = c.get('c15tContext');
818
833
  const logger = ctx.logger;
@@ -838,21 +853,42 @@ const listSubjectsHandler = async (c)=>{
838
853
  const subjects = await db.findMany('subject', {
839
854
  where: (b)=>b('externalId', '=', externalId)
840
855
  });
841
- const subjectItems = await Promise.all(subjects.map(async (subject)=>{
842
- const consents = await db.findMany('consent', {
843
- where: (b)=>b('subjectId', '=', subject.id)
856
+ if (0 === subjects.length) {
857
+ logger.info('Found subjects for externalId', {
858
+ externalId,
859
+ count: 0
844
860
  });
845
- const consentItems = await enrichConsents(consents, {
846
- db,
847
- registry
861
+ return c.json({
862
+ subjects: []
848
863
  });
849
- return {
864
+ }
865
+ const subjectIds = subjects.map((subject)=>subject.id);
866
+ const findConsents = (batch)=>db.findMany('consent', {
867
+ where: (b)=>b('subjectId', 'in', batch)
868
+ });
869
+ const consents = await findConsents(subjectIds.slice(0, SUBJECT_ID_BATCH_SIZE));
870
+ for(let index = SUBJECT_ID_BATCH_SIZE; index < subjectIds.length; index += SUBJECT_ID_BATCH_SIZE){
871
+ const batch = await findConsents(subjectIds.slice(index, index + SUBJECT_ID_BATCH_SIZE));
872
+ consents.push(...batch);
873
+ }
874
+ const consentItems = await enrichConsents(consents, {
875
+ db,
876
+ registry
877
+ });
878
+ const consentsBySubjectId = new Map();
879
+ for (const [index, consent] of consents.entries()){
880
+ const consentItem = consentItems[index];
881
+ if (!consentItem) throw new Error('Consent enrichment returned an incomplete result');
882
+ const subjectConsents = consentsBySubjectId.get(consent.subjectId) ?? [];
883
+ subjectConsents.push(consentItem);
884
+ consentsBySubjectId.set(consent.subjectId, subjectConsents);
885
+ }
886
+ const subjectItems = subjects.map((subject)=>({
850
887
  id: subject.id,
851
888
  externalId: subject.externalId ?? externalId,
852
889
  createdAt: subject.createdAt,
853
- consents: consentItems
854
- };
855
- }));
890
+ consents: consentsBySubjectId.get(subject.id) ?? []
891
+ }));
856
892
  logger.info('Found subjects for externalId', {
857
893
  externalId,
858
894
  count: subjectItems.length
@@ -1041,9 +1077,6 @@ const patchSubjectHandler = async (c)=>{
1041
1077
  };
1042
1078
  const DEFAULT_ISSUER = 'c15t';
1043
1079
  const DEFAULT_AUDIENCE = 'c15t-legal-document-snapshot';
1044
- function isLegalDocumentPolicyType(type) {
1045
- return 'privacy_policy' === type || 'terms_and_conditions' === type || 'dpa' === type;
1046
- }
1047
1080
  function resolveSnapshotIssuer(options) {
1048
1081
  return options?.issuer?.trim() || DEFAULT_ISSUER;
1049
1082
  }
@@ -1056,7 +1089,7 @@ function getSigningKey(secret) {
1056
1089
  return new TextEncoder().encode(secret);
1057
1090
  }
1058
1091
  function isLegalDocumentSnapshotPayload(payload) {
1059
- return 'string' == typeof payload.iss && 'string' == typeof payload.aud && 'string' == typeof payload.sub && isLegalDocumentPolicyType(payload.type) && 'string' == typeof payload.version && 'string' == typeof payload.hash && 'string' == typeof payload.effectiveDate && 'number' == typeof payload.iat && 'number' == typeof payload.exp;
1092
+ return 'string' == typeof payload.iss && 'string' == typeof payload.aud && 'string' == typeof payload.sub && isLegalDocumentType(payload.type) && 'string' == typeof payload.version && 'string' == typeof payload.hash && 'string' == typeof payload.effectiveDate && 'number' == typeof payload.iat && 'number' == typeof payload.exp;
1060
1093
  }
1061
1094
  async function verifyLegalDocumentSnapshotToken(params) {
1062
1095
  const { token, options, tenantId } = params;
@@ -1112,6 +1145,38 @@ async function verifyLegalDocumentSnapshotToken(params) {
1112
1145
  };
1113
1146
  }
1114
1147
  }
1148
+ function buildConsentId(input) {
1149
+ return generateDeterministicId('consent', input.givenAt.getTime(), [
1150
+ input.tenantId ?? null,
1151
+ input.subjectId,
1152
+ input.domainId,
1153
+ input.policyId ?? null,
1154
+ input.givenAt.toISOString()
1155
+ ]);
1156
+ }
1157
+ function findConsentById(database, consentId) {
1158
+ return database.findFirst('consent', {
1159
+ where: (builder)=>builder('id', '=', consentId)
1160
+ });
1161
+ }
1162
+ async function findExistingConsentSubmission(database, consentId, identity) {
1163
+ const deterministicConsent = await findConsentById(database, consentId);
1164
+ if (deterministicConsent) return deterministicConsent;
1165
+ return database.findFirst('consent', {
1166
+ where: (builder)=>builder.and(builder('subjectId', '=', identity.subjectId), builder('domainId', '=', identity.domainId), builder('policyId', '=', identity.policyId ?? null), builder('givenAt', '=', identity.givenAt), void 0 === identity.tenantId ? builder.isNull('tenantId') : builder('tenantId', '=', identity.tenantId))
1167
+ });
1168
+ }
1169
+ function isUniqueConstraintViolationError(error) {
1170
+ const code = 'object' == typeof error && null !== error && 'code' in error ? String(error.code) : void 0;
1171
+ if ('23505' === code || 'ER_DUP_ENTRY' === code || '1062' === code || 'P2002' === code || code?.startsWith('SQLITE_CONSTRAINT')) return true;
1172
+ const message = extractErrorMessage(error).toLowerCase();
1173
+ return message.includes('unique constraint') || message.includes('unique violation') || message.includes('duplicate key') || message.includes('duplicate entry') || message.includes('unique conflict');
1174
+ }
1175
+ const MAX_FUTURE_CONSENT_TIME_DRIFT_MS = 300000;
1176
+ function clampConsentGivenAt(givenAt, now = Date.now()) {
1177
+ if (givenAt.getTime() > now + MAX_FUTURE_CONSENT_TIME_DRIFT_MS) return new Date(now);
1178
+ return givenAt;
1179
+ }
1115
1180
  function buildRuntimeDecisionDedupeKey(input) {
1116
1181
  return [
1117
1182
  input.tenantId ?? 'default',
@@ -1191,9 +1256,6 @@ function parseLanguageFromHeader(header) {
1191
1256
  if (!firstLanguage) return;
1192
1257
  return firstLanguage.split('-')[0]?.toLowerCase();
1193
1258
  }
1194
- function isLegalDocumentType(type) {
1195
- return 'privacy_policy' === type || 'terms_and_conditions' === type || 'dpa' === type;
1196
- }
1197
1259
  function resolveSnapshotFailureMode(ctx) {
1198
1260
  return ctx.policySnapshot?.onValidationFailure ?? 'reject';
1199
1261
  }
@@ -1275,7 +1337,13 @@ const postSubjectHandler = async (c)=>{
1275
1337
  const input = await c.req.json();
1276
1338
  const { type, subjectId, identityProvider, externalSubjectId, domain, metadata, givenAt: givenAtEpoch } = input;
1277
1339
  const preferences = 'preferences' in input ? input.preferences : void 0;
1278
- const givenAt = new Date(givenAtEpoch);
1340
+ const requestedGivenAt = new Date(givenAtEpoch);
1341
+ const givenAt = clampConsentGivenAt(requestedGivenAt);
1342
+ const wasGivenAtClamped = givenAt.getTime() !== requestedGivenAt.getTime();
1343
+ if (wasGivenAtClamped) logger.warn('Consent givenAt was too far in the future and was clamped to server time', {
1344
+ requestedGivenAt: requestedGivenAt.toISOString(),
1345
+ clampedGivenAt: givenAt.toISOString()
1346
+ });
1279
1347
  const rawConsentAction = 'consentAction' in input ? input.consentAction : void 0;
1280
1348
  let derivedConsentAction;
1281
1349
  logger.debug('Request parameters', {
@@ -1510,6 +1578,9 @@ const postSubjectHandler = async (c)=>{
1510
1578
  ...metadata ?? {},
1511
1579
  ...shouldStoreLanguage && effectiveLanguage ? {
1512
1580
  policyLanguage: effectiveLanguage
1581
+ } : {},
1582
+ ...wasGivenAtClamped ? {
1583
+ clientGivenAt: requestedGivenAt.toISOString()
1513
1584
  } : {}
1514
1585
  };
1515
1586
  const effectiveJurisdiction = hasValidSnapshot && snapshotPayload ? snapshotPayload.jurisdiction : resolvedJurisdiction;
@@ -1528,9 +1599,15 @@ const postSubjectHandler = async (c)=>{
1528
1599
  language: effectiveLanguage,
1529
1600
  proofConfig
1530
1601
  });
1531
- const existingConsent = await db.findFirst('consent', {
1532
- where: (b)=>b.and(b('subjectId', '=', subject.id), b('domainId', '=', domainRecord.id), b('policyId', '=', policyId), b('givenAt', '=', givenAt))
1533
- });
1602
+ const consentIdentity = {
1603
+ tenantId: ctx.tenantId,
1604
+ subjectId: subject.id,
1605
+ domainId: domainRecord.id,
1606
+ policyId,
1607
+ givenAt: requestedGivenAt
1608
+ };
1609
+ const consentId = await buildConsentId(consentIdentity);
1610
+ const existingConsent = await findExistingConsentSubmission(db, consentId, consentIdentity);
1534
1611
  if (existingConsent) {
1535
1612
  logger.debug('Duplicate consent detected, returning existing record', {
1536
1613
  consentId: existingConsent.id
@@ -1547,103 +1624,132 @@ const postSubjectHandler = async (c)=>{
1547
1624
  givenAt: existingConsent.givenAt
1548
1625
  });
1549
1626
  }
1550
- const result = await db.transaction(async (tx)=>{
1551
- logger.debug('Creating consent record', {
1552
- subjectId: subject.id,
1553
- domainId: domainRecord.id,
1554
- policyId,
1555
- purposeIds
1556
- });
1557
- const runtimePolicyDecision = decisionPayload ? await tx.findFirst('runtimePolicyDecision', {
1558
- where: (b)=>b('dedupeKey', '=', decisionPayload.dedupeKey)
1559
- }) ?? await tx.create('runtimePolicyDecision', {
1560
- id: `rpd_${crypto.randomUUID().replaceAll('-', '')}`,
1561
- tenantId: decisionPayload.tenantId,
1562
- policyId: decisionPayload.policyId,
1563
- fingerprint: decisionPayload.fingerprint,
1564
- matchedBy: decisionPayload.matchedBy,
1565
- countryCode: decisionPayload.countryCode,
1566
- regionCode: decisionPayload.regionCode,
1567
- jurisdiction: decisionPayload.jurisdiction,
1568
- language: decisionPayload.language,
1569
- model: decisionPayload.model,
1570
- policyI18n: decisionPayload.policyI18n ? {
1571
- json: decisionPayload.policyI18n
1572
- } : void 0,
1573
- uiMode: decisionPayload.uiMode,
1574
- bannerUi: decisionPayload.bannerUi ? {
1575
- json: decisionPayload.bannerUi
1576
- } : void 0,
1577
- dialogUi: decisionPayload.dialogUi ? {
1578
- json: decisionPayload.dialogUi
1579
- } : void 0,
1580
- categories: decisionPayload.categories ? {
1581
- json: decisionPayload.categories
1582
- } : void 0,
1583
- preselectedCategories: decisionPayload.preselectedCategories ? {
1584
- json: decisionPayload.preselectedCategories
1585
- } : void 0,
1586
- proofConfig: decisionPayload.proofConfig ? {
1587
- json: decisionPayload.proofConfig
1588
- } : void 0,
1589
- dedupeKey: decisionPayload.dedupeKey
1590
- }).catch(async ()=>tx.findFirst('runtimePolicyDecision', {
1627
+ const runConsentTransaction = ()=>db.transaction(async (tx)=>{
1628
+ logger.debug('Creating consent record', {
1629
+ subjectId: subject.id,
1630
+ domainId: domainRecord.id,
1631
+ policyId,
1632
+ purposeIds
1633
+ });
1634
+ const runtimePolicyDecision = decisionPayload ? await tx.findFirst('runtimePolicyDecision', {
1591
1635
  where: (b)=>b('dedupeKey', '=', decisionPayload.dedupeKey)
1592
- })) : void 0;
1593
- const consentRecord = await tx.create('consent', {
1594
- id: await utils_generateUniqueId(tx, 'consent', ctx),
1595
- subjectId: subject.id,
1596
- domainId: domainRecord.id,
1597
- policyId,
1598
- purposeIds: {
1599
- json: purposeIds
1600
- },
1601
- metadata: Object.keys(metadataWithPolicy).length > 0 ? {
1602
- json: metadataWithPolicy
1603
- } : void 0,
1604
- ipAddress: shouldStoreIp ? ctx.ipAddress : null,
1605
- userAgent: shouldStoreUserAgent ? ctx.userAgent : null,
1606
- jurisdiction: effectiveJurisdiction,
1607
- jurisdictionModel: effectiveModel,
1608
- tcString: input.tcString,
1609
- uiSource: input.uiSource,
1610
- consentAction: derivedConsentAction,
1611
- givenAt,
1612
- validUntil,
1613
- runtimePolicyDecisionId: runtimePolicyDecision?.id,
1614
- runtimePolicySource: decisionPayload?.source
1615
- });
1616
- logger.debug('Created consent', {
1617
- consentRecord: consentRecord.id
1618
- });
1619
- if (!consentRecord) throw new HTTPException(500, {
1620
- message: 'Failed to create consent',
1621
- cause: {
1622
- code: 'CONSENT_CREATION_FAILED',
1636
+ }) ?? await tx.create('runtimePolicyDecision', {
1637
+ id: `rpd_${crypto.randomUUID().replaceAll('-', '')}`,
1638
+ tenantId: decisionPayload.tenantId,
1639
+ policyId: decisionPayload.policyId,
1640
+ fingerprint: decisionPayload.fingerprint,
1641
+ matchedBy: decisionPayload.matchedBy,
1642
+ countryCode: decisionPayload.countryCode,
1643
+ regionCode: decisionPayload.regionCode,
1644
+ jurisdiction: decisionPayload.jurisdiction,
1645
+ language: decisionPayload.language,
1646
+ model: decisionPayload.model,
1647
+ policyI18n: decisionPayload.policyI18n ? {
1648
+ json: decisionPayload.policyI18n
1649
+ } : void 0,
1650
+ uiMode: decisionPayload.uiMode,
1651
+ bannerUi: decisionPayload.bannerUi ? {
1652
+ json: decisionPayload.bannerUi
1653
+ } : void 0,
1654
+ dialogUi: decisionPayload.dialogUi ? {
1655
+ json: decisionPayload.dialogUi
1656
+ } : void 0,
1657
+ categories: decisionPayload.categories ? {
1658
+ json: decisionPayload.categories
1659
+ } : void 0,
1660
+ preselectedCategories: decisionPayload.preselectedCategories ? {
1661
+ json: decisionPayload.preselectedCategories
1662
+ } : void 0,
1663
+ proofConfig: decisionPayload.proofConfig ? {
1664
+ json: decisionPayload.proofConfig
1665
+ } : void 0,
1666
+ dedupeKey: decisionPayload.dedupeKey
1667
+ }) : void 0;
1668
+ const consentRecord = await tx.create('consent', {
1669
+ id: consentId,
1623
1670
  subjectId: subject.id,
1624
- domain
1625
- }
1671
+ domainId: domainRecord.id,
1672
+ policyId,
1673
+ purposeIds: {
1674
+ json: purposeIds
1675
+ },
1676
+ metadata: Object.keys(metadataWithPolicy).length > 0 ? {
1677
+ json: metadataWithPolicy
1678
+ } : void 0,
1679
+ ipAddress: shouldStoreIp ? ctx.ipAddress : null,
1680
+ userAgent: shouldStoreUserAgent ? ctx.userAgent : null,
1681
+ jurisdiction: effectiveJurisdiction,
1682
+ jurisdictionModel: effectiveModel,
1683
+ tcString: input.tcString,
1684
+ uiSource: input.uiSource,
1685
+ consentAction: derivedConsentAction,
1686
+ givenAt,
1687
+ validUntil,
1688
+ runtimePolicyDecisionId: runtimePolicyDecision?.id,
1689
+ runtimePolicySource: decisionPayload?.source
1690
+ });
1691
+ if (!consentRecord) throw new HTTPException(500, {
1692
+ message: 'Failed to create consent',
1693
+ cause: {
1694
+ code: 'CONSENT_CREATION_FAILED',
1695
+ subjectId: subject.id,
1696
+ domain
1697
+ }
1698
+ });
1699
+ logger.debug('Created consent', {
1700
+ consentRecord: consentRecord.id
1701
+ });
1702
+ return {
1703
+ consent: consentRecord,
1704
+ created: true
1705
+ };
1626
1706
  });
1627
- return {
1628
- consent: consentRecord
1629
- };
1707
+ const maxTransactionAttempts = 3;
1708
+ let result;
1709
+ for(let attempt = 1; attempt <= maxTransactionAttempts; attempt++)try {
1710
+ result = await runConsentTransaction();
1711
+ break;
1712
+ } catch (error) {
1713
+ if (!isUniqueConstraintViolationError(error)) throw error;
1714
+ const concurrentConsent = await findConsentById(db, consentId);
1715
+ if (concurrentConsent) {
1716
+ logger.debug('Consent insert conflicted, returning existing record', {
1717
+ consentId: concurrentConsent.id
1718
+ });
1719
+ result = {
1720
+ consent: concurrentConsent,
1721
+ created: false
1722
+ };
1723
+ break;
1724
+ }
1725
+ if (attempt === maxTransactionAttempts) throw error;
1726
+ }
1727
+ if (!result) throw new HTTPException(500, {
1728
+ message: 'Failed to create consent',
1729
+ cause: {
1730
+ code: 'CONSENT_CREATION_FAILED',
1731
+ subjectId: subject.id,
1732
+ domain
1733
+ }
1630
1734
  });
1631
- const metrics = getMetrics();
1632
- if (metrics) {
1633
- const jurisdiction = effectiveJurisdiction;
1634
- metrics.recordConsentCreated({
1635
- type,
1636
- jurisdiction
1637
- });
1638
- const hasAccepted = preferences && Object.values(preferences).some(Boolean);
1639
- if (hasAccepted) metrics.recordConsentAccepted({
1640
- type,
1641
- jurisdiction
1642
- });
1643
- else metrics.recordConsentRejected({
1644
- type,
1645
- jurisdiction
1646
- });
1735
+ if (result.created) {
1736
+ const metrics = getMetrics();
1737
+ if (metrics) {
1738
+ const jurisdiction = effectiveJurisdiction;
1739
+ metrics.recordConsentCreated({
1740
+ type,
1741
+ jurisdiction
1742
+ });
1743
+ const hasAccepted = preferences && Object.values(preferences).some(Boolean);
1744
+ if (hasAccepted) metrics.recordConsentAccepted({
1745
+ type,
1746
+ jurisdiction
1747
+ });
1748
+ else metrics.recordConsentRejected({
1749
+ type,
1750
+ jurisdiction
1751
+ });
1752
+ }
1647
1753
  }
1648
1754
  return c.json({
1649
1755
  subjectId: subject.id,
@@ -1698,10 +1804,10 @@ const createSubjectRoutes = ()=>{
1698
1804
  description: 'Subject not found for the given ID'
1699
1805
  }
1700
1806
  }
1701
- }), validator('param', getSubjectInputSchema), getSubjectHandler);
1807
+ }), validator('param', getSubjectParamsSchema), validator('query', getSubjectQuerySchema), getSubjectHandler);
1702
1808
  app.post('/', describeRoute({
1703
1809
  summary: 'Record consent for a subject',
1704
- description: "Creates a new consent record (append-only). Creates the subject if it does not exist.\n\n**Request body by `type`:**\n- `cookie_banner` – Requires `preferences` object\n- `privacy_policy`, `dpa`, `terms_and_conditions` – Prefer a signed `documentSnapshotToken`; otherwise use a release `policyHash`, with `policyId` kept only for compatibility\n- `marketing_communications`, `age_verification`, `other` – Optional `preferences`",
1810
+ description: "Creates a new consent record (append-only). Creates the subject if it does not exist.\n\n**Request body by `type`:**\n- `cookie_banner` – Requires `preferences` object\n- `privacy_policy`, `dpa`, `terms_and_conditions`, and suffixed legal-document variants such as `terms_and_conditions_b2b` – Prefer a signed `documentSnapshotToken`; otherwise use a release `policyHash`, with `policyId` kept only for compatibility\n- `marketing_communications`, `age_verification`, `other` – Optional `preferences`",
1705
1811
  tags: [
1706
1812
  'Subject',
1707
1813
  'Consent'
@@ -1739,12 +1845,7 @@ const createSubjectRoutes = ()=>{
1739
1845
  description: 'Subject not found for the given ID'
1740
1846
  }
1741
1847
  }
1742
- }), validator('param', __rspack_external_valibot.object({
1743
- id: subjectIdSchema
1744
- })), validator('json', __rspack_external_valibot.object({
1745
- externalId: __rspack_external_valibot.string(),
1746
- identityProvider: __rspack_external_valibot.optional(__rspack_external_valibot.string())
1747
- })), patchSubjectHandler);
1848
+ }), validator('param', patchSubjectParamsSchema), validator('json', patchSubjectInputSchema), patchSubjectHandler);
1748
1849
  app.get('/', describeRoute({
1749
1850
  summary: 'List subjects by external ID (API key required)',
1750
1851
  description: 'Returns all subjects linked to the given external ID. Requires Bearer token (API key). Use for server-side consent lookups.',
@@ -1,4 +1,5 @@
1
1
  import { SpanKind, SpanStatusCode as api_SpanStatusCode, context, metrics, trace } from "@opentelemetry/api";
2
+ const version = '2.2.0-canary-20260728085441';
2
3
  function extractErrorMessage(error) {
3
4
  if (error instanceof AggregateError && error.errors?.length > 0) {
4
5
  const inner = error.errors.map((e)=>e instanceof Error ? e.message : String(e)).join('; ');
@@ -13,7 +14,7 @@ function createTelemetryOptions(appName = 'c15t', telemetryConfig, tenantId) {
13
14
  const defaultAttributes = {
14
15
  ...telemetryConfig?.defaultAttributes || {},
15
16
  'service.name': String(appName),
16
- 'service.version': "2.1.0"
17
+ 'service.version': version
17
18
  };
18
19
  if (tenantId) defaultAttributes['tenant.id'] = tenantId;
19
20
  const config = {
@@ -315,7 +316,6 @@ function getMetrics(options) {
315
316
  metricsInstance = createMetrics(getMeter(options));
316
317
  return metricsInstance;
317
318
  }
318
- const GVL_TTL_MS = 259200000;
319
319
  const memory_memoryCache = new Map();
320
320
  function createMemoryCacheAdapter() {
321
321
  return {
@@ -368,6 +368,7 @@ function createCacheKey(appName, namespace, ...parts) {
368
368
  ];
369
369
  return allParts.join(':');
370
370
  }
371
+ const GVL_TTL_MS = 259200000;
371
372
  const GVL_ENDPOINT = 'https://gvl.inth.app';
372
373
  const inflightRequests = new Map();
373
374
  async function fetchGVLWithLanguage(language, vendorIds, endpoint = GVL_ENDPOINT) {
@@ -469,4 +470,4 @@ function createGVLResolver(options) {
469
470
  }
470
471
  };
471
472
  }
472
- export { GVL_TTL_MS, clearMemoryCache, createCacheKey, createGVLCacheKey, createGVLResolver, createMemoryCacheAdapter, createRequestSpan, createTelemetryOptions, extractErrorMessage, getMemoryCacheSize, getMetrics, getTraceContext, handleSpanError, isTelemetryEnabled, withDatabaseSpan, withSpanContext };
473
+ export { GVL_TTL_MS, clearMemoryCache, createCacheKey, createGVLCacheKey, createGVLResolver, createMemoryCacheAdapter, createRequestSpan, createTelemetryOptions, extractErrorMessage, getMemoryCacheSize, getMetrics, getTraceContext, handleSpanError, isTelemetryEnabled, version, withDatabaseSpan, withSpanContext };