@gradial/aci 0.1.52-rc.1 → 0.1.52-rc.13
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/audience-data-models.d.ts +96 -0
- package/dist/audience-data-models.js +52 -0
- 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/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/personalization-core.js +13 -1
- package/dist/internal/domain/edge/compose.d.ts +24 -0
- package/dist/internal/domain/edge/compose.js +89 -0
- 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 +59 -0
- package/dist/internal/domain/edge/personalization-core.d.ts +4 -1
- package/dist/internal/domain/edge/personalization-core.js +25 -8
- package/dist/internal/domain/edge/resolve.js +19 -9
- package/dist/internal/domain/edge/types.d.ts +16 -0
- package/dist/mimic/core.d.ts +34 -0
- package/dist/mimic/core.js +127 -0
- package/dist/next/collect-route.d.ts +6 -0
- package/dist/next/collect-route.js +15 -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/personalization-host.d.ts +4 -0
- package/dist/next/personalization-host.js +18 -7
- package/dist/next/server.js +28 -1
- package/dist/next/vercel-edge-projection-v1.js +7 -1
- package/dist/next/vercel-s3-personalization.d.ts +1 -0
- package/dist/next/vercel-s3-personalization.js +1 -0
- package/dist/providers/s3.js +11 -4
- package/dist/types/block-ref.d.ts +2 -0
- package/dist/types/block-ref.js +1 -0
- package/package.json +7 -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/authoring/SKILL.md +11 -3
- package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -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 +2 -1
- 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/next/middleware.d.ts +0 -40
- package/dist/next/middleware.js +0 -475
- /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,9 +1,16 @@
|
|
|
1
1
|
import type { LayoutEntry } from './provider.js';
|
|
2
2
|
import type { KernelPageBlock } from './types.js';
|
|
3
|
-
export interface
|
|
4
|
-
|
|
3
|
+
export interface FragmentBlock {
|
|
4
|
+
$type: 'component';
|
|
5
5
|
component: string;
|
|
6
6
|
props?: Record<string, unknown>;
|
|
7
7
|
}
|
|
8
|
+
export interface FragmentData {
|
|
9
|
+
fragmentId?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
block?: FragmentBlock;
|
|
12
|
+
component?: string;
|
|
13
|
+
props?: Record<string, unknown>;
|
|
14
|
+
}
|
|
8
15
|
export type FragmentLoader = (id: string) => Promise<FragmentData>;
|
|
9
16
|
export declare function resolveSlots(layout: LayoutEntry, pageRegions: Record<string, KernelPageBlock[]>, getFragment: FragmentLoader): Promise<Record<string, KernelPageBlock[]>>;
|
|
@@ -11,10 +11,12 @@ export async function resolveSlots(layout, pageRegions, getFragment) {
|
|
|
11
11
|
const blocks = [];
|
|
12
12
|
for (const ref of defaults) {
|
|
13
13
|
const fragment = await getFragment(ref.fragmentId);
|
|
14
|
+
const block = fragment.block ?? { component: fragment.component ?? '', props: fragment.props };
|
|
14
15
|
blocks.push({
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
$type: 'component',
|
|
17
|
+
id: fragment.id ?? fragment.fragmentId ?? ref.fragmentId,
|
|
18
|
+
component: block.component,
|
|
19
|
+
props: block.props || {},
|
|
18
20
|
});
|
|
19
21
|
}
|
|
20
22
|
resolved[slot.name] = blocks;
|
package/dist/content/types.d.ts
CHANGED
|
@@ -42,11 +42,13 @@ export interface KernelPageSeo {
|
|
|
42
42
|
canonical?: string;
|
|
43
43
|
}
|
|
44
44
|
export interface KernelPageBlock {
|
|
45
|
+
$type: 'component';
|
|
45
46
|
id: string;
|
|
46
47
|
component: string;
|
|
47
48
|
props: Record<string, unknown>;
|
|
48
49
|
}
|
|
49
50
|
export interface KernelAtomicBlock {
|
|
51
|
+
$type: 'component';
|
|
50
52
|
id: string;
|
|
51
53
|
component: string;
|
|
52
54
|
props: Record<string, unknown>;
|
|
@@ -13,6 +13,7 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
|
|
|
13
13
|
const candidates = Object.values(experiments)
|
|
14
14
|
.filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
|
|
15
15
|
.filter((item) => routeHasExperiment(route, item.id))
|
|
16
|
+
.filter((item) => experimentEligible(item, claims))
|
|
16
17
|
.sort((left, right) => (right.priority || 0) - (left.priority || 0)
|
|
17
18
|
|| String(left.id || '').localeCompare(String(right.id || '')));
|
|
18
19
|
for (let index = 0; index < candidates.length; index++) {
|
|
@@ -39,7 +40,8 @@ function selectRuleVariant(request, route, claims, experiments) {
|
|
|
39
40
|
const values = variant.variantValues || variantValuesFromKey(key);
|
|
40
41
|
const experimentID = String(values.experiment || '').trim();
|
|
41
42
|
if (experimentID && (!experiments || !experiments[experimentID]
|
|
42
|
-
|| experiments[experimentID].status !== 'running'
|
|
43
|
+
|| experiments[experimentID].status !== 'running'
|
|
44
|
+
|| !experimentEligible(experiments[experimentID], claims)))
|
|
43
45
|
continue;
|
|
44
46
|
if (!requestDimensionsMatch(request, values))
|
|
45
47
|
continue;
|
|
@@ -66,6 +68,16 @@ function variantValuesFromKey(key) {
|
|
|
66
68
|
}
|
|
67
69
|
return values;
|
|
68
70
|
}
|
|
71
|
+
// The eligibility gate (audience-scoped-experiments ASE-F4/F5), mirrored
|
|
72
|
+
// from internal/domain/edge/personalization-core.ts: an experiment declaring
|
|
73
|
+
// eligibilityAudienceId is skipped unless the verified claims carry the ID.
|
|
74
|
+
// Ungated experiments return true, preserving byte-identical behavior.
|
|
75
|
+
function experimentEligible(item, claims) {
|
|
76
|
+
const audienceID = String(item.eligibilityAudienceId || '').trim();
|
|
77
|
+
if (!audienceID)
|
|
78
|
+
return true;
|
|
79
|
+
return !!claims && (claims.aud || []).includes(audienceID);
|
|
80
|
+
}
|
|
69
81
|
function routeHasExperiment(route, experimentID) {
|
|
70
82
|
for (const key in route.variants || {}) {
|
|
71
83
|
const values = route.variants[key].variantValues || variantValuesFromKey(key);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface ObjectReader {
|
|
2
|
+
readObject(path: string): Promise<Uint8Array>;
|
|
3
|
+
}
|
|
4
|
+
export interface ComposeObjectRef {
|
|
5
|
+
path: string;
|
|
6
|
+
digest: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ComposeSlot {
|
|
9
|
+
name: string;
|
|
10
|
+
required: boolean;
|
|
11
|
+
placeholder: string;
|
|
12
|
+
parts: ComposeObjectRef[];
|
|
13
|
+
}
|
|
14
|
+
export interface ComposeEnvelope {
|
|
15
|
+
schema: 'gradial.compose-envelope.v1';
|
|
16
|
+
algorithm: 'aci-html-compose-v1';
|
|
17
|
+
shell: ComposeObjectRef;
|
|
18
|
+
slots: ComposeSlot[];
|
|
19
|
+
}
|
|
20
|
+
export declare function composeEnvelope(reader: ObjectReader, envelopePath: string, envelopeDigest: string): Promise<{
|
|
21
|
+
html: string;
|
|
22
|
+
contentType: string;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function validateEnvelope(envelope: unknown): ComposeEnvelope;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
2
|
+
export async function composeEnvelope(reader, envelopePath, envelopeDigest) {
|
|
3
|
+
const envelopeBytes = await reader.readObject(envelopePath);
|
|
4
|
+
await requireDigest(envelopeBytes, envelopeDigest, 'compose envelope');
|
|
5
|
+
let parsed;
|
|
6
|
+
try {
|
|
7
|
+
parsed = JSON.parse(decoder.decode(envelopeBytes));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error('compose envelope is malformed');
|
|
11
|
+
}
|
|
12
|
+
const envelope = validateEnvelope(parsed);
|
|
13
|
+
const shellBytes = await reader.readObject(envelope.shell.path);
|
|
14
|
+
await requireDigest(shellBytes, envelope.shell.digest, 'compose shell');
|
|
15
|
+
let html = decoder.decode(shellBytes);
|
|
16
|
+
for (const slot of envelope.slots) {
|
|
17
|
+
if (occurrences(html, slot.placeholder) !== 1) {
|
|
18
|
+
throw new Error(`compose slot ${JSON.stringify(slot.name)} placeholder must occur exactly once`);
|
|
19
|
+
}
|
|
20
|
+
if (slot.required && slot.parts.length === 0) {
|
|
21
|
+
throw new Error(`compose slot ${JSON.stringify(slot.name)} is required`);
|
|
22
|
+
}
|
|
23
|
+
const parts = [];
|
|
24
|
+
for (const part of slot.parts) {
|
|
25
|
+
const bytes = await reader.readObject(part.path);
|
|
26
|
+
await requireDigest(bytes, part.digest, `compose slot ${slot.name}`);
|
|
27
|
+
parts.push(decoder.decode(bytes));
|
|
28
|
+
}
|
|
29
|
+
html = html.replace(slot.placeholder, parts.join(''));
|
|
30
|
+
}
|
|
31
|
+
if (html.includes('<!--ACI_SLOT:')) {
|
|
32
|
+
throw new Error('compose shell has unresolved slots');
|
|
33
|
+
}
|
|
34
|
+
return { html, contentType: 'text/html; charset=utf-8' };
|
|
35
|
+
}
|
|
36
|
+
export function validateEnvelope(envelope) {
|
|
37
|
+
if (!envelope || typeof envelope !== 'object') {
|
|
38
|
+
throw new Error('compose envelope is malformed');
|
|
39
|
+
}
|
|
40
|
+
const obj = envelope;
|
|
41
|
+
if (obj.schema !== 'gradial.compose-envelope.v1'
|
|
42
|
+
|| obj.algorithm !== 'aci-html-compose-v1'
|
|
43
|
+
|| !Array.isArray(obj.slots)) {
|
|
44
|
+
throw new Error('compose envelope is malformed');
|
|
45
|
+
}
|
|
46
|
+
const env = envelope;
|
|
47
|
+
validateRef(env.shell);
|
|
48
|
+
const names = new Set();
|
|
49
|
+
for (const slot of env.slots) {
|
|
50
|
+
if (!slot || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(slot.name) || names.has(slot.name)) {
|
|
51
|
+
throw new Error('compose slot is malformed');
|
|
52
|
+
}
|
|
53
|
+
names.add(slot.name);
|
|
54
|
+
if (slot.placeholder !== `<!--ACI_SLOT:${slot.name}-->` || !Array.isArray(slot.parts)) {
|
|
55
|
+
throw new Error(`compose slot ${JSON.stringify(slot.name)} is malformed`);
|
|
56
|
+
}
|
|
57
|
+
for (const part of slot.parts)
|
|
58
|
+
validateRef(part);
|
|
59
|
+
}
|
|
60
|
+
return env;
|
|
61
|
+
}
|
|
62
|
+
function validateRef(ref) {
|
|
63
|
+
const path = String(ref?.path || '');
|
|
64
|
+
if (!path || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
65
|
+
throw new Error('compose object path is invalid');
|
|
66
|
+
}
|
|
67
|
+
if (!/^[0-9a-f]{64}$/.test(String(ref.digest || '').toLowerCase())) {
|
|
68
|
+
throw new Error('compose object digest is invalid');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function requireDigest(bytes, want, label) {
|
|
72
|
+
const ab = new ArrayBuffer(bytes.byteLength);
|
|
73
|
+
new Uint8Array(ab).set(bytes);
|
|
74
|
+
const actual = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', ab)), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
75
|
+
if (actual !== want.toLowerCase())
|
|
76
|
+
throw new Error(`${label} digest mismatch`);
|
|
77
|
+
}
|
|
78
|
+
function occurrences(value, needle) {
|
|
79
|
+
let count = 0;
|
|
80
|
+
let offset = 0;
|
|
81
|
+
while (true) {
|
|
82
|
+
offset = value.indexOf(needle, offset);
|
|
83
|
+
if (offset < 0)
|
|
84
|
+
break;
|
|
85
|
+
count += 1;
|
|
86
|
+
offset += needle.length;
|
|
87
|
+
}
|
|
88
|
+
return count;
|
|
89
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface MimicClaims {
|
|
2
|
+
scope: string;
|
|
3
|
+
siteId: string;
|
|
4
|
+
aud?: string[];
|
|
5
|
+
seg?: string[];
|
|
6
|
+
sub?: string;
|
|
7
|
+
jti?: string;
|
|
8
|
+
iat: string;
|
|
9
|
+
expiresAt: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const MIMIC_MAX_MEMBERSHIPS = 32;
|
|
12
|
+
export declare const MIMIC_MAX_TOKEN_BYTES = 2048;
|
|
13
|
+
export declare function validateMimicToken(token: string, signingKey: string, siteId: string): Promise<MimicClaims | null>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Mimic token validation for the edge runtime (specs/features/audience-mimic).
|
|
2
|
+
//
|
|
3
|
+
// Same HS256 envelope and signing key as preview; the scope claim makes the
|
|
4
|
+
// tokens non-interchangeable (D-6). This validator is the twin of
|
|
5
|
+
// internal/domain/edge/routing/mimic.go, pinned by the shared vector file
|
|
6
|
+
// (internal/domain/edge/routing/testdata/mimic_token_vectors.json); the SDK
|
|
7
|
+
// mirror for framework hosts is packages/sdk/src/mimic/core.ts.
|
|
8
|
+
export const MIMIC_MAX_MEMBERSHIPS = 32;
|
|
9
|
+
export const MIMIC_MAX_TOKEN_BYTES = 2048;
|
|
10
|
+
export async function validateMimicToken(token, signingKey, siteId) {
|
|
11
|
+
if (!token || token.length > MIMIC_MAX_TOKEN_BYTES || !signingKey || !siteId)
|
|
12
|
+
return null;
|
|
13
|
+
const parts = token.split('.');
|
|
14
|
+
if (parts.length !== 3)
|
|
15
|
+
return null;
|
|
16
|
+
const signingInput = parts[0] + '.' + parts[1];
|
|
17
|
+
let signatureBytes;
|
|
18
|
+
try {
|
|
19
|
+
signatureBytes = base64URLDecode(parts[2]);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(signingKey), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
25
|
+
const valid = await crypto.subtle.verify('HMAC', key, signatureBytes, new TextEncoder().encode(signingInput));
|
|
26
|
+
if (!valid)
|
|
27
|
+
return null;
|
|
28
|
+
let claims;
|
|
29
|
+
try {
|
|
30
|
+
claims = JSON.parse(new TextDecoder().decode(base64URLDecode(parts[1])));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (claims.scope !== 'mimic')
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof claims.siteId !== 'string' || claims.siteId !== siteId)
|
|
38
|
+
return null;
|
|
39
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [];
|
|
40
|
+
const seg = Array.isArray(claims.seg) ? claims.seg : [];
|
|
41
|
+
if (aud.length + seg.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
if (aud.length > MIMIC_MAX_MEMBERSHIPS || seg.length > MIMIC_MAX_MEMBERSHIPS)
|
|
44
|
+
return null;
|
|
45
|
+
const expiresAt = new Date(claims.expiresAt);
|
|
46
|
+
if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= Date.now())
|
|
47
|
+
return null;
|
|
48
|
+
return { ...claims, aud, seg };
|
|
49
|
+
}
|
|
50
|
+
function base64URLDecode(value) {
|
|
51
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
52
|
+
+ '='.repeat((4 - value.length % 4) % 4);
|
|
53
|
+
const binary = atob(padded);
|
|
54
|
+
const bytes = new Uint8Array(binary.length);
|
|
55
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
56
|
+
bytes[index] = binary.charCodeAt(index);
|
|
57
|
+
}
|
|
58
|
+
return bytes;
|
|
59
|
+
}
|
|
@@ -8,9 +8,12 @@ export declare function resolvePersonalizationDecision(request: PersonalizationR
|
|
|
8
8
|
* @param data Release-level classification data (audiences, segments, experiments)
|
|
9
9
|
* @param identity Visitor identity (for experiment bucketing). Pass undefined
|
|
10
10
|
* if no identity or privacy denied.
|
|
11
|
+
* @param claims The verified claims the caller already holds (eligibility
|
|
12
|
+
* gate input, ASE-F4). Undefined/null means "no claims" —
|
|
13
|
+
* gated experiments are skipped.
|
|
11
14
|
* @returns Classification — matched audiences, segments, and experiment arm assignments
|
|
12
15
|
*/
|
|
13
|
-
export declare function classify(ctx: PersonalizationRequestContext, data: ClassificationData, identity?: PersonalizationIdentity): Classification;
|
|
16
|
+
export declare function classify(ctx: PersonalizationRequestContext, data: ClassificationData, identity?: PersonalizationIdentity, claims?: PersonalizationClaims | null): Classification;
|
|
14
17
|
/**
|
|
15
18
|
* Phase 2: Decide the winning variant for a specific route.
|
|
16
19
|
*
|
|
@@ -20,6 +20,7 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
|
|
|
20
20
|
const candidates = Object.values(experiments)
|
|
21
21
|
.filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
|
|
22
22
|
.filter((item) => routeHasExperiment(route, item.id))
|
|
23
|
+
.filter((item) => experimentEligible(item, claims))
|
|
23
24
|
.sort((left, right) => (right.priority || 0) - (left.priority || 0)
|
|
24
25
|
|| String(left.id || '').localeCompare(String(right.id || '')));
|
|
25
26
|
for (let index = 0; index < candidates.length; index++) {
|
|
@@ -50,7 +51,8 @@ function selectRuleVariant(request, route, claims, experiments) {
|
|
|
50
51
|
const values = variant.variantValues || variantValuesFromKey(key);
|
|
51
52
|
const experimentID = String(values.experiment || '').trim();
|
|
52
53
|
if (experimentID && (!experiments || !experiments[experimentID]
|
|
53
|
-
|| experiments[experimentID].status !== 'running'
|
|
54
|
+
|| experiments[experimentID].status !== 'running'
|
|
55
|
+
|| !experimentEligible(experiments[experimentID], claims)))
|
|
54
56
|
continue;
|
|
55
57
|
if (!requestDimensionsMatch(request, values))
|
|
56
58
|
continue;
|
|
@@ -64,6 +66,16 @@ function selectRuleVariant(request, route, claims, experiments) {
|
|
|
64
66
|
|| String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
|
|
65
67
|
return matches[0] || null;
|
|
66
68
|
}
|
|
69
|
+
// The eligibility gate (audience-scoped-experiments ASE-F4/F5): an
|
|
70
|
+
// experiment declaring eligibilityAudienceId is skipped entirely — before
|
|
71
|
+
// bucketing or arm candidacy — unless the verified claims carry the ID.
|
|
72
|
+
// Ungated experiments return true, preserving byte-identical behavior.
|
|
73
|
+
function experimentEligible(item, claims) {
|
|
74
|
+
const audienceID = String(item.eligibilityAudienceId || '').trim();
|
|
75
|
+
if (!audienceID)
|
|
76
|
+
return true;
|
|
77
|
+
return !!claims && (claims.aud || []).includes(audienceID);
|
|
78
|
+
}
|
|
67
79
|
function routeHasExperiment(route, experimentID) {
|
|
68
80
|
for (const key in route.variants || {}) {
|
|
69
81
|
const values = route.variants[key].variantValues || variantValuesFromKey(key);
|
|
@@ -279,9 +291,12 @@ function utf8(value) {
|
|
|
279
291
|
* @param data Release-level classification data (audiences, segments, experiments)
|
|
280
292
|
* @param identity Visitor identity (for experiment bucketing). Pass undefined
|
|
281
293
|
* if no identity or privacy denied.
|
|
294
|
+
* @param claims The verified claims the caller already holds (eligibility
|
|
295
|
+
* gate input, ASE-F4). Undefined/null means "no claims" —
|
|
296
|
+
* gated experiments are skipped.
|
|
282
297
|
* @returns Classification — matched audiences, segments, and experiment arm assignments
|
|
283
298
|
*/
|
|
284
|
-
export function classify(ctx, data, identity) {
|
|
299
|
+
export function classify(ctx, data, identity, claims) {
|
|
285
300
|
// Evaluate audience rules
|
|
286
301
|
const matchedAudiences = [];
|
|
287
302
|
for (const key in data.audiences) {
|
|
@@ -307,6 +322,8 @@ export function classify(ctx, data, identity) {
|
|
|
307
322
|
continue;
|
|
308
323
|
if (exp.kind !== 'ab' && exp.kind !== 'abn')
|
|
309
324
|
continue;
|
|
325
|
+
if (!experimentEligible(exp, claims))
|
|
326
|
+
continue;
|
|
310
327
|
const assignment = assignExperiment(exp, identity);
|
|
311
328
|
if (assignment) {
|
|
312
329
|
experimentArms[expId] = assignment;
|
|
@@ -348,21 +365,21 @@ export function decide(record, classification, ctx) {
|
|
|
348
365
|
if (!matched && values.segment && classification.matchedSegments.includes(values.segment)) {
|
|
349
366
|
matched = true;
|
|
350
367
|
}
|
|
351
|
-
// Experiment arm assignment
|
|
368
|
+
// Experiment arm assignment. The arm is a candidate only — committing it
|
|
369
|
+
// inside the win block prevents a losing arm variant from leaving a
|
|
370
|
+
// stale experiment decision behind when another variant wins.
|
|
371
|
+
let armMatch = null;
|
|
352
372
|
if (!matched && experimentID) {
|
|
353
373
|
const arm = classification.experimentArms[experimentID];
|
|
354
374
|
if (arm && arm.variantKey === variant.variantKey) {
|
|
355
375
|
matched = true;
|
|
356
|
-
|
|
376
|
+
armMatch = arm;
|
|
357
377
|
}
|
|
358
378
|
}
|
|
359
379
|
if (matched) {
|
|
360
380
|
if (!bestVariant || betterVariant(variant.priority || 0, variant.variantKey, bestVariant.priority || 0, bestVariant.variantKey)) {
|
|
361
381
|
bestVariant = variant;
|
|
362
|
-
|
|
363
|
-
if (!experimentID || !classification.experimentArms[experimentID]) {
|
|
364
|
-
bestExperiment = null;
|
|
365
|
-
}
|
|
382
|
+
bestExperiment = armMatch;
|
|
366
383
|
}
|
|
367
384
|
}
|
|
368
385
|
}
|
|
@@ -10,20 +10,28 @@ export async function resolvePersonalizationRequest(request, config) {
|
|
|
10
10
|
const cookies = parseCookieHeader(request.headers.get('cookie'));
|
|
11
11
|
const consent = consentSignals(request, cookies);
|
|
12
12
|
const now = (config.now ?? (() => new Date()))();
|
|
13
|
-
// Consent-gated identity model (R25):
|
|
13
|
+
// Consent-gated identity model (R25): artifact selection NEVER mints
|
|
14
14
|
// identity. It reads cookies for classification but never writes them.
|
|
15
|
-
// Identity (aci_id) is minted
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// Identity (aci_id) is minted only after explicit consent — by the
|
|
16
|
+
// collect/consent endpoint, and by the hosting edge router / framework
|
|
17
|
+
// host on consented 200 HTML page responses (which then bypass shared
|
|
18
|
+
// caches). Unconsented content responses stay cookie-free and cacheable
|
|
19
|
+
// (no non-essential cookie before consent).
|
|
18
20
|
//
|
|
19
21
|
// GPC/DNT deny fully: no read, no identity, no experiment bucketing.
|
|
20
|
-
|
|
22
|
+
//
|
|
23
|
+
// Mimic (specs/features/audience-mimic): a validated mimicClaims input
|
|
24
|
+
// bypasses identity resolution entirely — the session reads no aci_id and
|
|
25
|
+
// no aci_claims (D-2/D-7), so identityHash stays empty and the experiment
|
|
26
|
+
// tier is structurally skipped (D-5).
|
|
27
|
+
const mimic = config.mimicClaims;
|
|
28
|
+
const rawIdentity = mimic || consent.privacyDenied
|
|
21
29
|
? null
|
|
22
30
|
: await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
|
|
23
31
|
// The identity hash feeds experiment bucketing for identified visitors.
|
|
24
32
|
// Claims are a signed cookie written by /collect; the EdgeRouter
|
|
25
33
|
// never reads the customer object store to refresh or revoke them.
|
|
26
|
-
let signedClaims = cookies.get('aci_claims') || '';
|
|
34
|
+
let signedClaims = mimic ? '' : cookies.get('aci_claims') || '';
|
|
27
35
|
let identityHash = '';
|
|
28
36
|
if (rawIdentity) {
|
|
29
37
|
identityHash = await identityHashHex('aci_id', rawIdentity);
|
|
@@ -36,9 +44,11 @@ export async function resolvePersonalizationRequest(request, config) {
|
|
|
36
44
|
const matchedRoute = config.route;
|
|
37
45
|
let variantKey = '';
|
|
38
46
|
if (matchedRoute) {
|
|
39
|
-
const claims =
|
|
40
|
-
?
|
|
41
|
-
:
|
|
47
|
+
const claims = mimic
|
|
48
|
+
? { aud: mimic.aud, seg: mimic.seg, iat: 0, exp: 0 }
|
|
49
|
+
: consent.allowsIdentityTier
|
|
50
|
+
? await verifyClaims(signedClaims, config.claimsKeys || {}, now)
|
|
51
|
+
: null;
|
|
42
52
|
const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request), matchedRoute, claims, config.experiments, identityHash
|
|
43
53
|
? { usable: true, siteId: config.siteId, namespace: 'aci_id', identityHash }
|
|
44
54
|
: undefined);
|
|
@@ -75,6 +75,7 @@ export interface PersonalizationExperiment {
|
|
|
75
75
|
priority?: number;
|
|
76
76
|
allocationVersion?: string;
|
|
77
77
|
salt?: string;
|
|
78
|
+
eligibilityAudienceId?: string;
|
|
78
79
|
arms: Array<{
|
|
79
80
|
id: string;
|
|
80
81
|
allocationBasisPoints?: number;
|
|
@@ -169,6 +170,10 @@ export interface PersonalizationHostConfig {
|
|
|
169
170
|
secureCookies?: boolean;
|
|
170
171
|
now?: () => Date;
|
|
171
172
|
randomBytes?: (length: number) => Uint8Array;
|
|
173
|
+
mimicClaims?: {
|
|
174
|
+
aud?: string[];
|
|
175
|
+
seg?: string[];
|
|
176
|
+
};
|
|
172
177
|
}
|
|
173
178
|
export interface PersonalizationResolution {
|
|
174
179
|
variantKey: string;
|
|
@@ -178,4 +183,15 @@ export interface PersonalizationResolution {
|
|
|
178
183
|
allocationVersion: string;
|
|
179
184
|
};
|
|
180
185
|
}
|
|
186
|
+
export interface CompositionDescriptor {
|
|
187
|
+
layoutArtifact: string;
|
|
188
|
+
fragments?: Record<string, string>;
|
|
189
|
+
}
|
|
190
|
+
export type SelectedTargetKind = 'direct' | 'compose' | 'render';
|
|
191
|
+
export interface SelectedTarget {
|
|
192
|
+
kind: SelectedTargetKind;
|
|
193
|
+
path: string;
|
|
194
|
+
digest: string;
|
|
195
|
+
composition?: CompositionDescriptor;
|
|
196
|
+
}
|
|
181
197
|
export { IdentityErasureFencedError } from '../../platform/collectionstore/errors.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface MimicClaims {
|
|
2
|
+
scope: string;
|
|
3
|
+
siteId: string;
|
|
4
|
+
aud?: string[];
|
|
5
|
+
seg?: string[];
|
|
6
|
+
sub?: string;
|
|
7
|
+
jti?: string;
|
|
8
|
+
iat: string;
|
|
9
|
+
expiresAt: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const MIMIC_PARAM = "aci_mimic";
|
|
12
|
+
export declare const MIMIC_COOKIE = "aci_mimic";
|
|
13
|
+
export declare const MIMIC_EXIT = "exit";
|
|
14
|
+
export declare const MIMIC_MAX_MEMBERSHIPS = 32;
|
|
15
|
+
export declare const MIMIC_MAX_TOKEN_BYTES = 2048;
|
|
16
|
+
export declare function validateMimicToken(token: string, signKey: string, siteId: string): Promise<MimicClaims | null>;
|
|
17
|
+
export interface MimicCookie {
|
|
18
|
+
name: string;
|
|
19
|
+
value: string;
|
|
20
|
+
options: {
|
|
21
|
+
httpOnly: boolean;
|
|
22
|
+
sameSite: 'lax';
|
|
23
|
+
maxAge: number;
|
|
24
|
+
path: string;
|
|
25
|
+
secure?: boolean;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare function createMimicEntryCookie(token: string, expiresAt: string, secure?: boolean): MimicCookie;
|
|
29
|
+
export declare function createMimicClearCookie(secure?: boolean): MimicCookie;
|
|
30
|
+
export declare function createMimicBannerHTML(claims: Pick<MimicClaims, 'aud' | 'seg'>, expiresAt: string): string;
|
|
31
|
+
export declare function injectMimicBannerIntoHTML(html: string, banner: string): string;
|
|
32
|
+
export declare const MIMIC_BANNER_HEADER = "x-aci-mimic-banner";
|
|
33
|
+
export declare function encodeMimicBanner(claims: MimicClaims): string;
|
|
34
|
+
export declare function decodeMimicBanner(encoded: string): string;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// View-as-audience mimic session helpers (specs/features/audience-mimic).
|
|
2
|
+
// Mirror of internal/domain/hosting/runtime/mimic.ts for SDK-hosted paths
|
|
3
|
+
// (Next middleware); the token validator is the twin of
|
|
4
|
+
// validateMimicToken in internal/domain/hosting/runtime/edge_router.ts and
|
|
5
|
+
// internal/domain/edge/routing/mimic.go, pinned by the shared vector file
|
|
6
|
+
// (internal/domain/edge/routing/testdata/mimic_token_vectors.json).
|
|
7
|
+
export const MIMIC_PARAM = 'aci_mimic';
|
|
8
|
+
export const MIMIC_COOKIE = 'aci_mimic';
|
|
9
|
+
export const MIMIC_EXIT = 'exit';
|
|
10
|
+
export const MIMIC_MAX_MEMBERSHIPS = 32;
|
|
11
|
+
export const MIMIC_MAX_TOKEN_BYTES = 2048;
|
|
12
|
+
export async function validateMimicToken(token, signKey, siteId) {
|
|
13
|
+
try {
|
|
14
|
+
if (!token || token.length > MIMIC_MAX_TOKEN_BYTES || !signKey || !siteId)
|
|
15
|
+
return null;
|
|
16
|
+
const parts = token.split('.');
|
|
17
|
+
if (parts.length !== 3)
|
|
18
|
+
return null;
|
|
19
|
+
const signingInput = `${parts[0]}.${parts[1]}`;
|
|
20
|
+
const signature = base64URLToBytes(parts[2]);
|
|
21
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(signKey), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
22
|
+
const expected = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)));
|
|
23
|
+
if (!constantTimeEqual(signature, expected))
|
|
24
|
+
return null;
|
|
25
|
+
const claims = JSON.parse(new TextDecoder().decode(base64URLToBytes(parts[1])));
|
|
26
|
+
if (claims.scope !== 'mimic')
|
|
27
|
+
return null;
|
|
28
|
+
if (typeof claims.siteId !== 'string' || claims.siteId !== siteId)
|
|
29
|
+
return null;
|
|
30
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [];
|
|
31
|
+
const seg = Array.isArray(claims.seg) ? claims.seg : [];
|
|
32
|
+
if (aud.length + seg.length === 0)
|
|
33
|
+
return null;
|
|
34
|
+
if (aud.length > MIMIC_MAX_MEMBERSHIPS || seg.length > MIMIC_MAX_MEMBERSHIPS)
|
|
35
|
+
return null;
|
|
36
|
+
const expiresAt = Date.parse(claims.expiresAt);
|
|
37
|
+
if (Number.isNaN(expiresAt) || expiresAt <= Date.now())
|
|
38
|
+
return null;
|
|
39
|
+
return { ...claims, aud, seg };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// The session cookie dies with the token so an expired session self-clears
|
|
46
|
+
// back to the genuine experience (D-12).
|
|
47
|
+
export function createMimicEntryCookie(token, expiresAt, secure = true) {
|
|
48
|
+
const maxAge = Math.max(1, Math.ceil((Date.parse(expiresAt) - Date.now()) / 1000));
|
|
49
|
+
return {
|
|
50
|
+
name: MIMIC_COOKIE,
|
|
51
|
+
value: token,
|
|
52
|
+
options: { httpOnly: true, sameSite: 'lax', maxAge, path: '/', secure },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function createMimicClearCookie(secure = true) {
|
|
56
|
+
return {
|
|
57
|
+
name: MIMIC_COOKIE,
|
|
58
|
+
value: '',
|
|
59
|
+
options: { httpOnly: true, sameSite: 'lax', maxAge: 0, path: '/', secure },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// The banner is the truth signal (REQ-4): present on every mimic render,
|
|
63
|
+
// absent otherwise. v1 lists raw IDs — the documented fallback (spec 04).
|
|
64
|
+
export function createMimicBannerHTML(claims, expiresAt) {
|
|
65
|
+
const names = [...(claims.aud ?? []), ...(claims.seg ?? [])]
|
|
66
|
+
.map(escapeHTML)
|
|
67
|
+
.join(', ');
|
|
68
|
+
const remainingMinutes = Math.max(1, Math.ceil((Date.parse(expiresAt) - Date.now()) / 60000));
|
|
69
|
+
return `<div role="alert" style="position:fixed;top:0;left:0;right:0;z-index:9999;display:flex;align-items:center;justify-content:center;gap:12px;padding:8px 16px;background-color:#0f2e1d;color:#fff;font-size:13px;font-family:system-ui,-apple-system,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,0.3);">
|
|
70
|
+
<span aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background-color:#10b981;color:#0f2e1d;font-weight:700;font-size:11px;flex-shrink:0;">M</span>
|
|
71
|
+
<span style="font-weight:500;">Viewing as audience</span>
|
|
72
|
+
<span style="font-family:monospace;opacity:0.85;font-size:12px;">${names}</span>
|
|
73
|
+
<span style="opacity:0.7;font-size:12px;">expires in ${remainingMinutes} min</span>
|
|
74
|
+
<a href="?${MIMIC_PARAM}=${MIMIC_EXIT}" style="margin-left:auto;padding:4px 12px;border-radius:6px;background-color:#dc2626;color:#fff;text-decoration:none;font-size:12px;font-weight:600;white-space:nowrap;">Exit</a>
|
|
75
|
+
</div>`;
|
|
76
|
+
}
|
|
77
|
+
export function injectMimicBannerIntoHTML(html, banner) {
|
|
78
|
+
return html.includes('</body>') ? html.replace('</body>', `${banner}</body>`) : html + banner;
|
|
79
|
+
}
|
|
80
|
+
// MIMIC_BANNER_HEADER carries the base64url-encoded banner HTML from the
|
|
81
|
+
// middleware to the framework render (the MimicBanner component in
|
|
82
|
+
// next/page.tsx). It is a reserved header: the middleware strips any inbound
|
|
83
|
+
// copy and sets it only for a validated session.
|
|
84
|
+
export const MIMIC_BANNER_HEADER = 'x-aci-mimic-banner';
|
|
85
|
+
export function encodeMimicBanner(claims) {
|
|
86
|
+
return bytesToBase64URL(new TextEncoder().encode(createMimicBannerHTML(claims, claims.expiresAt)));
|
|
87
|
+
}
|
|
88
|
+
export function decodeMimicBanner(encoded) {
|
|
89
|
+
try {
|
|
90
|
+
return new TextDecoder().decode(base64URLToBytes(encoded));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function bytesToBase64URL(bytes) {
|
|
97
|
+
let binary = '';
|
|
98
|
+
for (const byte of bytes)
|
|
99
|
+
binary += String.fromCharCode(byte);
|
|
100
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
101
|
+
}
|
|
102
|
+
function base64URLToBytes(value) {
|
|
103
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
104
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
|
|
105
|
+
const binary = atob(padded);
|
|
106
|
+
const bytes = new Uint8Array(binary.length);
|
|
107
|
+
for (let i = 0; i < binary.length; i++) {
|
|
108
|
+
bytes[i] = binary.charCodeAt(i);
|
|
109
|
+
}
|
|
110
|
+
return bytes;
|
|
111
|
+
}
|
|
112
|
+
function constantTimeEqual(left, right) {
|
|
113
|
+
if (left.length !== right.length)
|
|
114
|
+
return false;
|
|
115
|
+
let diff = 0;
|
|
116
|
+
for (let i = 0; i < left.length; i++) {
|
|
117
|
+
diff |= left[i] ^ right[i];
|
|
118
|
+
}
|
|
119
|
+
return diff === 0;
|
|
120
|
+
}
|
|
121
|
+
function escapeHTML(value) {
|
|
122
|
+
return value
|
|
123
|
+
.replace(/&/g, '&')
|
|
124
|
+
.replace(/</g, '<')
|
|
125
|
+
.replace(/>/g, '>')
|
|
126
|
+
.replace(/"/g, '"');
|
|
127
|
+
}
|
|
@@ -12,6 +12,12 @@ export interface PersonalizationCollectRouteConfig {
|
|
|
12
12
|
* suppression disabled (fails safe).
|
|
13
13
|
*/
|
|
14
14
|
claimsKeys?: Record<string, string>;
|
|
15
|
+
/**
|
|
16
|
+
* Mimic sign key (audience-mimic design/05): a validly signed, unexpired
|
|
17
|
+
* aci_mimic cookie gets the uniform 202 with nothing stored and no claims
|
|
18
|
+
* rotation. An invalid cookie is ignored. Absent = no suppression.
|
|
19
|
+
*/
|
|
20
|
+
mimicSignKey?: string;
|
|
15
21
|
retentionSeconds?: number;
|
|
16
22
|
secureCookies?: boolean;
|
|
17
23
|
now?: () => Date;
|