@c15t/backend 2.0.0-rc.4 → 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.
- package/dist/302.js +473 -0
- package/dist/364.js +1140 -0
- package/dist/583.js +540 -0
- package/dist/cache.cjs +1 -1
- package/dist/cache.js +4 -415
- package/dist/core.cjs +849 -96
- package/dist/core.js +147 -1817
- package/dist/db/adapters/drizzle.cjs +1 -1
- package/dist/db/adapters/drizzle.js +1 -2
- package/dist/db/adapters/kysely.cjs +1 -1
- package/dist/db/adapters/kysely.js +1 -2
- package/dist/db/adapters/mongo.cjs +1 -1
- package/dist/db/adapters/mongo.js +1 -2
- package/dist/db/adapters/prisma.cjs +1 -1
- package/dist/db/adapters/prisma.js +1 -2
- package/dist/db/adapters/typeorm.cjs +1 -1
- package/dist/db/adapters/typeorm.js +1 -2
- package/dist/db/adapters.cjs +1 -1
- package/dist/db/migrator.cjs +1 -1
- package/dist/db/schema.cjs +38 -1
- package/dist/db/schema.js +33 -2
- package/dist/define-config.cjs +1 -1
- package/dist/edge.cjs +1106 -0
- package/dist/edge.js +190 -0
- package/dist/router.cjs +629 -81
- package/dist/router.js +1 -1509
- package/dist/types/index.cjs +1 -1
- package/{dist → dist-types}/cache/adapters/cloudflare-kv.d.ts +0 -1
- package/{dist → dist-types}/cache/adapters/index.d.ts +0 -1
- package/{dist → dist-types}/cache/adapters/memory.d.ts +0 -1
- package/{dist → dist-types}/cache/adapters/upstash-redis.d.ts +0 -1
- package/{dist → dist-types}/cache/gvl-resolver.d.ts +1 -2
- package/{dist → dist-types}/cache/index.d.ts +0 -1
- package/{dist → dist-types}/cache/keys.d.ts +0 -1
- package/{dist → dist-types}/cache/types.d.ts +0 -1
- package/{dist → dist-types}/core.d.ts +8 -1
- package/{dist → dist-types}/db/migrator/index.d.ts +0 -1
- package/{dist → dist-types}/db/registry/consent-policy.d.ts +0 -1
- package/{dist → dist-types}/db/registry/consent-purpose.d.ts +0 -1
- package/{dist → dist-types}/db/registry/domain.d.ts +0 -1
- package/{dist → dist-types}/db/registry/index.d.ts +22 -2
- package/dist-types/db/registry/runtime-policy-decision.d.ts +60 -0
- package/{dist → dist-types}/db/registry/subject.d.ts +0 -1
- package/{dist → dist-types}/db/registry/types.d.ts +1 -2
- package/{dist → dist-types}/db/registry/utils/generate-id.d.ts +0 -1
- package/{dist → dist-types}/db/registry/utils.d.ts +0 -1
- package/{dist → dist-types}/db/schema/1.0.0/audit-log.d.ts +0 -1
- package/{dist → dist-types}/db/schema/1.0.0/consent-policy.d.ts +0 -1
- package/{dist → dist-types}/db/schema/1.0.0/consent-purpose.d.ts +0 -1
- package/{dist → dist-types}/db/schema/1.0.0/consent-record.d.ts +0 -1
- package/{dist → dist-types}/db/schema/1.0.0/consent.d.ts +2 -3
- package/{dist → dist-types}/db/schema/1.0.0/domain.d.ts +0 -1
- package/{dist → dist-types}/db/schema/1.0.0/index.d.ts +0 -1
- package/{dist → dist-types}/db/schema/1.0.0/subject.d.ts +0 -1
- package/{dist → dist-types}/db/schema/2.0.0/audit-log.d.ts +2 -3
- package/{dist → dist-types}/db/schema/2.0.0/consent-policy.d.ts +2 -3
- package/{dist → dist-types}/db/schema/2.0.0/consent-purpose.d.ts +2 -3
- package/{dist → dist-types}/db/schema/2.0.0/consent.d.ts +6 -3
- package/{dist → dist-types}/db/schema/2.0.0/domain.d.ts +2 -3
- package/{dist → dist-types}/db/schema/2.0.0/index.d.ts +432 -17
- package/dist-types/db/schema/2.0.0/runtime-policy-decision.d.ts +23 -0
- package/{dist → dist-types}/db/schema/2.0.0/subject.d.ts +2 -3
- package/{dist → dist-types}/db/schema/index.d.ts +862 -33
- package/{dist → dist-types}/db/tenant-scope.d.ts +0 -1
- package/{dist → dist-types}/define-config.d.ts +0 -1
- package/dist-types/edge/index.d.ts +5 -0
- package/dist-types/edge/init-handler.d.ts +38 -0
- package/dist-types/edge/resolve-consent.d.ts +80 -0
- package/dist-types/edge/types.d.ts +13 -0
- package/{dist → dist-types}/handlers/consent/check.handler.d.ts +0 -1
- package/{src/handlers/consent/index.ts → dist-types/handlers/consent/index.d.ts} +0 -1
- package/{dist → dist-types}/handlers/init/geo.d.ts +2 -3
- package/{dist → dist-types}/handlers/init/index.d.ts +4 -5
- package/dist-types/handlers/init/policy.d.ts +26 -0
- package/dist-types/handlers/init/resolve-init.d.ts +44 -0
- package/dist-types/handlers/init/translations.d.ts +48 -0
- package/dist-types/handlers/policy/snapshot.d.ts +99 -0
- package/{src/handlers/status/index.ts → dist-types/handlers/status/index.d.ts} +0 -1
- package/{dist → dist-types}/handlers/status/status.handler.d.ts +0 -1
- package/{dist → dist-types}/handlers/subject/get.handler.d.ts +0 -1
- package/{src/handlers/subject/index.ts → dist-types/handlers/subject/index.d.ts} +0 -1
- package/{dist → dist-types}/handlers/subject/list.handler.d.ts +0 -1
- package/{dist → dist-types}/handlers/subject/patch.handler.d.ts +0 -1
- package/{dist → dist-types}/handlers/subject/post.handler.d.ts +12 -1
- package/{dist → dist-types}/handlers/utils/consent-enrichment.d.ts +0 -1
- package/{dist → dist-types}/init.d.ts +0 -1
- package/{dist → dist-types}/middleware/auth/index.d.ts +0 -1
- package/{dist → dist-types}/middleware/auth/validate-api-key.d.ts +0 -1
- package/{dist → dist-types}/middleware/cors/cors.d.ts +0 -1
- package/{src/middleware/cors/index.ts → dist-types/middleware/cors/index.d.ts} +0 -1
- package/{dist → dist-types}/middleware/cors/is-origin-trusted.d.ts +1 -2
- package/{dist → dist-types}/middleware/cors/process-cors.d.ts +0 -1
- package/{dist → dist-types}/middleware/openapi/config.d.ts +0 -1
- package/{dist → dist-types}/middleware/openapi/handlers.d.ts +0 -1
- package/{src/middleware/openapi/index.ts → dist-types/middleware/openapi/index.d.ts} +0 -1
- package/{dist → dist-types}/middleware/process-ip/index.d.ts +0 -1
- package/dist-types/policies/builder.d.ts +127 -0
- package/dist-types/policies/defaults.d.ts +2 -0
- package/dist-types/policies/matchers.d.ts +3 -0
- package/{dist → dist-types}/router.d.ts +0 -1
- package/{dist → dist-types}/routes/consent.d.ts +0 -1
- package/{src/routes/index.ts → dist-types/routes/index.d.ts} +0 -1
- package/{dist → dist-types}/routes/init.d.ts +0 -1
- package/{dist → dist-types}/routes/status.d.ts +0 -1
- package/{dist → dist-types}/routes/subject.d.ts +0 -1
- package/{dist → dist-types}/types/api.d.ts +0 -1
- package/{dist → dist-types}/types/index.d.ts +110 -6
- package/dist-types/utils/background.d.ts +6 -0
- package/{dist → dist-types}/utils/create-telemetry-options.d.ts +0 -1
- package/{dist → dist-types}/utils/env.d.ts +0 -1
- package/{dist → dist-types}/utils/extract-error-message.d.ts +0 -1
- package/{dist → dist-types}/utils/instrumentation.d.ts +0 -1
- package/{dist → dist-types}/utils/logger.d.ts +1 -2
- package/{dist → dist-types}/utils/metrics.d.ts +0 -1
- package/dist-types/version.d.ts +1 -0
- package/docs/README.md +49 -0
- package/docs/api/configuration.md +197 -0
- package/docs/api/endpoints.md +211 -0
- package/docs/guides/caching.md +85 -0
- package/docs/guides/database-setup.md +128 -0
- package/docs/guides/edge-deployment.md +248 -0
- package/docs/guides/framework-integration.md +142 -0
- package/docs/guides/iab-tcf.md +89 -0
- package/docs/guides/observability.md +96 -0
- package/docs/guides/policy-packs.md +396 -0
- package/docs/quickstart.md +129 -0
- package/package.json +45 -31
- package/.turbo/turbo-build.log +0 -49
- package/CHANGELOG.md +0 -123
- package/dist/cache/adapters/cloudflare-kv.d.ts.map +0 -1
- package/dist/cache/adapters/index.d.ts.map +0 -1
- package/dist/cache/adapters/memory.d.ts.map +0 -1
- package/dist/cache/adapters/upstash-redis.d.ts.map +0 -1
- package/dist/cache/gvl-resolver.d.ts.map +0 -1
- package/dist/cache/index.d.ts.map +0 -1
- package/dist/cache/keys.d.ts.map +0 -1
- package/dist/cache/types.d.ts.map +0 -1
- package/dist/core.d.ts.map +0 -1
- package/dist/db/adapters/drizzle.d.ts +0 -2
- package/dist/db/adapters/drizzle.d.ts.map +0 -1
- package/dist/db/adapters/index.d.ts +0 -2
- package/dist/db/adapters/index.d.ts.map +0 -1
- package/dist/db/adapters/kysely.d.ts +0 -2
- package/dist/db/adapters/kysely.d.ts.map +0 -1
- package/dist/db/adapters/mongo.d.ts +0 -2
- package/dist/db/adapters/mongo.d.ts.map +0 -1
- package/dist/db/adapters/prisma.d.ts +0 -2
- package/dist/db/adapters/prisma.d.ts.map +0 -1
- package/dist/db/adapters/typeorm.d.ts +0 -2
- package/dist/db/adapters/typeorm.d.ts.map +0 -1
- package/dist/db/migrator/index.d.ts.map +0 -1
- package/dist/db/registry/consent-policy.d.ts.map +0 -1
- package/dist/db/registry/consent-purpose.d.ts.map +0 -1
- package/dist/db/registry/domain.d.ts.map +0 -1
- package/dist/db/registry/index.d.ts.map +0 -1
- package/dist/db/registry/subject.d.ts.map +0 -1
- package/dist/db/registry/types.d.ts.map +0 -1
- package/dist/db/registry/utils/generate-id.d.ts.map +0 -1
- package/dist/db/registry/utils.d.ts.map +0 -1
- package/dist/db/schema/1.0.0/audit-log.d.ts.map +0 -1
- package/dist/db/schema/1.0.0/consent-policy.d.ts.map +0 -1
- package/dist/db/schema/1.0.0/consent-purpose.d.ts.map +0 -1
- package/dist/db/schema/1.0.0/consent-record.d.ts.map +0 -1
- package/dist/db/schema/1.0.0/consent.d.ts.map +0 -1
- package/dist/db/schema/1.0.0/domain.d.ts.map +0 -1
- package/dist/db/schema/1.0.0/index.d.ts.map +0 -1
- package/dist/db/schema/1.0.0/subject.d.ts.map +0 -1
- package/dist/db/schema/2.0.0/audit-log.d.ts.map +0 -1
- package/dist/db/schema/2.0.0/consent-policy.d.ts.map +0 -1
- package/dist/db/schema/2.0.0/consent-purpose.d.ts.map +0 -1
- package/dist/db/schema/2.0.0/consent.d.ts.map +0 -1
- package/dist/db/schema/2.0.0/domain.d.ts.map +0 -1
- package/dist/db/schema/2.0.0/index.d.ts.map +0 -1
- package/dist/db/schema/2.0.0/subject.d.ts.map +0 -1
- package/dist/db/schema/index.d.ts.map +0 -1
- package/dist/db/tenant-scope.d.ts.map +0 -1
- package/dist/define-config.d.ts.map +0 -1
- package/dist/handlers/consent/check.handler.d.ts.map +0 -1
- package/dist/handlers/consent/index.d.ts +0 -12
- package/dist/handlers/consent/index.d.ts.map +0 -1
- package/dist/handlers/init/geo.d.ts.map +0 -1
- package/dist/handlers/init/index.d.ts.map +0 -1
- package/dist/handlers/init/translations.d.ts +0 -26
- package/dist/handlers/init/translations.d.ts.map +0 -1
- package/dist/handlers/status/index.d.ts +0 -7
- package/dist/handlers/status/index.d.ts.map +0 -1
- package/dist/handlers/status/status.handler.d.ts.map +0 -1
- package/dist/handlers/subject/get.handler.d.ts.map +0 -1
- package/dist/handlers/subject/index.d.ts +0 -10
- package/dist/handlers/subject/index.d.ts.map +0 -1
- package/dist/handlers/subject/list.handler.d.ts.map +0 -1
- package/dist/handlers/subject/patch.handler.d.ts.map +0 -1
- package/dist/handlers/subject/post.handler.d.ts.map +0 -1
- package/dist/handlers/utils/consent-enrichment.d.ts.map +0 -1
- package/dist/init.d.ts.map +0 -1
- package/dist/middleware/auth/index.d.ts.map +0 -1
- package/dist/middleware/auth/validate-api-key.d.ts.map +0 -1
- package/dist/middleware/cors/cors.d.ts.map +0 -1
- package/dist/middleware/cors/index.d.ts +0 -30
- package/dist/middleware/cors/index.d.ts.map +0 -1
- package/dist/middleware/cors/is-origin-trusted.d.ts.map +0 -1
- package/dist/middleware/cors/process-cors.d.ts.map +0 -1
- package/dist/middleware/openapi/config.d.ts.map +0 -1
- package/dist/middleware/openapi/handlers.d.ts.map +0 -1
- package/dist/middleware/openapi/index.d.ts +0 -12
- package/dist/middleware/openapi/index.d.ts.map +0 -1
- package/dist/middleware/process-ip/index.d.ts.map +0 -1
- package/dist/router.d.ts.map +0 -1
- package/dist/routes/consent.d.ts.map +0 -1
- package/dist/routes/index.d.ts +0 -10
- package/dist/routes/index.d.ts.map +0 -1
- package/dist/routes/init.d.ts.map +0 -1
- package/dist/routes/status.d.ts.map +0 -1
- package/dist/routes/subject.d.ts.map +0 -1
- package/dist/types/api.d.ts.map +0 -1
- package/dist/types/index.d.ts.map +0 -1
- package/dist/utils/create-telemetry-options.d.ts.map +0 -1
- package/dist/utils/env.d.ts.map +0 -1
- package/dist/utils/extract-error-message.d.ts.map +0 -1
- package/dist/utils/index.d.ts +0 -4
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/instrumentation.d.ts.map +0 -1
- package/dist/utils/logger.d.ts.map +0 -1
- package/dist/utils/metrics.d.ts.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- package/knip.json +0 -31
- package/rslib.config.ts +0 -93
- package/src/cache/adapters/cloudflare-kv.ts +0 -71
- package/src/cache/adapters/index.ts +0 -22
- package/src/cache/adapters/memory.ts +0 -111
- package/src/cache/adapters/upstash-redis.ts +0 -113
- package/src/cache/gvl-resolver.ts +0 -289
- package/src/cache/index.ts +0 -34
- package/src/cache/keys.ts +0 -68
- package/src/cache/types.ts +0 -66
- package/src/core.ts +0 -369
- package/src/db/migrator/index.ts +0 -80
- package/src/db/registry/consent-policy.test.ts +0 -451
- package/src/db/registry/consent-policy.ts +0 -82
- package/src/db/registry/consent-purpose.test.ts +0 -428
- package/src/db/registry/consent-purpose.ts +0 -61
- package/src/db/registry/domain.test.ts +0 -445
- package/src/db/registry/domain.ts +0 -91
- package/src/db/registry/index.ts +0 -14
- package/src/db/registry/subject.test.ts +0 -371
- package/src/db/registry/subject.ts +0 -126
- package/src/db/registry/types.ts +0 -10
- package/src/db/registry/utils/generate-id.test.ts +0 -216
- package/src/db/registry/utils/generate-id.ts +0 -133
- package/src/db/registry/utils.ts +0 -133
- package/src/db/schema/1.0.0/audit-log.ts +0 -15
- package/src/db/schema/1.0.0/consent-policy.ts +0 -14
- package/src/db/schema/1.0.0/consent-purpose.ts +0 -14
- package/src/db/schema/1.0.0/consent-record.ts +0 -10
- package/src/db/schema/1.0.0/consent.ts +0 -20
- package/src/db/schema/1.0.0/domain.ts +0 -12
- package/src/db/schema/1.0.0/index.ts +0 -48
- package/src/db/schema/1.0.0/subject.ts +0 -11
- package/src/db/schema/2.0.0/audit-log.ts +0 -18
- package/src/db/schema/2.0.0/consent-policy.ts +0 -28
- package/src/db/schema/2.0.0/consent-purpose.ts +0 -12
- package/src/db/schema/2.0.0/consent.ts +0 -28
- package/src/db/schema/2.0.0/domain.ts +0 -12
- package/src/db/schema/2.0.0/index.ts +0 -47
- package/src/db/schema/2.0.0/subject.ts +0 -13
- package/src/db/schema/index.ts +0 -15
- package/src/db/tenant-scope.test.ts +0 -747
- package/src/db/tenant-scope.ts +0 -103
- package/src/define-config.ts +0 -19
- package/src/handlers/consent/check.handler.ts +0 -126
- package/src/handlers/init/geo.test.ts +0 -317
- package/src/handlers/init/geo.ts +0 -195
- package/src/handlers/init/index.test.ts +0 -205
- package/src/handlers/init/index.ts +0 -114
- package/src/handlers/init/translations.test.ts +0 -121
- package/src/handlers/init/translations.ts +0 -69
- package/src/handlers/status/status.handler.test.ts +0 -155
- package/src/handlers/status/status.handler.ts +0 -51
- package/src/handlers/subject/get.handler.ts +0 -92
- package/src/handlers/subject/list.handler.ts +0 -92
- package/src/handlers/subject/patch.handler.ts +0 -119
- package/src/handlers/subject/post.handler.test.ts +0 -294
- package/src/handlers/subject/post.handler.ts +0 -268
- package/src/handlers/utils/consent-enrichment.test.ts +0 -380
- package/src/handlers/utils/consent-enrichment.ts +0 -218
- package/src/init.test.ts +0 -122
- package/src/init.ts +0 -88
- package/src/middleware/auth/index.ts +0 -11
- package/src/middleware/auth/validate-api-key.test.ts +0 -86
- package/src/middleware/auth/validate-api-key.ts +0 -107
- package/src/middleware/cors/cors.test.ts +0 -135
- package/src/middleware/cors/cors.ts +0 -186
- package/src/middleware/cors/is-origin-trusted.test.ts +0 -164
- package/src/middleware/cors/is-origin-trusted.ts +0 -130
- package/src/middleware/cors/process-cors.ts +0 -91
- package/src/middleware/openapi/config.ts +0 -29
- package/src/middleware/openapi/handlers.ts +0 -34
- package/src/middleware/process-ip/index.test.ts +0 -193
- package/src/middleware/process-ip/index.ts +0 -199
- package/src/router.ts +0 -15
- package/src/routes/consent.ts +0 -52
- package/src/routes/init.ts +0 -105
- package/src/routes/status.ts +0 -46
- package/src/routes/subject.ts +0 -152
- package/src/types/api.ts +0 -48
- package/src/types/index.ts +0 -391
- package/src/utils/create-telemetry-options.test.ts +0 -286
- package/src/utils/create-telemetry-options.ts +0 -229
- package/src/utils/env.ts +0 -84
- package/src/utils/extract-error-message.ts +0 -21
- package/src/utils/instrumentation.test.ts +0 -183
- package/src/utils/instrumentation.ts +0 -194
- package/src/utils/logger.ts +0 -41
- package/src/utils/metrics.test.ts +0 -311
- package/src/utils/metrics.ts +0 -402
- package/src/utils/telemetry-pii.test.ts +0 -323
- package/src/version.ts +0 -2
- package/tsconfig.json +0 -11
- package/vitest.config.ts +0 -28
- /package/{src/db/adapters/drizzle.ts → dist-types/db/adapters/drizzle.d.ts} +0 -0
- /package/{src/db/adapters/index.ts → dist-types/db/adapters/index.d.ts} +0 -0
- /package/{src/db/adapters/kysely.ts → dist-types/db/adapters/kysely.d.ts} +0 -0
- /package/{src/db/adapters/mongo.ts → dist-types/db/adapters/mongo.d.ts} +0 -0
- /package/{src/db/adapters/prisma.ts → dist-types/db/adapters/prisma.d.ts} +0 -0
- /package/{src/db/adapters/typeorm.ts → dist-types/db/adapters/typeorm.d.ts} +0 -0
- /package/{src/utils/index.ts → dist-types/utils/index.d.ts} +0 -0
package/dist/364.js
ADDED
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
import { checkConsentOutputSchema, checkConsentQuerySchema, getSubjectInputSchema, getSubjectOutputSchema, initOutputSchema, listSubjectsOutputSchema, listSubjectsQuerySchema, patchSubjectOutputSchema, postSubjectInputSchema, postSubjectOutputSchema, statusOutputSchema, subjectIdSchema } from "@c15t/schema";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { describeRoute, resolver, validator } from "hono-openapi";
|
|
4
|
+
import { HTTPException } from "hono/http-exception";
|
|
5
|
+
import base_x from "base-x";
|
|
6
|
+
import { version, getMetrics, extractErrorMessage } from "./302.js";
|
|
7
|
+
import { getLocation, resolveInitPayload, policy_resolvePolicyDecision, verifyPolicySnapshotToken, getJurisdiction } from "./583.js";
|
|
8
|
+
import * as __rspack_external_valibot from "valibot";
|
|
9
|
+
function parsePurposeIds(purposeIds) {
|
|
10
|
+
if (null == purposeIds) return [];
|
|
11
|
+
const ids = 'object' == typeof purposeIds && 'json' in purposeIds ? purposeIds.json : purposeIds;
|
|
12
|
+
return Array.isArray(ids) ? ids : [];
|
|
13
|
+
}
|
|
14
|
+
async function batchLoadPolicies(policyIds, ctx) {
|
|
15
|
+
const { db, registry } = ctx;
|
|
16
|
+
const policyMap = new Map();
|
|
17
|
+
if (policyIds.size > 0) {
|
|
18
|
+
const policies = await db.findMany('consentPolicy', {
|
|
19
|
+
where: (b)=>b('id', 'in', [
|
|
20
|
+
...policyIds
|
|
21
|
+
])
|
|
22
|
+
});
|
|
23
|
+
for (const p of policies)policyMap.set(p.id, p);
|
|
24
|
+
}
|
|
25
|
+
const uniqueTypes = new Set();
|
|
26
|
+
for (const p of policyMap.values())uniqueTypes.add(p.type);
|
|
27
|
+
const latestPolicyByType = new Map();
|
|
28
|
+
for (const type of uniqueTypes){
|
|
29
|
+
const latest = await registry.findOrCreatePolicy(type);
|
|
30
|
+
if (latest) latestPolicyByType.set(type, latest.id);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
policyMap,
|
|
34
|
+
latestPolicyByType
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function enrichConsents(consents, ctx) {
|
|
38
|
+
if (0 === consents.length) return [];
|
|
39
|
+
const policyIds = new Set();
|
|
40
|
+
for (const c of consents)if (c.policyId) policyIds.add(c.policyId);
|
|
41
|
+
const { policyMap, latestPolicyByType } = await batchLoadPolicies(policyIds, ctx);
|
|
42
|
+
const allPurposeIds = new Set();
|
|
43
|
+
for (const c of consents)for (const id of parsePurposeIds(c.purposeIds))allPurposeIds.add(id);
|
|
44
|
+
const purposeMap = new Map();
|
|
45
|
+
if (allPurposeIds.size > 0) {
|
|
46
|
+
const purposes = await ctx.db.findMany('consentPurpose', {
|
|
47
|
+
where: (b)=>b('id', 'in', [
|
|
48
|
+
...allPurposeIds
|
|
49
|
+
])
|
|
50
|
+
});
|
|
51
|
+
for (const p of purposes)purposeMap.set(p.id, p.code);
|
|
52
|
+
}
|
|
53
|
+
return consents.map((consent)=>{
|
|
54
|
+
let policyType = 'unknown';
|
|
55
|
+
let isLatestPolicy = false;
|
|
56
|
+
if (consent.policyId) {
|
|
57
|
+
const policy = policyMap.get(consent.policyId);
|
|
58
|
+
if (policy) {
|
|
59
|
+
policyType = policy.type;
|
|
60
|
+
isLatestPolicy = latestPolicyByType.get(policyType) === consent.policyId;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let preferences;
|
|
64
|
+
const ids = parsePurposeIds(consent.purposeIds);
|
|
65
|
+
if (ids.length > 0) {
|
|
66
|
+
preferences = {};
|
|
67
|
+
for (const purposeId of ids){
|
|
68
|
+
const code = purposeMap.get(purposeId);
|
|
69
|
+
if (code) preferences[code] = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
id: consent.id,
|
|
74
|
+
type: policyType,
|
|
75
|
+
policyId: consent.policyId ?? void 0,
|
|
76
|
+
isLatestPolicy,
|
|
77
|
+
preferences,
|
|
78
|
+
givenAt: consent.givenAt
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async function resolveConsentPolicies(consents, ctx) {
|
|
83
|
+
if (0 === consents.length) return [];
|
|
84
|
+
const policyIds = new Set();
|
|
85
|
+
for (const c of consents)if (c.policyId) policyIds.add(c.policyId);
|
|
86
|
+
const { policyMap, latestPolicyByType } = await batchLoadPolicies(policyIds, ctx);
|
|
87
|
+
return consents.map((consent)=>{
|
|
88
|
+
let policyType = 'unknown';
|
|
89
|
+
let isLatestPolicy = false;
|
|
90
|
+
if (consent.policyId) {
|
|
91
|
+
const policy = policyMap.get(consent.policyId);
|
|
92
|
+
if (policy) {
|
|
93
|
+
policyType = policy.type;
|
|
94
|
+
isLatestPolicy = latestPolicyByType.get(policyType) === consent.policyId;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
consentId: consent.id,
|
|
99
|
+
policyType,
|
|
100
|
+
policyId: consent.policyId ?? void 0,
|
|
101
|
+
isLatestPolicy
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const checkConsentHandler = async (c)=>{
|
|
106
|
+
const ctx = c.get('c15tContext');
|
|
107
|
+
const logger = ctx.logger;
|
|
108
|
+
logger.info('Handling GET /consents/check request');
|
|
109
|
+
const { db, registry } = ctx;
|
|
110
|
+
const externalId = c.req.query('externalId');
|
|
111
|
+
const type = c.req.query('type');
|
|
112
|
+
if (!externalId) throw new HTTPException(422, {
|
|
113
|
+
message: 'externalId query parameter is required',
|
|
114
|
+
cause: {
|
|
115
|
+
code: 'EXTERNAL_ID_REQUIRED'
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
if (!type) throw new HTTPException(422, {
|
|
119
|
+
message: 'type query parameter is required',
|
|
120
|
+
cause: {
|
|
121
|
+
code: 'TYPE_REQUIRED'
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const types = type.split(',').map((t)=>t.trim());
|
|
125
|
+
logger.debug('Request parameters', {
|
|
126
|
+
externalId,
|
|
127
|
+
types
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
const subjects = await db.findMany('subject', {
|
|
131
|
+
where: (b)=>b('externalId', '=', externalId)
|
|
132
|
+
});
|
|
133
|
+
const subjectIds = subjects.map((s)=>s.id);
|
|
134
|
+
const results = {};
|
|
135
|
+
for (const t of types)results[t] = {
|
|
136
|
+
hasConsent: false,
|
|
137
|
+
isLatestPolicy: false
|
|
138
|
+
};
|
|
139
|
+
if (0 === subjectIds.length) {
|
|
140
|
+
logger.debug('No subjects found for externalId', {
|
|
141
|
+
externalId
|
|
142
|
+
});
|
|
143
|
+
return c.json({
|
|
144
|
+
results
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const allConsents = await Promise.all(subjectIds.map((subjectId)=>db.findMany('consent', {
|
|
148
|
+
where: (b)=>b('subjectId', '=', subjectId)
|
|
149
|
+
})));
|
|
150
|
+
const consents = allConsents.flat();
|
|
151
|
+
const policyInfos = await resolveConsentPolicies(consents, {
|
|
152
|
+
db,
|
|
153
|
+
registry
|
|
154
|
+
});
|
|
155
|
+
for (const info of policyInfos){
|
|
156
|
+
if (!types.includes(info.policyType)) continue;
|
|
157
|
+
const entry = results[info.policyType];
|
|
158
|
+
if (entry) {
|
|
159
|
+
entry.hasConsent = true;
|
|
160
|
+
if (info.isLatestPolicy) entry.isLatestPolicy = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
logger.debug('Consent check results', {
|
|
164
|
+
externalId,
|
|
165
|
+
results
|
|
166
|
+
});
|
|
167
|
+
const metrics = getMetrics();
|
|
168
|
+
if (metrics) for (const [type, result] of Object.entries(results))metrics.recordConsentCheck(type, result.hasConsent);
|
|
169
|
+
return c.json({
|
|
170
|
+
results
|
|
171
|
+
});
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.error('Error in GET /consents/check handler', {
|
|
174
|
+
error: extractErrorMessage(error),
|
|
175
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error
|
|
176
|
+
});
|
|
177
|
+
if (error instanceof HTTPException) throw error;
|
|
178
|
+
throw new HTTPException(500, {
|
|
179
|
+
message: 'Internal server error',
|
|
180
|
+
cause: {
|
|
181
|
+
code: 'INTERNAL_SERVER_ERROR'
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const createConsentRoutes = ()=>{
|
|
187
|
+
const app = new Hono();
|
|
188
|
+
app.get('/check', describeRoute({
|
|
189
|
+
summary: 'Check consent by external user ID',
|
|
190
|
+
description: "Pre-banner cross-device consent check. Use to avoid showing the banner when the user has already consented on another device.\n\n**Query parameters:**\n- `externalId` – External user ID to check\n- `type` – Consent type(s) to check (comma-separated)",
|
|
191
|
+
tags: [
|
|
192
|
+
'Consent'
|
|
193
|
+
],
|
|
194
|
+
responses: {
|
|
195
|
+
200: {
|
|
196
|
+
description: 'Consent check result per requested type(s)',
|
|
197
|
+
content: {
|
|
198
|
+
'application/json': {
|
|
199
|
+
schema: resolver(checkConsentOutputSchema)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
422: {
|
|
204
|
+
description: 'Invalid or missing query parameters'
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}), validator('query', checkConsentQuerySchema), checkConsentHandler);
|
|
208
|
+
return app;
|
|
209
|
+
};
|
|
210
|
+
const createInitRoute = (options)=>{
|
|
211
|
+
const app = new Hono();
|
|
212
|
+
app.get('/', describeRoute({
|
|
213
|
+
summary: 'Get initial consent manager state',
|
|
214
|
+
description: `Returns the initial state required to render the consent manager.
|
|
215
|
+
|
|
216
|
+
- **Jurisdiction** – User's jurisdiction (defaults to GDPR if geo-location is disabled)
|
|
217
|
+
- **Location** – User's location (null if geo-location is disabled)
|
|
218
|
+
- **Translations** – Consent manager copy (from \`Accept-Language\` header)
|
|
219
|
+
- **Branding** – Configured branding key
|
|
220
|
+
- **GVL** – Global Vendor List when IAB is active for the request
|
|
221
|
+
|
|
222
|
+
Use for geo-targeted consent banners and regional compliance.`,
|
|
223
|
+
tags: [
|
|
224
|
+
'Init'
|
|
225
|
+
],
|
|
226
|
+
responses: {
|
|
227
|
+
200: {
|
|
228
|
+
description: 'Initialization payload (jurisdiction, location, translations, branding, GVL)',
|
|
229
|
+
content: {
|
|
230
|
+
'application/json': {
|
|
231
|
+
schema: resolver(initOutputSchema)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}), async (c)=>{
|
|
237
|
+
const ctx = c.get('c15tContext');
|
|
238
|
+
const payload = await resolveInitPayload(c.req.raw, options, ctx?.logger);
|
|
239
|
+
return c.json(payload);
|
|
240
|
+
});
|
|
241
|
+
return app;
|
|
242
|
+
};
|
|
243
|
+
function getHeaders(headers) {
|
|
244
|
+
if (!headers) return {
|
|
245
|
+
countryCode: null,
|
|
246
|
+
regionCode: null,
|
|
247
|
+
acceptLanguage: null
|
|
248
|
+
};
|
|
249
|
+
const normalizeHeader = (value)=>{
|
|
250
|
+
if (!value) return null;
|
|
251
|
+
return Array.isArray(value) ? value[0] ?? null : value;
|
|
252
|
+
};
|
|
253
|
+
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'));
|
|
254
|
+
const regionCode = normalizeHeader(headers.get('x-c15t-region')) ?? normalizeHeader(headers.get('x-vercel-ip-country-region')) ?? normalizeHeader(headers.get('x-region-code'));
|
|
255
|
+
const acceptLanguage = normalizeHeader(headers.get('accept-language'));
|
|
256
|
+
return {
|
|
257
|
+
countryCode,
|
|
258
|
+
regionCode,
|
|
259
|
+
acceptLanguage
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const statusHandler = async (c)=>{
|
|
263
|
+
const ctx = c.get('c15tContext');
|
|
264
|
+
const { countryCode, regionCode, acceptLanguage } = getHeaders(ctx.headers);
|
|
265
|
+
const clientInfo = {
|
|
266
|
+
ip: ctx.ipAddress ?? null,
|
|
267
|
+
acceptLanguage,
|
|
268
|
+
userAgent: ctx.userAgent ?? null,
|
|
269
|
+
region: {
|
|
270
|
+
countryCode,
|
|
271
|
+
regionCode
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
try {
|
|
275
|
+
await ctx.db.findFirst('subject', {});
|
|
276
|
+
return c.json({
|
|
277
|
+
version: version,
|
|
278
|
+
timestamp: new Date(),
|
|
279
|
+
client: clientInfo
|
|
280
|
+
});
|
|
281
|
+
} catch (error) {
|
|
282
|
+
ctx.logger.error('Database health check failed', {
|
|
283
|
+
error
|
|
284
|
+
});
|
|
285
|
+
throw new HTTPException(503, {
|
|
286
|
+
message: 'Database health check failed',
|
|
287
|
+
cause: {
|
|
288
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
289
|
+
error
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const createStatusRoute = ()=>{
|
|
295
|
+
const app = new Hono();
|
|
296
|
+
app.get('/', describeRoute({
|
|
297
|
+
summary: 'Health check and API status',
|
|
298
|
+
description: `Returns API version, timestamp, and client info (IP, region, user agent).
|
|
299
|
+
|
|
300
|
+
Use for health checks, load balancer probes, and debugging. Performs a lightweight DB check; returns 503 if the database is unreachable.`,
|
|
301
|
+
tags: [
|
|
302
|
+
'Status'
|
|
303
|
+
],
|
|
304
|
+
responses: {
|
|
305
|
+
200: {
|
|
306
|
+
description: 'API is healthy (version, timestamp, client info)',
|
|
307
|
+
content: {
|
|
308
|
+
'application/json': {
|
|
309
|
+
schema: resolver(statusOutputSchema)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
503: {
|
|
314
|
+
description: 'Service unavailable (e.g. database unreachable)'
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}), statusHandler);
|
|
318
|
+
return app;
|
|
319
|
+
};
|
|
320
|
+
const getSubjectHandler = async (c)=>{
|
|
321
|
+
const ctx = c.get('c15tContext');
|
|
322
|
+
const logger = ctx.logger;
|
|
323
|
+
logger.info('Handling GET /subjects/:id request');
|
|
324
|
+
const { db, registry } = ctx;
|
|
325
|
+
const subjectId = c.req.param('id');
|
|
326
|
+
const type = c.req.query('type');
|
|
327
|
+
const typeFilter = type?.split(',').map((t)=>t.trim()) || [];
|
|
328
|
+
if (!subjectId) throw new HTTPException(400, {
|
|
329
|
+
message: 'Subject ID is required',
|
|
330
|
+
cause: {
|
|
331
|
+
code: 'SUBJECT_ID_REQUIRED'
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
logger.debug('Request parameters', {
|
|
335
|
+
subjectId,
|
|
336
|
+
typeFilter
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
339
|
+
const subject = await db.findFirst('subject', {
|
|
340
|
+
where: (b)=>b('id', '=', subjectId)
|
|
341
|
+
});
|
|
342
|
+
if (!subject) throw new HTTPException(404, {
|
|
343
|
+
message: 'Subject not found',
|
|
344
|
+
cause: {
|
|
345
|
+
code: 'SUBJECT_NOT_FOUND',
|
|
346
|
+
subjectId
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
const consents = await db.findMany('consent', {
|
|
350
|
+
where: (b)=>b('subjectId', '=', subjectId)
|
|
351
|
+
});
|
|
352
|
+
const consentItems = await enrichConsents(consents, {
|
|
353
|
+
db,
|
|
354
|
+
registry
|
|
355
|
+
});
|
|
356
|
+
const filteredConsents = typeFilter.length > 0 ? consentItems.filter((consent)=>typeFilter.includes(consent.type)) : consentItems;
|
|
357
|
+
const isValid = 0 === typeFilter.length || typeFilter.every((t)=>filteredConsents.some((consent)=>consent.type === t && consent.isLatestPolicy));
|
|
358
|
+
return c.json({
|
|
359
|
+
subject: {
|
|
360
|
+
id: subject.id,
|
|
361
|
+
externalId: subject.externalId ?? void 0,
|
|
362
|
+
createdAt: subject.createdAt
|
|
363
|
+
},
|
|
364
|
+
consents: filteredConsents,
|
|
365
|
+
isValid
|
|
366
|
+
});
|
|
367
|
+
} catch (error) {
|
|
368
|
+
logger.error('Error in GET /subjects/:id handler', {
|
|
369
|
+
error: extractErrorMessage(error),
|
|
370
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error
|
|
371
|
+
});
|
|
372
|
+
if (error instanceof HTTPException) throw error;
|
|
373
|
+
throw new HTTPException(500, {
|
|
374
|
+
message: 'Internal server error',
|
|
375
|
+
cause: {
|
|
376
|
+
code: 'INTERNAL_SERVER_ERROR'
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const listSubjectsHandler = async (c)=>{
|
|
382
|
+
const ctx = c.get('c15tContext');
|
|
383
|
+
const logger = ctx.logger;
|
|
384
|
+
logger.info('Handling GET /subjects request');
|
|
385
|
+
const { db, registry } = ctx;
|
|
386
|
+
if (!ctx.apiKeyAuthenticated) throw new HTTPException(401, {
|
|
387
|
+
message: 'API key required. Use Authorization: Bearer <api_key>',
|
|
388
|
+
cause: {
|
|
389
|
+
code: 'UNAUTHORIZED'
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
const externalId = c.req.query('externalId');
|
|
393
|
+
if (!externalId) throw new HTTPException(422, {
|
|
394
|
+
message: 'externalId query parameter is required',
|
|
395
|
+
cause: {
|
|
396
|
+
code: 'EXTERNAL_ID_REQUIRED'
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
logger.debug('Request parameters', {
|
|
400
|
+
externalId
|
|
401
|
+
});
|
|
402
|
+
try {
|
|
403
|
+
const subjects = await db.findMany('subject', {
|
|
404
|
+
where: (b)=>b('externalId', '=', externalId)
|
|
405
|
+
});
|
|
406
|
+
const subjectItems = await Promise.all(subjects.map(async (subject)=>{
|
|
407
|
+
const consents = await db.findMany('consent', {
|
|
408
|
+
where: (b)=>b('subjectId', '=', subject.id)
|
|
409
|
+
});
|
|
410
|
+
const consentItems = await enrichConsents(consents, {
|
|
411
|
+
db,
|
|
412
|
+
registry
|
|
413
|
+
});
|
|
414
|
+
return {
|
|
415
|
+
id: subject.id,
|
|
416
|
+
externalId: subject.externalId ?? externalId,
|
|
417
|
+
createdAt: subject.createdAt,
|
|
418
|
+
consents: consentItems
|
|
419
|
+
};
|
|
420
|
+
}));
|
|
421
|
+
logger.info('Found subjects for externalId', {
|
|
422
|
+
externalId,
|
|
423
|
+
count: subjectItems.length
|
|
424
|
+
});
|
|
425
|
+
return c.json({
|
|
426
|
+
subjects: subjectItems
|
|
427
|
+
});
|
|
428
|
+
} catch (error) {
|
|
429
|
+
logger.error('Error in GET /subjects handler', {
|
|
430
|
+
error: extractErrorMessage(error),
|
|
431
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error
|
|
432
|
+
});
|
|
433
|
+
if (error instanceof HTTPException) throw error;
|
|
434
|
+
throw new HTTPException(500, {
|
|
435
|
+
message: 'Internal server error',
|
|
436
|
+
cause: {
|
|
437
|
+
code: 'INTERNAL_SERVER_ERROR'
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const prefixes = {
|
|
443
|
+
auditLog: 'log',
|
|
444
|
+
consent: 'cns',
|
|
445
|
+
consentPolicy: 'pol',
|
|
446
|
+
consentPurpose: 'pur',
|
|
447
|
+
domain: 'dom',
|
|
448
|
+
subject: 'sub'
|
|
449
|
+
};
|
|
450
|
+
const b58 = base_x('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
|
|
451
|
+
function generateId(model) {
|
|
452
|
+
const buf = crypto.getRandomValues(new Uint8Array(20));
|
|
453
|
+
const prefix = prefixes[model];
|
|
454
|
+
const EPOCH_TIMESTAMP = 1700000000000;
|
|
455
|
+
const t = Date.now() - EPOCH_TIMESTAMP;
|
|
456
|
+
const high = Math.floor(t / 0x100000000);
|
|
457
|
+
const low = t >>> 0;
|
|
458
|
+
buf[0] = high >>> 24 & 255;
|
|
459
|
+
buf[1] = high >>> 16 & 255;
|
|
460
|
+
buf[2] = high >>> 8 & 255;
|
|
461
|
+
buf[3] = 255 & high;
|
|
462
|
+
buf[4] = low >>> 24 & 255;
|
|
463
|
+
buf[5] = low >>> 16 & 255;
|
|
464
|
+
buf[6] = low >>> 8 & 255;
|
|
465
|
+
buf[7] = 255 & low;
|
|
466
|
+
return `${prefix}_${b58.encode(buf)}`;
|
|
467
|
+
}
|
|
468
|
+
async function generateUniqueId(db, model, ctx, options = {}) {
|
|
469
|
+
const { maxRetries = 10, attempt = 0, baseDelay = 5 } = options;
|
|
470
|
+
if (attempt >= maxRetries) {
|
|
471
|
+
const error = new Error(`Failed to generate unique ID for ${model} after ${maxRetries} attempts`);
|
|
472
|
+
ctx?.logger?.error?.('ID generation failed', {
|
|
473
|
+
model,
|
|
474
|
+
maxRetries
|
|
475
|
+
});
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
const id = generateId(model);
|
|
479
|
+
try {
|
|
480
|
+
const existing = await db.findFirst(model, {
|
|
481
|
+
where: (b)=>b('id', '=', id)
|
|
482
|
+
});
|
|
483
|
+
if (existing) {
|
|
484
|
+
ctx?.logger?.debug?.('ID conflict detected', {
|
|
485
|
+
id,
|
|
486
|
+
model,
|
|
487
|
+
attempt: attempt + 1,
|
|
488
|
+
maxRetries
|
|
489
|
+
});
|
|
490
|
+
const delay = Math.min(baseDelay * 2 ** attempt, 1000);
|
|
491
|
+
await new Promise((resolve)=>setTimeout(resolve, delay));
|
|
492
|
+
return generateUniqueId(db, model, ctx, {
|
|
493
|
+
maxRetries,
|
|
494
|
+
attempt: attempt + 1,
|
|
495
|
+
baseDelay
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
return id;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
ctx?.logger?.error?.('Error checking ID uniqueness', {
|
|
501
|
+
error: error.message,
|
|
502
|
+
model,
|
|
503
|
+
attempt
|
|
504
|
+
});
|
|
505
|
+
if (attempt < maxRetries - 1) {
|
|
506
|
+
const delay = Math.min(baseDelay * 2 ** attempt, 2000);
|
|
507
|
+
await new Promise((resolve)=>setTimeout(resolve, delay));
|
|
508
|
+
return generateUniqueId(db, model, ctx, {
|
|
509
|
+
maxRetries,
|
|
510
|
+
attempt: attempt + 1,
|
|
511
|
+
baseDelay
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const patchSubjectHandler = async (c)=>{
|
|
518
|
+
const ctx = c.get('c15tContext');
|
|
519
|
+
const logger = ctx.logger;
|
|
520
|
+
logger.info('Handling PATCH /subjects/:id request');
|
|
521
|
+
const { db } = ctx;
|
|
522
|
+
const subjectId = c.req.param('id');
|
|
523
|
+
const body = await c.req.json();
|
|
524
|
+
const { externalId, identityProvider = 'external' } = body;
|
|
525
|
+
if (!subjectId) throw new HTTPException(400, {
|
|
526
|
+
message: 'Subject ID is required',
|
|
527
|
+
cause: {
|
|
528
|
+
code: 'SUBJECT_ID_REQUIRED'
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
logger.debug('Request parameters', {
|
|
532
|
+
subjectId,
|
|
533
|
+
externalId,
|
|
534
|
+
identityProvider
|
|
535
|
+
});
|
|
536
|
+
try {
|
|
537
|
+
const subject = await db.findFirst('subject', {
|
|
538
|
+
where: (b)=>b('id', '=', subjectId)
|
|
539
|
+
});
|
|
540
|
+
if (!subject) throw new HTTPException(404, {
|
|
541
|
+
message: 'Subject not found',
|
|
542
|
+
cause: {
|
|
543
|
+
code: 'SUBJECT_NOT_FOUND',
|
|
544
|
+
subjectId
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
await db.transaction(async (tx)=>{
|
|
548
|
+
await tx.updateMany('subject', {
|
|
549
|
+
where: (b)=>b('id', '=', subjectId),
|
|
550
|
+
set: {
|
|
551
|
+
externalId,
|
|
552
|
+
identityProvider,
|
|
553
|
+
updatedAt: new Date()
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
await tx.create('auditLog', {
|
|
557
|
+
id: await generateUniqueId(tx, 'auditLog', ctx),
|
|
558
|
+
subjectId,
|
|
559
|
+
entityType: 'subject',
|
|
560
|
+
entityId: subjectId,
|
|
561
|
+
actionType: 'identify_user',
|
|
562
|
+
ipAddress: ctx.ipAddress || null,
|
|
563
|
+
userAgent: ctx.userAgent || null,
|
|
564
|
+
changes: {
|
|
565
|
+
externalId: {
|
|
566
|
+
from: subject.externalId,
|
|
567
|
+
to: externalId
|
|
568
|
+
},
|
|
569
|
+
identityProvider: {
|
|
570
|
+
from: subject.identityProvider,
|
|
571
|
+
to: identityProvider
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
metadata: {
|
|
575
|
+
externalId,
|
|
576
|
+
identityProvider
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
logger.info('Subject linked to external ID', {
|
|
581
|
+
subjectId,
|
|
582
|
+
externalId,
|
|
583
|
+
identityProvider
|
|
584
|
+
});
|
|
585
|
+
getMetrics()?.recordSubjectLinked(identityProvider);
|
|
586
|
+
return c.json({
|
|
587
|
+
success: true,
|
|
588
|
+
subject: {
|
|
589
|
+
id: subjectId,
|
|
590
|
+
externalId
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
} catch (error) {
|
|
594
|
+
logger.error('Error in PATCH /subjects/:id handler', {
|
|
595
|
+
error: extractErrorMessage(error),
|
|
596
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error
|
|
597
|
+
});
|
|
598
|
+
if (error instanceof HTTPException) throw error;
|
|
599
|
+
throw new HTTPException(500, {
|
|
600
|
+
message: 'Internal server error',
|
|
601
|
+
cause: {
|
|
602
|
+
code: 'INTERNAL_SERVER_ERROR'
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
function buildRuntimeDecisionDedupeKey(input) {
|
|
608
|
+
return [
|
|
609
|
+
input.tenantId ?? 'default',
|
|
610
|
+
input.fingerprint,
|
|
611
|
+
input.matchedBy,
|
|
612
|
+
input.countryCode ?? 'none',
|
|
613
|
+
input.regionCode ?? 'none',
|
|
614
|
+
input.jurisdiction,
|
|
615
|
+
input.language ?? 'none'
|
|
616
|
+
].join('|');
|
|
617
|
+
}
|
|
618
|
+
function buildDecisionPayload(params) {
|
|
619
|
+
const { tenantId, snapshot, decision, location, jurisdiction, language, proofConfig } = params;
|
|
620
|
+
if (snapshot?.valid && snapshot.payload) {
|
|
621
|
+
const sp = snapshot.payload;
|
|
622
|
+
return {
|
|
623
|
+
tenantId,
|
|
624
|
+
policyId: sp.policyId,
|
|
625
|
+
fingerprint: sp.fingerprint,
|
|
626
|
+
matchedBy: sp.matchedBy,
|
|
627
|
+
countryCode: sp.country,
|
|
628
|
+
regionCode: sp.region,
|
|
629
|
+
jurisdiction: sp.jurisdiction,
|
|
630
|
+
language: sp.language,
|
|
631
|
+
model: sp.model,
|
|
632
|
+
policyI18n: sp.policyI18n,
|
|
633
|
+
uiMode: sp.uiMode,
|
|
634
|
+
bannerUi: sp.bannerUi,
|
|
635
|
+
dialogUi: sp.dialogUi,
|
|
636
|
+
categories: sp.categories,
|
|
637
|
+
preselectedCategories: sp.preselectedCategories,
|
|
638
|
+
proofConfig: sp.proofConfig,
|
|
639
|
+
dedupeKey: buildRuntimeDecisionDedupeKey({
|
|
640
|
+
tenantId,
|
|
641
|
+
fingerprint: sp.fingerprint,
|
|
642
|
+
matchedBy: sp.matchedBy,
|
|
643
|
+
countryCode: sp.country,
|
|
644
|
+
regionCode: sp.region,
|
|
645
|
+
jurisdiction: sp.jurisdiction,
|
|
646
|
+
language: sp.language
|
|
647
|
+
}),
|
|
648
|
+
source: 'snapshot_token'
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
if (decision) return {
|
|
652
|
+
tenantId,
|
|
653
|
+
policyId: decision.policy.id,
|
|
654
|
+
fingerprint: decision.fingerprint,
|
|
655
|
+
matchedBy: decision.matchedBy,
|
|
656
|
+
countryCode: location.countryCode,
|
|
657
|
+
regionCode: location.regionCode,
|
|
658
|
+
jurisdiction,
|
|
659
|
+
language,
|
|
660
|
+
model: decision.policy.model,
|
|
661
|
+
policyI18n: decision.policy.i18n,
|
|
662
|
+
uiMode: decision.policy.ui?.mode,
|
|
663
|
+
bannerUi: decision.policy.ui?.banner,
|
|
664
|
+
dialogUi: decision.policy.ui?.dialog,
|
|
665
|
+
categories: decision.policy.consent?.categories,
|
|
666
|
+
preselectedCategories: decision.policy.consent?.preselectedCategories,
|
|
667
|
+
proofConfig,
|
|
668
|
+
dedupeKey: buildRuntimeDecisionDedupeKey({
|
|
669
|
+
tenantId,
|
|
670
|
+
fingerprint: decision.fingerprint,
|
|
671
|
+
matchedBy: decision.matchedBy,
|
|
672
|
+
countryCode: location.countryCode,
|
|
673
|
+
regionCode: location.regionCode,
|
|
674
|
+
jurisdiction,
|
|
675
|
+
language
|
|
676
|
+
}),
|
|
677
|
+
source: 'write_time_fallback'
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function parseLanguageFromHeader(header) {
|
|
681
|
+
if (!header) return;
|
|
682
|
+
const firstLanguage = header.split(',')[0]?.split(';')[0]?.trim();
|
|
683
|
+
if (!firstLanguage) return;
|
|
684
|
+
return firstLanguage.split('-')[0]?.toLowerCase();
|
|
685
|
+
}
|
|
686
|
+
function resolveSnapshotFailureMode(ctx) {
|
|
687
|
+
return ctx.policySnapshot?.onValidationFailure ?? 'reject';
|
|
688
|
+
}
|
|
689
|
+
function buildSnapshotHttpException(reason) {
|
|
690
|
+
switch(reason){
|
|
691
|
+
case 'missing':
|
|
692
|
+
return new HTTPException(409, {
|
|
693
|
+
message: 'Policy snapshot token is required',
|
|
694
|
+
cause: {
|
|
695
|
+
code: 'POLICY_SNAPSHOT_REQUIRED'
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
case 'expired':
|
|
699
|
+
return new HTTPException(409, {
|
|
700
|
+
message: 'Policy snapshot token has expired',
|
|
701
|
+
cause: {
|
|
702
|
+
code: 'POLICY_SNAPSHOT_EXPIRED'
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
case 'malformed':
|
|
706
|
+
case 'invalid':
|
|
707
|
+
return new HTTPException(409, {
|
|
708
|
+
message: 'Policy snapshot token is invalid',
|
|
709
|
+
cause: {
|
|
710
|
+
code: 'POLICY_SNAPSHOT_INVALID'
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
default:
|
|
714
|
+
{
|
|
715
|
+
const _exhaustive = reason;
|
|
716
|
+
throw new Error(`Unhandled policy snapshot verification failure reason: ${_exhaustive}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const postSubjectHandler = async (c)=>{
|
|
721
|
+
const ctx = c.get('c15tContext');
|
|
722
|
+
const logger = ctx.logger;
|
|
723
|
+
logger.info('Handling POST /subjects request');
|
|
724
|
+
const { db, registry } = ctx;
|
|
725
|
+
const input = await c.req.json();
|
|
726
|
+
const { type, subjectId, identityProvider, externalSubjectId, domain, metadata, givenAt: givenAtEpoch } = input;
|
|
727
|
+
const preferences = 'preferences' in input ? input.preferences : void 0;
|
|
728
|
+
const givenAt = new Date(givenAtEpoch);
|
|
729
|
+
const rawConsentAction = 'consentAction' in input ? input.consentAction : void 0;
|
|
730
|
+
let derivedConsentAction;
|
|
731
|
+
logger.debug('Request parameters', {
|
|
732
|
+
type,
|
|
733
|
+
subjectId,
|
|
734
|
+
identityProvider,
|
|
735
|
+
externalSubjectId,
|
|
736
|
+
domain
|
|
737
|
+
});
|
|
738
|
+
try {
|
|
739
|
+
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.');
|
|
740
|
+
const request = c.req.raw ?? new Request('https://c15t.local/subjects');
|
|
741
|
+
const acceptLanguage = request.headers.get('accept-language');
|
|
742
|
+
const requestLanguage = parseLanguageFromHeader(acceptLanguage);
|
|
743
|
+
const location = await getLocation(request, ctx);
|
|
744
|
+
const resolvedJurisdiction = getJurisdiction(location, ctx);
|
|
745
|
+
const snapshotVerification = await verifyPolicySnapshotToken({
|
|
746
|
+
token: input.policySnapshotToken,
|
|
747
|
+
options: ctx.policySnapshot,
|
|
748
|
+
tenantId: ctx.tenantId
|
|
749
|
+
});
|
|
750
|
+
const hasValidSnapshot = snapshotVerification.valid;
|
|
751
|
+
const snapshotPayload = snapshotVerification.valid ? snapshotVerification.payload : null;
|
|
752
|
+
const shouldRequireSnapshot = !!ctx.policySnapshot?.signingKey && 'reject' === resolveSnapshotFailureMode(ctx);
|
|
753
|
+
if (!hasValidSnapshot && shouldRequireSnapshot) throw buildSnapshotHttpException(snapshotVerification.reason);
|
|
754
|
+
const resolvedPolicyDecision = hasValidSnapshot ? void 0 : await policy_resolvePolicyDecision({
|
|
755
|
+
policies: ctx.policyPacks,
|
|
756
|
+
countryCode: location.countryCode,
|
|
757
|
+
regionCode: location.regionCode,
|
|
758
|
+
jurisdiction: resolvedJurisdiction,
|
|
759
|
+
iabEnabled: ctx.iab?.enabled === true
|
|
760
|
+
});
|
|
761
|
+
const effectivePolicy = hasValidSnapshot && snapshotPayload ? {
|
|
762
|
+
id: snapshotPayload.policyId,
|
|
763
|
+
model: snapshotPayload.model,
|
|
764
|
+
i18n: snapshotPayload.policyI18n,
|
|
765
|
+
consent: {
|
|
766
|
+
expiryDays: snapshotPayload.expiryDays,
|
|
767
|
+
scopeMode: snapshotPayload.scopeMode,
|
|
768
|
+
categories: snapshotPayload.categories,
|
|
769
|
+
preselectedCategories: snapshotPayload.preselectedCategories,
|
|
770
|
+
gpc: snapshotPayload.gpc
|
|
771
|
+
},
|
|
772
|
+
ui: {
|
|
773
|
+
mode: snapshotPayload.uiMode,
|
|
774
|
+
banner: snapshotPayload.bannerUi,
|
|
775
|
+
dialog: snapshotPayload.dialogUi
|
|
776
|
+
},
|
|
777
|
+
proof: snapshotPayload.proofConfig
|
|
778
|
+
} : resolvedPolicyDecision?.policy;
|
|
779
|
+
const effectiveModel = effectivePolicy?.model ?? ('opt-in' === input.jurisdictionModel || 'opt-out' === input.jurisdictionModel || 'iab' === input.jurisdictionModel ? input.jurisdictionModel : void 0);
|
|
780
|
+
if ('all' === rawConsentAction) derivedConsentAction = 'accept_all';
|
|
781
|
+
else if ('necessary' === rawConsentAction) derivedConsentAction = 'opt-out' === effectiveModel ? 'opt_out' : 'reject_all';
|
|
782
|
+
else if ('custom' === rawConsentAction) derivedConsentAction = 'custom';
|
|
783
|
+
const subject = await registry.findOrCreateSubject({
|
|
784
|
+
subjectId,
|
|
785
|
+
externalSubjectId,
|
|
786
|
+
identityProvider,
|
|
787
|
+
ipAddress: ctx.ipAddress
|
|
788
|
+
});
|
|
789
|
+
if (!subject) throw new HTTPException(500, {
|
|
790
|
+
message: 'Failed to create subject',
|
|
791
|
+
cause: {
|
|
792
|
+
code: 'SUBJECT_CREATION_FAILED',
|
|
793
|
+
subjectId
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
logger.debug('Subject found/created', {
|
|
797
|
+
subjectId: subject.id
|
|
798
|
+
});
|
|
799
|
+
const domainRecord = await registry.findOrCreateDomain(domain);
|
|
800
|
+
if (!domainRecord) throw new HTTPException(500, {
|
|
801
|
+
message: 'Failed to create domain',
|
|
802
|
+
cause: {
|
|
803
|
+
code: 'DOMAIN_CREATION_FAILED',
|
|
804
|
+
domain
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
let policyId;
|
|
808
|
+
let purposeIds = [];
|
|
809
|
+
let appliedPreferences;
|
|
810
|
+
const inputPolicyId = 'policyId' in input ? input.policyId : void 0;
|
|
811
|
+
if (inputPolicyId) {
|
|
812
|
+
policyId = inputPolicyId;
|
|
813
|
+
const policy = await registry.findConsentPolicyById(inputPolicyId);
|
|
814
|
+
if (!policy) throw new HTTPException(404, {
|
|
815
|
+
message: 'Policy not found',
|
|
816
|
+
cause: {
|
|
817
|
+
code: 'POLICY_NOT_FOUND',
|
|
818
|
+
policyId,
|
|
819
|
+
type
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
if (!policy.isActive) throw new HTTPException(400, {
|
|
823
|
+
message: 'Policy is inactive',
|
|
824
|
+
cause: {
|
|
825
|
+
code: 'POLICY_INACTIVE',
|
|
826
|
+
policyId,
|
|
827
|
+
type
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
} else {
|
|
831
|
+
const policy = await registry.findOrCreatePolicy(type);
|
|
832
|
+
if (!policy) throw new HTTPException(500, {
|
|
833
|
+
message: 'Failed to create policy',
|
|
834
|
+
cause: {
|
|
835
|
+
code: 'POLICY_CREATION_FAILED',
|
|
836
|
+
type
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
policyId = policy.id;
|
|
840
|
+
}
|
|
841
|
+
if (preferences) {
|
|
842
|
+
const allowedCategories = effectivePolicy?.consent?.categories;
|
|
843
|
+
const effectiveScopeMode = effectivePolicy?.consent?.scopeMode ?? 'permissive';
|
|
844
|
+
const hasWildcardCategoryScope = allowedCategories?.includes('*') === true;
|
|
845
|
+
const appliedPreferenceEntries = Object.entries(preferences);
|
|
846
|
+
let filteredAppliedPreferenceEntries = appliedPreferenceEntries;
|
|
847
|
+
if (allowedCategories && allowedCategories.length > 0 && !hasWildcardCategoryScope) {
|
|
848
|
+
const disallowed = appliedPreferenceEntries.map(([purpose])=>purpose).filter((purpose)=>!allowedCategories.includes(purpose));
|
|
849
|
+
filteredAppliedPreferenceEntries = appliedPreferenceEntries.filter(([purpose])=>allowedCategories.includes(purpose));
|
|
850
|
+
if (disallowed.length > 0 && 'strict' === effectiveScopeMode) throw new HTTPException(400, {
|
|
851
|
+
message: 'Preferences include categories not allowed by policy',
|
|
852
|
+
cause: {
|
|
853
|
+
code: 'PURPOSE_NOT_ALLOWED',
|
|
854
|
+
disallowed
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
appliedPreferences = Object.fromEntries(filteredAppliedPreferenceEntries);
|
|
859
|
+
const filteredConsentedPurposeCodes = filteredAppliedPreferenceEntries.filter(([_, isConsented])=>isConsented).map(([purposeCode])=>purposeCode);
|
|
860
|
+
logger.debug('Consented purposes', {
|
|
861
|
+
consentedPurposes: filteredConsentedPurposeCodes
|
|
862
|
+
});
|
|
863
|
+
const purposesRaw = await Promise.all(filteredConsentedPurposeCodes.map((purposeCode)=>registry.findOrCreateConsentPurposeByCode(purposeCode)));
|
|
864
|
+
const purposes = purposesRaw.map((purpose)=>purpose?.id ?? null).filter((id)=>Boolean(id));
|
|
865
|
+
logger.debug('Filtered purposes', {
|
|
866
|
+
purposes
|
|
867
|
+
});
|
|
868
|
+
if (0 === purposes.length) logger.warn('No valid purpose IDs found after filtering. Using empty list.', {
|
|
869
|
+
consentedPurposes: filteredConsentedPurposeCodes
|
|
870
|
+
});
|
|
871
|
+
purposeIds = purposes;
|
|
872
|
+
}
|
|
873
|
+
const expiryDays = effectivePolicy?.consent?.expiryDays;
|
|
874
|
+
const validUntil = 'number' == typeof expiryDays && Number.isFinite(expiryDays) ? new Date(givenAt.getTime() + 86400000 * Math.max(0, expiryDays)) : void 0;
|
|
875
|
+
const proofConfig = effectivePolicy?.proof;
|
|
876
|
+
const shouldStoreIp = proofConfig?.storeIp ?? true;
|
|
877
|
+
const shouldStoreUserAgent = proofConfig?.storeUserAgent ?? true;
|
|
878
|
+
const shouldStoreLanguage = proofConfig?.storeLanguage ?? false;
|
|
879
|
+
const effectiveLanguage = (snapshotPayload?.language && hasValidSnapshot ? snapshotPayload.language : requestLanguage) ?? void 0;
|
|
880
|
+
const metadataWithPolicy = {
|
|
881
|
+
...metadata ?? {},
|
|
882
|
+
...shouldStoreLanguage && effectiveLanguage ? {
|
|
883
|
+
policyLanguage: effectiveLanguage
|
|
884
|
+
} : {}
|
|
885
|
+
};
|
|
886
|
+
const effectiveJurisdiction = hasValidSnapshot && snapshotPayload ? snapshotPayload.jurisdiction : resolvedJurisdiction;
|
|
887
|
+
const decisionPayload = buildDecisionPayload({
|
|
888
|
+
tenantId: ctx.tenantId,
|
|
889
|
+
snapshot: hasValidSnapshot && snapshotPayload ? {
|
|
890
|
+
valid: true,
|
|
891
|
+
payload: snapshotPayload
|
|
892
|
+
} : null,
|
|
893
|
+
decision: resolvedPolicyDecision,
|
|
894
|
+
location: {
|
|
895
|
+
countryCode: location.countryCode,
|
|
896
|
+
regionCode: location.regionCode
|
|
897
|
+
},
|
|
898
|
+
jurisdiction: resolvedJurisdiction,
|
|
899
|
+
language: effectiveLanguage,
|
|
900
|
+
proofConfig
|
|
901
|
+
});
|
|
902
|
+
const existingConsent = await db.findFirst('consent', {
|
|
903
|
+
where: (b)=>b.and(b('subjectId', '=', subject.id), b('domainId', '=', domainRecord.id), b('policyId', '=', policyId), b('givenAt', '=', givenAt))
|
|
904
|
+
});
|
|
905
|
+
if (existingConsent) {
|
|
906
|
+
logger.debug('Duplicate consent detected, returning existing record', {
|
|
907
|
+
consentId: existingConsent.id
|
|
908
|
+
});
|
|
909
|
+
return c.json({
|
|
910
|
+
subjectId: subject.id,
|
|
911
|
+
consentId: existingConsent.id,
|
|
912
|
+
domainId: domainRecord.id,
|
|
913
|
+
domain: domainRecord.name,
|
|
914
|
+
type,
|
|
915
|
+
metadata,
|
|
916
|
+
appliedPreferences,
|
|
917
|
+
uiSource: input.uiSource,
|
|
918
|
+
givenAt: existingConsent.givenAt
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
const result = await db.transaction(async (tx)=>{
|
|
922
|
+
logger.debug('Creating consent record', {
|
|
923
|
+
subjectId: subject.id,
|
|
924
|
+
domainId: domainRecord.id,
|
|
925
|
+
policyId,
|
|
926
|
+
purposeIds
|
|
927
|
+
});
|
|
928
|
+
const runtimePolicyDecision = decisionPayload ? await tx.findFirst('runtimePolicyDecision', {
|
|
929
|
+
where: (b)=>b('dedupeKey', '=', decisionPayload.dedupeKey)
|
|
930
|
+
}) ?? await tx.create('runtimePolicyDecision', {
|
|
931
|
+
id: `rpd_${crypto.randomUUID().replaceAll('-', '')}`,
|
|
932
|
+
tenantId: decisionPayload.tenantId,
|
|
933
|
+
policyId: decisionPayload.policyId,
|
|
934
|
+
fingerprint: decisionPayload.fingerprint,
|
|
935
|
+
matchedBy: decisionPayload.matchedBy,
|
|
936
|
+
countryCode: decisionPayload.countryCode,
|
|
937
|
+
regionCode: decisionPayload.regionCode,
|
|
938
|
+
jurisdiction: decisionPayload.jurisdiction,
|
|
939
|
+
language: decisionPayload.language,
|
|
940
|
+
model: decisionPayload.model,
|
|
941
|
+
policyI18n: decisionPayload.policyI18n ? {
|
|
942
|
+
json: decisionPayload.policyI18n
|
|
943
|
+
} : void 0,
|
|
944
|
+
uiMode: decisionPayload.uiMode,
|
|
945
|
+
bannerUi: decisionPayload.bannerUi ? {
|
|
946
|
+
json: decisionPayload.bannerUi
|
|
947
|
+
} : void 0,
|
|
948
|
+
dialogUi: decisionPayload.dialogUi ? {
|
|
949
|
+
json: decisionPayload.dialogUi
|
|
950
|
+
} : void 0,
|
|
951
|
+
categories: decisionPayload.categories ? {
|
|
952
|
+
json: decisionPayload.categories
|
|
953
|
+
} : void 0,
|
|
954
|
+
preselectedCategories: decisionPayload.preselectedCategories ? {
|
|
955
|
+
json: decisionPayload.preselectedCategories
|
|
956
|
+
} : void 0,
|
|
957
|
+
proofConfig: decisionPayload.proofConfig ? {
|
|
958
|
+
json: decisionPayload.proofConfig
|
|
959
|
+
} : void 0,
|
|
960
|
+
dedupeKey: decisionPayload.dedupeKey
|
|
961
|
+
}).catch(async ()=>tx.findFirst('runtimePolicyDecision', {
|
|
962
|
+
where: (b)=>b('dedupeKey', '=', decisionPayload.dedupeKey)
|
|
963
|
+
})) : void 0;
|
|
964
|
+
const consentRecord = await tx.create('consent', {
|
|
965
|
+
id: await generateUniqueId(tx, 'consent', ctx),
|
|
966
|
+
subjectId: subject.id,
|
|
967
|
+
domainId: domainRecord.id,
|
|
968
|
+
policyId,
|
|
969
|
+
purposeIds: {
|
|
970
|
+
json: purposeIds
|
|
971
|
+
},
|
|
972
|
+
metadata: Object.keys(metadataWithPolicy).length > 0 ? {
|
|
973
|
+
json: metadataWithPolicy
|
|
974
|
+
} : void 0,
|
|
975
|
+
ipAddress: shouldStoreIp ? ctx.ipAddress : null,
|
|
976
|
+
userAgent: shouldStoreUserAgent ? ctx.userAgent : null,
|
|
977
|
+
jurisdiction: effectiveJurisdiction,
|
|
978
|
+
jurisdictionModel: effectiveModel,
|
|
979
|
+
tcString: input.tcString,
|
|
980
|
+
uiSource: input.uiSource,
|
|
981
|
+
consentAction: derivedConsentAction,
|
|
982
|
+
givenAt,
|
|
983
|
+
validUntil,
|
|
984
|
+
runtimePolicyDecisionId: runtimePolicyDecision?.id,
|
|
985
|
+
runtimePolicySource: decisionPayload?.source
|
|
986
|
+
});
|
|
987
|
+
logger.debug('Created consent', {
|
|
988
|
+
consentRecord: consentRecord.id
|
|
989
|
+
});
|
|
990
|
+
if (!consentRecord) throw new HTTPException(500, {
|
|
991
|
+
message: 'Failed to create consent',
|
|
992
|
+
cause: {
|
|
993
|
+
code: 'CONSENT_CREATION_FAILED',
|
|
994
|
+
subjectId: subject.id,
|
|
995
|
+
domain
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
return {
|
|
999
|
+
consent: consentRecord
|
|
1000
|
+
};
|
|
1001
|
+
});
|
|
1002
|
+
const metrics = getMetrics();
|
|
1003
|
+
if (metrics) {
|
|
1004
|
+
const jurisdiction = effectiveJurisdiction;
|
|
1005
|
+
metrics.recordConsentCreated({
|
|
1006
|
+
type,
|
|
1007
|
+
jurisdiction
|
|
1008
|
+
});
|
|
1009
|
+
const hasAccepted = preferences && Object.values(preferences).some(Boolean);
|
|
1010
|
+
if (hasAccepted) metrics.recordConsentAccepted({
|
|
1011
|
+
type,
|
|
1012
|
+
jurisdiction
|
|
1013
|
+
});
|
|
1014
|
+
else metrics.recordConsentRejected({
|
|
1015
|
+
type,
|
|
1016
|
+
jurisdiction
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
return c.json({
|
|
1020
|
+
subjectId: subject.id,
|
|
1021
|
+
consentId: result.consent.id,
|
|
1022
|
+
domainId: domainRecord.id,
|
|
1023
|
+
domain: domainRecord.name,
|
|
1024
|
+
type,
|
|
1025
|
+
metadata,
|
|
1026
|
+
appliedPreferences,
|
|
1027
|
+
uiSource: input.uiSource,
|
|
1028
|
+
givenAt: result.consent.givenAt
|
|
1029
|
+
});
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
logger.error('Error in POST /subjects handler', {
|
|
1032
|
+
error: extractErrorMessage(error),
|
|
1033
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error
|
|
1034
|
+
});
|
|
1035
|
+
if (error instanceof HTTPException) throw error;
|
|
1036
|
+
throw new HTTPException(500, {
|
|
1037
|
+
message: 'Internal server error',
|
|
1038
|
+
cause: {
|
|
1039
|
+
code: 'INTERNAL_SERVER_ERROR'
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
const createSubjectRoutes = ()=>{
|
|
1045
|
+
const app = new Hono();
|
|
1046
|
+
app.get('/:id', describeRoute({
|
|
1047
|
+
summary: 'Get subject consent status',
|
|
1048
|
+
description: "Returns the subject's consent status for this device. Use to check if the subject has valid consent for given policy types.\n\n**Query:** `type` – Filter by consent type(s), comma-separated (e.g. `privacy_policy,cookie_banner`).\n\n**Response:** `subject`, `consents` (matching filter), `isValid` (valid consent for requested type(s)).",
|
|
1049
|
+
tags: [
|
|
1050
|
+
'Subject',
|
|
1051
|
+
'Consent'
|
|
1052
|
+
],
|
|
1053
|
+
responses: {
|
|
1054
|
+
200: {
|
|
1055
|
+
description: 'Subject and consent records for the requested type(s)',
|
|
1056
|
+
content: {
|
|
1057
|
+
'application/json': {
|
|
1058
|
+
schema: resolver(getSubjectOutputSchema)
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
404: {
|
|
1063
|
+
description: 'Subject not found for the given ID'
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}), validator('param', getSubjectInputSchema), getSubjectHandler);
|
|
1067
|
+
app.post('/', describeRoute({
|
|
1068
|
+
summary: 'Record consent for a subject',
|
|
1069
|
+
description: "Creates a new consent record (append-only). Creates the subject if it does not exist.\n\n**Request body by `type`:**\n- `cookie_banner` – Requires `preferences` object\n- `privacy_policy`, `dpa`, `terms_and_conditions` – Optional `policyId`\n- `marketing_communications`, `age_verification`, `other` – Optional `preferences`",
|
|
1070
|
+
tags: [
|
|
1071
|
+
'Subject',
|
|
1072
|
+
'Consent'
|
|
1073
|
+
],
|
|
1074
|
+
responses: {
|
|
1075
|
+
200: {
|
|
1076
|
+
description: 'Consent recorded; subject and consent in response',
|
|
1077
|
+
content: {
|
|
1078
|
+
'application/json': {
|
|
1079
|
+
schema: resolver(postSubjectOutputSchema)
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
422: {
|
|
1084
|
+
description: 'Invalid request body (schema or validation failed)'
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}), validator('json', postSubjectInputSchema), postSubjectHandler);
|
|
1088
|
+
app.patch('/:id', describeRoute({
|
|
1089
|
+
summary: 'Link external ID to subject',
|
|
1090
|
+
description: 'Associates an external user ID with an existing subject (e.g. after login). Enables cross-device consent sync.',
|
|
1091
|
+
tags: [
|
|
1092
|
+
'Subject'
|
|
1093
|
+
],
|
|
1094
|
+
responses: {
|
|
1095
|
+
200: {
|
|
1096
|
+
description: 'Subject updated with external ID',
|
|
1097
|
+
content: {
|
|
1098
|
+
'application/json': {
|
|
1099
|
+
schema: resolver(patchSubjectOutputSchema)
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
404: {
|
|
1104
|
+
description: 'Subject not found for the given ID'
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}), validator('param', __rspack_external_valibot.object({
|
|
1108
|
+
id: subjectIdSchema
|
|
1109
|
+
})), validator('json', __rspack_external_valibot.object({
|
|
1110
|
+
externalId: __rspack_external_valibot.string(),
|
|
1111
|
+
identityProvider: __rspack_external_valibot.optional(__rspack_external_valibot.string())
|
|
1112
|
+
})), patchSubjectHandler);
|
|
1113
|
+
app.get('/', describeRoute({
|
|
1114
|
+
summary: 'List subjects by external ID (API key required)',
|
|
1115
|
+
description: 'Returns all subjects linked to the given external ID. Requires Bearer token (API key). Use for server-side consent lookups.',
|
|
1116
|
+
tags: [
|
|
1117
|
+
'Subject'
|
|
1118
|
+
],
|
|
1119
|
+
security: [
|
|
1120
|
+
{
|
|
1121
|
+
bearerAuth: []
|
|
1122
|
+
}
|
|
1123
|
+
],
|
|
1124
|
+
responses: {
|
|
1125
|
+
200: {
|
|
1126
|
+
description: 'List of subjects for the external ID',
|
|
1127
|
+
content: {
|
|
1128
|
+
'application/json': {
|
|
1129
|
+
schema: resolver(listSubjectsOutputSchema)
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
},
|
|
1133
|
+
401: {
|
|
1134
|
+
description: 'Missing or invalid API key'
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}), validator('query', listSubjectsQuerySchema), listSubjectsHandler);
|
|
1138
|
+
return app;
|
|
1139
|
+
};
|
|
1140
|
+
export { createConsentRoutes, createInitRoute, createStatusRoute, createSubjectRoutes };
|