@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +4 -4
  3. package/dist/audience-data-models.d.ts +96 -0
  4. package/dist/audience-data-models.js +52 -0
  5. package/dist/cli/compile-registry.d.mts +5 -1
  6. package/dist/cli/compile-registry.mjs +25 -10
  7. package/dist/cli/validate-content.d.mts +31 -1
  8. package/dist/cli/validate-content.mjs +50 -12
  9. package/dist/collect/aci.js +1 -1
  10. package/dist/collect/aci.js.sha256 +1 -1
  11. package/dist/collect/api.d.ts +3 -7
  12. package/dist/collect/api.js +2 -8
  13. package/dist/collect/index.js +1 -1
  14. package/dist/content/provider.d.ts +1 -0
  15. package/dist/content/resolve-slots.d.ts +9 -2
  16. package/dist/content/resolve-slots.js +5 -3
  17. package/dist/content/types.d.ts +2 -0
  18. package/dist/edge/personalization-core.js +13 -1
  19. package/dist/internal/domain/edge/compose.d.ts +24 -0
  20. package/dist/internal/domain/edge/compose.js +89 -0
  21. package/dist/internal/domain/edge/index.d.ts +2 -0
  22. package/dist/internal/domain/edge/index.js +2 -0
  23. package/dist/internal/domain/edge/mimic_token.d.ts +13 -0
  24. package/dist/internal/domain/edge/mimic_token.js +59 -0
  25. package/dist/internal/domain/edge/personalization-core.d.ts +4 -1
  26. package/dist/internal/domain/edge/personalization-core.js +25 -8
  27. package/dist/internal/domain/edge/resolve.js +19 -9
  28. package/dist/internal/domain/edge/types.d.ts +16 -0
  29. package/dist/mimic/core.d.ts +34 -0
  30. package/dist/mimic/core.js +127 -0
  31. package/dist/next/collect-route.d.ts +6 -0
  32. package/dist/next/collect-route.js +15 -0
  33. package/dist/next/error-seat-payload.js +1 -1
  34. package/dist/next/hosted-page-target.d.ts +7 -0
  35. package/dist/next/hosted-page-target.js +39 -0
  36. package/dist/next/index.d.ts +0 -1
  37. package/dist/next/index.js +0 -1
  38. package/dist/next/page.d.ts +29 -6
  39. package/dist/next/page.js +47 -25
  40. package/dist/next/personalization-host.d.ts +4 -0
  41. package/dist/next/personalization-host.js +18 -7
  42. package/dist/next/server.js +28 -1
  43. package/dist/next/vercel-edge-projection-v1.js +7 -1
  44. package/dist/next/vercel-s3-personalization.d.ts +1 -0
  45. package/dist/next/vercel-s3-personalization.js +1 -0
  46. package/dist/providers/s3.js +11 -4
  47. package/dist/types/block-ref.d.ts +2 -0
  48. package/dist/types/block-ref.js +1 -0
  49. package/package.json +7 -7
  50. package/src/cli/compile-registry.mjs +24 -10
  51. package/src/cli/validate-content.mjs +52 -13
  52. package/src/types/block-ref.test.ts +45 -0
  53. package/src/types/block-ref.ts +2 -0
  54. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  55. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  56. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  57. package/templates/astro/template/package.json.tmpl +1 -1
  58. package/templates/astro/template/src/layouts/SiteLayout.astro +2 -2
  59. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  60. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  61. package/templates/common/.content/pages/{home/_index.json → _index.json} +10 -0
  62. package/templates/common/.content/schemas/site.schema.json +1 -1
  63. package/templates/common/.nvmrc +1 -1
  64. package/templates/common/starter-content/.content/.rules-baseline.json +8 -0
  65. package/templates/common/starter-content/.content/config/site.json +1 -1
  66. package/templates/common/starter-content/.content/content-rules/no-public-resource-paths.json +6 -0
  67. package/templates/common/starter-content/.content/fragments/navbar/_index.json +23 -20
  68. package/templates/common/starter-content/.content/fragments/site-footer/_index.json +59 -56
  69. package/templates/common/starter-content/.content/pages/404/_index.json +1 -0
  70. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  71. package/templates/common/starter-content/.content/pages/error/_index.json +1 -0
  72. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  73. package/templates/common/starter-content/.content/patterns/components/button.json +55 -0
  74. package/templates/common/starter-content/.content/patterns/components/container.json +62 -0
  75. package/templates/common/starter-content/.content/patterns/components/feature_card.json +30 -0
  76. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +57 -0
  77. package/templates/common/starter-content/.content/patterns/components/home_hero.json +142 -0
  78. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +42 -0
  79. package/templates/common/starter-content/.content/patterns/components/site-footer.json +203 -0
  80. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +115 -0
  81. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +45 -0
  82. package/templates/common/starter-content/.content/patterns/layouts/default.json +14 -0
  83. package/templates/common/starter-content/.content/schemas/site.schema.json +1 -1
  84. package/templates/nextjs/template/.aci.yaml +10 -0
  85. package/templates/nextjs/template/package.json.tmpl +2 -1
  86. package/templates/nextjs/template/src/app/_components/c15t-bridge.ts +30 -0
  87. package/templates/nextjs/template/src/app/_components/c15t-consent-ui.tsx +37 -0
  88. package/templates/nextjs/template/src/app/_components/consent-manager.css +63 -0
  89. package/templates/nextjs/template/src/app/_components/consent-manager.tsx +76 -0
  90. package/templates/nextjs/template/src/app/layout.tsx +2 -0
  91. package/dist/next/middleware.d.ts +0 -40
  92. package/dist/next/middleware.js +0 -475
  93. /package/templates/common/.content/overlays/audience/preference-blue/pages/{home/_index.json → _index.json} +0 -0
  94. /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 FragmentData {
4
- id: string;
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
- id: fragment.id,
16
- component: fragment.component,
17
- props: fragment.props || {},
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;
@@ -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
+ }
@@ -1,4 +1,6 @@
1
1
  export * from './types.js';
2
2
  export * from './personalization-core.js';
3
3
  export * from './identity.js';
4
+ export * from './mimic_token.js';
5
+ export * from './compose.js';
4
6
  export { resolvePersonalizationRequest, } from './resolve.js';
@@ -3,4 +3,6 @@
3
3
  export * from './types.js';
4
4
  export * from './personalization-core.js';
5
5
  export * from './identity.js';
6
+ export * from './mimic_token.js';
7
+ export * from './compose.js';
6
8
  export { resolvePersonalizationRequest, } from './resolve.js';
@@ -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
- bestExperiment = arm;
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
- // Only set experiment if this variant's experiment arm won
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): static artifact selection NEVER mints
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 by the collect/consent endpoint after
16
- // explicit consent. This keeps all content responses cacheable and is
17
- // GDPR-compliant (no non-essential cookie before consent).
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
- const rawIdentity = consent.privacyDenied
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 = consent.allowsIdentityTier
40
- ? await verifyClaims(signedClaims, config.claimsKeys || {}, now)
41
- : null;
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, '&amp;')
124
+ .replace(/</g, '&lt;')
125
+ .replace(/>/g, '&gt;')
126
+ .replace(/"/g, '&quot;');
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;