@gradial/aci 0.1.52-rc.2 → 0.1.52-rc.21
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/CHANGELOG.md +7 -0
- package/README.md +4 -4
- package/dist/astro/preview.js +2 -17
- package/dist/audience-data-models.d.ts +289 -16
- package/dist/audience-data-models.js +99 -2
- package/dist/cli/compile-registry.d.mts +5 -1
- package/dist/cli/compile-registry.mjs +25 -10
- package/dist/cli/validate-content.d.mts +31 -1
- package/dist/cli/validate-content.mjs +50 -12
- package/dist/collect/aci.js +1 -1
- package/dist/collect/aci.js.sha256 +1 -1
- package/dist/collect/api.d.ts +3 -7
- package/dist/collect/api.js +2 -8
- package/dist/collect/batcher.d.ts +6 -5
- package/dist/collect/batcher.js +6 -5
- package/dist/collect/index.js +1 -1
- package/dist/content/provider.d.ts +1 -0
- package/dist/content/resolve-slots.d.ts +9 -2
- package/dist/content/resolve-slots.js +5 -3
- package/dist/content/types.d.ts +2 -0
- package/dist/edge/expression-capabilities.v1.d.ts +106 -0
- package/dist/edge/expression-capabilities.v1.js +165 -0
- package/dist/internal/domain/edge/compose.d.ts +24 -0
- package/dist/internal/domain/edge/compose.js +89 -0
- package/dist/internal/domain/edge/identity.js +1 -1
- package/dist/internal/domain/edge/index.d.ts +2 -0
- package/dist/internal/domain/edge/index.js +2 -0
- package/dist/internal/domain/edge/mimic_token.d.ts +13 -0
- package/dist/internal/domain/edge/mimic_token.js +58 -0
- package/dist/internal/domain/edge/personalization-core.d.ts +69 -5
- package/dist/internal/domain/edge/personalization-core.js +693 -58
- package/dist/internal/domain/edge/record/codec.d.ts +5 -2
- package/dist/internal/domain/edge/record/codec.js +85 -12
- package/dist/internal/domain/edge/resolve.js +38 -18
- package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
- package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
- package/dist/internal/domain/edge/types.d.ts +52 -9
- package/dist/internal/domain/edge/types.js +3 -3
- package/dist/mimic/core.d.ts +2 -0
- package/dist/mimic/core.js +31 -0
- package/dist/next/error-seat-payload.js +1 -1
- package/dist/next/hosted-page-target.d.ts +7 -0
- package/dist/next/hosted-page-target.js +39 -0
- package/dist/next/index.d.ts +0 -1
- package/dist/next/index.js +0 -1
- package/dist/next/page.d.ts +29 -6
- package/dist/next/page.js +47 -25
- package/dist/next/server.js +28 -1
- package/dist/next/vercel-edge-projection-v1.d.ts +0 -9
- package/dist/next/vercel-edge-projection-v1.js +9 -539
- package/dist/preview/core.d.ts +1 -0
- package/dist/preview/core.js +27 -0
- package/dist/providers/s3.js +4 -3
- package/dist/react/VideoPlayer.d.ts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/types/block-ref.d.ts +2 -0
- package/dist/types/block-ref.js +1 -0
- package/package.json +16 -7
- package/src/cli/compile-registry.mjs +24 -10
- package/src/cli/validate-content.mjs +52 -13
- package/src/types/block-ref.test.ts +45 -0
- package/src/types/block-ref.ts +2 -0
- package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
- package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
- package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
- package/templates/astro/template/package.json.tmpl +1 -1
- package/templates/astro/template/src/layouts/SiteLayout.astro +2 -2
- package/templates/common/.agents/skills/aci/SKILL.md +6 -0
- package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
- package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
- package/templates/common/.agents/skills/dam-delivery/SKILL.md +228 -0
- package/templates/common/.agents/skills/dam-delivery/references/parameter-contract.md +107 -0
- package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +131 -0
- package/templates/common/.content/pages/{home/_index.json → _index.json} +10 -0
- package/templates/common/.content/schemas/site.schema.json +1 -1
- package/templates/common/.nvmrc +1 -1
- package/templates/common/starter-content/.content/.rules-baseline.json +8 -0
- package/templates/common/starter-content/.content/config/site.json +1 -1
- package/templates/common/starter-content/.content/content-rules/no-public-resource-paths.json +6 -0
- package/templates/common/starter-content/.content/fragments/navbar/_index.json +23 -20
- package/templates/common/starter-content/.content/fragments/site-footer/_index.json +59 -56
- package/templates/common/starter-content/.content/pages/404/_index.json +1 -0
- package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
- package/templates/common/starter-content/.content/pages/error/_index.json +1 -0
- package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
- package/templates/common/starter-content/.content/patterns/components/button.json +55 -0
- package/templates/common/starter-content/.content/patterns/components/container.json +62 -0
- package/templates/common/starter-content/.content/patterns/components/feature_card.json +30 -0
- package/templates/common/starter-content/.content/patterns/components/feature_grid.json +57 -0
- package/templates/common/starter-content/.content/patterns/components/home_hero.json +142 -0
- package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +42 -0
- package/templates/common/starter-content/.content/patterns/components/site-footer.json +203 -0
- package/templates/common/starter-content/.content/patterns/components/site-navigation.json +115 -0
- package/templates/common/starter-content/.content/patterns/components/stats_bar.json +45 -0
- package/templates/common/starter-content/.content/patterns/layouts/default.json +14 -0
- package/templates/common/starter-content/.content/schemas/site.schema.json +1 -1
- package/templates/nextjs/template/.aci.yaml +10 -0
- package/templates/nextjs/template/package.json.tmpl +4 -3
- package/templates/nextjs/template/src/app/_components/c15t-bridge.ts +30 -0
- package/templates/nextjs/template/src/app/_components/c15t-consent-ui.tsx +37 -0
- package/templates/nextjs/template/src/app/_components/consent-manager.css +63 -0
- package/templates/nextjs/template/src/app/_components/consent-manager.tsx +76 -0
- package/templates/nextjs/template/src/app/layout.tsx +2 -0
- package/dist/edge/personalization-core.d.ts +0 -11
- package/dist/edge/personalization-core.js +0 -269
- package/dist/next/collect-route.d.ts +0 -19
- package/dist/next/collect-route.js +0 -459
- package/dist/next/envelope-digest.d.ts +0 -21
- package/dist/next/envelope-digest.js +0 -58
- package/dist/next/middleware.d.ts +0 -40
- package/dist/next/middleware.js +0 -475
- package/dist/next/personalization-host.d.ts +0 -134
- package/dist/next/personalization-host.js +0 -270
- package/dist/next/root-layout.d.ts +0 -4
- package/dist/next/root-layout.js +0 -5
- package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
- package/dist/next/vercel-collect-sdk-route.js +0 -181
- package/dist/next/vercel-personalization.d.ts +0 -4
- package/dist/next/vercel-personalization.js +0 -4
- package/dist/next/vercel-s3-personalization.d.ts +0 -79
- package/dist/next/vercel-s3-personalization.js +0 -570
- /package/templates/common/.content/overlays/audience/preference-blue/pages/{home/_index.json → _index.json} +0 -0
- /package/templates/common/.content/overlays/audience/preference-red/pages/{home/_index.json → _index.json} +0 -0
|
@@ -1,459 +0,0 @@
|
|
|
1
|
-
import { MAX_BATCH_EVENTS } from '../collect/batcher.js';
|
|
2
|
-
import { computeEnvelopeDigest } from './envelope-digest.js';
|
|
3
|
-
import { consentSignals, IdentityErasureFencedError, identityHashHex, parseCookieHeader, verifyClaims, verifyIdentityCookie, } from './personalization-host.js';
|
|
4
|
-
const MAX_COLLECT_BODY_BYTES = 64 * 1024;
|
|
5
|
-
const DEFAULT_RETENTION_SECONDS = 13 * 30 * 24 * 60 * 60;
|
|
6
|
-
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i;
|
|
7
|
-
const UTM_KEYS = ['source', 'medium', 'campaign', 'term', 'content', 'id'];
|
|
8
|
-
const ENVELOPE_FIELDS = new Set(['envelopeId', 'events']);
|
|
9
|
-
const EVENT_FIELDS = new Set([
|
|
10
|
-
'eventId',
|
|
11
|
-
'eventName',
|
|
12
|
-
'occurredAt',
|
|
13
|
-
'properties',
|
|
14
|
-
'url',
|
|
15
|
-
'referrer',
|
|
16
|
-
'title',
|
|
17
|
-
'path',
|
|
18
|
-
'utm',
|
|
19
|
-
'viewport',
|
|
20
|
-
'experiment',
|
|
21
|
-
'deviceClass',
|
|
22
|
-
]);
|
|
23
|
-
const DEVICE_CLASSES = new Set(['desktop', 'mobile', 'tablet', 'unknown']);
|
|
24
|
-
function identityHashShard(identityHash) {
|
|
25
|
-
const hash = identityHash.trim().toLowerCase();
|
|
26
|
-
if (hash.length < 2)
|
|
27
|
-
throw new Error('identity hash is too short');
|
|
28
|
-
return hash.slice(0, 2);
|
|
29
|
-
}
|
|
30
|
-
export function createPersonalizationCollectRoute(config) {
|
|
31
|
-
return async function collect(request) {
|
|
32
|
-
if (request.method === 'OPTIONS')
|
|
33
|
-
return new Response(null, { status: 204 });
|
|
34
|
-
if (request.method !== 'POST')
|
|
35
|
-
return new Response(null, { status: 405, headers: { allow: 'POST, OPTIONS' } });
|
|
36
|
-
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
37
|
-
if (bytes.byteLength > MAX_COLLECT_BODY_BYTES)
|
|
38
|
-
return new Response(null, { status: 413 });
|
|
39
|
-
let envelopeId;
|
|
40
|
-
let events;
|
|
41
|
-
try {
|
|
42
|
-
const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
43
|
-
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
|
44
|
-
throw new CollectValidationError('collect envelope must be an object');
|
|
45
|
-
}
|
|
46
|
-
for (const key of Object.keys(envelope)) {
|
|
47
|
-
if (!ENVELOPE_FIELDS.has(key)) {
|
|
48
|
-
throw new CollectValidationError(`field ${key} is not allowed on the collect envelope`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (typeof envelope.envelopeId !== 'string' || !ULID.test(envelope.envelopeId.trim())) {
|
|
52
|
-
throw new CollectValidationError('envelopeId must be a valid ULID');
|
|
53
|
-
}
|
|
54
|
-
envelopeId = envelope.envelopeId.trim().toUpperCase();
|
|
55
|
-
if (!Array.isArray(envelope.events))
|
|
56
|
-
throw new CollectValidationError('events must be an array');
|
|
57
|
-
// The TS runtime has no cross-instance quota checker (Go's REQ-14/
|
|
58
|
-
// REQ-20 guard); the 64KiB body cap plus this per-batch event cap and
|
|
59
|
-
// the platform WAF are the abuse bounds. The cap is the client
|
|
60
|
-
// batcher's MAX_BATCH_EVENTS — one constant, both sides of the wire.
|
|
61
|
-
if (envelope.events.length > MAX_BATCH_EVENTS)
|
|
62
|
-
throw new CollectValidationError('too many events in one batch');
|
|
63
|
-
events = envelope.events;
|
|
64
|
-
const eventIds = new Set();
|
|
65
|
-
for (const event of events) {
|
|
66
|
-
validateEvent(event);
|
|
67
|
-
const eventId = event.eventId.trim().toUpperCase();
|
|
68
|
-
if (eventId === envelopeId) {
|
|
69
|
-
throw new CollectValidationError('envelopeId must be distinct from every eventId');
|
|
70
|
-
}
|
|
71
|
-
if (eventIds.has(eventId)) {
|
|
72
|
-
throw new CollectValidationError('eventId must be unique within an envelope');
|
|
73
|
-
}
|
|
74
|
-
eventIds.add(eventId);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
catch (error) {
|
|
78
|
-
return validationResponse(error);
|
|
79
|
-
}
|
|
80
|
-
const cookies = parseCookieHeader(request.headers.get('cookie'));
|
|
81
|
-
const signals = consentSignals(request, cookies);
|
|
82
|
-
const rawIdentity = await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
|
|
83
|
-
const now = (config.now ?? (() => new Date()))();
|
|
84
|
-
const secure = config.secureCookies ?? new URL(request.url).protocol === 'https:';
|
|
85
|
-
const responseHeaders = new Headers({ 'cache-control': 'no-store', 'content-type': 'application/json' });
|
|
86
|
-
try {
|
|
87
|
-
// Withdrawal EVIDENCE (anonymous-experiments REQ-6): a signature-
|
|
88
|
-
// verified aci_claims cookie proves prior identity-tier state exists
|
|
89
|
-
// to suppress. Never-consented visitors present no verifiable claims
|
|
90
|
-
// and stay measurement-only.
|
|
91
|
-
const evidence = rawIdentity
|
|
92
|
-
? await verifyClaims(cookies.get('aci_claims') || '', config.claimsKeys || {}, now) !== null
|
|
93
|
-
: false;
|
|
94
|
-
const putSuppression = async (trigger) => {
|
|
95
|
-
const hash = await identityHashHex('aci_id', rawIdentity);
|
|
96
|
-
await config.store.putSuppression(`audience/suppression/${identityHashShard(hash)}/${config.siteId}/aci_id/${hash}/${trigger}.json`, { schemaVersion: 1, trigger, observedAt: now.toISOString() });
|
|
97
|
-
};
|
|
98
|
-
if (signals.privacyDenied) {
|
|
99
|
-
if (rawIdentity && evidence) {
|
|
100
|
-
await putSuppression('gpc');
|
|
101
|
-
}
|
|
102
|
-
if (cookies.has('aci_claims'))
|
|
103
|
-
appendCookie(responseHeaders, expiredClaimsCookie(secure));
|
|
104
|
-
return accepted(responseHeaders);
|
|
105
|
-
}
|
|
106
|
-
if (!rawIdentity)
|
|
107
|
-
return accepted(responseHeaders);
|
|
108
|
-
// Measurement tier (anonymous-experiments REQ-5): a verified aci_id
|
|
109
|
-
// stores events even without a personalization grant — the snapshot
|
|
110
|
-
// records what was (not) granted. An explicit denial with verified
|
|
111
|
-
// evidence is an observed withdrawal; measurement storage continues.
|
|
112
|
-
const personalization = signals.allowsIdentityTier;
|
|
113
|
-
if (!personalization && signals.hasConsent && evidence) {
|
|
114
|
-
await putSuppression('withdrawal');
|
|
115
|
-
}
|
|
116
|
-
const identityHash = await identityHashHex('aci_id', rawIdentity);
|
|
117
|
-
const geo = collectGeo(request);
|
|
118
|
-
const collectedAt = now.toISOString();
|
|
119
|
-
const expiresAt = new Date(now.getTime() +
|
|
120
|
-
(config.retentionSeconds ?? DEFAULT_RETENTION_SECONDS) * 1000).toISOString();
|
|
121
|
-
const consent = signals.snapshot
|
|
122
|
-
? {
|
|
123
|
-
v: signals.snapshot.version,
|
|
124
|
-
p: signals.snapshot.purposes,
|
|
125
|
-
...(signals.snapshot.grantedAt ? { t: signals.snapshot.grantedAt } : {}),
|
|
126
|
-
...(signals.snapshot.source ? { src: signals.snapshot.source } : {}),
|
|
127
|
-
}
|
|
128
|
-
: { v: 0, p: [] };
|
|
129
|
-
const records = events.map((event) => {
|
|
130
|
-
const normalized = normalizeEvent(event, now);
|
|
131
|
-
return {
|
|
132
|
-
schemaVersion: 2,
|
|
133
|
-
eventId: normalized.eventId,
|
|
134
|
-
eventName: normalized.eventName,
|
|
135
|
-
eventCategory: normalized.eventCategory,
|
|
136
|
-
siteId: config.siteId,
|
|
137
|
-
identity: { namespace: 'aci_id', hash: identityHash },
|
|
138
|
-
// Go decodes `t` as time.Time and CANNOT parse an empty string —
|
|
139
|
-
// an empty-t record permanently poisons that identity's
|
|
140
|
-
// classification and DSAR reads. Omit t unless a real timestamp
|
|
141
|
-
// was observed (legacy bare tokens have none).
|
|
142
|
-
consent,
|
|
143
|
-
...(normalized.properties ? { properties: normalized.properties } : {}),
|
|
144
|
-
page: normalized.page,
|
|
145
|
-
...(normalized.utm ? { utm: normalized.utm } : {}),
|
|
146
|
-
...(normalized.viewport ? { viewport: normalized.viewport } : {}),
|
|
147
|
-
...(normalized.experiment ? { experiment: normalized.experiment } : {}),
|
|
148
|
-
// Server-stamped geo (country + region only, D-PDP-14) from the
|
|
149
|
-
// platform-injected viewer headers; never from the event body.
|
|
150
|
-
...(geo ? { geo } : {}),
|
|
151
|
-
deviceClass: normalized.deviceClass,
|
|
152
|
-
occurredAt: normalized.occurredAt,
|
|
153
|
-
collectedAt,
|
|
154
|
-
expiresAt,
|
|
155
|
-
};
|
|
156
|
-
});
|
|
157
|
-
if (records.length > 0) {
|
|
158
|
-
const key = `audience/first-party/${identityHashShard(identityHash)}/${config.siteId}/aci_id/${identityHash}/${envelopeId}.json`;
|
|
159
|
-
const digest = await computeEnvelopeDigest({
|
|
160
|
-
siteId: config.siteId,
|
|
161
|
-
acceptedBody: bytes,
|
|
162
|
-
identity: { namespace: 'aci_id', hash: identityHash },
|
|
163
|
-
consent: {
|
|
164
|
-
version: signals.snapshot?.version ?? 0,
|
|
165
|
-
purposes: signals.snapshot?.purposes ?? [],
|
|
166
|
-
grantedAt: signals.snapshot?.grantedAt,
|
|
167
|
-
source: signals.snapshot?.source,
|
|
168
|
-
},
|
|
169
|
-
});
|
|
170
|
-
try {
|
|
171
|
-
await config.store.appendEnvelope({
|
|
172
|
-
key,
|
|
173
|
-
envelopeId,
|
|
174
|
-
siteId: config.siteId,
|
|
175
|
-
identityNamespace: 'aci_id',
|
|
176
|
-
identityHash,
|
|
177
|
-
records,
|
|
178
|
-
digest,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
catch (error) {
|
|
182
|
-
if (error instanceof IdentityErasureFencedError) {
|
|
183
|
-
if (cookies.has('aci_claims'))
|
|
184
|
-
appendCookie(responseHeaders, expiredClaimsCookie(secure));
|
|
185
|
-
return accepted(responseHeaders);
|
|
186
|
-
}
|
|
187
|
-
throw error;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
if (personalization) {
|
|
191
|
-
const claims = await config.store.readClaims(config.siteId, 'aci_id', identityHash);
|
|
192
|
-
if (claims)
|
|
193
|
-
appendCookie(responseHeaders, claimsCookie(claims, secure));
|
|
194
|
-
else if (cookies.has('aci_claims'))
|
|
195
|
-
appendCookie(responseHeaders, expiredClaimsCookie(secure));
|
|
196
|
-
}
|
|
197
|
-
else if (cookies.has('aci_claims')) {
|
|
198
|
-
// Claims are personalization; a non-granting touch expires them.
|
|
199
|
-
appendCookie(responseHeaders, expiredClaimsCookie(secure));
|
|
200
|
-
}
|
|
201
|
-
return accepted(responseHeaders);
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
return Response.json({ error: 'internal error', status: 500 }, { status: 500, headers: { 'cache-control': 'no-store' } });
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
function validateEvent(value) {
|
|
209
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
210
|
-
throw new CollectValidationError('event must be an object');
|
|
211
|
-
for (const key of Object.keys(value)) {
|
|
212
|
-
if (!EVENT_FIELDS.has(key))
|
|
213
|
-
throw new CollectValidationError(`field ${key} is not allowed on the public collect route`);
|
|
214
|
-
}
|
|
215
|
-
if (typeof value.eventId !== 'string' || !ULID.test(value.eventId.trim()))
|
|
216
|
-
throw new CollectValidationError('eventId must be a valid ULID');
|
|
217
|
-
if (typeof value.eventName !== 'string')
|
|
218
|
-
throw new CollectValidationError('event name is required');
|
|
219
|
-
normalizeEventName(value.eventName);
|
|
220
|
-
for (const field of ['url', 'referrer', 'title', 'path']) {
|
|
221
|
-
if (value[field] !== undefined && typeof value[field] !== 'string')
|
|
222
|
-
throw new CollectValidationError(`${field} must be a string`);
|
|
223
|
-
}
|
|
224
|
-
if (value.properties !== undefined && (!value.properties || typeof value.properties !== 'object' || Array.isArray(value.properties))) {
|
|
225
|
-
throw new CollectValidationError('properties must be an object');
|
|
226
|
-
}
|
|
227
|
-
if (value.properties !== undefined)
|
|
228
|
-
sanitizeProperties(value.properties, 'properties', 0);
|
|
229
|
-
if (value.occurredAt !== undefined && (typeof value.occurredAt !== 'string' || Number.isNaN(Date.parse(value.occurredAt)))) {
|
|
230
|
-
throw new CollectValidationError('occurredAt must be an RFC3339 timestamp');
|
|
231
|
-
}
|
|
232
|
-
if (value.utm !== undefined && (!value.utm || typeof value.utm !== 'object' || Array.isArray(value.utm))) {
|
|
233
|
-
throw new CollectValidationError('utm must be an object');
|
|
234
|
-
}
|
|
235
|
-
if (value.deviceClass !== undefined && typeof value.deviceClass !== 'string') {
|
|
236
|
-
throw new CollectValidationError('deviceClass must be a string');
|
|
237
|
-
}
|
|
238
|
-
normalizeViewport(value.viewport);
|
|
239
|
-
normalizeExperiment(value.experiment);
|
|
240
|
-
}
|
|
241
|
-
function normalizeEvent(event, now) {
|
|
242
|
-
const [eventName, eventCategory] = normalizeEventName(String(event.eventName));
|
|
243
|
-
const rawURL = typeof event.url === 'string' ? event.url.trim() : '';
|
|
244
|
-
const pageURL = sanitizeURL(rawURL, 'url');
|
|
245
|
-
const referrer = sanitizeURL(typeof event.referrer === 'string' ? event.referrer.trim() : '', 'referrer');
|
|
246
|
-
const parsedURL = rawURL ? new URL(rawURL, 'https://aci.invalid') : null;
|
|
247
|
-
let path = typeof event.path === 'string' ? event.path.trim() : parsedURL?.pathname || '';
|
|
248
|
-
if (path && !path.startsWith('/'))
|
|
249
|
-
path = `/${path}`;
|
|
250
|
-
const utm = normalizeUTM(event.utm, parsedURL);
|
|
251
|
-
const occurredAt = typeof event.occurredAt === 'string' && !Number.isNaN(Date.parse(event.occurredAt))
|
|
252
|
-
? new Date(event.occurredAt).toISOString()
|
|
253
|
-
: now.toISOString();
|
|
254
|
-
return {
|
|
255
|
-
eventId: String(event.eventId).trim().toUpperCase(),
|
|
256
|
-
eventName,
|
|
257
|
-
eventCategory,
|
|
258
|
-
occurredAt,
|
|
259
|
-
deviceClass: normalizeDeviceClass(event.deviceClass),
|
|
260
|
-
properties: event.properties === undefined ? undefined : sanitizeProperties(event.properties, 'properties', 0),
|
|
261
|
-
page: {
|
|
262
|
-
...(pageURL ? { url: pageURL } : {}),
|
|
263
|
-
...(referrer ? { referrer } : {}),
|
|
264
|
-
...(typeof event.title === 'string' && event.title.trim() ? { title: event.title.trim() } : {}),
|
|
265
|
-
...(path ? { path } : {}),
|
|
266
|
-
},
|
|
267
|
-
utm,
|
|
268
|
-
viewport: normalizeViewport(event.viewport),
|
|
269
|
-
experiment: normalizeExperiment(event.experiment),
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
// collectGeo stamps the request geo from the platform-injected viewer
|
|
273
|
-
// headers (Vercel overwrites x-vercel-ip-* on every request, so they are
|
|
274
|
-
// trusted). Country + region only — city is never captured (D-PDP-14).
|
|
275
|
-
// Returns undefined when neither dimension normalizes, so the lake record
|
|
276
|
-
// keeps its historical byte shape for no-source requests.
|
|
277
|
-
function collectGeo(request) {
|
|
278
|
-
const country = normalizeGeoCountry(request.headers.get('x-vercel-ip-country'));
|
|
279
|
-
const region = normalizeGeoRegion(request.headers.get('x-vercel-ip-country-region'));
|
|
280
|
-
if (!country && !region)
|
|
281
|
-
return undefined;
|
|
282
|
-
return { ...(country ? { country } : {}), ...(region ? { region } : {}) };
|
|
283
|
-
}
|
|
284
|
-
function normalizeGeoCountry(value) {
|
|
285
|
-
const clean = (value ?? '').trim();
|
|
286
|
-
return /^[a-zA-Z]{2}$/.test(clean) ? clean.toUpperCase() : '';
|
|
287
|
-
}
|
|
288
|
-
function normalizeGeoRegion(value) {
|
|
289
|
-
const clean = (value ?? '').trim();
|
|
290
|
-
return /^[a-zA-Z0-9]{1,3}$/.test(clean) ? clean.toUpperCase() : '';
|
|
291
|
-
}
|
|
292
|
-
// normalizeDeviceClass floors any client value onto the closed enum —
|
|
293
|
-
// never a free-form echo (the value joins a rollup primary key, D-PR-22).
|
|
294
|
-
function normalizeDeviceClass(value) {
|
|
295
|
-
const clean = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
296
|
-
return DEVICE_CLASSES.has(clean) ? clean : 'unknown';
|
|
297
|
-
}
|
|
298
|
-
function normalizeEventName(value) {
|
|
299
|
-
const name = value.trim().toLowerCase();
|
|
300
|
-
if (!name || name.length > 128 || /[\s/\\]/.test(name))
|
|
301
|
-
throw new CollectValidationError('event name must be dot-delimited');
|
|
302
|
-
const rum = new Set(['rum.page_view', 'rum.link_click', 'rum.variation_view', 'rum.web_vital']);
|
|
303
|
-
const experiment = new Set(['experiment.exposure', 'experiment.conversion', 'experiment.engagement']);
|
|
304
|
-
if (rum.has(name))
|
|
305
|
-
return [name, 'rum'];
|
|
306
|
-
if (experiment.has(name))
|
|
307
|
-
return [name, 'experiment'];
|
|
308
|
-
if (name.startsWith('custom.') && name.length > 'custom.'.length)
|
|
309
|
-
return [name, 'custom'];
|
|
310
|
-
throw new CollectValidationError('event name must be one of the supported rum.* or experiment.* events, or use the custom.* namespace');
|
|
311
|
-
}
|
|
312
|
-
function sanitizeURL(value, field) {
|
|
313
|
-
if (!value)
|
|
314
|
-
return '';
|
|
315
|
-
if (value.length > 2048)
|
|
316
|
-
throw new CollectValidationError(`${field} is too long`);
|
|
317
|
-
let parsed;
|
|
318
|
-
try {
|
|
319
|
-
parsed = new URL(value, 'https://aci.invalid');
|
|
320
|
-
}
|
|
321
|
-
catch {
|
|
322
|
-
throw new CollectValidationError(`${field} is invalid`);
|
|
323
|
-
}
|
|
324
|
-
if (!['http:', 'https:'].includes(parsed.protocol))
|
|
325
|
-
throw new CollectValidationError(`${field} scheme is not allowed`);
|
|
326
|
-
parsed.search = '';
|
|
327
|
-
parsed.hash = '';
|
|
328
|
-
if (parsed.origin === 'https://aci.invalid')
|
|
329
|
-
return parsed.pathname;
|
|
330
|
-
return parsed.toString().replace(/\/$/, parsed.pathname === '/' ? '/' : '');
|
|
331
|
-
}
|
|
332
|
-
function normalizeUTM(value, url) {
|
|
333
|
-
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
334
|
-
const out = {};
|
|
335
|
-
for (const key of UTM_KEYS) {
|
|
336
|
-
const raw = source[key] ?? source[`utm_${key}`] ?? url?.searchParams.get(`utm_${key}`);
|
|
337
|
-
if (typeof raw === 'string' && raw.trim())
|
|
338
|
-
out[key] = raw.trim();
|
|
339
|
-
}
|
|
340
|
-
return Object.keys(out).length > 0 ? out : undefined;
|
|
341
|
-
}
|
|
342
|
-
function sanitizeProperties(value, field, depth) {
|
|
343
|
-
if (depth > 4)
|
|
344
|
-
throw new CollectValidationError(`${field} is nested too deeply`);
|
|
345
|
-
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
346
|
-
return value;
|
|
347
|
-
if (typeof value === 'string')
|
|
348
|
-
return sanitizeString(value, field, 2048);
|
|
349
|
-
if (Array.isArray(value)) {
|
|
350
|
-
if (value.length > 32)
|
|
351
|
-
throw new CollectValidationError(`${field} has too many values`);
|
|
352
|
-
return value.map((item, index) => sanitizeProperties(item, `${field}[${index}]`, depth + 1));
|
|
353
|
-
}
|
|
354
|
-
if (typeof value !== 'object')
|
|
355
|
-
throw new CollectValidationError(`${field} has unsupported value type`);
|
|
356
|
-
const entries = Object.entries(value);
|
|
357
|
-
if (entries.length > 64)
|
|
358
|
-
throw new CollectValidationError(`${field} has too many keys`);
|
|
359
|
-
const sanitized = {};
|
|
360
|
-
for (const [rawKey, item] of entries) {
|
|
361
|
-
const key = sanitizeString(rawKey, `${field} key`, 256);
|
|
362
|
-
if (!key)
|
|
363
|
-
throw new CollectValidationError(`${field} contains an empty key`);
|
|
364
|
-
if (secretLikeKey(key))
|
|
365
|
-
throw new CollectValidationError(`${field} contains a sensitive key`);
|
|
366
|
-
sanitized[key] = sanitizeProperties(item, `${field}.${key}`, depth + 1);
|
|
367
|
-
}
|
|
368
|
-
return sanitized;
|
|
369
|
-
}
|
|
370
|
-
function secretLikeKey(key) {
|
|
371
|
-
const normalized = key.toLowerCase().replace(/[-. ]/g, '_');
|
|
372
|
-
const compact = normalized.replace(/_/g, '');
|
|
373
|
-
const normalizedMarkers = [
|
|
374
|
-
'authorization', 'access_token', 'refresh_token', 'id_token', 'api_key',
|
|
375
|
-
'private_key', 'secret', 'password', 'passwd', 'credential', 'cookie', 'set_cookie',
|
|
376
|
-
];
|
|
377
|
-
const compactMarkers = [
|
|
378
|
-
'authorization', 'accesstoken', 'refreshtoken', 'idtoken', 'apikey',
|
|
379
|
-
'privatekey', 'secret', 'password', 'passwd', 'credential', 'cookie', 'setcookie',
|
|
380
|
-
];
|
|
381
|
-
return normalizedMarkers.some((marker) => normalized.includes(marker))
|
|
382
|
-
|| compactMarkers.some((marker) => compact.includes(marker));
|
|
383
|
-
}
|
|
384
|
-
function sanitizeString(value, field, limit) {
|
|
385
|
-
const clean = value.trim();
|
|
386
|
-
if (clean.length > limit)
|
|
387
|
-
throw new CollectValidationError(`${field} is too long`);
|
|
388
|
-
if (/[\u0000-\u001F\u007F-\u009F]/u.test(clean))
|
|
389
|
-
throw new CollectValidationError(`${field} contains control characters`);
|
|
390
|
-
return clean;
|
|
391
|
-
}
|
|
392
|
-
function normalizeViewport(value) {
|
|
393
|
-
if (value === undefined || value === null)
|
|
394
|
-
return undefined;
|
|
395
|
-
if (typeof value !== 'object' || Array.isArray(value))
|
|
396
|
-
throw new CollectValidationError('viewport is invalid');
|
|
397
|
-
const viewport = value;
|
|
398
|
-
const width = viewport.width ?? 0;
|
|
399
|
-
const height = viewport.height ?? 0;
|
|
400
|
-
if (!Number.isInteger(width) || !Number.isInteger(height) || Number(width) < 0 || Number(height) < 0 || Number(width) > 20_000 || Number(height) > 20_000) {
|
|
401
|
-
throw new CollectValidationError('viewport is invalid');
|
|
402
|
-
}
|
|
403
|
-
if (width === 0 && height === 0)
|
|
404
|
-
return undefined;
|
|
405
|
-
return { width: Number(width), height: Number(height) };
|
|
406
|
-
}
|
|
407
|
-
function normalizeExperiment(value) {
|
|
408
|
-
if (value === undefined || value === null)
|
|
409
|
-
return undefined;
|
|
410
|
-
if (typeof value !== 'object' || Array.isArray(value))
|
|
411
|
-
throw new CollectValidationError('experiment is invalid');
|
|
412
|
-
const source = value;
|
|
413
|
-
const experimentId = shortOptionalString(source.experimentId, 'experiment.experimentId');
|
|
414
|
-
const armId = shortOptionalString(source.armId, 'experiment.armId');
|
|
415
|
-
const allocationVersion = shortOptionalString(source.allocationVersion, 'experiment.allocationVersion');
|
|
416
|
-
const goalId = shortOptionalString(source.goalId, 'experiment.goalId');
|
|
417
|
-
const bucket = source.assignmentBucket;
|
|
418
|
-
if (!experimentId && !armId && !allocationVersion && !goalId && bucket === undefined)
|
|
419
|
-
return undefined;
|
|
420
|
-
if (!experimentId)
|
|
421
|
-
throw new CollectValidationError('experiment.experimentId is required when experiment context is present');
|
|
422
|
-
if (!armId)
|
|
423
|
-
throw new CollectValidationError('experiment.armId is required when experiment context is present');
|
|
424
|
-
if (bucket !== undefined && (!Number.isInteger(bucket) || Number(bucket) < 0 || Number(bucket) > 9999)) {
|
|
425
|
-
throw new CollectValidationError('experiment.assignmentBucket must be between 0 and 9999');
|
|
426
|
-
}
|
|
427
|
-
return {
|
|
428
|
-
experimentId,
|
|
429
|
-
armId,
|
|
430
|
-
...(allocationVersion ? { allocationVersion } : {}),
|
|
431
|
-
...(goalId ? { goalId } : {}),
|
|
432
|
-
...(bucket !== undefined ? { assignmentBucket: bucket } : {}),
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
function shortOptionalString(value, field) {
|
|
436
|
-
if (value === undefined || value === null)
|
|
437
|
-
return '';
|
|
438
|
-
if (typeof value !== 'string')
|
|
439
|
-
throw new CollectValidationError(`${field} must be a string`);
|
|
440
|
-
return sanitizeString(value, field, 128);
|
|
441
|
-
}
|
|
442
|
-
function accepted(headers) {
|
|
443
|
-
return new Response('{"accepted":true}', { status: 202, headers });
|
|
444
|
-
}
|
|
445
|
-
function validationResponse(error) {
|
|
446
|
-
const message = error instanceof CollectValidationError ? error.message : 'invalid JSON body';
|
|
447
|
-
return Response.json({ error: message, status: 400 }, { status: 400, headers: { 'cache-control': 'no-store' } });
|
|
448
|
-
}
|
|
449
|
-
function claimsCookie(value, secure) {
|
|
450
|
-
return `aci_claims=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure ? '; Secure' : ''}`;
|
|
451
|
-
}
|
|
452
|
-
function expiredClaimsCookie(secure) {
|
|
453
|
-
return `aci_claims=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT${secure ? '; Secure' : ''}`;
|
|
454
|
-
}
|
|
455
|
-
function appendCookie(headers, cookie) {
|
|
456
|
-
headers.append('set-cookie', cookie);
|
|
457
|
-
}
|
|
458
|
-
class CollectValidationError extends Error {
|
|
459
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
export interface EnvelopeDigestInput {
|
|
2
|
-
siteId: string;
|
|
3
|
-
acceptedBody: Uint8Array;
|
|
4
|
-
identity: {
|
|
5
|
-
namespace: string;
|
|
6
|
-
hash: string;
|
|
7
|
-
};
|
|
8
|
-
consent: {
|
|
9
|
-
version: number;
|
|
10
|
-
purposes: readonly string[];
|
|
11
|
-
grantedAt?: string;
|
|
12
|
-
source?: string;
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Computes the cross-runtime digest used to distinguish an identical
|
|
17
|
-
* envelope retry from reuse of the same envelope ID with different accepted
|
|
18
|
-
* inputs. Every field is framed with an unsigned 8-byte big-endian length so
|
|
19
|
-
* concatenation cannot create ambiguous inputs.
|
|
20
|
-
*/
|
|
21
|
-
export declare function computeEnvelopeDigest(input: EnvelopeDigestInput): Promise<string>;
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
const ENVELOPE_DIGEST_ALGORITHM = 'aci-envelope-digest-v1';
|
|
2
|
-
const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/;
|
|
3
|
-
const encoder = new TextEncoder();
|
|
4
|
-
const CONSENT_EDGE_WHITESPACE = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
|
|
5
|
-
/**
|
|
6
|
-
* Computes the cross-runtime digest used to distinguish an identical
|
|
7
|
-
* envelope retry from reuse of the same envelope ID with different accepted
|
|
8
|
-
* inputs. Every field is framed with an unsigned 8-byte big-endian length so
|
|
9
|
-
* concatenation cannot create ambiguous inputs.
|
|
10
|
-
*/
|
|
11
|
-
export async function computeEnvelopeDigest(input) {
|
|
12
|
-
if (!Number.isInteger(input.consent.version)) {
|
|
13
|
-
throw new Error('consent version must be an integer');
|
|
14
|
-
}
|
|
15
|
-
const purposes = input.consent.purposes
|
|
16
|
-
.map((purpose) => trimConsentWhitespace(purpose).toLowerCase())
|
|
17
|
-
.sort();
|
|
18
|
-
const fields = [
|
|
19
|
-
encoder.encode(ENVELOPE_DIGEST_ALGORITHM),
|
|
20
|
-
encoder.encode(input.siteId.trim()),
|
|
21
|
-
input.acceptedBody,
|
|
22
|
-
encoder.encode(input.identity.namespace.trim()),
|
|
23
|
-
encoder.encode(input.identity.hash.trim()),
|
|
24
|
-
encoder.encode(String(input.consent.version)),
|
|
25
|
-
encoder.encode(String(purposes.length)),
|
|
26
|
-
...purposes.map((purpose) => encoder.encode(purpose)),
|
|
27
|
-
encoder.encode(canonicalGrantedAt(input.consent.grantedAt || '')),
|
|
28
|
-
encoder.encode(trimConsentWhitespace(input.consent.source || '')),
|
|
29
|
-
];
|
|
30
|
-
const byteLength = fields.reduce((total, field) => total + 8 + field.byteLength, 0);
|
|
31
|
-
const framed = new Uint8Array(byteLength);
|
|
32
|
-
const view = new DataView(framed.buffer);
|
|
33
|
-
let offset = 0;
|
|
34
|
-
for (const field of fields) {
|
|
35
|
-
view.setBigUint64(offset, BigInt(field.byteLength), false);
|
|
36
|
-
offset += 8;
|
|
37
|
-
framed.set(field, offset);
|
|
38
|
-
offset += field.byteLength;
|
|
39
|
-
}
|
|
40
|
-
const digest = await globalThis.crypto.subtle.digest('SHA-256', framed);
|
|
41
|
-
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
42
|
-
}
|
|
43
|
-
function canonicalGrantedAt(value) {
|
|
44
|
-
const clean = trimConsentWhitespace(value);
|
|
45
|
-
if (!clean)
|
|
46
|
-
return '';
|
|
47
|
-
const match = RFC3339.exec(clean);
|
|
48
|
-
const epochMillis = Date.parse(clean);
|
|
49
|
-
if (!match || Number.isNaN(epochMillis)) {
|
|
50
|
-
throw new Error('consent grantedAt must be an RFC3339 timestamp');
|
|
51
|
-
}
|
|
52
|
-
const fraction = (match[7] || '').replace(/0+$/, '');
|
|
53
|
-
const seconds = new Date(epochMillis).toISOString().slice(0, 19);
|
|
54
|
-
return `${seconds}${fraction ? `.${fraction}` : ''}Z`;
|
|
55
|
-
}
|
|
56
|
-
function trimConsentWhitespace(value) {
|
|
57
|
-
return value.replace(CONSENT_EDGE_WHITESPACE, '');
|
|
58
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { NextResponse, type NextRequest } from 'next/server.js';
|
|
2
|
-
import { type PersonalizationHostConfig } from './personalization-host.js';
|
|
3
|
-
export interface GradialMiddlewareConfig {
|
|
4
|
-
siteId?: string;
|
|
5
|
-
edgeConfig?: string;
|
|
6
|
-
previewSignKey?: string;
|
|
7
|
-
deploymentId?: string;
|
|
8
|
-
deploymentProtectionBypass?: string;
|
|
9
|
-
/** Site-scoped token shared only with the outer ACI framework proxy. */
|
|
10
|
-
frameworkOriginAuthToken?: string;
|
|
11
|
-
personalization?: Omit<PersonalizationHostConfig, 'siteId'> & {
|
|
12
|
-
siteId?: string;
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
export declare function createGradialMiddleware(config?: GradialMiddlewareConfig): (request: NextRequest) => Promise<Response>;
|
|
16
|
-
/**
|
|
17
|
-
* Any response that writes a cookie is per-visitor and must never enter a
|
|
18
|
-
* shared cache (anonymous-experiments REQ-3(a)): a CDN-cached Set-Cookie
|
|
19
|
-
* replays one visitor's identity/claims to everyone under that cache key.
|
|
20
|
-
* Per-variant CACHING itself stays legitimate — the variant travels in the
|
|
21
|
-
* cache-keyed query parameter, not in cookies.
|
|
22
|
-
*/
|
|
23
|
-
export declare function enforceCookieCacheRule(response: NextResponse): void;
|
|
24
|
-
export declare const aciMiddlewareMatcher: (string | {
|
|
25
|
-
source: string;
|
|
26
|
-
has: {
|
|
27
|
-
type: string;
|
|
28
|
-
key: string;
|
|
29
|
-
}[];
|
|
30
|
-
})[];
|
|
31
|
-
export declare const config: {
|
|
32
|
-
runtime: string;
|
|
33
|
-
matcher: (string | {
|
|
34
|
-
source: string;
|
|
35
|
-
has: {
|
|
36
|
-
type: string;
|
|
37
|
-
key: string;
|
|
38
|
-
}[];
|
|
39
|
-
})[];
|
|
40
|
-
};
|