@gradial/aci 0.1.52-rc.1 → 0.1.52-rc.10
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/README.md +4 -4
- package/dist/audience-data-models.d.ts +9 -0
- package/dist/audience-data-models.js +3 -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/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 +1 -0
- package/dist/internal/domain/edge/index.js +1 -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 +6 -4
- package/dist/internal/domain/edge/types.d.ts +12 -0
- package/dist/next/error-seat-payload.js +1 -1
- package/dist/next/page.js +2 -2
- package/dist/next/vercel-edge-projection-v1.js +7 -1
- 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/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/starter-content/.content/.rules-baseline.json +8 -0
- 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/nextjs/template/.aci.yaml +10 -0
- /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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Agentic Content Infrastructure SDK
|
|
2
2
|
|
|
3
|
-
`@gradial/aci` `0.1.52-rc.
|
|
3
|
+
`@gradial/aci` `0.1.52-rc.10` is a pre-1.0 public-surface reset for the ACI
|
|
4
4
|
client SDK. Breaking changes from the old `0.1.x` accidental surface are
|
|
5
5
|
intentional: the root import is framework-neutral, framework runtime behavior is
|
|
6
6
|
behind explicit subpaths, and the first required customer path is React/Next.
|
|
@@ -8,7 +8,7 @@ behind explicit subpaths, and the first required customer path is React/Next.
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
npm install @gradial/aci@0.1.52-rc.
|
|
11
|
+
npm install @gradial/aci@0.1.52-rc.10 zod@^4
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
## Root Import
|
|
@@ -78,13 +78,13 @@ host bundles select and serve immutable release artifacts.
|
|
|
78
78
|
The first RC scaffold path is Next:
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
npx @gradial/aci@0.1.52-rc.
|
|
81
|
+
npx @gradial/aci@0.1.52-rc.10 init my-site --template next-starter
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
List available templates:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
npx @gradial/aci@0.1.52-rc.
|
|
87
|
+
npx @gradial/aci@0.1.52-rc.10 init --list
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
## Personalization: The One-Tag Install
|
|
@@ -336,6 +336,9 @@ export declare const experimentDataModel: import("./index.js").DataModelContract
|
|
|
336
336
|
routes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
337
337
|
fragments: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
338
338
|
}, z.core.$strip>;
|
|
339
|
+
eligibility: z.ZodOptional<z.ZodObject<{
|
|
340
|
+
audienceRef: z.ZodString;
|
|
341
|
+
}, z.core.$strict>>;
|
|
339
342
|
primaryGoalRef: z.ZodOptional<z.ZodString>;
|
|
340
343
|
primaryGoalId: z.ZodOptional<z.ZodString>;
|
|
341
344
|
goalRefs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -426,6 +429,9 @@ export declare const experimentationDataModels: (import("./index.js").DataModelC
|
|
|
426
429
|
routes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
427
430
|
fragments: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
428
431
|
}, z.core.$strip>;
|
|
432
|
+
eligibility: z.ZodOptional<z.ZodObject<{
|
|
433
|
+
audienceRef: z.ZodString;
|
|
434
|
+
}, z.core.$strict>>;
|
|
429
435
|
primaryGoalRef: z.ZodOptional<z.ZodString>;
|
|
430
436
|
primaryGoalId: z.ZodOptional<z.ZodString>;
|
|
431
437
|
goalRefs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -794,6 +800,9 @@ export declare const audienceDataModels: (import("./index.js").DataModelContract
|
|
|
794
800
|
routes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
795
801
|
fragments: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
796
802
|
}, z.core.$strip>;
|
|
803
|
+
eligibility: z.ZodOptional<z.ZodObject<{
|
|
804
|
+
audienceRef: z.ZodString;
|
|
805
|
+
}, z.core.$strict>>;
|
|
797
806
|
primaryGoalRef: z.ZodOptional<z.ZodString>;
|
|
798
807
|
primaryGoalId: z.ZodOptional<z.ZodString>;
|
|
799
808
|
goalRefs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -127,6 +127,9 @@ export const experimentDataModel = defineDataModel({
|
|
|
127
127
|
routes: z.array(z.string().min(1)).optional(),
|
|
128
128
|
fragments: z.array(z.string().min(1)).optional(),
|
|
129
129
|
}),
|
|
130
|
+
eligibility: z.object({
|
|
131
|
+
audienceRef: z.string().min(1),
|
|
132
|
+
}).strict().optional(),
|
|
130
133
|
primaryGoalRef: z.string().optional(),
|
|
131
134
|
primaryGoalId: z.string().optional(),
|
|
132
135
|
goalRefs: z.array(z.string().min(1)).optional(),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
2
3
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
6
|
import { z } from 'zod';
|
|
6
7
|
import { ImageSchema } from '../types/image.ts';
|
|
7
8
|
import { VideoSchema } from '../types/video.ts';
|
|
@@ -18,14 +19,16 @@ try {
|
|
|
18
19
|
globalThis.React = React;
|
|
19
20
|
}
|
|
20
21
|
catch { /* react not available — component files without JSX will still work */ }
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
if (isMainModule()) {
|
|
23
|
+
const args = parseArgs(process.argv.slice(2));
|
|
24
|
+
try {
|
|
25
|
+
const result = await compileRegistry(args);
|
|
26
|
+
console.log(JSON.stringify({ success: true, ...result, errors: [] }, null, 2));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.error(JSON.stringify({ success: false, errors: [String(error?.message ?? error)] }, null, 2));
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
29
32
|
}
|
|
30
33
|
async function compileRegistry(options) {
|
|
31
34
|
const required = ['components', 'layouts', 'output'];
|
|
@@ -440,9 +443,10 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
|
|
|
440
443
|
const defs = {
|
|
441
444
|
baseBlock: {
|
|
442
445
|
type: 'object',
|
|
443
|
-
required: ['id', 'component', 'props'],
|
|
446
|
+
required: ['$type', 'id', 'component', 'props'],
|
|
444
447
|
additionalProperties: true,
|
|
445
448
|
properties: {
|
|
449
|
+
$type: { const: 'component' },
|
|
446
450
|
id: { type: 'string', minLength: 1 },
|
|
447
451
|
component: { type: 'string' },
|
|
448
452
|
props: { type: 'object' },
|
|
@@ -757,3 +761,14 @@ function parseArgs(argv) {
|
|
|
757
761
|
}
|
|
758
762
|
return out;
|
|
759
763
|
}
|
|
764
|
+
function isMainModule() {
|
|
765
|
+
if (!process.argv[1])
|
|
766
|
+
return false;
|
|
767
|
+
try {
|
|
768
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
export { compileRegistry, generatePageSchema };
|
|
@@ -1,2 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export {
|
|
2
|
+
export function validateContent(options: any): Promise<{
|
|
3
|
+
warnings?: any[] | undefined;
|
|
4
|
+
success: boolean;
|
|
5
|
+
pagesValidated: number;
|
|
6
|
+
blocksValidated: number;
|
|
7
|
+
errors: ({
|
|
8
|
+
page: string;
|
|
9
|
+
message: string;
|
|
10
|
+
fragment?: undefined;
|
|
11
|
+
component?: undefined;
|
|
12
|
+
path?: undefined;
|
|
13
|
+
} | {
|
|
14
|
+
fragment: string;
|
|
15
|
+
message: string;
|
|
16
|
+
page?: undefined;
|
|
17
|
+
component?: undefined;
|
|
18
|
+
path?: undefined;
|
|
19
|
+
} | {
|
|
20
|
+
fragment: string;
|
|
21
|
+
component: any;
|
|
22
|
+
message: string;
|
|
23
|
+
page?: undefined;
|
|
24
|
+
path?: undefined;
|
|
25
|
+
} | {
|
|
26
|
+
fragment: string;
|
|
27
|
+
component: any;
|
|
28
|
+
path: any;
|
|
29
|
+
message: any;
|
|
30
|
+
page?: undefined;
|
|
31
|
+
})[];
|
|
32
|
+
}>;
|
|
@@ -19,21 +19,25 @@
|
|
|
19
19
|
* Uses the JSON schemas generated by compile-registry — portable, no React/CSS
|
|
20
20
|
* imports needed, same schemas usable by Go backend or other tools.
|
|
21
21
|
*/
|
|
22
|
+
import { realpathSync } from 'node:fs';
|
|
22
23
|
import { readdir, readFile } from 'node:fs/promises';
|
|
23
24
|
import path from 'node:path';
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
if (isMainModule()) {
|
|
27
|
+
const args = parseArgs(process.argv.slice(2));
|
|
28
|
+
try {
|
|
29
|
+
const result = await validateContent(args);
|
|
30
|
+
console.log(JSON.stringify(result, null, 2));
|
|
31
|
+
if (!result.success)
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error(JSON.stringify({
|
|
36
|
+
success: false,
|
|
37
|
+
errors: [{ message: String(error?.message ?? error) }],
|
|
38
|
+
}, null, 2));
|
|
29
39
|
process.exitCode = 1;
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
console.error(JSON.stringify({
|
|
33
|
-
success: false,
|
|
34
|
-
errors: [{ message: String(error?.message ?? error) }],
|
|
35
|
-
}, null, 2));
|
|
36
|
-
process.exitCode = 1;
|
|
40
|
+
}
|
|
37
41
|
}
|
|
38
42
|
// ---------------------------------------------------------------------------
|
|
39
43
|
// Main
|
|
@@ -86,6 +90,22 @@ async function validateContent(options) {
|
|
|
86
90
|
const blocks = collectBlocks(pageData);
|
|
87
91
|
for (const block of blocks) {
|
|
88
92
|
blocksValidated++;
|
|
93
|
+
if (!block || typeof block !== 'object') {
|
|
94
|
+
collector.push({
|
|
95
|
+
page: relativePath,
|
|
96
|
+
blockId: '<unknown>',
|
|
97
|
+
message: 'page region entry must be a component block',
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (block.$type !== 'component') {
|
|
102
|
+
collector.push({
|
|
103
|
+
page: relativePath,
|
|
104
|
+
blockId: block.id ?? '<unknown>',
|
|
105
|
+
path: '$type',
|
|
106
|
+
message: 'block "$type" must be "component"',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
89
109
|
if (!block.component) {
|
|
90
110
|
collector.push({
|
|
91
111
|
page: relativePath,
|
|
@@ -348,6 +368,13 @@ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props
|
|
|
348
368
|
issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
|
|
349
369
|
continue;
|
|
350
370
|
}
|
|
371
|
+
if (nestedBlock.$type !== 'component') {
|
|
372
|
+
issues.push({
|
|
373
|
+
path: `${nestedPath}.$type`,
|
|
374
|
+
message: `${parentBlock.component}: block ref child "$type" must be "component"`,
|
|
375
|
+
});
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
351
378
|
if (!componentMap.has(nestedBlock.component)) {
|
|
352
379
|
issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
|
|
353
380
|
continue;
|
|
@@ -424,3 +451,14 @@ function parseArgs(argv) {
|
|
|
424
451
|
}
|
|
425
452
|
return out;
|
|
426
453
|
}
|
|
454
|
+
function isMainModule() {
|
|
455
|
+
if (!process.argv[1])
|
|
456
|
+
return false;
|
|
457
|
+
try {
|
|
458
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
export { validateContent };
|
|
@@ -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
|
+
}
|
|
@@ -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,11 +10,13 @@ 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
|
const rawIdentity = consent.privacyDenied
|
|
@@ -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;
|
|
@@ -178,4 +179,15 @@ export interface PersonalizationResolution {
|
|
|
178
179
|
allocationVersion: string;
|
|
179
180
|
};
|
|
180
181
|
}
|
|
182
|
+
export interface CompositionDescriptor {
|
|
183
|
+
layoutArtifact: string;
|
|
184
|
+
fragments?: Record<string, string>;
|
|
185
|
+
}
|
|
186
|
+
export type SelectedTargetKind = 'direct' | 'compose' | 'render';
|
|
187
|
+
export interface SelectedTarget {
|
|
188
|
+
kind: SelectedTargetKind;
|
|
189
|
+
path: string;
|
|
190
|
+
digest: string;
|
|
191
|
+
composition?: CompositionDescriptor;
|
|
192
|
+
}
|
|
181
193
|
export { IdentityErasureFencedError } from '../../platform/collectionstore/errors.js';
|
|
@@ -104,7 +104,7 @@ export function buildErrorSeatPayload(contentRoot, posture) {
|
|
|
104
104
|
if (!fragmentRef)
|
|
105
105
|
continue;
|
|
106
106
|
const fragment = readJSON(root, fragmentRef);
|
|
107
|
-
blocks.push({ id: fragment.id, component: fragment.component, props: fragment.props || {} });
|
|
107
|
+
blocks.push({ $type: 'component', id: fragment.id, component: fragment.component, props: fragment.props || {} });
|
|
108
108
|
}
|
|
109
109
|
if (blocks.length)
|
|
110
110
|
slots[slot.name] = blocks;
|
package/dist/next/page.js
CHANGED
|
@@ -84,9 +84,9 @@ export function createPage(registry, options) {
|
|
|
84
84
|
}
|
|
85
85
|
return (_jsx(Fragment, { children: renderedBlocks }, slot.name));
|
|
86
86
|
}));
|
|
87
|
-
return (_jsxs(_Fragment, { children: [renderProofContent
|
|
87
|
+
return (_jsxs(_Fragment, { children: [renderedSlots.filter(Boolean), _jsx(AssignmentsScript, { page: input.page }), _jsx(CollectScript, { siteConfig: input.siteConfig }), renderProofContent
|
|
88
88
|
? _jsx("meta", { name: "aci-render-proof", content: renderProofContent })
|
|
89
|
-
: null
|
|
89
|
+
: null] }));
|
|
90
90
|
}
|
|
91
91
|
catch (error) {
|
|
92
92
|
if (error instanceof PageNotFoundError)
|