@c15t/backend 2.0.0-rc.5 → 2.0.0-rc.6

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 (47) hide show
  1. package/dist/302.js +473 -0
  2. package/dist/364.js +1140 -0
  3. package/dist/583.js +540 -0
  4. package/dist/cache.cjs +1 -1
  5. package/dist/cache.js +4 -415
  6. package/dist/core.cjs +21 -24
  7. package/dist/core.js +18 -2420
  8. package/dist/db/adapters/drizzle.cjs +1 -1
  9. package/dist/db/adapters/drizzle.js +1 -2
  10. package/dist/db/adapters/kysely.cjs +1 -1
  11. package/dist/db/adapters/kysely.js +1 -2
  12. package/dist/db/adapters/mongo.cjs +1 -1
  13. package/dist/db/adapters/mongo.js +1 -2
  14. package/dist/db/adapters/prisma.cjs +1 -1
  15. package/dist/db/adapters/prisma.js +1 -2
  16. package/dist/db/adapters/typeorm.cjs +1 -1
  17. package/dist/db/adapters/typeorm.js +1 -2
  18. package/dist/db/adapters.cjs +1 -1
  19. package/dist/db/migrator.cjs +1 -1
  20. package/dist/db/schema.cjs +1 -1
  21. package/dist/db/schema.js +1 -1
  22. package/dist/define-config.cjs +1 -1
  23. package/dist/edge.cjs +1 -1
  24. package/dist/edge.js +3 -882
  25. package/dist/router.cjs +17 -18
  26. package/dist/router.js +1 -2058
  27. package/dist/types/index.cjs +1 -1
  28. package/dist-types/cache/gvl-resolver.d.ts +1 -1
  29. package/dist-types/db/registry/runtime-policy-decision.d.ts +1 -1
  30. package/dist-types/db/schema/1.0.0/consent.d.ts +1 -1
  31. package/dist-types/db/schema/2.0.0/audit-log.d.ts +1 -1
  32. package/dist-types/db/schema/2.0.0/consent-policy.d.ts +1 -1
  33. package/dist-types/db/schema/2.0.0/consent-purpose.d.ts +1 -1
  34. package/dist-types/db/schema/2.0.0/consent.d.ts +1 -1
  35. package/dist-types/db/schema/2.0.0/domain.d.ts +1 -1
  36. package/dist-types/db/schema/2.0.0/runtime-policy-decision.d.ts +1 -1
  37. package/dist-types/db/schema/2.0.0/subject.d.ts +1 -1
  38. package/dist-types/handlers/init/index.d.ts +1 -1
  39. package/dist-types/handlers/init/policy.d.ts +1 -1
  40. package/dist-types/handlers/init/resolve-init.d.ts +1 -1
  41. package/dist-types/handlers/policy/snapshot.d.ts +1 -1
  42. package/dist-types/policies/defaults.d.ts +1 -1
  43. package/dist-types/policies/matchers.d.ts +2 -2
  44. package/dist-types/types/index.d.ts +2 -2
  45. package/dist-types/version.d.ts +1 -1
  46. package/docs/guides/policy-packs.md +1 -1
  47. package/package.json +15 -15
package/dist/core.js CHANGED
@@ -1,19 +1,17 @@
1
1
  import { createLogger as logger_createLogger } from "@c15t/logger";
2
- import { SpanKind, SpanStatusCode as api_SpanStatusCode, context as api_context, metrics as api_metrics, trace } from "@opentelemetry/api";
2
+ import { SpanStatusCode } from "@opentelemetry/api";
3
3
  import { apiReference } from "@scalar/hono-api-reference";
4
4
  import { Hono } from "hono";
5
5
  import { cors } from "hono/cors";
6
6
  import { HTTPException } from "hono/http-exception";
7
- import { describeRoute, openAPIRouteHandler, resolver, validator } from "hono-openapi";
8
- import { EEA_COUNTRY_CODES, EU_COUNTRY_CODES, POLICY_MATCH_DATASET_VERSION, UK_COUNTRY_CODES, inspectPolicies, policyMatchers, resolvePolicyDecision, validatePolicyI18nConfig } from "@c15t/schema/types";
9
- import { deepMergeTranslations, selectLanguage } from "@c15t/translations";
10
- import { baseTranslations } from "@c15t/translations/all";
7
+ import { openAPIRouteHandler } from "hono-openapi";
11
8
  import base_x from "base-x";
12
- import { fumadb } from "fumadb";
13
- import { column, idColumn, schema, table as schema_table } from "fumadb/schema";
14
- import { checkConsentOutputSchema, checkConsentQuerySchema, compactDefined, dedupeTrimmedStrings, getSubjectInputSchema, getSubjectOutputSchema, initOutputSchema, listSubjectsOutputSchema, listSubjectsQuerySchema, patchSubjectOutputSchema, policyPackPresets, postSubjectInputSchema, postSubjectOutputSchema, statusOutputSchema, subjectIdSchema } from "@c15t/schema";
15
- import { SignJWT, errors, jwtVerify } from "jose";
16
- import { object, optional, string } from "valibot";
9
+ import { compactDefined, dedupeTrimmedStrings, policyPackPresets } from "@c15t/schema";
10
+ import { EEA_COUNTRY_CODES, EU_COUNTRY_CODES, POLICY_MATCH_DATASET_VERSION, UK_COUNTRY_CODES, policyMatchers } from "@c15t/schema/types";
11
+ import { version as version_version, extractErrorMessage, withDatabaseSpan, getTraceContext as create_telemetry_options_getTraceContext, createRequestSpan, handleSpanError, createTelemetryOptions, getMetrics, isTelemetryEnabled, withSpanContext } from "./302.js";
12
+ import { validateMessages, inspectPolicies as policy_inspectPolicies } from "./583.js";
13
+ import { DB } from "./db/schema.js";
14
+ import { createStatusRoute, createInitRoute, createConsentRoutes, createSubjectRoutes } from "./364.js";
17
15
  function extractBearerToken(authHeader) {
18
16
  if (!authHeader) return null;
19
17
  const parts = authHeader.split(' ');
@@ -139,8 +137,7 @@ function createCORSOptions(trustedOrigins) {
139
137
  ]
140
138
  };
141
139
  }
142
- const version_version = '2.0.0-rc.5';
143
- const config_createOpenAPIConfig = (options)=>({
140
+ const createOpenAPIConfig = (options)=>({
144
141
  enabled: options.openapi?.enabled !== false,
145
142
  specPath: '/spec.json',
146
143
  docsPath: '/docs',
@@ -255,449 +252,6 @@ function getIpAddress(req, options) {
255
252
  }
256
253
  return null;
257
254
  }
258
- const DEFAULT_PROFILE = 'default';
259
- const warnedKeys = new Set();
260
- function isSupportedBaseLanguage(lang) {
261
- return lang in baseTranslations;
262
- }
263
- function warnOnce(logger, key, message, metadata) {
264
- if (!logger || warnedKeys.has(key)) return;
265
- warnedKeys.add(key);
266
- logger.warn(message, metadata);
267
- }
268
- function normalizeLanguage(value) {
269
- if (!value) return;
270
- const normalized = value.split(',')[0]?.split(';')[0]?.trim().toLowerCase();
271
- if (!normalized) return;
272
- return normalized.split('-')[0] ?? void 0;
273
- }
274
- function normalizeProfiles(params) {
275
- const profiles = params.i18n?.messages;
276
- const legacy = params.customTranslations;
277
- if (profiles && Object.keys(profiles).length > 0) {
278
- if (legacy && Object.keys(legacy).length > 0) warnOnce(params.logger, 'i18n.customTranslations.ignored', '`customTranslations` is deprecated and ignored when `i18n.messages` is configured.');
279
- return profiles;
280
- }
281
- if (legacy && Object.keys(legacy).length > 0) {
282
- warnOnce(params.logger, 'i18n.customTranslations.deprecated', '`customTranslations` is deprecated. Use `i18n.messages` instead.');
283
- return {
284
- [DEFAULT_PROFILE]: {
285
- translations: legacy
286
- }
287
- };
288
- }
289
- return {};
290
- }
291
- function buildCandidates(input) {
292
- const raw = [
293
- {
294
- language: input.language,
295
- reason: 'profile_language'
296
- },
297
- {
298
- language: input.fallbackLanguage,
299
- reason: 'profile_fallback'
300
- }
301
- ];
302
- const dedupe = new Set();
303
- return raw.filter((candidate)=>{
304
- const key = candidate.language;
305
- if (dedupe.has(key)) return false;
306
- dedupe.add(key);
307
- return true;
308
- });
309
- }
310
- function getProfileLanguages(profiles, profile) {
311
- return Object.keys(profiles[profile]?.translations ?? {}).sort();
312
- }
313
- function getSelectableLanguages(input) {
314
- return getProfileLanguages(input.profiles, input.profile);
315
- }
316
- function resolveFallbackLanguage(input) {
317
- const configuredFallbackLanguage = normalizeLanguage(input.profile?.fallbackLanguage) ?? 'en';
318
- const profileLanguages = Object.keys(input.profile?.translations ?? {}).sort();
319
- if (profileLanguages.includes(configuredFallbackLanguage)) return configuredFallbackLanguage;
320
- if (profileLanguages.includes('en')) return 'en';
321
- return profileLanguages[0] ?? configuredFallbackLanguage;
322
- }
323
- function resolveActiveProfile(input) {
324
- const requestedProfile = input.policyProfile ?? input.defaultProfile;
325
- if (input.profiles[requestedProfile]) return requestedProfile;
326
- if (input.policyProfile) warnOnce(input.logger, `i18n.profile.missing:${requestedProfile}`, `Policy i18n profile '${requestedProfile}' does not exist. Falling back to default profile '${input.defaultProfile}'.`);
327
- return input.defaultProfile;
328
- }
329
- function validateMessages(options) {
330
- return validatePolicyI18nConfig({
331
- customTranslations: options.customTranslations,
332
- i18n: options.i18n,
333
- policies: options.policies
334
- });
335
- }
336
- function translations_getTranslationsData(acceptLanguage, customTranslations, options) {
337
- const profiles = normalizeProfiles({
338
- customTranslations,
339
- i18n: options?.i18n,
340
- logger: options?.logger
341
- });
342
- const defaultProfile = options?.i18n?.defaultProfile ?? DEFAULT_PROFILE;
343
- const profile = resolveActiveProfile({
344
- profiles,
345
- defaultProfile,
346
- policyProfile: options?.policyI18n?.messageProfile,
347
- logger: options?.logger
348
- });
349
- const configuredLanguages = Object.keys(profiles).length > 0 ? getSelectableLanguages({
350
- profiles,
351
- profile
352
- }) : Object.keys(baseTranslations);
353
- const fallbackLanguage = Object.keys(profiles).length > 0 ? resolveFallbackLanguage({
354
- profile: profiles[profile]
355
- }) : 'en';
356
- const policyLanguage = normalizeLanguage(options?.policyI18n?.language);
357
- const requestedLanguage = policyLanguage ?? selectLanguage(configuredLanguages, {
358
- header: acceptLanguage,
359
- fallback: fallbackLanguage
360
- });
361
- const candidates = buildCandidates({
362
- language: requestedLanguage,
363
- fallbackLanguage
364
- });
365
- const selectedCandidate = candidates.find((candidate)=>!!profiles[profile]?.translations[candidate.language]);
366
- if (selectedCandidate && 'profile_language' !== selectedCandidate.reason) warnOnce(options?.logger, `i18n.fallback:${profile}:${requestedLanguage}:${selectedCandidate.language}`, `Policy translation fallback used (${selectedCandidate.reason}).`, {
367
- requestedProfile: profile,
368
- requestedLanguage,
369
- resolvedProfile: profile,
370
- resolvedLanguage: selectedCandidate.language
371
- });
372
- let language = selectedCandidate?.language ?? requestedLanguage;
373
- if (!selectedCandidate && !isSupportedBaseLanguage(language)) {
374
- warnOnce(options?.logger, `i18n.base-fallback:${language}`, `No translation found for '${language}'. Falling back to base English translations.`);
375
- language = 'en';
376
- }
377
- const base = isSupportedBaseLanguage(language) ? baseTranslations[language] : baseTranslations.en;
378
- const custom = selectedCandidate ? profiles[profile]?.translations[selectedCandidate.language] : void 0;
379
- const translations = custom ? deepMergeTranslations(base, custom) : base;
380
- return {
381
- translations: translations,
382
- language
383
- };
384
- }
385
- function extractErrorMessage(error) {
386
- if (error instanceof AggregateError && error.errors?.length > 0) {
387
- const inner = error.errors.map((e)=>e instanceof Error ? e.message : String(e)).join('; ');
388
- return `AggregateError: ${inner}`;
389
- }
390
- if (error instanceof Error) return error.message || error.name;
391
- return String(error);
392
- }
393
- let cachedConfig = null;
394
- let cachedDefaultAttributes = {};
395
- function createTelemetryOptions(appName = 'c15t', telemetryConfig, tenantId) {
396
- const defaultAttributes = {
397
- ...telemetryConfig?.defaultAttributes || {},
398
- 'service.name': String(appName),
399
- 'service.version': version_version
400
- };
401
- if (tenantId) defaultAttributes['tenant.id'] = tenantId;
402
- const config = {
403
- enabled: telemetryConfig?.enabled ?? false,
404
- tracer: telemetryConfig?.tracer,
405
- meter: telemetryConfig?.meter,
406
- defaultAttributes
407
- };
408
- cachedConfig = config;
409
- cachedDefaultAttributes = defaultAttributes;
410
- return config;
411
- }
412
- function isTelemetryEnabled(options) {
413
- if (options) return options.telemetry?.enabled === true;
414
- return cachedConfig?.enabled === true;
415
- }
416
- const getTracer = (options)=>{
417
- if (!isTelemetryEnabled(options)) return trace.getTracer('c15t-noop');
418
- const tracer = options?.telemetry?.tracer ?? cachedConfig?.tracer;
419
- if (tracer) return tracer;
420
- return trace.getTracer(options?.appName ?? 'c15t');
421
- };
422
- const getMeter = (options)=>{
423
- if (!isTelemetryEnabled(options)) return api_metrics.getMeter('c15t-noop');
424
- const meter = options?.telemetry?.meter ?? cachedConfig?.meter;
425
- if (meter) return meter;
426
- return api_metrics.getMeter(options?.appName ?? 'c15t');
427
- };
428
- function getDefaultAttributes() {
429
- return cachedDefaultAttributes;
430
- }
431
- const createRequestSpan = (method, path, options)=>{
432
- if (!isTelemetryEnabled(options)) return null;
433
- const tracer = getTracer(options);
434
- const defaultAttrs = options?.telemetry?.defaultAttributes || getDefaultAttributes();
435
- const span = tracer.startSpan(`${method} ${path}`, {
436
- attributes: {
437
- 'http.method': method,
438
- ...defaultAttrs
439
- }
440
- });
441
- return span;
442
- };
443
- const handleSpanError = (span, error)=>{
444
- span.setStatus({
445
- code: api_SpanStatusCode.ERROR,
446
- message: extractErrorMessage(error)
447
- });
448
- if (error instanceof Error) span.setAttribute('error.type', error.name);
449
- };
450
- function create_telemetry_options_getTraceContext() {
451
- const activeSpan = trace.getActiveSpan();
452
- if (!activeSpan) return null;
453
- const spanContext = activeSpan.spanContext();
454
- if (!spanContext) return null;
455
- return {
456
- traceId: spanContext.traceId,
457
- spanId: spanContext.spanId
458
- };
459
- }
460
- const withSpanContext = async (span, operation)=>api_context["with"](trace.setSpan(api_context.active(), span), operation);
461
- async function executeWithSpan(span, operation) {
462
- try {
463
- const result = await withSpanContext(span, operation);
464
- span.setStatus({
465
- code: api_SpanStatusCode.OK
466
- });
467
- return result;
468
- } catch (error) {
469
- handleSpanError(span, error);
470
- throw error;
471
- } finally{
472
- span.end();
473
- }
474
- }
475
- function resolveDefaultAttributes(options) {
476
- return options?.telemetry?.defaultAttributes || getDefaultAttributes();
477
- }
478
- async function withDatabaseSpan(attributes, operation, options) {
479
- if (!isTelemetryEnabled(options)) return operation();
480
- const tracer = getTracer(options);
481
- const spanName = `db.${attributes.entity}.${attributes.operation}`;
482
- const span = tracer.startSpan(spanName, {
483
- kind: SpanKind.CLIENT,
484
- attributes: {
485
- 'db.system': 'c15t',
486
- 'db.operation': attributes.operation,
487
- 'db.entity': attributes.entity,
488
- ...resolveDefaultAttributes(options),
489
- ...Object.fromEntries(Object.entries(attributes).filter(([key])=>![
490
- 'operation',
491
- 'entity'
492
- ].includes(key)))
493
- }
494
- });
495
- return executeWithSpan(span, operation);
496
- }
497
- async function withExternalSpan(attributes, operation, options) {
498
- if (!isTelemetryEnabled(options)) return operation();
499
- const tracer = getTracer(options);
500
- const url = new URL(attributes.url);
501
- const spanName = `HTTP ${attributes.method} ${url.hostname}`;
502
- const span = tracer.startSpan(spanName, {
503
- kind: SpanKind.CLIENT,
504
- attributes: {
505
- 'http.method': attributes.method,
506
- 'http.url': `${url.origin}${url.pathname}`,
507
- 'http.host': url.hostname,
508
- ...resolveDefaultAttributes(options),
509
- ...Object.fromEntries(Object.entries(attributes).filter(([key])=>![
510
- 'url',
511
- 'method'
512
- ].includes(key)))
513
- }
514
- });
515
- return executeWithSpan(span, operation);
516
- }
517
- async function withCacheSpan(operation, layer, fn, options) {
518
- if (!isTelemetryEnabled(options)) return fn();
519
- const tracer = getTracer(options);
520
- const spanName = `cache.${layer}.${operation}`;
521
- const span = tracer.startSpan(spanName, {
522
- kind: SpanKind.CLIENT,
523
- attributes: {
524
- 'cache.operation': operation,
525
- 'cache.layer': layer,
526
- ...resolveDefaultAttributes(options)
527
- }
528
- });
529
- return executeWithSpan(span, fn);
530
- }
531
- function sanitizeAttributes(attrs) {
532
- return Object.fromEntries(Object.entries(attrs).filter(([_, v])=>null != v));
533
- }
534
- function createMetrics(meter) {
535
- const consentCreated = meter.createCounter('c15t.consent.created', {
536
- description: 'Number of consent submissions',
537
- unit: '1'
538
- });
539
- const consentAccepted = meter.createCounter('c15t.consent.accepted', {
540
- description: 'Number of consents accepted',
541
- unit: '1'
542
- });
543
- const consentRejected = meter.createCounter('c15t.consent.rejected', {
544
- description: 'Number of consents rejected',
545
- unit: '1'
546
- });
547
- const subjectCreated = meter.createCounter('c15t.subject.created', {
548
- description: 'Number of new subjects created',
549
- unit: '1'
550
- });
551
- const subjectLinked = meter.createCounter('c15t.subject.linked', {
552
- description: 'Number of subjects linked to external ID',
553
- unit: '1'
554
- });
555
- const consentCheckCount = meter.createCounter('c15t.consent_check.count', {
556
- description: 'Number of cross-device consent checks',
557
- unit: '1'
558
- });
559
- const initCount = meter.createCounter('c15t.init.count', {
560
- description: 'Number of init endpoint calls',
561
- unit: '1'
562
- });
563
- const httpRequestDuration = meter.createHistogram('c15t.http.request.duration', {
564
- description: 'HTTP request latency',
565
- unit: 'ms'
566
- });
567
- const httpRequestCount = meter.createCounter('c15t.http.request.count', {
568
- description: 'Number of HTTP requests',
569
- unit: '1'
570
- });
571
- const httpErrorCount = meter.createCounter('c15t.http.error.count', {
572
- description: 'Number of HTTP errors',
573
- unit: '1'
574
- });
575
- const dbQueryDuration = meter.createHistogram('c15t.db.query.duration', {
576
- description: 'Database query latency',
577
- unit: 'ms'
578
- });
579
- const dbQueryCount = meter.createCounter('c15t.db.query.count', {
580
- description: 'Number of database queries',
581
- unit: '1'
582
- });
583
- const dbErrorCount = meter.createCounter('c15t.db.error.count', {
584
- description: 'Number of database errors',
585
- unit: '1'
586
- });
587
- const cacheHit = meter.createCounter('c15t.cache.hit', {
588
- description: 'Number of cache hits',
589
- unit: '1'
590
- });
591
- const cacheMiss = meter.createCounter('c15t.cache.miss', {
592
- description: 'Number of cache misses',
593
- unit: '1'
594
- });
595
- const cacheLatency = meter.createHistogram('c15t.cache.latency', {
596
- description: 'Cache operation latency',
597
- unit: 'ms'
598
- });
599
- const gvlFetchDuration = meter.createHistogram('c15t.gvl.fetch.duration', {
600
- description: 'GVL fetch latency',
601
- unit: 'ms'
602
- });
603
- const gvlFetchCount = meter.createCounter('c15t.gvl.fetch.count', {
604
- description: 'Number of GVL fetches',
605
- unit: '1'
606
- });
607
- const gvlFetchError = meter.createCounter('c15t.gvl.fetch.error', {
608
- description: 'Number of GVL fetch errors',
609
- unit: '1'
610
- });
611
- return {
612
- consentCreated,
613
- consentAccepted,
614
- consentRejected,
615
- subjectCreated,
616
- subjectLinked,
617
- consentCheckCount,
618
- initCount,
619
- httpRequestDuration,
620
- httpRequestCount,
621
- httpErrorCount,
622
- dbQueryDuration,
623
- dbQueryCount,
624
- dbErrorCount,
625
- cacheHit,
626
- cacheMiss,
627
- cacheLatency,
628
- gvlFetchDuration,
629
- gvlFetchCount,
630
- gvlFetchError,
631
- recordConsentCreated (attributes) {
632
- consentCreated.add(1, sanitizeAttributes(attributes));
633
- },
634
- recordConsentAccepted (attributes) {
635
- consentAccepted.add(1, sanitizeAttributes(attributes));
636
- },
637
- recordConsentRejected (attributes) {
638
- consentRejected.add(1, sanitizeAttributes(attributes));
639
- },
640
- recordSubjectCreated (attributes) {
641
- subjectCreated.add(1, sanitizeAttributes(attributes));
642
- },
643
- recordSubjectLinked (identityProvider) {
644
- subjectLinked.add(1, {
645
- identityProvider: identityProvider || 'unknown'
646
- });
647
- },
648
- recordConsentCheck (type, found) {
649
- consentCheckCount.add(1, {
650
- type,
651
- found: String(found)
652
- });
653
- },
654
- recordInit (attributes) {
655
- initCount.add(1, sanitizeAttributes(attributes));
656
- },
657
- recordHttpRequest (attributes, durationMs) {
658
- const attrs = sanitizeAttributes(attributes);
659
- httpRequestCount.add(1, attrs);
660
- httpRequestDuration.record(durationMs, attrs);
661
- if (attributes.status >= 400) httpErrorCount.add(1, attrs);
662
- },
663
- recordDbQuery (attributes, durationMs) {
664
- const attrs = sanitizeAttributes(attributes);
665
- dbQueryCount.add(1, attrs);
666
- dbQueryDuration.record(durationMs, attrs);
667
- },
668
- recordDbError (attributes) {
669
- dbErrorCount.add(1, sanitizeAttributes(attributes));
670
- },
671
- recordCacheHit (layer) {
672
- cacheHit.add(1, {
673
- layer
674
- });
675
- },
676
- recordCacheMiss (layer) {
677
- cacheMiss.add(1, {
678
- layer
679
- });
680
- },
681
- recordCacheLatency (attributes, durationMs) {
682
- cacheLatency.record(durationMs, sanitizeAttributes(attributes));
683
- },
684
- recordGvlFetch (attributes, durationMs) {
685
- const attrs = sanitizeAttributes(attributes);
686
- gvlFetchCount.add(1, attrs);
687
- gvlFetchDuration.record(durationMs, attrs);
688
- },
689
- recordGvlError (attributes) {
690
- gvlFetchError.add(1, sanitizeAttributes(attributes));
691
- }
692
- };
693
- }
694
- let metricsInstance = null;
695
- function getMetrics(options) {
696
- if (metricsInstance) return metricsInstance;
697
- if (!isTelemetryEnabled(options)) return null;
698
- metricsInstance = createMetrics(getMeter(options));
699
- return metricsInstance;
700
- }
701
255
  const prefixes = {
702
256
  auditLog: 'log',
703
257
  consent: 'cns',
@@ -1104,292 +658,6 @@ const createRegistry = (ctx)=>({
1104
658
  ...domainRegistry(ctx),
1105
659
  ...runtimePolicyDecisionRegistry(ctx)
1106
660
  });
1107
- const auditLogTable = schema_table('auditLog', {
1108
- id: idColumn('id', 'varchar(255)'),
1109
- entityType: column('entityType', 'string'),
1110
- entityId: column('entityId', 'string'),
1111
- actionType: column('actionType', 'string'),
1112
- subjectId: column('subjectId', 'string').nullable(),
1113
- ipAddress: column('ipAddress', 'string').nullable(),
1114
- userAgent: column('userAgent', 'string').nullable(),
1115
- changes: column('changes', 'json').nullable(),
1116
- metadata: column('metadata', 'json').nullable(),
1117
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1118
- eventTimezone: column('eventTimezone', 'string').defaultTo$(()=>'UTC')
1119
- });
1120
- const consentTable = schema_table('consent', {
1121
- id: idColumn('id', 'varchar(255)'),
1122
- subjectId: column('subjectId', 'string'),
1123
- domainId: column('domainId', 'string'),
1124
- policyId: column('policyId', 'string').nullable(),
1125
- purposeIds: column('purposeIds', 'json'),
1126
- metadata: column('metadata', 'json').nullable(),
1127
- ipAddress: column('ipAddress', 'string').nullable(),
1128
- userAgent: column('userAgent', 'string').nullable(),
1129
- status: column('status', 'string').defaultTo$(()=>'active'),
1130
- withdrawalReason: column('withdrawalReason', 'string').nullable(),
1131
- givenAt: column('givenAt', 'timestamp').defaultTo$('now'),
1132
- validUntil: column('validUntil', 'timestamp').nullable(),
1133
- isActive: column('isActive', 'bool').defaultTo$(()=>true)
1134
- });
1135
- const consentPolicyTable = schema_table('consentPolicy', {
1136
- id: idColumn('id', 'varchar(255)'),
1137
- version: column('version', 'string'),
1138
- type: column('type', 'string'),
1139
- name: column('name', 'string'),
1140
- effectiveDate: column('effectiveDate', 'timestamp'),
1141
- expirationDate: column('expirationDate', 'timestamp').nullable(),
1142
- content: column('content', 'string'),
1143
- contentHash: column('contentHash', 'string'),
1144
- isActive: column('isActive', 'bool').defaultTo$(()=>true),
1145
- createdAt: column('createdAt', 'timestamp').defaultTo$('now')
1146
- });
1147
- const consentPurposeTable = schema_table('consentPurpose', {
1148
- id: idColumn('id', 'varchar(255)'),
1149
- code: column('code', 'string'),
1150
- name: column('name', 'string'),
1151
- description: column("description", 'string'),
1152
- isEssential: column('isEssential', 'bool'),
1153
- dataCategory: column('dataCategory', 'string').nullable(),
1154
- legalBasis: column('legalBasis', 'string').nullable(),
1155
- isActive: column('isActive', 'bool').defaultTo$(()=>true),
1156
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1157
- updatedAt: column('updatedAt', 'timestamp').defaultTo$('now')
1158
- });
1159
- const consentRecordTable = schema_table('consentRecord', {
1160
- id: idColumn('id', 'varchar(255)'),
1161
- subjectId: column('subjectId', 'string'),
1162
- consentId: column('consentId', 'string').nullable(),
1163
- actionType: column('actionType', 'string'),
1164
- details: column('details', 'json').nullable(),
1165
- createdAt: column('createdAt', 'timestamp').defaultTo$('now')
1166
- });
1167
- const domainTable = schema_table('domain', {
1168
- id: idColumn('id', 'varchar(255)'),
1169
- name: column('name', 'string').unique(),
1170
- description: column("description", 'string').nullable(),
1171
- allowedOrigins: column('allowedOrigins', 'json').nullable(),
1172
- isVerified: column('isVerified', 'bool').defaultTo$(()=>true),
1173
- isActive: column('isActive', 'bool').defaultTo$(()=>true),
1174
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1175
- updatedAt: column('updatedAt', 'timestamp').defaultTo$('now')
1176
- });
1177
- const subjectTable = schema_table('subject', {
1178
- id: idColumn('id', 'varchar(255)'),
1179
- externalId: column('externalId', 'string').nullable(),
1180
- identityProvider: column('identityProvider', 'string').nullable(),
1181
- lastIpAddress: column('lastIpAddress', 'string').nullable(),
1182
- subjectTimezone: column('subjectTimezone', 'string').nullable(),
1183
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1184
- updatedAt: column('updatedAt', 'timestamp').defaultTo$('now')
1185
- });
1186
- const v1 = schema({
1187
- version: '1.0.0',
1188
- tables: {
1189
- subject: subjectTable,
1190
- domain: domainTable,
1191
- consentPolicy: consentPolicyTable,
1192
- consentPurpose: consentPurposeTable,
1193
- consent: consentTable,
1194
- auditLog: auditLogTable,
1195
- consentRecord: consentRecordTable
1196
- },
1197
- relations: {
1198
- subject: ({ many })=>({
1199
- consents: many('consent'),
1200
- consentRecords: many('consentRecord'),
1201
- auditLogs: many('auditLog')
1202
- }),
1203
- domain: ({ many })=>({
1204
- consents: many('consent')
1205
- }),
1206
- consentPolicy: ({ many })=>({
1207
- consents: many('consent')
1208
- }),
1209
- consentPurpose: ()=>({}),
1210
- consent: ({ one, many })=>({
1211
- subject: one('subject', [
1212
- 'subjectId',
1213
- 'id'
1214
- ]).foreignKey(),
1215
- domain: one('domain', [
1216
- 'domainId',
1217
- 'id'
1218
- ]).foreignKey(),
1219
- policy: one('consentPolicy', [
1220
- 'policyId',
1221
- 'id'
1222
- ]).foreignKey(),
1223
- consentRecords: many('consentRecord')
1224
- }),
1225
- consentRecord: ({ one })=>({
1226
- subject: one('subject', [
1227
- 'subjectId',
1228
- 'id'
1229
- ]).foreignKey(),
1230
- consent: one('consent', [
1231
- 'consentId',
1232
- 'id'
1233
- ]).foreignKey()
1234
- }),
1235
- auditLog: ({ one })=>({
1236
- subject: one('subject', [
1237
- 'subjectId',
1238
- 'id'
1239
- ]).foreignKey()
1240
- })
1241
- }
1242
- });
1243
- const audit_log_auditLogTable = schema_table('auditLog', {
1244
- id: idColumn('id', 'varchar(255)'),
1245
- entityType: column('entityType', 'string'),
1246
- entityId: column('entityId', 'string'),
1247
- actionType: column('actionType', 'string'),
1248
- subjectId: column('subjectId', 'string').nullable(),
1249
- ipAddress: column('ipAddress', 'string').nullable(),
1250
- userAgent: column('userAgent', 'string').nullable(),
1251
- changes: column('changes', 'json').nullable(),
1252
- metadata: column('metadata', 'json').nullable(),
1253
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1254
- tenantId: column('tenantId', 'string').nullable()
1255
- });
1256
- const consent_consentTable = schema_table('consent', {
1257
- id: idColumn('id', 'varchar(255)'),
1258
- subjectId: column('subjectId', 'string'),
1259
- domainId: column('domainId', 'string'),
1260
- policyId: column('policyId', 'string').nullable(),
1261
- purposeIds: column('purposeIds', 'json'),
1262
- metadata: column('metadata', 'json').nullable(),
1263
- ipAddress: column('ipAddress', 'string').nullable(),
1264
- userAgent: column('userAgent', 'string').nullable(),
1265
- givenAt: column('givenAt', 'timestamp').defaultTo$('now'),
1266
- validUntil: column('validUntil', 'timestamp').nullable(),
1267
- jurisdiction: column('jurisdiction', 'string').nullable(),
1268
- jurisdictionModel: column('jurisdictionModel', 'string').nullable(),
1269
- tcString: column('tcString', 'string').nullable(),
1270
- uiSource: column('uiSource', 'string').nullable(),
1271
- consentAction: column('consentAction', 'string').nullable(),
1272
- runtimePolicyDecisionId: column('runtimePolicyDecisionId', 'string').nullable(),
1273
- runtimePolicySource: column('runtimePolicySource', 'string').nullable(),
1274
- tenantId: column('tenantId', 'string').nullable()
1275
- });
1276
- const consent_policy_consentPolicyTable = schema_table('consentPolicy', {
1277
- id: idColumn('id', 'varchar(255)'),
1278
- version: column('version', 'string'),
1279
- type: column('type', 'string'),
1280
- effectiveDate: column('effectiveDate', 'timestamp'),
1281
- isActive: column('isActive', 'bool').defaultTo$(()=>true),
1282
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1283
- tenantId: column('tenantId', 'string').nullable()
1284
- });
1285
- const consent_purpose_consentPurposeTable = schema_table('consentPurpose', {
1286
- id: idColumn('id', 'varchar(255)'),
1287
- code: column('code', 'string'),
1288
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1289
- updatedAt: column('updatedAt', 'timestamp').defaultTo$('now'),
1290
- tenantId: column('tenantId', 'string').nullable()
1291
- });
1292
- const domain_domainTable = schema_table('domain', {
1293
- id: idColumn('id', 'varchar(255)'),
1294
- name: column('name', 'string'),
1295
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1296
- updatedAt: column('updatedAt', 'timestamp').defaultTo$('now'),
1297
- tenantId: column('tenantId', 'string').nullable()
1298
- });
1299
- const runtimePolicyDecisionTable = schema_table('runtimePolicyDecision', {
1300
- id: idColumn('id', 'varchar(255)'),
1301
- tenantId: column('tenantId', 'string').nullable(),
1302
- policyId: column('policyId', 'string'),
1303
- fingerprint: column('fingerprint', 'string'),
1304
- matchedBy: column('matchedBy', 'string'),
1305
- countryCode: column('countryCode', 'string').nullable(),
1306
- regionCode: column('regionCode', 'string').nullable(),
1307
- jurisdiction: column('jurisdiction', 'string'),
1308
- language: column('language', 'string').nullable(),
1309
- model: column('model', 'string'),
1310
- policyI18n: column('policyI18n', 'json').nullable(),
1311
- uiMode: column('uiMode', 'string').nullable(),
1312
- bannerUi: column('bannerUi', 'json').nullable(),
1313
- dialogUi: column('dialogUi', 'json').nullable(),
1314
- categories: column('categories', 'json').nullable(),
1315
- preselectedCategories: column('preselectedCategories', 'json').nullable(),
1316
- proofConfig: column('proofConfig', 'json').nullable(),
1317
- dedupeKey: column('dedupeKey', 'string').unique(),
1318
- createdAt: column('createdAt', 'timestamp').defaultTo$('now')
1319
- });
1320
- const subject_subjectTable = schema_table('subject', {
1321
- id: idColumn('id', 'varchar(255)'),
1322
- externalId: column('externalId', 'string').nullable(),
1323
- identityProvider: column('identityProvider', 'string').nullable(),
1324
- createdAt: column('createdAt', 'timestamp').defaultTo$('now'),
1325
- updatedAt: column('updatedAt', 'timestamp').defaultTo$('now'),
1326
- tenantId: column('tenantId', 'string').nullable()
1327
- });
1328
- const v2 = schema({
1329
- version: '2.0.0',
1330
- tables: {
1331
- subject: subject_subjectTable,
1332
- domain: domain_domainTable,
1333
- consentPolicy: consent_policy_consentPolicyTable,
1334
- runtimePolicyDecision: runtimePolicyDecisionTable,
1335
- consentPurpose: consent_purpose_consentPurposeTable,
1336
- consent: consent_consentTable,
1337
- auditLog: audit_log_auditLogTable
1338
- },
1339
- relations: {
1340
- subject: ({ many })=>({
1341
- consents: many('consent'),
1342
- auditLogs: many('auditLog')
1343
- }),
1344
- domain: ({ many })=>({
1345
- consents: many('consent')
1346
- }),
1347
- consentPolicy: ({ many })=>({
1348
- consents: many('consent')
1349
- }),
1350
- runtimePolicyDecision: ({ many })=>({
1351
- consents: many('consent')
1352
- }),
1353
- consentPurpose: ()=>({}),
1354
- consent: ({ one })=>({
1355
- subject: one('subject', [
1356
- 'subjectId',
1357
- 'id'
1358
- ]).foreignKey(),
1359
- domain: one('domain', [
1360
- 'domainId',
1361
- 'id'
1362
- ]).foreignKey(),
1363
- policy: one('consentPolicy', [
1364
- 'policyId',
1365
- 'id'
1366
- ]).foreignKey(),
1367
- runtimePolicyDecision: one('runtimePolicyDecision', [
1368
- 'runtimePolicyDecisionId',
1369
- 'id'
1370
- ]).foreignKey()
1371
- }),
1372
- auditLog: ({ one })=>({
1373
- subject: one('subject', [
1374
- 'subjectId',
1375
- 'id'
1376
- ]).foreignKey()
1377
- })
1378
- }
1379
- });
1380
- const DB = fumadb({
1381
- namespace: 'c15t',
1382
- schemas: [
1383
- v1,
1384
- v2
1385
- ]
1386
- });
1387
- fumadb({
1388
- namespace: 'c15t',
1389
- schemas: [
1390
- v2
1391
- ]
1392
- });
1393
661
  const SCOPED_METHODS = new Set([
1394
662
  'create',
1395
663
  'createMany',
@@ -1453,18 +721,6 @@ function withTenantScope(db, tenantId) {
1453
721
  }
1454
722
  });
1455
723
  }
1456
- function policy_inspectPolicies(policies, options) {
1457
- return inspectPolicies(policies, options);
1458
- }
1459
- async function policy_resolvePolicyDecision(params) {
1460
- return resolvePolicyDecision({
1461
- policies: params.policies,
1462
- countryCode: params.countryCode,
1463
- regionCode: params.regionCode,
1464
- jurisdiction: params.jurisdiction,
1465
- iabEnabled: params.iabEnabled
1466
- });
1467
- }
1468
724
  let globalLogger;
1469
725
  function initLogger(options) {
1470
726
  globalLogger = logger_createLogger({
@@ -1517,1665 +773,6 @@ const init = (options)=>{
1517
773
  };
1518
774
  return context;
1519
775
  };
1520
- function parsePurposeIds(purposeIds) {
1521
- if (null == purposeIds) return [];
1522
- const ids = 'object' == typeof purposeIds && 'json' in purposeIds ? purposeIds.json : purposeIds;
1523
- return Array.isArray(ids) ? ids : [];
1524
- }
1525
- async function batchLoadPolicies(policyIds, ctx) {
1526
- const { db, registry } = ctx;
1527
- const policyMap = new Map();
1528
- if (policyIds.size > 0) {
1529
- const policies = await db.findMany('consentPolicy', {
1530
- where: (b)=>b('id', 'in', [
1531
- ...policyIds
1532
- ])
1533
- });
1534
- for (const p of policies)policyMap.set(p.id, p);
1535
- }
1536
- const uniqueTypes = new Set();
1537
- for (const p of policyMap.values())uniqueTypes.add(p.type);
1538
- const latestPolicyByType = new Map();
1539
- for (const type of uniqueTypes){
1540
- const latest = await registry.findOrCreatePolicy(type);
1541
- if (latest) latestPolicyByType.set(type, latest.id);
1542
- }
1543
- return {
1544
- policyMap,
1545
- latestPolicyByType
1546
- };
1547
- }
1548
- async function enrichConsents(consents, ctx) {
1549
- if (0 === consents.length) return [];
1550
- const policyIds = new Set();
1551
- for (const c of consents)if (c.policyId) policyIds.add(c.policyId);
1552
- const { policyMap, latestPolicyByType } = await batchLoadPolicies(policyIds, ctx);
1553
- const allPurposeIds = new Set();
1554
- for (const c of consents)for (const id of parsePurposeIds(c.purposeIds))allPurposeIds.add(id);
1555
- const purposeMap = new Map();
1556
- if (allPurposeIds.size > 0) {
1557
- const purposes = await ctx.db.findMany('consentPurpose', {
1558
- where: (b)=>b('id', 'in', [
1559
- ...allPurposeIds
1560
- ])
1561
- });
1562
- for (const p of purposes)purposeMap.set(p.id, p.code);
1563
- }
1564
- return consents.map((consent)=>{
1565
- let policyType = 'unknown';
1566
- let isLatestPolicy = false;
1567
- if (consent.policyId) {
1568
- const policy = policyMap.get(consent.policyId);
1569
- if (policy) {
1570
- policyType = policy.type;
1571
- isLatestPolicy = latestPolicyByType.get(policyType) === consent.policyId;
1572
- }
1573
- }
1574
- let preferences;
1575
- const ids = parsePurposeIds(consent.purposeIds);
1576
- if (ids.length > 0) {
1577
- preferences = {};
1578
- for (const purposeId of ids){
1579
- const code = purposeMap.get(purposeId);
1580
- if (code) preferences[code] = true;
1581
- }
1582
- }
1583
- return {
1584
- id: consent.id,
1585
- type: policyType,
1586
- policyId: consent.policyId ?? void 0,
1587
- isLatestPolicy,
1588
- preferences,
1589
- givenAt: consent.givenAt
1590
- };
1591
- });
1592
- }
1593
- async function resolveConsentPolicies(consents, ctx) {
1594
- if (0 === consents.length) return [];
1595
- const policyIds = new Set();
1596
- for (const c of consents)if (c.policyId) policyIds.add(c.policyId);
1597
- const { policyMap, latestPolicyByType } = await batchLoadPolicies(policyIds, ctx);
1598
- return consents.map((consent)=>{
1599
- let policyType = 'unknown';
1600
- let isLatestPolicy = false;
1601
- if (consent.policyId) {
1602
- const policy = policyMap.get(consent.policyId);
1603
- if (policy) {
1604
- policyType = policy.type;
1605
- isLatestPolicy = latestPolicyByType.get(policyType) === consent.policyId;
1606
- }
1607
- }
1608
- return {
1609
- consentId: consent.id,
1610
- policyType,
1611
- policyId: consent.policyId ?? void 0,
1612
- isLatestPolicy
1613
- };
1614
- });
1615
- }
1616
- const checkConsentHandler = async (c)=>{
1617
- const ctx = c.get('c15tContext');
1618
- const logger = ctx.logger;
1619
- logger.info('Handling GET /consents/check request');
1620
- const { db, registry } = ctx;
1621
- const externalId = c.req.query('externalId');
1622
- const type = c.req.query('type');
1623
- if (!externalId) throw new HTTPException(422, {
1624
- message: 'externalId query parameter is required',
1625
- cause: {
1626
- code: 'EXTERNAL_ID_REQUIRED'
1627
- }
1628
- });
1629
- if (!type) throw new HTTPException(422, {
1630
- message: 'type query parameter is required',
1631
- cause: {
1632
- code: 'TYPE_REQUIRED'
1633
- }
1634
- });
1635
- const types = type.split(',').map((t)=>t.trim());
1636
- logger.debug('Request parameters', {
1637
- externalId,
1638
- types
1639
- });
1640
- try {
1641
- const subjects = await db.findMany('subject', {
1642
- where: (b)=>b('externalId', '=', externalId)
1643
- });
1644
- const subjectIds = subjects.map((s)=>s.id);
1645
- const results = {};
1646
- for (const t of types)results[t] = {
1647
- hasConsent: false,
1648
- isLatestPolicy: false
1649
- };
1650
- if (0 === subjectIds.length) {
1651
- logger.debug('No subjects found for externalId', {
1652
- externalId
1653
- });
1654
- return c.json({
1655
- results
1656
- });
1657
- }
1658
- const allConsents = await Promise.all(subjectIds.map((subjectId)=>db.findMany('consent', {
1659
- where: (b)=>b('subjectId', '=', subjectId)
1660
- })));
1661
- const consents = allConsents.flat();
1662
- const policyInfos = await resolveConsentPolicies(consents, {
1663
- db,
1664
- registry
1665
- });
1666
- for (const info of policyInfos){
1667
- if (!types.includes(info.policyType)) continue;
1668
- const entry = results[info.policyType];
1669
- if (entry) {
1670
- entry.hasConsent = true;
1671
- if (info.isLatestPolicy) entry.isLatestPolicy = true;
1672
- }
1673
- }
1674
- logger.debug('Consent check results', {
1675
- externalId,
1676
- results
1677
- });
1678
- const metrics = getMetrics();
1679
- if (metrics) for (const [type, result] of Object.entries(results))metrics.recordConsentCheck(type, result.hasConsent);
1680
- return c.json({
1681
- results
1682
- });
1683
- } catch (error) {
1684
- logger.error('Error in GET /consents/check handler', {
1685
- error: extractErrorMessage(error),
1686
- errorType: error instanceof Error ? error.constructor.name : typeof error
1687
- });
1688
- if (error instanceof HTTPException) throw error;
1689
- throw new HTTPException(500, {
1690
- message: 'Internal server error',
1691
- cause: {
1692
- code: 'INTERNAL_SERVER_ERROR'
1693
- }
1694
- });
1695
- }
1696
- };
1697
- const createConsentRoutes = ()=>{
1698
- const app = new Hono();
1699
- app.get('/check', describeRoute({
1700
- summary: 'Check consent by external user ID',
1701
- description: `Pre-banner cross-device consent check. Use to avoid showing the banner when the user has already consented on another device.
1702
-
1703
- **Query parameters:**
1704
- - \`externalId\` – External user ID to check
1705
- - \`type\` – Consent type(s) to check (comma-separated)`,
1706
- tags: [
1707
- 'Consent'
1708
- ],
1709
- responses: {
1710
- 200: {
1711
- description: 'Consent check result per requested type(s)',
1712
- content: {
1713
- 'application/json': {
1714
- schema: resolver(checkConsentOutputSchema)
1715
- }
1716
- }
1717
- },
1718
- 422: {
1719
- description: 'Invalid or missing query parameters'
1720
- }
1721
- }
1722
- }), validator('query', checkConsentQuerySchema), checkConsentHandler);
1723
- return app;
1724
- };
1725
- const GVL_TTL_MS = 259200000;
1726
- const memory_memoryCache = new Map();
1727
- function createMemoryCacheAdapter() {
1728
- return {
1729
- async get (key) {
1730
- const entry = memory_memoryCache.get(key);
1731
- if (!entry) return null;
1732
- if (Date.now() > entry.expiresAt) {
1733
- memory_memoryCache.delete(key);
1734
- return null;
1735
- }
1736
- return entry.value;
1737
- },
1738
- async set (key, value, ttlMs = 300000) {
1739
- memory_memoryCache.set(key, {
1740
- value,
1741
- expiresAt: Date.now() + ttlMs
1742
- });
1743
- },
1744
- async delete (key) {
1745
- memory_memoryCache.delete(key);
1746
- },
1747
- async has (key) {
1748
- const entry = memory_memoryCache.get(key);
1749
- if (!entry) return false;
1750
- if (Date.now() > entry.expiresAt) {
1751
- memory_memoryCache.delete(key);
1752
- return false;
1753
- }
1754
- return true;
1755
- }
1756
- };
1757
- }
1758
- function createGVLCacheKey(appName, language, vendorIds) {
1759
- const sortedIds = vendorIds ? [
1760
- ...vendorIds
1761
- ].sort((a, b)=>a - b).join(',') : 'all';
1762
- return `${appName}:gvl:${language}:${sortedIds}`;
1763
- }
1764
- const GVL_ENDPOINT = 'https://gvl.consent.io';
1765
- const inflightRequests = new Map();
1766
- async function fetchGVLWithLanguage(language, vendorIds, endpoint = GVL_ENDPOINT) {
1767
- const sortedVendorIds = vendorIds ? [
1768
- ...vendorIds
1769
- ].sort((a, b)=>a - b) : [];
1770
- const dedupeKey = `${endpoint}|${language}|${sortedVendorIds.join(',')}`;
1771
- const existingRequest = inflightRequests.get(dedupeKey);
1772
- if (existingRequest) return existingRequest;
1773
- const url = new URL(endpoint);
1774
- if (sortedVendorIds.length > 0) url.searchParams.set('vendorIds', sortedVendorIds.join(','));
1775
- const promise = (async ()=>{
1776
- const fetchStart = Date.now();
1777
- try {
1778
- const gvl = await withExternalSpan({
1779
- url: url.toString(),
1780
- method: 'GET'
1781
- }, async ()=>{
1782
- const response = await fetch(url.toString(), {
1783
- headers: {
1784
- 'Accept-Language': language
1785
- }
1786
- });
1787
- if (204 === response.status) return null;
1788
- if (!response.ok) throw new Error(`Failed to fetch GVL: ${response.status} ${response.statusText}`);
1789
- const text = await response.text();
1790
- const trimmed = text.trim().replace(/^\uFEFF/, '');
1791
- let parsed;
1792
- try {
1793
- parsed = JSON.parse(trimmed);
1794
- } catch {
1795
- let depth = 0;
1796
- let end = -1;
1797
- const start = trimmed.indexOf('{');
1798
- if (start >= 0) for(let i = start; i < trimmed.length; i++){
1799
- const c = trimmed[i];
1800
- if ('{' === c) depth++;
1801
- else if ('}' === c) {
1802
- depth--;
1803
- if (0 === depth) {
1804
- end = i + 1;
1805
- break;
1806
- }
1807
- }
1808
- }
1809
- if (end > 0) parsed = JSON.parse(trimmed.slice(0, end));
1810
- else throw new SyntaxError('Invalid GVL response: not valid JSON');
1811
- }
1812
- if (!parsed.vendorListVersion || !parsed.purposes || !parsed.vendors) throw new Error('Invalid GVL response: missing required fields');
1813
- return parsed;
1814
- });
1815
- getMetrics()?.recordGvlFetch({
1816
- language,
1817
- source: 'fetch',
1818
- status: 200
1819
- }, Date.now() - fetchStart);
1820
- return gvl;
1821
- } catch (error) {
1822
- getMetrics()?.recordGvlError({
1823
- language,
1824
- errorType: error instanceof Error ? error.name : 'UnknownError'
1825
- });
1826
- throw error;
1827
- } finally{
1828
- inflightRequests.delete(dedupeKey);
1829
- }
1830
- })();
1831
- inflightRequests.set(dedupeKey, promise);
1832
- return promise;
1833
- }
1834
- function createGVLResolver(options) {
1835
- const { appName, bundled, cacheAdapter, vendorIds, endpoint } = options;
1836
- const memoryCache = createMemoryCacheAdapter();
1837
- return {
1838
- async get (language) {
1839
- const cacheKey = createGVLCacheKey(appName, language, vendorIds);
1840
- if (bundled?.[language]) return bundled[language];
1841
- const memoryHit = await withCacheSpan('get', 'memory', ()=>memoryCache.get(cacheKey));
1842
- if (memoryHit) {
1843
- getMetrics()?.recordCacheHit('memory');
1844
- return memoryHit;
1845
- }
1846
- getMetrics()?.recordCacheMiss('memory');
1847
- if (cacheAdapter) {
1848
- const externalHit = await withCacheSpan('get', 'external', ()=>cacheAdapter.get(cacheKey));
1849
- if (externalHit) {
1850
- getMetrics()?.recordCacheHit('external');
1851
- await withCacheSpan('set', 'memory', ()=>memoryCache.set(cacheKey, externalHit, 300000));
1852
- return externalHit;
1853
- }
1854
- getMetrics()?.recordCacheMiss('external');
1855
- }
1856
- const gvl = await fetchGVLWithLanguage(language, vendorIds, endpoint);
1857
- if (gvl) {
1858
- await withCacheSpan('set', 'memory', ()=>memoryCache.set(cacheKey, gvl, 300000));
1859
- if (cacheAdapter) await withCacheSpan('set', 'external', ()=>cacheAdapter.set(cacheKey, gvl, GVL_TTL_MS));
1860
- }
1861
- return gvl;
1862
- }
1863
- };
1864
- }
1865
- const POLICY_SNAPSHOT_JWT_HEADER = {
1866
- alg: 'HS256',
1867
- typ: 'JWT'
1868
- };
1869
- const DEFAULT_POLICY_SNAPSHOT_ISSUER = 'c15t';
1870
- const DEFAULT_POLICY_SNAPSHOT_AUDIENCE = 'c15t-policy-snapshot';
1871
- function resolveSnapshotIssuer(options) {
1872
- return options?.issuer?.trim() || DEFAULT_POLICY_SNAPSHOT_ISSUER;
1873
- }
1874
- function resolveSnapshotAudience(params) {
1875
- const configuredAudience = params.options?.audience?.trim();
1876
- if (configuredAudience) return configuredAudience;
1877
- return params.tenantId ? `${DEFAULT_POLICY_SNAPSHOT_AUDIENCE}:${params.tenantId}` : DEFAULT_POLICY_SNAPSHOT_AUDIENCE;
1878
- }
1879
- function getSigningKey(secret) {
1880
- return new TextEncoder().encode(secret);
1881
- }
1882
- function isPolicySnapshotPayload(payload) {
1883
- return 'string' == typeof payload.policyId && 'string' == typeof payload.fingerprint && 'string' == typeof payload.matchedBy && 'string' == typeof payload.jurisdiction && 'string' == typeof payload.model && 'string' == typeof payload.iss && 'string' == typeof payload.aud && 'string' == typeof payload.sub && 'number' == typeof payload.iat && 'number' == typeof payload.exp;
1884
- }
1885
- async function createPolicySnapshotToken(params) {
1886
- const { options } = params;
1887
- if (!options?.signingKey) return;
1888
- const iat = Math.floor(Date.now() / 1000);
1889
- const ttlSeconds = options.ttlSeconds ?? 1800;
1890
- const exp = iat + ttlSeconds;
1891
- const iss = resolveSnapshotIssuer(options);
1892
- const aud = resolveSnapshotAudience({
1893
- options,
1894
- tenantId: params.tenantId
1895
- });
1896
- const payload = {
1897
- iss,
1898
- aud,
1899
- sub: params.policyId,
1900
- tenantId: params.tenantId,
1901
- policyId: params.policyId,
1902
- fingerprint: params.fingerprint,
1903
- matchedBy: params.matchedBy,
1904
- country: params.country,
1905
- region: params.region,
1906
- jurisdiction: params.jurisdiction,
1907
- language: params.language,
1908
- model: params.model,
1909
- policyI18n: params.policyI18n,
1910
- expiryDays: params.expiryDays,
1911
- scopeMode: params.scopeMode,
1912
- uiMode: params.uiMode,
1913
- bannerUi: params.bannerUi,
1914
- dialogUi: params.dialogUi,
1915
- categories: params.categories,
1916
- preselectedCategories: params.preselectedCategories,
1917
- gpc: params.gpc,
1918
- proofConfig: params.proofConfig,
1919
- iat,
1920
- exp
1921
- };
1922
- const token = await new SignJWT(payload).setProtectedHeader(POLICY_SNAPSHOT_JWT_HEADER).setIssuedAt(iat).setExpirationTime(exp).sign(getSigningKey(options.signingKey));
1923
- return {
1924
- token,
1925
- payload
1926
- };
1927
- }
1928
- async function verifyPolicySnapshotToken(params) {
1929
- const { token, options, tenantId } = params;
1930
- if (!options?.signingKey) return {
1931
- valid: false,
1932
- reason: 'missing'
1933
- };
1934
- if (!token) return {
1935
- valid: false,
1936
- reason: 'missing'
1937
- };
1938
- if (3 !== token.split('.').length) return {
1939
- valid: false,
1940
- reason: 'malformed'
1941
- };
1942
- try {
1943
- const { payload, protectedHeader } = await jwtVerify(token, getSigningKey(options.signingKey), {
1944
- issuer: resolveSnapshotIssuer(options),
1945
- audience: resolveSnapshotAudience({
1946
- options,
1947
- tenantId
1948
- })
1949
- });
1950
- const header = protectedHeader;
1951
- if ('HS256' !== header.alg || 'JWT' !== header.typ) return {
1952
- valid: false,
1953
- reason: 'invalid'
1954
- };
1955
- if (!isPolicySnapshotPayload(payload)) return {
1956
- valid: false,
1957
- reason: 'invalid'
1958
- };
1959
- if (payload.sub !== payload.policyId) return {
1960
- valid: false,
1961
- reason: 'invalid'
1962
- };
1963
- if ((tenantId ?? void 0) !== (payload.tenantId ?? void 0)) return {
1964
- valid: false,
1965
- reason: 'invalid'
1966
- };
1967
- return {
1968
- valid: true,
1969
- payload
1970
- };
1971
- } catch (error) {
1972
- if (error instanceof errors.JWTExpired) return {
1973
- valid: false,
1974
- reason: 'expired'
1975
- };
1976
- return {
1977
- valid: false,
1978
- reason: 'invalid'
1979
- };
1980
- }
1981
- }
1982
- function geo_normalizeHeader(value) {
1983
- if (!value) return null;
1984
- return Array.isArray(value) ? value[0] ?? null : value;
1985
- }
1986
- function getGeoHeaders(headers) {
1987
- const countryCode = geo_normalizeHeader(headers.get('x-c15t-country')) ?? geo_normalizeHeader(headers.get('cf-ipcountry')) ?? geo_normalizeHeader(headers.get('x-vercel-ip-country')) ?? geo_normalizeHeader(headers.get('x-amz-cf-ipcountry')) ?? geo_normalizeHeader(headers.get('x-country-code'));
1988
- const regionCode = geo_normalizeHeader(headers.get('x-c15t-region')) ?? geo_normalizeHeader(headers.get('x-vercel-ip-country-region')) ?? geo_normalizeHeader(headers.get('x-region-code'));
1989
- return {
1990
- countryCode,
1991
- regionCode
1992
- };
1993
- }
1994
- function checkJurisdiction(countryCode, regionCode) {
1995
- const jurisdictions = {
1996
- EU: new Set([
1997
- 'AT',
1998
- 'BE',
1999
- 'BG',
2000
- 'HR',
2001
- 'CY',
2002
- 'CZ',
2003
- 'DK',
2004
- 'EE',
2005
- 'FI',
2006
- 'FR',
2007
- 'DE',
2008
- 'GR',
2009
- 'HU',
2010
- 'IE',
2011
- 'IT',
2012
- 'LV',
2013
- 'LT',
2014
- 'LU',
2015
- 'MT',
2016
- 'NL',
2017
- 'PL',
2018
- 'PT',
2019
- 'RO',
2020
- 'SK',
2021
- 'SI',
2022
- 'ES',
2023
- 'SE'
2024
- ]),
2025
- EEA: new Set([
2026
- 'IS',
2027
- 'NO',
2028
- 'LI'
2029
- ]),
2030
- UK: new Set([
2031
- 'GB'
2032
- ]),
2033
- CH: new Set([
2034
- 'CH'
2035
- ]),
2036
- BR: new Set([
2037
- 'BR'
2038
- ]),
2039
- CA: new Set([
2040
- 'CA'
2041
- ]),
2042
- AU: new Set([
2043
- 'AU'
2044
- ]),
2045
- JP: new Set([
2046
- 'JP'
2047
- ]),
2048
- KR: new Set([
2049
- 'KR'
2050
- ]),
2051
- US_CCPA_REGIONS: new Set([
2052
- 'CA'
2053
- ]),
2054
- CA_QC_REGIONS: new Set([
2055
- 'QC'
2056
- ])
2057
- };
2058
- let jurisdiction = 'NONE';
2059
- if (countryCode) {
2060
- const normalizedCountryCode = countryCode.toUpperCase();
2061
- const normalizedRegionCode = regionCode && 'string' == typeof regionCode ? (regionCode.includes('-') ? regionCode.split('-').pop() : regionCode).toUpperCase() : null;
2062
- if ('US' === normalizedCountryCode && normalizedRegionCode && jurisdictions.US_CCPA_REGIONS.has(normalizedRegionCode)) return 'CCPA';
2063
- if ('CA' === normalizedCountryCode && normalizedRegionCode && jurisdictions.CA_QC_REGIONS.has(normalizedRegionCode)) return 'QC_LAW25';
2064
- const jurisdictionMap = [
2065
- {
2066
- sets: [
2067
- jurisdictions.UK
2068
- ],
2069
- code: 'UK_GDPR'
2070
- },
2071
- {
2072
- sets: [
2073
- jurisdictions.EU,
2074
- jurisdictions.EEA
2075
- ],
2076
- code: 'GDPR'
2077
- },
2078
- {
2079
- sets: [
2080
- jurisdictions.CH
2081
- ],
2082
- code: 'CH'
2083
- },
2084
- {
2085
- sets: [
2086
- jurisdictions.BR
2087
- ],
2088
- code: 'BR'
2089
- },
2090
- {
2091
- sets: [
2092
- jurisdictions.CA
2093
- ],
2094
- code: 'PIPEDA'
2095
- },
2096
- {
2097
- sets: [
2098
- jurisdictions.AU
2099
- ],
2100
- code: 'AU'
2101
- },
2102
- {
2103
- sets: [
2104
- jurisdictions.JP
2105
- ],
2106
- code: 'APPI'
2107
- },
2108
- {
2109
- sets: [
2110
- jurisdictions.KR
2111
- ],
2112
- code: 'PIPA'
2113
- }
2114
- ];
2115
- for (const { sets, code } of jurisdictionMap)if (sets.some((set)=>set.has(normalizedCountryCode))) {
2116
- jurisdiction = code;
2117
- break;
2118
- }
2119
- }
2120
- return jurisdiction;
2121
- }
2122
- async function getLocation(request, options) {
2123
- if (options.disableGeoLocation) return {
2124
- countryCode: null,
2125
- regionCode: null
2126
- };
2127
- const { countryCode, regionCode } = getGeoHeaders(request.headers);
2128
- return {
2129
- countryCode,
2130
- regionCode
2131
- };
2132
- }
2133
- function getJurisdiction(location, options) {
2134
- if (options.disableGeoLocation) return 'GDPR';
2135
- return checkJurisdiction(location.countryCode, location.regionCode);
2136
- }
2137
- function stripIabTranslations(translations) {
2138
- const { iab: _iab, ...rest } = translations;
2139
- return rest;
2140
- }
2141
- function resolveNoPolicyFallback() {
2142
- return {
2143
- id: 'no_banner',
2144
- model: 'none',
2145
- ui: {
2146
- mode: 'none'
2147
- }
2148
- };
2149
- }
2150
- async function resolveInitPayload(request, options, logger) {
2151
- const acceptLanguage = request.headers.get('accept-language') || 'en';
2152
- const location = await getLocation(request, options);
2153
- const jurisdiction = getJurisdiction(location, options);
2154
- const hasExplicitPolicyPack = void 0 !== options.policyPacks;
2155
- const isExplicitEmptyPolicyPack = hasExplicitPolicyPack && (options.policyPacks?.length ?? 0) === 0;
2156
- const policyDecision = isExplicitEmptyPolicyPack ? void 0 : await policy_resolvePolicyDecision({
2157
- policies: options.policyPacks,
2158
- countryCode: location.countryCode,
2159
- regionCode: location.regionCode,
2160
- jurisdiction,
2161
- iabEnabled: options.iab?.enabled === true
2162
- });
2163
- if (hasExplicitPolicyPack && !isExplicitEmptyPolicyPack && !policyDecision) logger?.warn('Policy packs configured but no policy matched', {
2164
- country: location.countryCode,
2165
- region: location.regionCode
2166
- });
2167
- const resolvedPolicy = hasExplicitPolicyPack ? policyDecision?.policy ?? resolveNoPolicyFallback() : void 0;
2168
- const iabOptions = options.iab;
2169
- const shouldIncludeIabPayload = iabOptions?.enabled === true && (!hasExplicitPolicyPack || resolvedPolicy?.model === 'iab');
2170
- const translationsResult = translations_getTranslationsData(acceptLanguage, options.customTranslations, {
2171
- i18n: options.i18n,
2172
- policyI18n: resolvedPolicy?.i18n,
2173
- logger
2174
- });
2175
- const responseTranslations = shouldIncludeIabPayload ? translationsResult : {
2176
- ...translationsResult,
2177
- translations: stripIabTranslations(translationsResult.translations)
2178
- };
2179
- let gvl = null;
2180
- if (shouldIncludeIabPayload && iabOptions) {
2181
- const language = translationsResult.language.split('-')[0] || 'en';
2182
- const gvlResolver = createGVLResolver({
2183
- appName: options.appName || 'c15t',
2184
- bundled: iabOptions.bundled,
2185
- cacheAdapter: options.cache?.adapter,
2186
- vendorIds: iabOptions.vendorIds,
2187
- endpoint: iabOptions.endpoint
2188
- });
2189
- gvl = await gvlResolver.get(language);
2190
- }
2191
- const customVendors = shouldIncludeIabPayload ? iabOptions?.customVendors : void 0;
2192
- const snapshot = policyDecision ? await createPolicySnapshotToken({
2193
- options: options.policySnapshot,
2194
- tenantId: options.tenantId,
2195
- policyId: policyDecision.policy.id,
2196
- fingerprint: policyDecision.fingerprint,
2197
- matchedBy: policyDecision.matchedBy,
2198
- country: location?.countryCode ?? null,
2199
- region: location?.regionCode ?? null,
2200
- jurisdiction,
2201
- language: translationsResult.language,
2202
- model: policyDecision.policy.model,
2203
- policyI18n: policyDecision.policy.i18n,
2204
- expiryDays: policyDecision.policy.consent?.expiryDays,
2205
- scopeMode: policyDecision.policy.consent?.scopeMode,
2206
- uiMode: policyDecision.policy.ui?.mode,
2207
- bannerUi: policyDecision.policy.ui?.banner,
2208
- dialogUi: policyDecision.policy.ui?.dialog,
2209
- categories: policyDecision.policy.consent?.categories,
2210
- preselectedCategories: policyDecision.policy.consent?.preselectedCategories,
2211
- gpc: policyDecision.policy.consent?.gpc,
2212
- proofConfig: policyDecision.policy.proof
2213
- }) : void 0;
2214
- const gpc = '1' === request.headers.get('sec-gpc');
2215
- getMetrics()?.recordInit({
2216
- jurisdiction,
2217
- country: location?.countryCode ?? void 0,
2218
- region: location?.regionCode ?? void 0,
2219
- gpc
2220
- });
2221
- return {
2222
- jurisdiction,
2223
- location,
2224
- translations: responseTranslations,
2225
- branding: options.branding || 'c15t',
2226
- ...shouldIncludeIabPayload && {
2227
- gvl,
2228
- customVendors
2229
- },
2230
- ...resolvedPolicy && {
2231
- policy: resolvedPolicy
2232
- },
2233
- ...policyDecision && {
2234
- policyDecision: {
2235
- policyId: policyDecision.policy.id,
2236
- fingerprint: policyDecision.fingerprint,
2237
- matchedBy: policyDecision.matchedBy,
2238
- country: location.countryCode,
2239
- region: location.regionCode,
2240
- jurisdiction
2241
- }
2242
- },
2243
- ...snapshot?.token && {
2244
- policySnapshotToken: snapshot.token
2245
- },
2246
- ...shouldIncludeIabPayload && iabOptions?.cmpId != null && {
2247
- cmpId: iabOptions.cmpId
2248
- }
2249
- };
2250
- }
2251
- const createInitRoute = (options)=>{
2252
- const app = new Hono();
2253
- app.get('/', describeRoute({
2254
- summary: 'Get initial consent manager state',
2255
- description: `Returns the initial state required to render the consent manager.
2256
-
2257
- - **Jurisdiction** – User's jurisdiction (defaults to GDPR if geo-location is disabled)
2258
- - **Location** – User's location (null if geo-location is disabled)
2259
- - **Translations** – Consent manager copy (from \`Accept-Language\` header)
2260
- - **Branding** – Configured branding key
2261
- - **GVL** – Global Vendor List when IAB is active for the request
2262
-
2263
- Use for geo-targeted consent banners and regional compliance.`,
2264
- tags: [
2265
- 'Init'
2266
- ],
2267
- responses: {
2268
- 200: {
2269
- description: 'Initialization payload (jurisdiction, location, translations, branding, GVL)',
2270
- content: {
2271
- 'application/json': {
2272
- schema: resolver(initOutputSchema)
2273
- }
2274
- }
2275
- }
2276
- }
2277
- }), async (c)=>{
2278
- const ctx = c.get('c15tContext');
2279
- const payload = await resolveInitPayload(c.req.raw, options, ctx?.logger);
2280
- return c.json(payload);
2281
- });
2282
- return app;
2283
- };
2284
- function getHeaders(headers) {
2285
- if (!headers) return {
2286
- countryCode: null,
2287
- regionCode: null,
2288
- acceptLanguage: null
2289
- };
2290
- const normalizeHeader = (value)=>{
2291
- if (!value) return null;
2292
- return Array.isArray(value) ? value[0] ?? null : value;
2293
- };
2294
- const countryCode = normalizeHeader(headers.get('x-c15t-country')) ?? normalizeHeader(headers.get('cf-ipcountry')) ?? normalizeHeader(headers.get('x-vercel-ip-country')) ?? normalizeHeader(headers.get('x-amz-cf-ipcountry')) ?? normalizeHeader(headers.get('x-country-code'));
2295
- const regionCode = normalizeHeader(headers.get('x-c15t-region')) ?? normalizeHeader(headers.get('x-vercel-ip-country-region')) ?? normalizeHeader(headers.get('x-region-code'));
2296
- const acceptLanguage = normalizeHeader(headers.get('accept-language'));
2297
- return {
2298
- countryCode,
2299
- regionCode,
2300
- acceptLanguage
2301
- };
2302
- }
2303
- const statusHandler = async (c)=>{
2304
- const ctx = c.get('c15tContext');
2305
- const { countryCode, regionCode, acceptLanguage } = getHeaders(ctx.headers);
2306
- const clientInfo = {
2307
- ip: ctx.ipAddress ?? null,
2308
- acceptLanguage,
2309
- userAgent: ctx.userAgent ?? null,
2310
- region: {
2311
- countryCode,
2312
- regionCode
2313
- }
2314
- };
2315
- try {
2316
- await ctx.db.findFirst('subject', {});
2317
- return c.json({
2318
- version: version_version,
2319
- timestamp: new Date(),
2320
- client: clientInfo
2321
- });
2322
- } catch (error) {
2323
- ctx.logger.error('Database health check failed', {
2324
- error
2325
- });
2326
- throw new HTTPException(503, {
2327
- message: 'Database health check failed',
2328
- cause: {
2329
- code: 'SERVICE_UNAVAILABLE',
2330
- error
2331
- }
2332
- });
2333
- }
2334
- };
2335
- const createStatusRoute = ()=>{
2336
- const app = new Hono();
2337
- app.get('/', describeRoute({
2338
- summary: 'Health check and API status',
2339
- description: `Returns API version, timestamp, and client info (IP, region, user agent).
2340
-
2341
- Use for health checks, load balancer probes, and debugging. Performs a lightweight DB check; returns 503 if the database is unreachable.`,
2342
- tags: [
2343
- 'Status'
2344
- ],
2345
- responses: {
2346
- 200: {
2347
- description: 'API is healthy (version, timestamp, client info)',
2348
- content: {
2349
- 'application/json': {
2350
- schema: resolver(statusOutputSchema)
2351
- }
2352
- }
2353
- },
2354
- 503: {
2355
- description: 'Service unavailable (e.g. database unreachable)'
2356
- }
2357
- }
2358
- }), statusHandler);
2359
- return app;
2360
- };
2361
- const getSubjectHandler = async (c)=>{
2362
- const ctx = c.get('c15tContext');
2363
- const logger = ctx.logger;
2364
- logger.info('Handling GET /subjects/:id request');
2365
- const { db, registry } = ctx;
2366
- const subjectId = c.req.param('id');
2367
- const type = c.req.query('type');
2368
- const typeFilter = type?.split(',').map((t)=>t.trim()) || [];
2369
- logger.debug('Request parameters', {
2370
- subjectId,
2371
- typeFilter
2372
- });
2373
- try {
2374
- const subject = await db.findFirst('subject', {
2375
- where: (b)=>b('id', '=', subjectId)
2376
- });
2377
- if (!subject) throw new HTTPException(404, {
2378
- message: 'Subject not found',
2379
- cause: {
2380
- code: 'SUBJECT_NOT_FOUND',
2381
- subjectId
2382
- }
2383
- });
2384
- const consents = await db.findMany('consent', {
2385
- where: (b)=>b('subjectId', '=', subjectId)
2386
- });
2387
- const consentItems = await enrichConsents(consents, {
2388
- db,
2389
- registry
2390
- });
2391
- const filteredConsents = typeFilter.length > 0 ? consentItems.filter((consent)=>typeFilter.includes(consent.type)) : consentItems;
2392
- const isValid = 0 === typeFilter.length || typeFilter.every((t)=>filteredConsents.some((consent)=>consent.type === t && consent.isLatestPolicy));
2393
- return c.json({
2394
- subject: {
2395
- id: subject.id,
2396
- externalId: subject.externalId ?? void 0,
2397
- createdAt: subject.createdAt
2398
- },
2399
- consents: filteredConsents,
2400
- isValid
2401
- });
2402
- } catch (error) {
2403
- logger.error('Error in GET /subjects/:id handler', {
2404
- error: extractErrorMessage(error),
2405
- errorType: error instanceof Error ? error.constructor.name : typeof error
2406
- });
2407
- if (error instanceof HTTPException) throw error;
2408
- throw new HTTPException(500, {
2409
- message: 'Internal server error',
2410
- cause: {
2411
- code: 'INTERNAL_SERVER_ERROR'
2412
- }
2413
- });
2414
- }
2415
- };
2416
- const listSubjectsHandler = async (c)=>{
2417
- const ctx = c.get('c15tContext');
2418
- const logger = ctx.logger;
2419
- logger.info('Handling GET /subjects request');
2420
- const { db, registry } = ctx;
2421
- if (!ctx.apiKeyAuthenticated) throw new HTTPException(401, {
2422
- message: 'API key required. Use Authorization: Bearer <api_key>',
2423
- cause: {
2424
- code: 'UNAUTHORIZED'
2425
- }
2426
- });
2427
- const externalId = c.req.query('externalId');
2428
- if (!externalId) throw new HTTPException(422, {
2429
- message: 'externalId query parameter is required',
2430
- cause: {
2431
- code: 'EXTERNAL_ID_REQUIRED'
2432
- }
2433
- });
2434
- logger.debug('Request parameters', {
2435
- externalId
2436
- });
2437
- try {
2438
- const subjects = await db.findMany('subject', {
2439
- where: (b)=>b('externalId', '=', externalId)
2440
- });
2441
- const subjectItems = await Promise.all(subjects.map(async (subject)=>{
2442
- const consents = await db.findMany('consent', {
2443
- where: (b)=>b('subjectId', '=', subject.id)
2444
- });
2445
- const consentItems = await enrichConsents(consents, {
2446
- db,
2447
- registry
2448
- });
2449
- return {
2450
- id: subject.id,
2451
- externalId: subject.externalId ?? externalId,
2452
- createdAt: subject.createdAt,
2453
- consents: consentItems
2454
- };
2455
- }));
2456
- logger.info('Found subjects for externalId', {
2457
- externalId,
2458
- count: subjectItems.length
2459
- });
2460
- return c.json({
2461
- subjects: subjectItems
2462
- });
2463
- } catch (error) {
2464
- logger.error('Error in GET /subjects handler', {
2465
- error: extractErrorMessage(error),
2466
- errorType: error instanceof Error ? error.constructor.name : typeof error
2467
- });
2468
- if (error instanceof HTTPException) throw error;
2469
- throw new HTTPException(500, {
2470
- message: 'Internal server error',
2471
- cause: {
2472
- code: 'INTERNAL_SERVER_ERROR'
2473
- }
2474
- });
2475
- }
2476
- };
2477
- const utils_prefixes = {
2478
- auditLog: 'log',
2479
- consent: 'cns',
2480
- consentPolicy: 'pol',
2481
- consentPurpose: 'pur',
2482
- domain: 'dom',
2483
- subject: 'sub'
2484
- };
2485
- const utils_b58 = base_x('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
2486
- function utils_generateId(model) {
2487
- const buf = crypto.getRandomValues(new Uint8Array(20));
2488
- const prefix = utils_prefixes[model];
2489
- const EPOCH_TIMESTAMP = 1700000000000;
2490
- const t = Date.now() - EPOCH_TIMESTAMP;
2491
- const high = Math.floor(t / 0x100000000);
2492
- const low = t >>> 0;
2493
- buf[0] = high >>> 24 & 255;
2494
- buf[1] = high >>> 16 & 255;
2495
- buf[2] = high >>> 8 & 255;
2496
- buf[3] = 255 & high;
2497
- buf[4] = low >>> 24 & 255;
2498
- buf[5] = low >>> 16 & 255;
2499
- buf[6] = low >>> 8 & 255;
2500
- buf[7] = 255 & low;
2501
- return `${prefix}_${utils_b58.encode(buf)}`;
2502
- }
2503
- async function utils_generateUniqueId(db, model, ctx, options = {}) {
2504
- const { maxRetries = 10, attempt = 0, baseDelay = 5 } = options;
2505
- if (attempt >= maxRetries) {
2506
- const error = new Error(`Failed to generate unique ID for ${model} after ${maxRetries} attempts`);
2507
- ctx?.logger?.error?.('ID generation failed', {
2508
- model,
2509
- maxRetries
2510
- });
2511
- throw error;
2512
- }
2513
- const id = utils_generateId(model);
2514
- try {
2515
- const existing = await db.findFirst(model, {
2516
- where: (b)=>b('id', '=', id)
2517
- });
2518
- if (existing) {
2519
- ctx?.logger?.debug?.('ID conflict detected', {
2520
- id,
2521
- model,
2522
- attempt: attempt + 1,
2523
- maxRetries
2524
- });
2525
- const delay = Math.min(baseDelay * 2 ** attempt, 1000);
2526
- await new Promise((resolve)=>setTimeout(resolve, delay));
2527
- return utils_generateUniqueId(db, model, ctx, {
2528
- maxRetries,
2529
- attempt: attempt + 1,
2530
- baseDelay
2531
- });
2532
- }
2533
- return id;
2534
- } catch (error) {
2535
- ctx?.logger?.error?.('Error checking ID uniqueness', {
2536
- error: error.message,
2537
- model,
2538
- attempt
2539
- });
2540
- if (attempt < maxRetries - 1) {
2541
- const delay = Math.min(baseDelay * 2 ** attempt, 2000);
2542
- await new Promise((resolve)=>setTimeout(resolve, delay));
2543
- return utils_generateUniqueId(db, model, ctx, {
2544
- maxRetries,
2545
- attempt: attempt + 1,
2546
- baseDelay
2547
- });
2548
- }
2549
- throw error;
2550
- }
2551
- }
2552
- const patchSubjectHandler = async (c)=>{
2553
- const ctx = c.get('c15tContext');
2554
- const logger = ctx.logger;
2555
- logger.info('Handling PATCH /subjects/:id request');
2556
- const { db } = ctx;
2557
- const subjectId = c.req.param('id');
2558
- const body = await c.req.json();
2559
- const { externalId, identityProvider = 'external' } = body;
2560
- logger.debug('Request parameters', {
2561
- subjectId,
2562
- externalId,
2563
- identityProvider
2564
- });
2565
- try {
2566
- const subject = await db.findFirst('subject', {
2567
- where: (b)=>b('id', '=', subjectId)
2568
- });
2569
- if (!subject) throw new HTTPException(404, {
2570
- message: 'Subject not found',
2571
- cause: {
2572
- code: 'SUBJECT_NOT_FOUND',
2573
- subjectId
2574
- }
2575
- });
2576
- await db.transaction(async (tx)=>{
2577
- await tx.updateMany('subject', {
2578
- where: (b)=>b('id', '=', subjectId),
2579
- set: {
2580
- externalId,
2581
- identityProvider,
2582
- updatedAt: new Date()
2583
- }
2584
- });
2585
- await tx.create('auditLog', {
2586
- id: await utils_generateUniqueId(tx, 'auditLog', ctx),
2587
- subjectId,
2588
- entityType: 'subject',
2589
- entityId: subjectId,
2590
- actionType: 'identify_user',
2591
- ipAddress: ctx.ipAddress || null,
2592
- userAgent: ctx.userAgent || null,
2593
- changes: {
2594
- externalId: {
2595
- from: subject.externalId,
2596
- to: externalId
2597
- },
2598
- identityProvider: {
2599
- from: subject.identityProvider,
2600
- to: identityProvider
2601
- }
2602
- },
2603
- metadata: {
2604
- externalId,
2605
- identityProvider
2606
- }
2607
- });
2608
- });
2609
- logger.info('Subject linked to external ID', {
2610
- subjectId,
2611
- externalId,
2612
- identityProvider
2613
- });
2614
- getMetrics()?.recordSubjectLinked(identityProvider);
2615
- return c.json({
2616
- success: true,
2617
- subject: {
2618
- id: subjectId,
2619
- externalId
2620
- }
2621
- });
2622
- } catch (error) {
2623
- logger.error('Error in PATCH /subjects/:id handler', {
2624
- error: extractErrorMessage(error),
2625
- errorType: error instanceof Error ? error.constructor.name : typeof error
2626
- });
2627
- if (error instanceof HTTPException) throw error;
2628
- throw new HTTPException(500, {
2629
- message: 'Internal server error',
2630
- cause: {
2631
- code: 'INTERNAL_SERVER_ERROR'
2632
- }
2633
- });
2634
- }
2635
- };
2636
- function buildRuntimeDecisionDedupeKey(input) {
2637
- return [
2638
- input.tenantId ?? 'default',
2639
- input.fingerprint,
2640
- input.matchedBy,
2641
- input.countryCode ?? 'none',
2642
- input.regionCode ?? 'none',
2643
- input.jurisdiction,
2644
- input.language ?? 'none'
2645
- ].join('|');
2646
- }
2647
- function buildDecisionPayload(params) {
2648
- const { tenantId, snapshot, decision, location, jurisdiction, language, proofConfig } = params;
2649
- if (snapshot?.valid && snapshot.payload) {
2650
- const sp = snapshot.payload;
2651
- return {
2652
- tenantId,
2653
- policyId: sp.policyId,
2654
- fingerprint: sp.fingerprint,
2655
- matchedBy: sp.matchedBy,
2656
- countryCode: sp.country,
2657
- regionCode: sp.region,
2658
- jurisdiction: sp.jurisdiction,
2659
- language: sp.language,
2660
- model: sp.model,
2661
- policyI18n: sp.policyI18n,
2662
- uiMode: sp.uiMode,
2663
- bannerUi: sp.bannerUi,
2664
- dialogUi: sp.dialogUi,
2665
- categories: sp.categories,
2666
- preselectedCategories: sp.preselectedCategories,
2667
- proofConfig: sp.proofConfig,
2668
- dedupeKey: buildRuntimeDecisionDedupeKey({
2669
- tenantId,
2670
- fingerprint: sp.fingerprint,
2671
- matchedBy: sp.matchedBy,
2672
- countryCode: sp.country,
2673
- regionCode: sp.region,
2674
- jurisdiction: sp.jurisdiction,
2675
- language: sp.language
2676
- }),
2677
- source: 'snapshot_token'
2678
- };
2679
- }
2680
- if (decision) return {
2681
- tenantId,
2682
- policyId: decision.policy.id,
2683
- fingerprint: decision.fingerprint,
2684
- matchedBy: decision.matchedBy,
2685
- countryCode: location.countryCode,
2686
- regionCode: location.regionCode,
2687
- jurisdiction,
2688
- language,
2689
- model: decision.policy.model,
2690
- policyI18n: decision.policy.i18n,
2691
- uiMode: decision.policy.ui?.mode,
2692
- bannerUi: decision.policy.ui?.banner,
2693
- dialogUi: decision.policy.ui?.dialog,
2694
- categories: decision.policy.consent?.categories,
2695
- preselectedCategories: decision.policy.consent?.preselectedCategories,
2696
- proofConfig,
2697
- dedupeKey: buildRuntimeDecisionDedupeKey({
2698
- tenantId,
2699
- fingerprint: decision.fingerprint,
2700
- matchedBy: decision.matchedBy,
2701
- countryCode: location.countryCode,
2702
- regionCode: location.regionCode,
2703
- jurisdiction,
2704
- language
2705
- }),
2706
- source: 'write_time_fallback'
2707
- };
2708
- }
2709
- function parseLanguageFromHeader(header) {
2710
- if (!header) return;
2711
- const firstLanguage = header.split(',')[0]?.split(';')[0]?.trim();
2712
- if (!firstLanguage) return;
2713
- return firstLanguage.split('-')[0]?.toLowerCase();
2714
- }
2715
- function resolveSnapshotFailureMode(ctx) {
2716
- return ctx.policySnapshot?.onValidationFailure ?? 'reject';
2717
- }
2718
- function buildSnapshotHttpException(reason) {
2719
- switch(reason){
2720
- case 'missing':
2721
- return new HTTPException(409, {
2722
- message: 'Policy snapshot token is required',
2723
- cause: {
2724
- code: 'POLICY_SNAPSHOT_REQUIRED'
2725
- }
2726
- });
2727
- case 'expired':
2728
- return new HTTPException(409, {
2729
- message: 'Policy snapshot token has expired',
2730
- cause: {
2731
- code: 'POLICY_SNAPSHOT_EXPIRED'
2732
- }
2733
- });
2734
- case 'malformed':
2735
- case 'invalid':
2736
- return new HTTPException(409, {
2737
- message: 'Policy snapshot token is invalid',
2738
- cause: {
2739
- code: 'POLICY_SNAPSHOT_INVALID'
2740
- }
2741
- });
2742
- default:
2743
- {
2744
- const _exhaustive = reason;
2745
- throw new Error(`Unhandled policy snapshot verification failure reason: ${_exhaustive}`);
2746
- }
2747
- }
2748
- }
2749
- const postSubjectHandler = async (c)=>{
2750
- const ctx = c.get('c15tContext');
2751
- const logger = ctx.logger;
2752
- logger.info('Handling POST /subjects request');
2753
- const { db, registry } = ctx;
2754
- const input = await c.req.json();
2755
- const { type, subjectId, identityProvider, externalSubjectId, domain, metadata, givenAt: givenAtEpoch } = input;
2756
- const preferences = 'preferences' in input ? input.preferences : void 0;
2757
- const givenAt = new Date(givenAtEpoch);
2758
- const rawConsentAction = 'consentAction' in input ? input.consentAction : void 0;
2759
- let derivedConsentAction;
2760
- logger.debug('Request parameters', {
2761
- type,
2762
- subjectId,
2763
- identityProvider,
2764
- externalSubjectId,
2765
- domain
2766
- });
2767
- try {
2768
- if ('cookie_banner' === type) logger.warn('`cookie_banner` policy type is deprecated in 2.0 RC and will be removed in 2.0 GA. Use backend runtime `policyPacks` for banner behavior.');
2769
- const request = c.req.raw ?? new Request('https://c15t.local/subjects');
2770
- const acceptLanguage = request.headers.get('accept-language');
2771
- const requestLanguage = parseLanguageFromHeader(acceptLanguage);
2772
- const location = await getLocation(request, ctx);
2773
- const resolvedJurisdiction = getJurisdiction(location, ctx);
2774
- const snapshotVerification = await verifyPolicySnapshotToken({
2775
- token: input.policySnapshotToken,
2776
- options: ctx.policySnapshot,
2777
- tenantId: ctx.tenantId
2778
- });
2779
- const hasValidSnapshot = snapshotVerification.valid;
2780
- const snapshotPayload = snapshotVerification.valid ? snapshotVerification.payload : null;
2781
- const shouldRequireSnapshot = !!ctx.policySnapshot?.signingKey && 'reject' === resolveSnapshotFailureMode(ctx);
2782
- if (!hasValidSnapshot && shouldRequireSnapshot) throw buildSnapshotHttpException(snapshotVerification.reason);
2783
- const resolvedPolicyDecision = hasValidSnapshot ? void 0 : await policy_resolvePolicyDecision({
2784
- policies: ctx.policyPacks,
2785
- countryCode: location.countryCode,
2786
- regionCode: location.regionCode,
2787
- jurisdiction: resolvedJurisdiction,
2788
- iabEnabled: ctx.iab?.enabled === true
2789
- });
2790
- const effectivePolicy = hasValidSnapshot && snapshotPayload ? {
2791
- id: snapshotPayload.policyId,
2792
- model: snapshotPayload.model,
2793
- i18n: snapshotPayload.policyI18n,
2794
- consent: {
2795
- expiryDays: snapshotPayload.expiryDays,
2796
- scopeMode: snapshotPayload.scopeMode,
2797
- categories: snapshotPayload.categories,
2798
- preselectedCategories: snapshotPayload.preselectedCategories,
2799
- gpc: snapshotPayload.gpc
2800
- },
2801
- ui: {
2802
- mode: snapshotPayload.uiMode,
2803
- banner: snapshotPayload.bannerUi,
2804
- dialog: snapshotPayload.dialogUi
2805
- },
2806
- proof: snapshotPayload.proofConfig
2807
- } : resolvedPolicyDecision?.policy;
2808
- const effectiveModel = effectivePolicy?.model ?? ('opt-in' === input.jurisdictionModel || 'opt-out' === input.jurisdictionModel || 'iab' === input.jurisdictionModel ? input.jurisdictionModel : void 0);
2809
- if ('all' === rawConsentAction) derivedConsentAction = 'accept_all';
2810
- else if ('necessary' === rawConsentAction) derivedConsentAction = 'opt-out' === effectiveModel ? 'opt_out' : 'reject_all';
2811
- else if ('custom' === rawConsentAction) derivedConsentAction = 'custom';
2812
- const subject = await registry.findOrCreateSubject({
2813
- subjectId,
2814
- externalSubjectId,
2815
- identityProvider,
2816
- ipAddress: ctx.ipAddress
2817
- });
2818
- if (!subject) throw new HTTPException(500, {
2819
- message: 'Failed to create subject',
2820
- cause: {
2821
- code: 'SUBJECT_CREATION_FAILED',
2822
- subjectId
2823
- }
2824
- });
2825
- logger.debug('Subject found/created', {
2826
- subjectId: subject.id
2827
- });
2828
- const domainRecord = await registry.findOrCreateDomain(domain);
2829
- if (!domainRecord) throw new HTTPException(500, {
2830
- message: 'Failed to create domain',
2831
- cause: {
2832
- code: 'DOMAIN_CREATION_FAILED',
2833
- domain
2834
- }
2835
- });
2836
- let policyId;
2837
- let purposeIds = [];
2838
- let appliedPreferences;
2839
- const inputPolicyId = 'policyId' in input ? input.policyId : void 0;
2840
- if (inputPolicyId) {
2841
- policyId = inputPolicyId;
2842
- const policy = await registry.findConsentPolicyById(inputPolicyId);
2843
- if (!policy) throw new HTTPException(404, {
2844
- message: 'Policy not found',
2845
- cause: {
2846
- code: 'POLICY_NOT_FOUND',
2847
- policyId,
2848
- type
2849
- }
2850
- });
2851
- if (!policy.isActive) throw new HTTPException(400, {
2852
- message: 'Policy is inactive',
2853
- cause: {
2854
- code: 'POLICY_INACTIVE',
2855
- policyId,
2856
- type
2857
- }
2858
- });
2859
- } else {
2860
- const policy = await registry.findOrCreatePolicy(type);
2861
- if (!policy) throw new HTTPException(500, {
2862
- message: 'Failed to create policy',
2863
- cause: {
2864
- code: 'POLICY_CREATION_FAILED',
2865
- type
2866
- }
2867
- });
2868
- policyId = policy.id;
2869
- }
2870
- if (preferences) {
2871
- const allowedCategories = effectivePolicy?.consent?.categories;
2872
- const effectiveScopeMode = effectivePolicy?.consent?.scopeMode ?? 'permissive';
2873
- const hasWildcardCategoryScope = allowedCategories?.includes('*') === true;
2874
- const appliedPreferenceEntries = Object.entries(preferences);
2875
- let filteredAppliedPreferenceEntries = appliedPreferenceEntries;
2876
- if (allowedCategories && allowedCategories.length > 0 && !hasWildcardCategoryScope) {
2877
- const disallowed = appliedPreferenceEntries.map(([purpose])=>purpose).filter((purpose)=>!allowedCategories.includes(purpose));
2878
- filteredAppliedPreferenceEntries = appliedPreferenceEntries.filter(([purpose])=>allowedCategories.includes(purpose));
2879
- if (disallowed.length > 0 && 'strict' === effectiveScopeMode) throw new HTTPException(400, {
2880
- message: 'Preferences include categories not allowed by policy',
2881
- cause: {
2882
- code: 'PURPOSE_NOT_ALLOWED',
2883
- disallowed
2884
- }
2885
- });
2886
- }
2887
- appliedPreferences = Object.fromEntries(filteredAppliedPreferenceEntries);
2888
- const filteredConsentedPurposeCodes = filteredAppliedPreferenceEntries.filter(([_, isConsented])=>isConsented).map(([purposeCode])=>purposeCode);
2889
- logger.debug('Consented purposes', {
2890
- consentedPurposes: filteredConsentedPurposeCodes
2891
- });
2892
- const purposesRaw = await Promise.all(filteredConsentedPurposeCodes.map((purposeCode)=>registry.findOrCreateConsentPurposeByCode(purposeCode)));
2893
- const purposes = purposesRaw.map((purpose)=>purpose?.id ?? null).filter((id)=>Boolean(id));
2894
- logger.debug('Filtered purposes', {
2895
- purposes
2896
- });
2897
- if (0 === purposes.length) logger.warn('No valid purpose IDs found after filtering. Using empty list.', {
2898
- consentedPurposes: filteredConsentedPurposeCodes
2899
- });
2900
- purposeIds = purposes;
2901
- }
2902
- const expiryDays = effectivePolicy?.consent?.expiryDays;
2903
- const validUntil = 'number' == typeof expiryDays && Number.isFinite(expiryDays) ? new Date(givenAt.getTime() + 86400000 * Math.max(0, expiryDays)) : void 0;
2904
- const proofConfig = effectivePolicy?.proof;
2905
- const shouldStoreIp = proofConfig?.storeIp ?? true;
2906
- const shouldStoreUserAgent = proofConfig?.storeUserAgent ?? true;
2907
- const shouldStoreLanguage = proofConfig?.storeLanguage ?? false;
2908
- const effectiveLanguage = (snapshotPayload?.language && hasValidSnapshot ? snapshotPayload.language : requestLanguage) ?? void 0;
2909
- const metadataWithPolicy = {
2910
- ...metadata ?? {},
2911
- ...shouldStoreLanguage && effectiveLanguage ? {
2912
- policyLanguage: effectiveLanguage
2913
- } : {}
2914
- };
2915
- const effectiveJurisdiction = hasValidSnapshot && snapshotPayload ? snapshotPayload.jurisdiction : resolvedJurisdiction;
2916
- const decisionPayload = buildDecisionPayload({
2917
- tenantId: ctx.tenantId,
2918
- snapshot: hasValidSnapshot && snapshotPayload ? {
2919
- valid: true,
2920
- payload: snapshotPayload
2921
- } : null,
2922
- decision: resolvedPolicyDecision,
2923
- location: {
2924
- countryCode: location.countryCode,
2925
- regionCode: location.regionCode
2926
- },
2927
- jurisdiction: resolvedJurisdiction,
2928
- language: effectiveLanguage,
2929
- proofConfig
2930
- });
2931
- const existingConsent = await db.findFirst('consent', {
2932
- where: (b)=>b.and(b('subjectId', '=', subject.id), b('domainId', '=', domainRecord.id), b('policyId', '=', policyId), b('givenAt', '=', givenAt))
2933
- });
2934
- if (existingConsent) {
2935
- logger.debug('Duplicate consent detected, returning existing record', {
2936
- consentId: existingConsent.id
2937
- });
2938
- return c.json({
2939
- subjectId: subject.id,
2940
- consentId: existingConsent.id,
2941
- domainId: domainRecord.id,
2942
- domain: domainRecord.name,
2943
- type,
2944
- metadata,
2945
- appliedPreferences,
2946
- uiSource: input.uiSource,
2947
- givenAt: existingConsent.givenAt
2948
- });
2949
- }
2950
- const result = await db.transaction(async (tx)=>{
2951
- logger.debug('Creating consent record', {
2952
- subjectId: subject.id,
2953
- domainId: domainRecord.id,
2954
- policyId,
2955
- purposeIds
2956
- });
2957
- const runtimePolicyDecision = decisionPayload ? await tx.findFirst('runtimePolicyDecision', {
2958
- where: (b)=>b('dedupeKey', '=', decisionPayload.dedupeKey)
2959
- }) ?? await tx.create('runtimePolicyDecision', {
2960
- id: `rpd_${crypto.randomUUID().replaceAll('-', '')}`,
2961
- tenantId: decisionPayload.tenantId,
2962
- policyId: decisionPayload.policyId,
2963
- fingerprint: decisionPayload.fingerprint,
2964
- matchedBy: decisionPayload.matchedBy,
2965
- countryCode: decisionPayload.countryCode,
2966
- regionCode: decisionPayload.regionCode,
2967
- jurisdiction: decisionPayload.jurisdiction,
2968
- language: decisionPayload.language,
2969
- model: decisionPayload.model,
2970
- policyI18n: decisionPayload.policyI18n ? {
2971
- json: decisionPayload.policyI18n
2972
- } : void 0,
2973
- uiMode: decisionPayload.uiMode,
2974
- bannerUi: decisionPayload.bannerUi ? {
2975
- json: decisionPayload.bannerUi
2976
- } : void 0,
2977
- dialogUi: decisionPayload.dialogUi ? {
2978
- json: decisionPayload.dialogUi
2979
- } : void 0,
2980
- categories: decisionPayload.categories ? {
2981
- json: decisionPayload.categories
2982
- } : void 0,
2983
- preselectedCategories: decisionPayload.preselectedCategories ? {
2984
- json: decisionPayload.preselectedCategories
2985
- } : void 0,
2986
- proofConfig: decisionPayload.proofConfig ? {
2987
- json: decisionPayload.proofConfig
2988
- } : void 0,
2989
- dedupeKey: decisionPayload.dedupeKey
2990
- }).catch(async ()=>tx.findFirst('runtimePolicyDecision', {
2991
- where: (b)=>b('dedupeKey', '=', decisionPayload.dedupeKey)
2992
- })) : void 0;
2993
- const consentRecord = await tx.create('consent', {
2994
- id: await utils_generateUniqueId(tx, 'consent', ctx),
2995
- subjectId: subject.id,
2996
- domainId: domainRecord.id,
2997
- policyId,
2998
- purposeIds: {
2999
- json: purposeIds
3000
- },
3001
- metadata: Object.keys(metadataWithPolicy).length > 0 ? {
3002
- json: metadataWithPolicy
3003
- } : void 0,
3004
- ipAddress: shouldStoreIp ? ctx.ipAddress : null,
3005
- userAgent: shouldStoreUserAgent ? ctx.userAgent : null,
3006
- jurisdiction: effectiveJurisdiction,
3007
- jurisdictionModel: effectiveModel,
3008
- tcString: input.tcString,
3009
- uiSource: input.uiSource,
3010
- consentAction: derivedConsentAction,
3011
- givenAt,
3012
- validUntil,
3013
- runtimePolicyDecisionId: runtimePolicyDecision?.id,
3014
- runtimePolicySource: decisionPayload?.source
3015
- });
3016
- logger.debug('Created consent', {
3017
- consentRecord: consentRecord.id
3018
- });
3019
- if (!consentRecord) throw new HTTPException(500, {
3020
- message: 'Failed to create consent',
3021
- cause: {
3022
- code: 'CONSENT_CREATION_FAILED',
3023
- subjectId: subject.id,
3024
- domain
3025
- }
3026
- });
3027
- return {
3028
- consent: consentRecord
3029
- };
3030
- });
3031
- const metrics = getMetrics();
3032
- if (metrics) {
3033
- const jurisdiction = effectiveJurisdiction;
3034
- metrics.recordConsentCreated({
3035
- type,
3036
- jurisdiction
3037
- });
3038
- const hasAccepted = preferences && Object.values(preferences).some(Boolean);
3039
- if (hasAccepted) metrics.recordConsentAccepted({
3040
- type,
3041
- jurisdiction
3042
- });
3043
- else metrics.recordConsentRejected({
3044
- type,
3045
- jurisdiction
3046
- });
3047
- }
3048
- return c.json({
3049
- subjectId: subject.id,
3050
- consentId: result.consent.id,
3051
- domainId: domainRecord.id,
3052
- domain: domainRecord.name,
3053
- type,
3054
- metadata,
3055
- appliedPreferences,
3056
- uiSource: input.uiSource,
3057
- givenAt: result.consent.givenAt
3058
- });
3059
- } catch (error) {
3060
- logger.error('Error in POST /subjects handler', {
3061
- error: extractErrorMessage(error),
3062
- errorType: error instanceof Error ? error.constructor.name : typeof error
3063
- });
3064
- if (error instanceof HTTPException) throw error;
3065
- throw new HTTPException(500, {
3066
- message: 'Internal server error',
3067
- cause: {
3068
- code: 'INTERNAL_SERVER_ERROR'
3069
- }
3070
- });
3071
- }
3072
- };
3073
- const createSubjectRoutes = ()=>{
3074
- const app = new Hono();
3075
- app.get('/:id', describeRoute({
3076
- summary: 'Get subject consent status',
3077
- description: `Returns the subject's consent status for this device. Use to check if the subject has valid consent for given policy types.
3078
-
3079
- **Query:** \`type\` – Filter by consent type(s), comma-separated (e.g. \`privacy_policy,cookie_banner\`).
3080
-
3081
- **Response:** \`subject\`, \`consents\` (matching filter), \`isValid\` (valid consent for requested type(s)).`,
3082
- tags: [
3083
- 'Subject',
3084
- 'Consent'
3085
- ],
3086
- responses: {
3087
- 200: {
3088
- description: 'Subject and consent records for the requested type(s)',
3089
- content: {
3090
- 'application/json': {
3091
- schema: resolver(getSubjectOutputSchema)
3092
- }
3093
- }
3094
- },
3095
- 404: {
3096
- description: 'Subject not found for the given ID'
3097
- }
3098
- }
3099
- }), validator('param', getSubjectInputSchema), getSubjectHandler);
3100
- app.post('/', describeRoute({
3101
- summary: 'Record consent for a subject',
3102
- description: `Creates a new consent record (append-only). Creates the subject if it does not exist.
3103
-
3104
- **Request body by \`type\`:**
3105
- - \`cookie_banner\` – Requires \`preferences\` object
3106
- - \`privacy_policy\`, \`dpa\`, \`terms_and_conditions\` – Optional \`policyId\`
3107
- - \`marketing_communications\`, \`age_verification\`, \`other\` – Optional \`preferences\``,
3108
- tags: [
3109
- 'Subject',
3110
- 'Consent'
3111
- ],
3112
- responses: {
3113
- 200: {
3114
- description: 'Consent recorded; subject and consent in response',
3115
- content: {
3116
- 'application/json': {
3117
- schema: resolver(postSubjectOutputSchema)
3118
- }
3119
- }
3120
- },
3121
- 422: {
3122
- description: 'Invalid request body (schema or validation failed)'
3123
- }
3124
- }
3125
- }), validator('json', postSubjectInputSchema), postSubjectHandler);
3126
- app.patch('/:id', describeRoute({
3127
- summary: 'Link external ID to subject',
3128
- description: 'Associates an external user ID with an existing subject (e.g. after login). Enables cross-device consent sync.',
3129
- tags: [
3130
- 'Subject'
3131
- ],
3132
- responses: {
3133
- 200: {
3134
- description: 'Subject updated with external ID',
3135
- content: {
3136
- 'application/json': {
3137
- schema: resolver(patchSubjectOutputSchema)
3138
- }
3139
- }
3140
- },
3141
- 404: {
3142
- description: 'Subject not found for the given ID'
3143
- }
3144
- }
3145
- }), validator('param', object({
3146
- id: subjectIdSchema
3147
- })), validator('json', object({
3148
- externalId: string(),
3149
- identityProvider: optional(string())
3150
- })), patchSubjectHandler);
3151
- app.get('/', describeRoute({
3152
- summary: 'List subjects by external ID (API key required)',
3153
- description: 'Returns all subjects linked to the given external ID. Requires Bearer token (API key). Use for server-side consent lookups.',
3154
- tags: [
3155
- 'Subject'
3156
- ],
3157
- security: [
3158
- {
3159
- bearerAuth: []
3160
- }
3161
- ],
3162
- responses: {
3163
- 200: {
3164
- description: 'List of subjects for the external ID',
3165
- content: {
3166
- 'application/json': {
3167
- schema: resolver(listSubjectsOutputSchema)
3168
- }
3169
- }
3170
- },
3171
- 401: {
3172
- description: 'Missing or invalid API key'
3173
- }
3174
- }
3175
- }), validator('query', listSubjectsQuerySchema), listSubjectsHandler);
3176
- return app;
3177
- };
3178
- const defineConfig = (config)=>config;
3179
776
  const DEFAULT_FALLBACK_POLICY_INPUT = {
3180
777
  id: 'world_no_banner',
3181
778
  isDefault: true,
@@ -3189,7 +786,7 @@ function compactUiSurface(value) {
3189
786
  if (!value) return;
3190
787
  return compactDefined({
3191
788
  allowedActions: dedupeTrimmedStrings(value.allowedActions),
3192
- primaryAction: value.primaryAction,
789
+ primaryActions: value.primaryActions,
3193
790
  layout: value.layout,
3194
791
  direction: value.direction,
3195
792
  uiProfile: value.uiProfile,
@@ -3254,13 +851,13 @@ const policyBuilder = {
3254
851
  create: buildPolicyConfig,
3255
852
  createPack: buildPolicyPack,
3256
853
  createPackWithDefault: buildPolicyPackWithDefault,
3257
- composePacks
854
+ composePacks: composePacks
3258
855
  };
3259
856
  const c15tInstance = (options)=>{
3260
857
  const context = init(options);
3261
858
  const logger = logger_createLogger(options.logger);
3262
859
  const app = new Hono();
3263
- const openApiConfig = config_createOpenAPIConfig(options);
860
+ const openApiConfig = createOpenAPIConfig(options);
3264
861
  const basePath = options.basePath || '/';
3265
862
  const corsOptions = createCORSOptions(options.trustedOrigins);
3266
863
  app.use('*', cors(corsOptions));
@@ -3291,7 +888,7 @@ const c15tInstance = (options)=>{
3291
888
  span.updateName(`${c.req.method} ${routePattern}`);
3292
889
  span.setAttribute('http.route', routePattern);
3293
890
  span.setStatus({
3294
- code: api_SpanStatusCode.OK
891
+ code: SpanStatusCode.OK
3295
892
  });
3296
893
  } else await runNext();
3297
894
  } catch (error) {
@@ -3337,9 +934,7 @@ const c15tInstance = (options)=>{
3337
934
  }));
3338
935
  const publicSpecUrl = `${basePath}${openApiConfig.specPath}`.replace(/\/+/g, '/');
3339
936
  app.get(openApiConfig.docsPath, apiReference({
3340
- spec: {
3341
- url: publicSpecUrl
3342
- },
937
+ url: publicSpecUrl,
3343
938
  pageTitle: `${options.appName || 'c15t API'} Documentation`
3344
939
  }));
3345
940
  }
@@ -3432,4 +1027,7 @@ const c15tInstance = (options)=>{
3432
1027
  getDocsUI
3433
1028
  };
3434
1029
  };
3435
- export { EEA_COUNTRY_CODES, EU_COUNTRY_CODES, POLICY_MATCH_DATASET_VERSION, UK_COUNTRY_CODES, c15tInstance, defineConfig, policy_inspectPolicies as inspectPolicies, policyBuilder, policyMatchers, policyPackPresets, version_version as version };
1030
+ export { defineConfig } from "./define-config.js";
1031
+ export { inspectPolicies } from "./583.js";
1032
+ export { version } from "./302.js";
1033
+ export { EEA_COUNTRY_CODES, EU_COUNTRY_CODES, POLICY_MATCH_DATASET_VERSION, UK_COUNTRY_CODES, c15tInstance, policyBuilder, policyMatchers, policyPackPresets };