@c15t/backend 2.0.0-rc.6 → 2.0.0

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/README.md +3 -3
  2. package/dist/302.js +2 -3
  3. package/dist/{364.js → 915.js} +656 -25
  4. package/dist/core.cjs +497 -15
  5. package/dist/core.js +8 -156
  6. package/dist/db/schema.cjs +4 -0
  7. package/dist/db/schema.js +3 -2
  8. package/dist/edge.cjs +8 -8
  9. package/dist/edge.js +3 -3
  10. package/dist/router.cjs +253 -42
  11. package/dist/router.js +1 -1
  12. package/dist-types/cache/gvl-resolver.d.ts +1 -1
  13. package/dist-types/db/registry/consent-policy.d.ts +57 -1
  14. package/dist-types/db/registry/index.d.ts +43 -1
  15. package/dist-types/db/registry/types.d.ts +2 -1
  16. package/dist-types/db/schema/1.0.0/consent.d.ts +1 -1
  17. package/dist-types/db/schema/2.0.0/audit-log.d.ts +1 -1
  18. package/dist-types/db/schema/2.0.0/consent-policy.d.ts +3 -2
  19. package/dist-types/db/schema/2.0.0/consent-purpose.d.ts +1 -1
  20. package/dist-types/db/schema/2.0.0/consent.d.ts +1 -1
  21. package/dist-types/db/schema/2.0.0/domain.d.ts +1 -1
  22. package/dist-types/db/schema/2.0.0/index.d.ts +7 -0
  23. package/dist-types/db/schema/2.0.0/runtime-policy-decision.d.ts +1 -1
  24. package/dist-types/db/schema/2.0.0/subject.d.ts +1 -1
  25. package/dist-types/db/schema/index.d.ts +14 -0
  26. package/dist-types/edge/index.d.ts +2 -2
  27. package/dist-types/edge/init-handler.d.ts +5 -3
  28. package/dist-types/edge/resolve-consent.d.ts +6 -6
  29. package/dist-types/edge/types.d.ts +1 -1
  30. package/dist-types/handlers/init/index.d.ts +4 -4
  31. package/dist-types/handlers/init/policy.d.ts +1 -1
  32. package/dist-types/handlers/init/resolve-init.d.ts +2 -2
  33. package/dist-types/handlers/init/translations.d.ts +1 -1
  34. package/dist-types/handlers/legal-document/current.handler.d.ts +11 -0
  35. package/dist-types/handlers/legal-document/snapshot.d.ts +39 -0
  36. package/dist-types/handlers/subject/get.handler.d.ts +3 -0
  37. package/dist-types/handlers/subject/list.handler.d.ts +3 -0
  38. package/dist-types/handlers/utils/consent-enrichment.d.ts +3 -0
  39. package/dist-types/middleware/cors/is-origin-trusted.d.ts +1 -1
  40. package/dist-types/policies/builder.d.ts +7 -7
  41. package/dist-types/policies/defaults.d.ts +2 -2
  42. package/dist-types/policies/matchers.d.ts +2 -2
  43. package/dist-types/routes/index.d.ts +1 -0
  44. package/dist-types/routes/legal-document.d.ts +7 -0
  45. package/dist-types/types/index.d.ts +39 -18
  46. package/dist-types/utils/instrumentation.d.ts +2 -2
  47. package/dist-types/utils/logger.d.ts +1 -1
  48. package/dist-types/version.d.ts +1 -1
  49. package/docs/api/configuration.md +24 -13
  50. package/docs/guides/database-setup.md +4 -4
  51. package/docs/guides/edge-deployment.md +18 -15
  52. package/docs/guides/iab-tcf.md +4 -4
  53. package/docs/quickstart.md +9 -9
  54. package/package.json +8 -8
@@ -1,11 +1,349 @@
1
- import { checkConsentOutputSchema, checkConsentQuerySchema, getSubjectInputSchema, getSubjectOutputSchema, initOutputSchema, listSubjectsOutputSchema, listSubjectsQuerySchema, patchSubjectOutputSchema, postSubjectInputSchema, postSubjectOutputSchema, statusOutputSchema, subjectIdSchema } from "@c15t/schema";
1
+ import { hashSha256Hex } from "@c15t/schema/types";
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";
2
4
  import { Hono } from "hono";
3
5
  import { describeRoute, resolver, validator } from "hono-openapi";
4
6
  import { HTTPException } from "hono/http-exception";
5
- import base_x from "base-x";
6
- import { version, getMetrics, extractErrorMessage } from "./302.js";
7
+ import { errors, jwtVerify } from "jose";
8
+ import { extractErrorMessage, getMetrics, withDatabaseSpan } from "./302.js";
7
9
  import { getLocation, resolveInitPayload, policy_resolvePolicyDecision, verifyPolicySnapshotToken, getJurisdiction } from "./583.js";
8
10
  import * as __rspack_external_valibot from "valibot";
11
+ const prefixes = {
12
+ auditLog: 'log',
13
+ consent: 'cns',
14
+ consentPolicy: 'pol',
15
+ consentPurpose: 'pur',
16
+ domain: 'dom',
17
+ runtimePolicyDecision: 'rpd',
18
+ subject: 'sub'
19
+ };
20
+ 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;
26
+ const high = Math.floor(t / 0x100000000);
27
+ const low = t >>> 0;
28
+ buf[0] = high >>> 24 & 255;
29
+ buf[1] = high >>> 16 & 255;
30
+ buf[2] = high >>> 8 & 255;
31
+ buf[3] = 255 & high;
32
+ buf[4] = low >>> 24 & 255;
33
+ buf[5] = low >>> 16 & 255;
34
+ buf[6] = low >>> 8 & 255;
35
+ buf[7] = 255 & low;
36
+ return `${prefix}_${b58.encode(buf)}`;
37
+ }
38
+ async function generateUniqueId(db, model, ctx, options = {}) {
39
+ const { maxRetries = 10, attempt = 0, baseDelay = 5 } = options;
40
+ if (attempt >= maxRetries) {
41
+ const error = new Error(`Failed to generate unique ID for ${model} after ${maxRetries} attempts`);
42
+ ctx?.logger?.error?.('ID generation failed', {
43
+ model,
44
+ maxRetries
45
+ });
46
+ throw error;
47
+ }
48
+ const id = generateId(model);
49
+ try {
50
+ const existing = await db.findFirst(model, {
51
+ where: (b)=>b('id', '=', id)
52
+ });
53
+ if (existing) {
54
+ ctx?.logger?.debug?.('ID conflict detected', {
55
+ id,
56
+ model,
57
+ attempt: attempt + 1,
58
+ maxRetries
59
+ });
60
+ const delay = Math.min(baseDelay * 2 ** attempt, 1000);
61
+ await new Promise((resolve)=>setTimeout(resolve, delay));
62
+ return generateUniqueId(db, model, ctx, {
63
+ maxRetries,
64
+ attempt: attempt + 1,
65
+ baseDelay
66
+ });
67
+ }
68
+ return id;
69
+ } catch (error) {
70
+ ctx?.logger?.error?.('Error checking ID uniqueness', {
71
+ error: error.message,
72
+ model,
73
+ attempt
74
+ });
75
+ if (attempt < maxRetries - 1) {
76
+ const delay = Math.min(baseDelay * 2 ** attempt, 2000);
77
+ await new Promise((resolve)=>setTimeout(resolve, delay));
78
+ return generateUniqueId(db, model, ctx, {
79
+ maxRetries,
80
+ attempt: attempt + 1,
81
+ baseDelay
82
+ });
83
+ }
84
+ throw error;
85
+ }
86
+ }
87
+ class LegalDocumentPolicyConflictError extends Error {
88
+ constructor(message){
89
+ super(message);
90
+ this.name = 'LegalDocumentPolicyConflictError';
91
+ }
92
+ }
93
+ async function buildLegalDocumentPolicyId(input) {
94
+ const digest = await hashSha256Hex([
95
+ input.tenantId ?? 'default',
96
+ input.type,
97
+ input.hash
98
+ ].join('|'));
99
+ return `pol_${digest}`;
100
+ }
101
+ function hasLegalDocumentPolicyConflict(policy, input) {
102
+ return policy.version !== input.version || policy.hash !== input.hash || policy.effectiveDate.getTime() !== input.effectiveDate.getTime();
103
+ }
104
+ function policyRegistry({ db, ctx }) {
105
+ const { logger } = ctx;
106
+ return {
107
+ findConsentPolicyById: async (policyId)=>{
108
+ const start = Date.now();
109
+ try {
110
+ const result = await withDatabaseSpan({
111
+ operation: 'find',
112
+ entity: 'consentPolicy'
113
+ }, async ()=>{
114
+ const policy = await db.findFirst('consentPolicy', {
115
+ where: (b)=>b('id', '=', policyId)
116
+ });
117
+ return policy;
118
+ });
119
+ getMetrics()?.recordDbQuery({
120
+ operation: 'find',
121
+ entity: 'consentPolicy'
122
+ }, Date.now() - start);
123
+ return result;
124
+ } catch (error) {
125
+ getMetrics()?.recordDbError({
126
+ operation: 'find',
127
+ entity: 'consentPolicy'
128
+ });
129
+ throw error;
130
+ }
131
+ },
132
+ findLatestPolicyByType: async (type)=>{
133
+ const start = Date.now();
134
+ try {
135
+ const result = await withDatabaseSpan({
136
+ operation: 'findLatest',
137
+ entity: 'consentPolicy'
138
+ }, async ()=>db.findFirst('consentPolicy', {
139
+ where: (b)=>b.and(b('isActive', '=', true), b('type', '=', type)),
140
+ orderBy: [
141
+ 'effectiveDate',
142
+ 'desc'
143
+ ]
144
+ }));
145
+ getMetrics()?.recordDbQuery({
146
+ operation: 'findLatest',
147
+ entity: 'consentPolicy'
148
+ }, Date.now() - start);
149
+ return result;
150
+ } catch (error) {
151
+ getMetrics()?.recordDbError({
152
+ operation: 'findLatest',
153
+ entity: 'consentPolicy'
154
+ });
155
+ throw error;
156
+ }
157
+ },
158
+ findLegalDocumentPolicyByHash: async (type, hash)=>{
159
+ const start = Date.now();
160
+ try {
161
+ const policyId = await buildLegalDocumentPolicyId({
162
+ tenantId: ctx.tenantId,
163
+ type,
164
+ hash
165
+ });
166
+ const result = await withDatabaseSpan({
167
+ operation: 'findByHash',
168
+ entity: 'consentPolicy'
169
+ }, async ()=>db.findFirst('consentPolicy', {
170
+ where: (b)=>b('id', '=', policyId)
171
+ }));
172
+ getMetrics()?.recordDbQuery({
173
+ operation: 'findByHash',
174
+ entity: 'consentPolicy'
175
+ }, Date.now() - start);
176
+ return result;
177
+ } catch (error) {
178
+ getMetrics()?.recordDbError({
179
+ operation: 'findByHash',
180
+ entity: 'consentPolicy'
181
+ });
182
+ throw error;
183
+ }
184
+ },
185
+ syncCurrentLegalDocumentPolicy: async (input)=>{
186
+ const start = Date.now();
187
+ try {
188
+ const result = await withDatabaseSpan({
189
+ operation: 'syncCurrent',
190
+ entity: 'consentPolicy'
191
+ }, async ()=>{
192
+ const policyId = await buildLegalDocumentPolicyId({
193
+ tenantId: ctx.tenantId,
194
+ type: input.type,
195
+ hash: input.hash
196
+ });
197
+ return db.transaction(async (tx)=>{
198
+ const existing = await tx.findFirst('consentPolicy', {
199
+ where: (b)=>b('id', '=', policyId)
200
+ });
201
+ if (existing) {
202
+ if (hasLegalDocumentPolicyConflict(existing, input)) throw new LegalDocumentPolicyConflictError('Release metadata conflicts with existing consent policy');
203
+ await tx.updateMany('consentPolicy', {
204
+ where: (b)=>b.and(b('type', '=', input.type), b('isActive', '=', true), b('id', '!=', existing.id)),
205
+ set: {
206
+ isActive: false
207
+ }
208
+ });
209
+ if (!existing.isActive) {
210
+ await tx.updateMany('consentPolicy', {
211
+ where: (b)=>b('id', '=', existing.id),
212
+ set: {
213
+ isActive: true
214
+ }
215
+ });
216
+ return {
217
+ ...existing,
218
+ isActive: true
219
+ };
220
+ }
221
+ return existing;
222
+ }
223
+ await tx.updateMany('consentPolicy', {
224
+ where: (b)=>b.and(b('type', '=', input.type), b('isActive', '=', true)),
225
+ set: {
226
+ isActive: false
227
+ }
228
+ });
229
+ const policy = await tx.create('consentPolicy', {
230
+ id: policyId,
231
+ version: input.version,
232
+ type: input.type,
233
+ hash: input.hash,
234
+ effectiveDate: input.effectiveDate,
235
+ isActive: true
236
+ });
237
+ return policy;
238
+ });
239
+ });
240
+ getMetrics()?.recordDbQuery({
241
+ operation: 'syncCurrent',
242
+ entity: 'consentPolicy'
243
+ }, Date.now() - start);
244
+ return result;
245
+ } catch (error) {
246
+ getMetrics()?.recordDbError({
247
+ operation: 'syncCurrent',
248
+ entity: 'consentPolicy'
249
+ });
250
+ throw error;
251
+ }
252
+ },
253
+ findOrCreateLegalDocumentPolicy: async (input)=>{
254
+ const start = Date.now();
255
+ try {
256
+ const result = await withDatabaseSpan({
257
+ operation: 'findOrCreateLegalDocument',
258
+ entity: 'consentPolicy'
259
+ }, async ()=>{
260
+ const policyId = await buildLegalDocumentPolicyId({
261
+ tenantId: ctx.tenantId,
262
+ type: input.type,
263
+ hash: input.hash
264
+ });
265
+ const existing = await db.findFirst('consentPolicy', {
266
+ where: (b)=>b('id', '=', policyId)
267
+ });
268
+ if (existing) {
269
+ if (hasLegalDocumentPolicyConflict(existing, input)) throw new LegalDocumentPolicyConflictError('Release metadata conflicts with existing consent policy');
270
+ return existing;
271
+ }
272
+ const policy = await db.create('consentPolicy', {
273
+ id: policyId,
274
+ version: input.version,
275
+ type: input.type,
276
+ hash: input.hash,
277
+ effectiveDate: input.effectiveDate,
278
+ isActive: false
279
+ }).catch(async ()=>{
280
+ const concurrent = await db.findFirst('consentPolicy', {
281
+ where: (b)=>b('id', '=', policyId)
282
+ });
283
+ if (!concurrent) throw new LegalDocumentPolicyConflictError('Failed to create legal document consent policy');
284
+ if (hasLegalDocumentPolicyConflict(concurrent, input)) throw new LegalDocumentPolicyConflictError('Release metadata conflicts with existing consent policy');
285
+ return concurrent;
286
+ });
287
+ return policy;
288
+ });
289
+ getMetrics()?.recordDbQuery({
290
+ operation: 'findOrCreateLegalDocument',
291
+ entity: 'consentPolicy'
292
+ }, Date.now() - start);
293
+ return result;
294
+ } catch (error) {
295
+ getMetrics()?.recordDbError({
296
+ operation: 'findOrCreateLegalDocument',
297
+ entity: 'consentPolicy'
298
+ });
299
+ throw error;
300
+ }
301
+ },
302
+ findOrCreatePolicy: async (type)=>{
303
+ const start = Date.now();
304
+ try {
305
+ const result = await withDatabaseSpan({
306
+ operation: 'findOrCreate',
307
+ entity: 'consentPolicy'
308
+ }, async ()=>{
309
+ const existingPolicy = await db.findFirst('consentPolicy', {
310
+ where: (b)=>b.and(b('isActive', '=', true), b('type', '=', type)),
311
+ orderBy: [
312
+ 'effectiveDate',
313
+ 'desc'
314
+ ]
315
+ });
316
+ if (existingPolicy) {
317
+ logger.debug('Found existing policy', {
318
+ type,
319
+ policyId: existingPolicy.id
320
+ });
321
+ return existingPolicy;
322
+ }
323
+ const policy = await db.create('consentPolicy', {
324
+ id: await generateUniqueId(db, 'consentPolicy', ctx),
325
+ version: '1.0.0',
326
+ type,
327
+ effectiveDate: new Date(),
328
+ isActive: true
329
+ });
330
+ return policy;
331
+ });
332
+ getMetrics()?.recordDbQuery({
333
+ operation: 'findOrCreate',
334
+ entity: 'consentPolicy'
335
+ }, Date.now() - start);
336
+ return result;
337
+ } catch (error) {
338
+ getMetrics()?.recordDbError({
339
+ operation: 'findOrCreate',
340
+ entity: 'consentPolicy'
341
+ });
342
+ throw error;
343
+ }
344
+ }
345
+ };
346
+ }
9
347
  function parsePurposeIds(purposeIds) {
10
348
  if (null == purposeIds) return [];
11
349
  const ids = 'object' == typeof purposeIds && 'json' in purposeIds ? purposeIds.json : purposeIds;
@@ -26,7 +364,7 @@ async function batchLoadPolicies(policyIds, ctx) {
26
364
  for (const p of policyMap.values())uniqueTypes.add(p.type);
27
365
  const latestPolicyByType = new Map();
28
366
  for (const type of uniqueTypes){
29
- const latest = await registry.findOrCreatePolicy(type);
367
+ const latest = await registry.findLatestPolicyByType(type);
30
368
  if (latest) latestPolicyByType.set(type, latest.id);
31
369
  }
32
370
  return {
@@ -52,11 +390,17 @@ async function enrichConsents(consents, ctx) {
52
390
  }
53
391
  return consents.map((consent)=>{
54
392
  let policyType = 'unknown';
393
+ let policyVersion;
394
+ let policyHash;
395
+ let policyEffectiveDate;
55
396
  let isLatestPolicy = false;
56
397
  if (consent.policyId) {
57
398
  const policy = policyMap.get(consent.policyId);
58
399
  if (policy) {
59
400
  policyType = policy.type;
401
+ policyVersion = policy.version;
402
+ policyHash = policy.hash ?? void 0;
403
+ policyEffectiveDate = policy.effectiveDate;
60
404
  isLatestPolicy = latestPolicyByType.get(policyType) === consent.policyId;
61
405
  }
62
406
  }
@@ -73,6 +417,9 @@ async function enrichConsents(consents, ctx) {
73
417
  id: consent.id,
74
418
  type: policyType,
75
419
  policyId: consent.policyId ?? void 0,
420
+ policyVersion,
421
+ policyHash,
422
+ policyEffectiveDate,
76
423
  isLatestPolicy,
77
424
  preferences,
78
425
  givenAt: consent.givenAt
@@ -240,6 +587,94 @@ Use for geo-targeted consent banners and regional compliance.`,
240
587
  });
241
588
  return app;
242
589
  };
590
+ const syncCurrentLegalDocumentHandler = async (c)=>{
591
+ const ctx = c.get('c15tContext');
592
+ const logger = ctx.logger;
593
+ logger.info('Handling PUT /legal-documents/:type/current request');
594
+ if (!ctx.apiKeyAuthenticated) throw new HTTPException(401, {
595
+ message: 'API key required. Use Authorization: Bearer <api_key>',
596
+ cause: {
597
+ code: 'UNAUTHORIZED'
598
+ }
599
+ });
600
+ const type = c.req.param('type');
601
+ const body = await c.req.json();
602
+ const effectiveDate = new Date(body.effectiveDate);
603
+ if (Number.isNaN(effectiveDate.getTime())) throw new HTTPException(422, {
604
+ message: 'effectiveDate must be a valid ISO-8601 string',
605
+ cause: {
606
+ code: 'INPUT_VALIDATION_FAILED'
607
+ }
608
+ });
609
+ try {
610
+ const policy = await ctx.registry.syncCurrentLegalDocumentPolicy({
611
+ type,
612
+ version: body.version,
613
+ hash: body.hash,
614
+ effectiveDate
615
+ });
616
+ return c.json({
617
+ policy: {
618
+ id: policy.id,
619
+ type: policy.type,
620
+ version: policy.version,
621
+ hash: policy.hash,
622
+ effectiveDate: policy.effectiveDate,
623
+ isActive: policy.isActive
624
+ }
625
+ });
626
+ } catch (error) {
627
+ logger.error('Error in PUT /legal-documents/:type/current handler', {
628
+ error: extractErrorMessage(error),
629
+ errorType: error instanceof Error ? error.constructor.name : typeof error
630
+ });
631
+ if (error instanceof LegalDocumentPolicyConflictError) throw new HTTPException(409, {
632
+ message: error.message,
633
+ cause: {
634
+ code: 'LEGAL_DOCUMENT_RELEASE_CONFLICT'
635
+ }
636
+ });
637
+ if (error instanceof HTTPException) throw error;
638
+ throw new HTTPException(500, {
639
+ message: 'Internal server error',
640
+ cause: {
641
+ code: 'INTERNAL_SERVER_ERROR'
642
+ }
643
+ });
644
+ }
645
+ };
646
+ const createLegalDocumentRoutes = ()=>{
647
+ const app = new Hono();
648
+ app.put('/:type/current', describeRoute({
649
+ summary: 'Sync the current legal document release (API key required)',
650
+ description: 'Marks a legal document release as the latest known version for its type. Requires a Bearer API key.',
651
+ tags: [
652
+ 'LegalDocument'
653
+ ],
654
+ security: [
655
+ {
656
+ bearerAuth: []
657
+ }
658
+ ],
659
+ responses: {
660
+ 200: {
661
+ description: 'Current legal document release synced successfully',
662
+ content: {
663
+ 'application/json': {
664
+ schema: resolver(legalDocumentCurrentOutputSchema)
665
+ }
666
+ }
667
+ },
668
+ 401: {
669
+ description: 'Missing or invalid API key'
670
+ },
671
+ 409: {
672
+ description: 'Release metadata conflicts with an existing release'
673
+ }
674
+ }
675
+ }), validator('param', legalDocumentCurrentParamsSchema), validator('json', legalDocumentCurrentInputSchema), syncCurrentLegalDocumentHandler);
676
+ return app;
677
+ };
243
678
  function getHeaders(headers) {
244
679
  if (!headers) return {
245
680
  countryCode: null,
@@ -274,7 +709,7 @@ const statusHandler = async (c)=>{
274
709
  try {
275
710
  await ctx.db.findFirst('subject', {});
276
711
  return c.json({
277
- version: version,
712
+ version: "2.0.0",
278
713
  timestamp: new Date(),
279
714
  client: clientInfo
280
715
  });
@@ -439,7 +874,7 @@ const listSubjectsHandler = async (c)=>{
439
874
  });
440
875
  }
441
876
  };
442
- const prefixes = {
877
+ const utils_prefixes = {
443
878
  auditLog: 'log',
444
879
  consent: 'cns',
445
880
  consentPolicy: 'pol',
@@ -447,10 +882,10 @@ const prefixes = {
447
882
  domain: 'dom',
448
883
  subject: 'sub'
449
884
  };
450
- const b58 = base_x('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
451
- function generateId(model) {
885
+ const utils_b58 = base_x('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
886
+ function utils_generateId(model) {
452
887
  const buf = crypto.getRandomValues(new Uint8Array(20));
453
- const prefix = prefixes[model];
888
+ const prefix = utils_prefixes[model];
454
889
  const EPOCH_TIMESTAMP = 1700000000000;
455
890
  const t = Date.now() - EPOCH_TIMESTAMP;
456
891
  const high = Math.floor(t / 0x100000000);
@@ -463,9 +898,9 @@ function generateId(model) {
463
898
  buf[5] = low >>> 16 & 255;
464
899
  buf[6] = low >>> 8 & 255;
465
900
  buf[7] = 255 & low;
466
- return `${prefix}_${b58.encode(buf)}`;
901
+ return `${prefix}_${utils_b58.encode(buf)}`;
467
902
  }
468
- async function generateUniqueId(db, model, ctx, options = {}) {
903
+ async function utils_generateUniqueId(db, model, ctx, options = {}) {
469
904
  const { maxRetries = 10, attempt = 0, baseDelay = 5 } = options;
470
905
  if (attempt >= maxRetries) {
471
906
  const error = new Error(`Failed to generate unique ID for ${model} after ${maxRetries} attempts`);
@@ -475,7 +910,7 @@ async function generateUniqueId(db, model, ctx, options = {}) {
475
910
  });
476
911
  throw error;
477
912
  }
478
- const id = generateId(model);
913
+ const id = utils_generateId(model);
479
914
  try {
480
915
  const existing = await db.findFirst(model, {
481
916
  where: (b)=>b('id', '=', id)
@@ -489,7 +924,7 @@ async function generateUniqueId(db, model, ctx, options = {}) {
489
924
  });
490
925
  const delay = Math.min(baseDelay * 2 ** attempt, 1000);
491
926
  await new Promise((resolve)=>setTimeout(resolve, delay));
492
- return generateUniqueId(db, model, ctx, {
927
+ return utils_generateUniqueId(db, model, ctx, {
493
928
  maxRetries,
494
929
  attempt: attempt + 1,
495
930
  baseDelay
@@ -505,7 +940,7 @@ async function generateUniqueId(db, model, ctx, options = {}) {
505
940
  if (attempt < maxRetries - 1) {
506
941
  const delay = Math.min(baseDelay * 2 ** attempt, 2000);
507
942
  await new Promise((resolve)=>setTimeout(resolve, delay));
508
- return generateUniqueId(db, model, ctx, {
943
+ return utils_generateUniqueId(db, model, ctx, {
509
944
  maxRetries,
510
945
  attempt: attempt + 1,
511
946
  baseDelay
@@ -554,7 +989,7 @@ const patchSubjectHandler = async (c)=>{
554
989
  }
555
990
  });
556
991
  await tx.create('auditLog', {
557
- id: await generateUniqueId(tx, 'auditLog', ctx),
992
+ id: await utils_generateUniqueId(tx, 'auditLog', ctx),
558
993
  subjectId,
559
994
  entityType: 'subject',
560
995
  entityId: subjectId,
@@ -604,6 +1039,79 @@ const patchSubjectHandler = async (c)=>{
604
1039
  });
605
1040
  }
606
1041
  };
1042
+ const DEFAULT_ISSUER = 'c15t';
1043
+ const DEFAULT_AUDIENCE = 'c15t-legal-document-snapshot';
1044
+ function isLegalDocumentPolicyType(type) {
1045
+ return 'privacy_policy' === type || 'terms_and_conditions' === type || 'dpa' === type;
1046
+ }
1047
+ function resolveSnapshotIssuer(options) {
1048
+ return options?.issuer?.trim() || DEFAULT_ISSUER;
1049
+ }
1050
+ function resolveSnapshotAudience(params) {
1051
+ const configuredAudience = params.options?.audience?.trim();
1052
+ if (configuredAudience) return configuredAudience;
1053
+ return params.tenantId ? `${DEFAULT_AUDIENCE}:${params.tenantId}` : DEFAULT_AUDIENCE;
1054
+ }
1055
+ function getSigningKey(secret) {
1056
+ return new TextEncoder().encode(secret);
1057
+ }
1058
+ 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;
1060
+ }
1061
+ async function verifyLegalDocumentSnapshotToken(params) {
1062
+ const { token, options, tenantId } = params;
1063
+ if (!options?.signingKey) return {
1064
+ valid: false,
1065
+ reason: 'missing'
1066
+ };
1067
+ if (!token) return {
1068
+ valid: false,
1069
+ reason: 'missing'
1070
+ };
1071
+ if (3 !== token.split('.').length) return {
1072
+ valid: false,
1073
+ reason: 'malformed'
1074
+ };
1075
+ try {
1076
+ const { payload, protectedHeader } = await jwtVerify(token, getSigningKey(options.signingKey), {
1077
+ issuer: resolveSnapshotIssuer(options),
1078
+ audience: resolveSnapshotAudience({
1079
+ options,
1080
+ tenantId
1081
+ })
1082
+ });
1083
+ const header = protectedHeader;
1084
+ if ('HS256' !== header.alg || 'JWT' !== header.typ) return {
1085
+ valid: false,
1086
+ reason: 'invalid'
1087
+ };
1088
+ if (!isLegalDocumentSnapshotPayload(payload)) return {
1089
+ valid: false,
1090
+ reason: 'invalid'
1091
+ };
1092
+ if (payload.sub !== payload.hash) return {
1093
+ valid: false,
1094
+ reason: 'invalid'
1095
+ };
1096
+ if ((tenantId ?? void 0) !== (payload.tenantId ?? void 0)) return {
1097
+ valid: false,
1098
+ reason: 'invalid'
1099
+ };
1100
+ return {
1101
+ valid: true,
1102
+ payload
1103
+ };
1104
+ } catch (error) {
1105
+ if (error instanceof errors.JWTExpired) return {
1106
+ valid: false,
1107
+ reason: 'expired'
1108
+ };
1109
+ return {
1110
+ valid: false,
1111
+ reason: 'invalid'
1112
+ };
1113
+ }
1114
+ }
607
1115
  function buildRuntimeDecisionDedupeKey(input) {
608
1116
  return [
609
1117
  input.tenantId ?? 'default',
@@ -683,6 +1191,9 @@ function parseLanguageFromHeader(header) {
683
1191
  if (!firstLanguage) return;
684
1192
  return firstLanguage.split('-')[0]?.toLowerCase();
685
1193
  }
1194
+ function isLegalDocumentType(type) {
1195
+ return 'privacy_policy' === type || 'terms_and_conditions' === type || 'dpa' === type;
1196
+ }
686
1197
  function resolveSnapshotFailureMode(ctx) {
687
1198
  return ctx.policySnapshot?.onValidationFailure ?? 'reject';
688
1199
  }
@@ -717,6 +1228,45 @@ function buildSnapshotHttpException(reason) {
717
1228
  }
718
1229
  }
719
1230
  }
1231
+ function buildLegalDocumentSnapshotHttpException(reason) {
1232
+ switch(reason){
1233
+ case 'missing':
1234
+ return new HTTPException(409, {
1235
+ message: 'Legal document snapshot token is required',
1236
+ cause: {
1237
+ code: 'LEGAL_DOCUMENT_SNAPSHOT_REQUIRED'
1238
+ }
1239
+ });
1240
+ case 'expired':
1241
+ return new HTTPException(409, {
1242
+ message: 'Legal document snapshot token has expired',
1243
+ cause: {
1244
+ code: 'LEGAL_DOCUMENT_SNAPSHOT_EXPIRED'
1245
+ }
1246
+ });
1247
+ case 'malformed':
1248
+ case 'invalid':
1249
+ return new HTTPException(409, {
1250
+ message: 'Legal document snapshot token is invalid',
1251
+ cause: {
1252
+ code: 'LEGAL_DOCUMENT_SNAPSHOT_INVALID'
1253
+ }
1254
+ });
1255
+ default:
1256
+ {
1257
+ const _exhaustive = reason;
1258
+ throw new Error(`Unhandled legal document snapshot verification failure reason: ${_exhaustive}`);
1259
+ }
1260
+ }
1261
+ }
1262
+ function buildLegalDocumentProofHttpException(message) {
1263
+ return new HTTPException(409, {
1264
+ message,
1265
+ cause: {
1266
+ code: 'LEGAL_DOCUMENT_PROOF_REQUIRED'
1267
+ }
1268
+ });
1269
+ }
720
1270
  const postSubjectHandler = async (c)=>{
721
1271
  const ctx = c.get('c15tContext');
722
1272
  const logger = ctx.logger;
@@ -742,16 +1292,30 @@ const postSubjectHandler = async (c)=>{
742
1292
  const requestLanguage = parseLanguageFromHeader(acceptLanguage);
743
1293
  const location = await getLocation(request, ctx);
744
1294
  const resolvedJurisdiction = getJurisdiction(location, ctx);
745
- const snapshotVerification = await verifyPolicySnapshotToken({
1295
+ const legalDocumentConsent = isLegalDocumentType(type);
1296
+ const runtimeSnapshotVerification = legalDocumentConsent ? {
1297
+ valid: false,
1298
+ reason: 'missing'
1299
+ } : await verifyPolicySnapshotToken({
746
1300
  token: input.policySnapshotToken,
747
1301
  options: ctx.policySnapshot,
748
1302
  tenantId: ctx.tenantId
749
1303
  });
750
- const hasValidSnapshot = snapshotVerification.valid;
751
- const snapshotPayload = snapshotVerification.valid ? snapshotVerification.payload : null;
752
- const shouldRequireSnapshot = !!ctx.policySnapshot?.signingKey && 'reject' === resolveSnapshotFailureMode(ctx);
753
- if (!hasValidSnapshot && shouldRequireSnapshot) throw buildSnapshotHttpException(snapshotVerification.reason);
754
- const resolvedPolicyDecision = hasValidSnapshot ? void 0 : await policy_resolvePolicyDecision({
1304
+ const legalDocumentSnapshotVerification = legalDocumentConsent ? await verifyLegalDocumentSnapshotToken({
1305
+ token: input.documentSnapshotToken,
1306
+ options: ctx.legalDocumentSnapshot,
1307
+ tenantId: ctx.tenantId
1308
+ }) : {
1309
+ valid: false,
1310
+ reason: 'missing'
1311
+ };
1312
+ const hasValidSnapshot = runtimeSnapshotVerification.valid;
1313
+ const snapshotPayload = runtimeSnapshotVerification.valid ? runtimeSnapshotVerification.payload : null;
1314
+ const shouldRequireSnapshot = !legalDocumentConsent && !!ctx.policySnapshot?.signingKey && 'reject' === resolveSnapshotFailureMode(ctx);
1315
+ if (!hasValidSnapshot && shouldRequireSnapshot) throw buildSnapshotHttpException(runtimeSnapshotVerification.reason);
1316
+ const shouldRequireLegalDocumentSnapshot = legalDocumentConsent && !!ctx.legalDocumentSnapshot?.signingKey;
1317
+ if (shouldRequireLegalDocumentSnapshot && !legalDocumentSnapshotVerification.valid) throw buildLegalDocumentSnapshotHttpException(legalDocumentSnapshotVerification.reason);
1318
+ const resolvedPolicyDecision = hasValidSnapshot ? void 0 : legalDocumentConsent ? void 0 : await policy_resolvePolicyDecision({
755
1319
  policies: ctx.policyPacks,
756
1320
  countryCode: location.countryCode,
757
1321
  regionCode: location.regionCode,
@@ -808,7 +1372,61 @@ const postSubjectHandler = async (c)=>{
808
1372
  let purposeIds = [];
809
1373
  let appliedPreferences;
810
1374
  const inputPolicyId = 'policyId' in input ? input.policyId : void 0;
811
- if (inputPolicyId) {
1375
+ const inputPolicyHash = 'policyHash' in input ? input.policyHash : void 0;
1376
+ if (legalDocumentConsent && legalDocumentSnapshotVerification.valid) {
1377
+ if (legalDocumentSnapshotVerification.payload.type !== type) throw buildLegalDocumentSnapshotHttpException('invalid');
1378
+ const effectiveDate = new Date(legalDocumentSnapshotVerification.payload.effectiveDate);
1379
+ if (Number.isNaN(effectiveDate.getTime())) throw buildLegalDocumentSnapshotHttpException('invalid');
1380
+ const documentPolicy = await registry.findOrCreateLegalDocumentPolicy({
1381
+ type,
1382
+ version: legalDocumentSnapshotVerification.payload.version,
1383
+ hash: legalDocumentSnapshotVerification.payload.hash,
1384
+ effectiveDate
1385
+ });
1386
+ policyId = documentPolicy.id;
1387
+ } else if (legalDocumentConsent) {
1388
+ if (!ctx.legalDocumentSnapshot?.signingKey && !inputPolicyId && !inputPolicyHash) throw buildLegalDocumentProofHttpException('Legal document consent requires policyId or policyHash when snapshot verification is disabled');
1389
+ if (inputPolicyId) {
1390
+ policyId = inputPolicyId;
1391
+ const policy = await registry.findConsentPolicyById(inputPolicyId);
1392
+ if (!policy) throw new HTTPException(404, {
1393
+ message: 'Policy not found',
1394
+ cause: {
1395
+ code: 'POLICY_NOT_FOUND',
1396
+ policyId,
1397
+ type
1398
+ }
1399
+ });
1400
+ if (!policy.isActive) throw new HTTPException(400, {
1401
+ message: 'Policy is inactive',
1402
+ cause: {
1403
+ code: 'POLICY_INACTIVE',
1404
+ policyId,
1405
+ type
1406
+ }
1407
+ });
1408
+ } else if (inputPolicyHash) {
1409
+ const policy = await registry.findLegalDocumentPolicyByHash(type, inputPolicyHash);
1410
+ if (!policy) throw new HTTPException(404, {
1411
+ message: 'Policy not found',
1412
+ cause: {
1413
+ code: 'POLICY_NOT_FOUND',
1414
+ type,
1415
+ policyHash: inputPolicyHash
1416
+ }
1417
+ });
1418
+ if (!policy.isActive) throw new HTTPException(400, {
1419
+ message: 'Policy is inactive',
1420
+ cause: {
1421
+ code: 'POLICY_INACTIVE',
1422
+ policyId: policy.id,
1423
+ type,
1424
+ policyHash: inputPolicyHash
1425
+ }
1426
+ });
1427
+ policyId = policy.id;
1428
+ }
1429
+ } else if (inputPolicyId) {
812
1430
  policyId = inputPolicyId;
813
1431
  const policy = await registry.findConsentPolicyById(inputPolicyId);
814
1432
  if (!policy) throw new HTTPException(404, {
@@ -870,6 +1488,13 @@ const postSubjectHandler = async (c)=>{
870
1488
  });
871
1489
  purposeIds = purposes;
872
1490
  }
1491
+ if (!policyId) throw new HTTPException(500, {
1492
+ message: 'Failed to resolve policy',
1493
+ cause: {
1494
+ code: 'POLICY_RESOLUTION_FAILED',
1495
+ type
1496
+ }
1497
+ });
873
1498
  const expiryDays = effectivePolicy?.consent?.expiryDays;
874
1499
  const validUntil = 'number' == typeof expiryDays && Number.isFinite(expiryDays) ? new Date(givenAt.getTime() + 86400000 * Math.max(0, expiryDays)) : void 0;
875
1500
  const proofConfig = effectivePolicy?.proof;
@@ -962,7 +1587,7 @@ const postSubjectHandler = async (c)=>{
962
1587
  where: (b)=>b('dedupeKey', '=', decisionPayload.dedupeKey)
963
1588
  })) : void 0;
964
1589
  const consentRecord = await tx.create('consent', {
965
- id: await generateUniqueId(tx, 'consent', ctx),
1590
+ id: await utils_generateUniqueId(tx, 'consent', ctx),
966
1591
  subjectId: subject.id,
967
1592
  domainId: domainRecord.id,
968
1593
  policyId,
@@ -1033,6 +1658,12 @@ const postSubjectHandler = async (c)=>{
1033
1658
  errorType: error instanceof Error ? error.constructor.name : typeof error
1034
1659
  });
1035
1660
  if (error instanceof HTTPException) throw error;
1661
+ if (error instanceof LegalDocumentPolicyConflictError) throw new HTTPException(409, {
1662
+ message: error.message,
1663
+ cause: {
1664
+ code: 'LEGAL_DOCUMENT_RELEASE_CONFLICT'
1665
+ }
1666
+ });
1036
1667
  throw new HTTPException(500, {
1037
1668
  message: 'Internal server error',
1038
1669
  cause: {
@@ -1066,7 +1697,7 @@ const createSubjectRoutes = ()=>{
1066
1697
  }), validator('param', getSubjectInputSchema), getSubjectHandler);
1067
1698
  app.post('/', describeRoute({
1068
1699
  summary: 'Record consent for a subject',
1069
- 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` – Optional `policyId`\n- `marketing_communications`, `age_verification`, `other` – Optional `preferences`",
1700
+ 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`",
1070
1701
  tags: [
1071
1702
  'Subject',
1072
1703
  'Consent'
@@ -1137,4 +1768,4 @@ const createSubjectRoutes = ()=>{
1137
1768
  }), validator('query', listSubjectsQuerySchema), listSubjectsHandler);
1138
1769
  return app;
1139
1770
  };
1140
- export { createConsentRoutes, createInitRoute, createStatusRoute, createSubjectRoutes };
1771
+ export { createConsentRoutes, createInitRoute, createLegalDocumentRoutes, createStatusRoute, createSubjectRoutes, generateUniqueId, policyRegistry };