@gradial/aci 0.1.52-rc.10 → 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 (45) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +4 -4
  3. package/dist/audience-data-models.d.ts +87 -0
  4. package/dist/audience-data-models.js +49 -0
  5. package/dist/collect/aci.js +1 -1
  6. package/dist/collect/aci.js.sha256 +1 -1
  7. package/dist/collect/api.d.ts +3 -7
  8. package/dist/collect/api.js +2 -8
  9. package/dist/collect/index.js +1 -1
  10. package/dist/internal/domain/edge/index.d.ts +1 -0
  11. package/dist/internal/domain/edge/index.js +1 -0
  12. package/dist/internal/domain/edge/mimic_token.d.ts +13 -0
  13. package/dist/internal/domain/edge/mimic_token.js +59 -0
  14. package/dist/internal/domain/edge/resolve.js +13 -5
  15. package/dist/internal/domain/edge/types.d.ts +4 -0
  16. package/dist/mimic/core.d.ts +34 -0
  17. package/dist/mimic/core.js +127 -0
  18. package/dist/next/collect-route.d.ts +6 -0
  19. package/dist/next/collect-route.js +15 -0
  20. package/dist/next/hosted-page-target.d.ts +7 -0
  21. package/dist/next/hosted-page-target.js +39 -0
  22. package/dist/next/index.d.ts +0 -1
  23. package/dist/next/index.js +0 -1
  24. package/dist/next/page.d.ts +29 -6
  25. package/dist/next/page.js +46 -24
  26. package/dist/next/personalization-host.d.ts +4 -0
  27. package/dist/next/personalization-host.js +18 -7
  28. package/dist/next/server.js +28 -1
  29. package/dist/next/vercel-s3-personalization.d.ts +1 -0
  30. package/dist/next/vercel-s3-personalization.js +1 -0
  31. package/package.json +7 -7
  32. package/templates/astro/template/package.json.tmpl +1 -1
  33. package/templates/astro/template/src/layouts/SiteLayout.astro +2 -2
  34. package/templates/common/.content/schemas/site.schema.json +1 -1
  35. package/templates/common/.nvmrc +1 -1
  36. package/templates/common/starter-content/.content/config/site.json +1 -1
  37. package/templates/common/starter-content/.content/schemas/site.schema.json +1 -1
  38. package/templates/nextjs/template/package.json.tmpl +2 -1
  39. package/templates/nextjs/template/src/app/_components/c15t-bridge.ts +30 -0
  40. package/templates/nextjs/template/src/app/_components/c15t-consent-ui.tsx +37 -0
  41. package/templates/nextjs/template/src/app/_components/consent-manager.css +63 -0
  42. package/templates/nextjs/template/src/app/_components/consent-manager.tsx +76 -0
  43. package/templates/nextjs/template/src/app/layout.tsx +2 -0
  44. package/dist/next/middleware.d.ts +0 -40
  45. package/dist/next/middleware.js +0 -475
@@ -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;
@@ -1,5 +1,6 @@
1
1
  import { MAX_BATCH_EVENTS } from '../collect/batcher.js';
2
2
  import { computeEnvelopeDigest } from './envelope-digest.js';
3
+ import { validateMimicToken } from '../mimic/core.js';
3
4
  import { consentSignals, IdentityErasureFencedError, identityHashHex, parseCookieHeader, verifyClaims, verifyIdentityCookie, } from './personalization-host.js';
4
5
  const MAX_COLLECT_BODY_BYTES = 64 * 1024;
5
6
  const DEFAULT_RETENTION_SECONDS = 13 * 30 * 24 * 60 * 60;
@@ -78,6 +79,20 @@ export function createPersonalizationCollectRoute(config) {
78
79
  return validationResponse(error);
79
80
  }
80
81
  const cookies = parseCookieHeader(request.headers.get('cookie'));
82
+ // Mimic suppression (audience-mimic REQ-5): a validly signed, unexpired
83
+ // aci_mimic cookie marks a marketer's view-as session — accept uniformly,
84
+ // store nothing, and never rotate the visitor's real aci_claims. Placed
85
+ // after schema validation so the 400/202 split stays independent of
86
+ // session type; an invalid cookie is ignored (events process normally).
87
+ if (config.mimicSignKey) {
88
+ const mimicCookie = cookies.get('aci_mimic') || '';
89
+ if (mimicCookie) {
90
+ const mimic = await validateMimicToken(mimicCookie, config.mimicSignKey, config.siteId);
91
+ if (mimic) {
92
+ return accepted(new Headers({ 'cache-control': 'no-store', 'content-type': 'application/json' }));
93
+ }
94
+ }
95
+ }
81
96
  const signals = consentSignals(request, cookies);
82
97
  const rawIdentity = await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
83
98
  const now = (config.now ?? (() => new Date()))();
@@ -0,0 +1,7 @@
1
+ export type HostedPageTarget = Readonly<{
2
+ kind: 'base' | 'variant';
3
+ route: string;
4
+ pageRef: string;
5
+ variantKey?: string;
6
+ }>;
7
+ export declare function parseHostedPageTarget(rawSegments: readonly string[]): HostedPageTarget | null;
@@ -0,0 +1,39 @@
1
+ const VARIANT_NAMESPACE = '__variant';
2
+ export function parseHostedPageTarget(rawSegments) {
3
+ const segments = decodeSegments(rawSegments);
4
+ if (!segments)
5
+ return null;
6
+ const variantAt = segments.indexOf(VARIANT_NAMESPACE);
7
+ if (variantAt < 0)
8
+ return target('base', segments, segments);
9
+ if (variantAt !== segments.lastIndexOf(VARIANT_NAMESPACE) || variantAt !== segments.length - 2) {
10
+ return null;
11
+ }
12
+ return {
13
+ ...target('variant', segments.slice(0, variantAt), segments),
14
+ variantKey: segments[variantAt + 1],
15
+ };
16
+ }
17
+ function target(kind, routeSegments, targetSegments) {
18
+ return {
19
+ kind,
20
+ route: routeSegments.length === 0 ? '/' : `/${routeSegments.join('/')}/`,
21
+ pageRef: ['routes', ...targetSegments, 'index.json'].join('/'),
22
+ };
23
+ }
24
+ function decodeSegments(rawSegments) {
25
+ const segments = [];
26
+ for (const raw of rawSegments) {
27
+ let segment;
28
+ try {
29
+ segment = decodeURIComponent(raw);
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (!segment || segment === '.' || segment === '..' || segment.includes('/'))
35
+ return null;
36
+ segments.push(segment);
37
+ }
38
+ return segments;
39
+ }
@@ -5,7 +5,6 @@ export { createPage, type CreatePageOptions, type SlotPropsFn } from './page.js'
5
5
  export { getSiteConfig, getFragment, getLayout, getPage, getRenderInput, routeFromNextParams } from './server.js';
6
6
  export { PageNotFoundError, FragmentNotFoundError, LayoutNotFoundError } from './server.js';
7
7
  export { CollectScript, type CollectScriptProps } from './collect-script.js';
8
- export { aciMiddlewareMatcher } from './middleware.js';
9
8
  export { AssignmentsScript, type AssignmentsScriptProps } from './assignments-script.js';
10
9
  export { createNotFound, renderPageForRoute, resolveErrorPage, type NextResolvedErrorPage, } from './error-pages.js';
11
10
  export { ErrorPageInstrumentation } from './error-page-instrumentation.js';
@@ -5,7 +5,6 @@ export { createPage } from './page.js';
5
5
  export { getSiteConfig, getFragment, getLayout, getPage, getRenderInput, routeFromNextParams } from './server.js';
6
6
  export { PageNotFoundError, FragmentNotFoundError, LayoutNotFoundError } from './server.js';
7
7
  export { CollectScript } from './collect-script.js';
8
- export { aciMiddlewareMatcher } from './middleware.js';
9
8
  export { AssignmentsScript } from './assignments-script.js';
10
9
  export { createNotFound, renderPageForRoute, resolveErrorPage, } from './error-pages.js';
11
10
  export { ErrorPageInstrumentation } from './error-page-instrumentation.js';
@@ -31,12 +31,10 @@ export interface CreatePageOptions {
31
31
  * the collect script tag exactly once per page (design/04 contract tag).
32
32
  *
33
33
  * Release-scoped routes (production): the middleware rewrites a public URL
34
- * to `/__r/{releaseId}/routes/{path}`. The optional catch-all `[[...slug]]`
35
- * receives `['__r', releaseId, 'routes', ...pathSegments]`. createPage
36
- * extracts the releaseId and passes it to the content loader, giving
37
- * each release a unique CDN cache key without a separate route folder.
38
- * The `routes` namespace segment leaves room for future siblings under
39
- * the release prefix (e.g. `/__r/{releaseId}/assets/*`).
34
+ * to `/__r/{releaseId}/{targetRef}`. The target ref selects the exact immutable
35
+ * base or variant page while its prefix identifies the logical public route.
36
+ * createPage passes both identities to the content loader, giving each target
37
+ * a distinct CDN cache key without sending selection headers into Next.
40
38
  *
41
39
  * Dev pass-through (ACI_CONTENT_PROVIDER=file): the middleware passes
42
40
  * requests through unrewritten, so `/` arrives with an empty/undefined
@@ -54,4 +52,29 @@ export declare function createPage(registry: Registry, options?: CreatePageOptio
54
52
  generateMetadata: ({ params }: NextPageProps) => Promise<Metadata>;
55
53
  default: ({ params }: NextPageProps) => Promise<import("react").JSX.Element>;
56
54
  };
55
+ /**
56
+ * Resolves the logical route and releaseId from Next.js catch-all params.
57
+ *
58
+ * Two shapes are supported:
59
+ *
60
+ * 1. **Rewritten (production):** middleware rewrites a public URL to the exact
61
+ * selected target under `/__r/{releaseId}`. A root variant therefore has
62
+ * `['__r', releaseId, '__variant', variantKey]`. The full suffix selects the
63
+ * immutable page object; the segments before the reserved `__variant`
64
+ * namespace form the logical public route.
65
+ *
66
+ * 2. **Dev pass-through (ACI_CONTENT_PROVIDER=file):** middleware passes
67
+ * requests through unrewritten, so the slug is the raw public path
68
+ * segments (possibly empty/undefined for `/`). No releaseId is present;
69
+ * the file provider serves content from disk.
70
+ */
71
+ export declare function resolveRouteAndRelease(params: Promise<{
72
+ slug?: string[];
73
+ }> | {
74
+ slug?: string[];
75
+ }): Promise<{
76
+ route: string;
77
+ releaseId?: string;
78
+ pageRef?: string;
79
+ }>;
57
80
  export {};
package/dist/next/page.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { notFound } from 'next/navigation.js';
3
+ import { headers } from 'next/headers.js';
3
4
  import { Fragment } from 'react';
4
5
  import { PageNotFoundError, getRenderInput, getLayout, getFragment, } from './server.js';
6
+ import { MIMIC_BANNER_HEADER, decodeMimicBanner } from '../mimic/core.js';
5
7
  import { routeMetadataForContent, resolveSlots, } from '../content/index.js';
6
8
  import { renderBlocks } from './render.js';
7
9
  import { AssignmentsScript } from './assignments-script.js';
8
10
  import { CollectScript } from './collect-script.js';
9
11
  import { renderProofContentFor } from '../internal/render-proof.js';
12
+ import { parseHostedPageTarget } from './hosted-page-target.js';
10
13
  /**
11
14
  * Creates the catch-all route handler for a Next.js ACI site.
12
15
  *
@@ -28,12 +31,10 @@ import { renderProofContentFor } from '../internal/render-proof.js';
28
31
  * the collect script tag exactly once per page (design/04 contract tag).
29
32
  *
30
33
  * Release-scoped routes (production): the middleware rewrites a public URL
31
- * to `/__r/{releaseId}/routes/{path}`. The optional catch-all `[[...slug]]`
32
- * receives `['__r', releaseId, 'routes', ...pathSegments]`. createPage
33
- * extracts the releaseId and passes it to the content loader, giving
34
- * each release a unique CDN cache key without a separate route folder.
35
- * The `routes` namespace segment leaves room for future siblings under
36
- * the release prefix (e.g. `/__r/{releaseId}/assets/*`).
34
+ * to `/__r/{releaseId}/{targetRef}`. The target ref selects the exact immutable
35
+ * base or variant page while its prefix identifies the logical public route.
36
+ * createPage passes both identities to the content loader, giving each target
37
+ * a distinct CDN cache key without sending selection headers into Next.
37
38
  *
38
39
  * Dev pass-through (ACI_CONTENT_PROVIDER=file): the middleware passes
39
40
  * requests through unrewritten, so `/` arrives with an empty/undefined
@@ -49,8 +50,9 @@ import { renderProofContentFor } from '../internal/render-proof.js';
49
50
  export function createPage(registry, options) {
50
51
  const dynamic = 'force-dynamic';
51
52
  async function generateMetadata({ params }) {
52
- const { route, releaseId } = await resolveRouteAndRelease(params);
53
- const input = await getRenderInput(route, { releaseId });
53
+ const { route, releaseId, pageRef } = await resolveRouteAndRelease(params);
54
+ const fetchConfig = { releaseId, pageRef };
55
+ const input = await getRenderInput(route, fetchConfig);
54
56
  const meta = routeMetadataForContent(input.siteConfig, input.page);
55
57
  return {
56
58
  title: meta.title,
@@ -64,8 +66,8 @@ export function createPage(registry, options) {
64
66
  };
65
67
  }
66
68
  async function CatchAllPage({ params }) {
67
- const { route, releaseId } = await resolveRouteAndRelease(params);
68
- const fetchConfig = { releaseId };
69
+ const { route, releaseId, pageRef } = await resolveRouteAndRelease(params);
70
+ const fetchConfig = { releaseId, pageRef };
69
71
  try {
70
72
  const input = await getRenderInput(route, fetchConfig);
71
73
  if (!input.page)
@@ -84,7 +86,7 @@ export function createPage(registry, options) {
84
86
  }
85
87
  return (_jsx(Fragment, { children: renderedBlocks }, slot.name));
86
88
  }));
87
- return (_jsxs(_Fragment, { children: [renderedSlots.filter(Boolean), _jsx(AssignmentsScript, { page: input.page }), _jsx(CollectScript, { siteConfig: input.siteConfig }), renderProofContent
89
+ return (_jsxs(_Fragment, { children: [renderedSlots.filter(Boolean), _jsx(AssignmentsScript, { page: input.page }), _jsx(CollectScript, { siteConfig: input.siteConfig }), _jsx(MimicBanner, { html: await readMimicBannerHTML() }), renderProofContent
88
90
  ? _jsx("meta", { name: "aci-render-proof", content: renderProofContent })
89
91
  : null] }));
90
92
  }
@@ -105,19 +107,18 @@ export function createPage(registry, options) {
105
107
  *
106
108
  * Two shapes are supported:
107
109
  *
108
- * 1. **Rewritten (production):** middleware rewrites a public URL to
109
- * `/__r/{releaseId}/routes/{path}`, so slug is
110
- * `['__r', releaseId, 'routes', ...pathSegments]`. The releaseId is
111
- * extracted; the remaining segments form the content route. The `routes`
112
- * segment is the namespace for page routes (siblings like `assets/` live
113
- * under the same release prefix).
110
+ * 1. **Rewritten (production):** middleware rewrites a public URL to the exact
111
+ * selected target under `/__r/{releaseId}`. A root variant therefore has
112
+ * `['__r', releaseId, '__variant', variantKey]`. The full suffix selects the
113
+ * immutable page object; the segments before the reserved `__variant`
114
+ * namespace form the logical public route.
114
115
  *
115
116
  * 2. **Dev pass-through (ACI_CONTENT_PROVIDER=file):** middleware passes
116
117
  * requests through unrewritten, so the slug is the raw public path
117
118
  * segments (possibly empty/undefined for `/`). No releaseId is present;
118
119
  * the file provider serves content from disk.
119
120
  */
120
- async function resolveRouteAndRelease(params) {
121
+ export async function resolveRouteAndRelease(params) {
121
122
  const resolved = await params;
122
123
  const slug = resolved.slug ?? [];
123
124
  if (slug.length >= 2 && slug[0] === '__r') {
@@ -125,13 +126,14 @@ async function resolveRouteAndRelease(params) {
125
126
  if (!releaseId || releaseId.includes('/') || releaseId === '.' || releaseId === '..') {
126
127
  return { route: '/' };
127
128
  }
128
- // Skip the 'routes' namespace segment when present.
129
- const rest = slug.length >= 3 && slug[2] === 'routes' ? slug.slice(3) : slug.slice(2);
130
- if (rest.length === 0)
131
- return { route: '/', releaseId };
132
- if (rest.some((s) => s === '' || s === '.' || s === '..'))
129
+ const target = parseHostedPageTarget(slug.slice(2));
130
+ if (!target)
133
131
  return { route: '/' };
134
- return { route: `/${rest.join('/')}/`, releaseId };
132
+ return {
133
+ route: target.route,
134
+ releaseId,
135
+ pageRef: target.pageRef,
136
+ };
135
137
  }
136
138
  const route = routeFromSlug(slug);
137
139
  return { route };
@@ -141,3 +143,23 @@ function routeFromSlug(slug) {
141
143
  return '/';
142
144
  return `/${slug.join('/')}/`;
143
145
  }
146
+ // The middleware emits the banner HTML in a reserved request header only
147
+ // after validating a signed mimic token; viewer-forged copies are stripped at
148
+ // the dispatch gate (specs/features/audience-mimic REQ-4). The header read
149
+ // happens here (the page already awaits request-scoped data) so the banner
150
+ // itself stays a synchronous leaf.
151
+ async function readMimicBannerHTML() {
152
+ try {
153
+ const current = await headers();
154
+ return decodeMimicBanner(current.get(MIMIC_BANNER_HEADER) || '');
155
+ }
156
+ catch {
157
+ // headers() has no request context in static generation and unit tests.
158
+ return '';
159
+ }
160
+ }
161
+ function MimicBanner({ html }) {
162
+ if (!html)
163
+ return null;
164
+ return _jsx("div", { dangerouslySetInnerHTML: { __html: html } });
165
+ }
@@ -88,6 +88,10 @@ export interface PersonalizationHostConfig {
88
88
  secureCookies?: boolean;
89
89
  now?: () => Date;
90
90
  randomBytes?: (length: number) => Uint8Array;
91
+ mimicClaims?: {
92
+ aud?: string[];
93
+ seg?: string[];
94
+ };
91
95
  }
92
96
  export interface CookieMutation {
93
97
  name: string;
@@ -23,6 +23,9 @@ export class IdentityErasureFencedError extends Error {
23
23
  }
24
24
  export async function resolvePersonalizationRequest(request, releaseId, config) {
25
25
  const requestHeaders = new Headers(request.headers);
26
+ // The mimic banner header is middleware-emitted only; a viewer-presented
27
+ // copy never reaches the render.
28
+ requestHeaders.delete('x-aci-mimic-banner');
26
29
  const cookies = parseCookieHeader(request.headers.get('cookie'));
27
30
  const consent = consentSignals(request, cookies);
28
31
  const mutations = [];
@@ -35,10 +38,14 @@ export async function resolvePersonalizationRequest(request, releaseId, config)
35
38
  // re-minting (REQ-4). aci_m is issue-only (D-11): a resolve-path refresh
36
39
  // would put a Set-Cookie on every returning visitor's response, defeating
37
40
  // the any-Set-Cookie => private, no-store cache rule.
38
- let rawIdentity = consent.privacyDenied
41
+ //
42
+ // Mimic (audience-mimic): a validated mimicClaims input skips identity
43
+ // entirely — no read, no mint, no claims-cookie touch (REQ-5).
44
+ const mimic = config.mimicClaims;
45
+ let rawIdentity = mimic || consent.privacyDenied
39
46
  ? null
40
47
  : await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
41
- if (!consent.privacyDenied && !rawIdentity) {
48
+ if (!mimic && !consent.privacyDenied && !rawIdentity) {
42
49
  rawIdentity = randomIdentity(config.randomBytes);
43
50
  const signed = await signIdentity(rawIdentity, config.identityKeys.current);
44
51
  const maxAge = config.identityCookieTTLSeconds ?? DEFAULT_IDENTITY_TTL_SECONDS;
@@ -48,7 +55,7 @@ export async function resolvePersonalizationRequest(request, releaseId, config)
48
55
  // The identity hash feeds experiment bucketing for EVERY identified
49
56
  // visitor. Claims are a signed cookie written by /collect; the EdgeRouter
50
57
  // never reads the customer object store to refresh or revoke them.
51
- let signedClaims = cookies.get('aci_claims') || '';
58
+ let signedClaims = mimic ? '' : cookies.get('aci_claims') || '';
52
59
  let identityHash = '';
53
60
  if (rawIdentity) {
54
61
  identityHash = await identityHashHex('aci_id', rawIdentity);
@@ -58,7 +65,9 @@ export async function resolvePersonalizationRequest(request, releaseId, config)
58
65
  signedClaims = '';
59
66
  }
60
67
  }
61
- if ((!consent.allowsIdentityTier || !rawIdentity || !signedClaims) && cookies.has('aci_claims')) {
68
+ // Under mimic the visitor's real aci_claims is never touched (REQ-5) — the
69
+ // exit path returns them to exactly their pre-mimic state.
70
+ if (!mimic && (!consent.allowsIdentityTier || !rawIdentity || !signedClaims) && cookies.has('aci_claims')) {
62
71
  mutations.push(expireCookie('aci_claims', secure));
63
72
  }
64
73
  const manifest = await config.store.readEdgeManifest(releaseId);
@@ -66,9 +75,11 @@ export async function resolvePersonalizationRequest(request, releaseId, config)
66
75
  let contentPath = route?.dispatch.contentPath || '';
67
76
  let variantKey = '';
68
77
  if (route) {
69
- const claims = consent.allowsIdentityTier
70
- ? await verifyClaims(signedClaims, config.claimsKeys || {}, now)
71
- : null;
78
+ const claims = mimic
79
+ ? { aud: mimic.aud, seg: mimic.seg, iat: 0, exp: 0 }
80
+ : consent.allowsIdentityTier
81
+ ? await verifyClaims(signedClaims, config.claimsKeys || {}, now)
82
+ : null;
72
83
  const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request), route, claims, manifest?.experiments, identityHash
73
84
  ? { usable: true, siteId: config.siteId, namespace: 'aci_id', identityHash }
74
85
  : undefined);
@@ -1,14 +1,18 @@
1
1
  import { headers } from 'next/headers.js';
2
2
  import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError, } from '../content/provider.js';
3
+ import { ObjectContentProvider } from '../providers/object.js';
3
4
  import { normalizeRoute } from '../content/routes.js';
4
5
  import { getCachedManifest, getCachedSiteConfig } from '../content/cache.js';
5
6
  import { S3ContentProvider } from '../providers/s3.js';
7
+ import { VercelReleaseObjectReader } from '../providers/vercel-object-reader.js';
6
8
  import { GRADIAL_RENDER_INPUT_HEADER, getPendingRenderInput } from '../runtime/page.js';
7
9
  import { attachRenderProofReceipt, selectVerifiedPage, } from '../internal/render-proof.js';
8
10
  import { getUncachedEdgeConfigValue } from './edge-config.js';
9
11
  import { activeReleaseV1Key, clampDeploymentCompatibleReleaseV1, decodeActiveReleaseV1, decodeDeploymentCompatibilityV1, deploymentCompatibilityKey, } from './vercel-edge-projection-v1.js';
10
12
  export { FragmentNotFoundError, PageNotFoundError, LayoutNotFoundError };
11
- const releaseObjectReaderSymbol = Symbol.for('aci.releaseObjectReader');
13
+ const releaseObjectReaderSymbol = Symbol.for('@gradial/aci.release-object-reader');
14
+ const objectProviders = new WeakMap();
15
+ let vercelReleaseObjectReader;
12
16
  export function setReleaseObjectReader(reader) {
13
17
  const scope = globalThis;
14
18
  if (reader)
@@ -339,6 +343,29 @@ async function resolveS3Provider(config) {
339
343
  }
340
344
  async function resolveProviderWithFallback(config) {
341
345
  const injected = getInjectedReader();
346
+ const pageRef = config.pageRef;
347
+ const hostedReaderConfigured = injected || config.roleArn || process.env.AWS_ROLE_ARN;
348
+ if (pageRef && hostedReaderConfigured) {
349
+ const releaseId = config.releaseId || process.env.ACI_RELEASE_ID || 'test';
350
+ let provider = objectProviders.get(config);
351
+ if (!provider) {
352
+ if (!injected && !vercelReleaseObjectReader) {
353
+ vercelReleaseObjectReader = new VercelReleaseObjectReader({
354
+ bucket: config.bucket,
355
+ keyPrefix: config.keyPrefix,
356
+ region: config.region,
357
+ roleArn: config.roleArn,
358
+ endpoint: config.endpoint,
359
+ });
360
+ }
361
+ const reader = injected || vercelReleaseObjectReader;
362
+ if (!reader)
363
+ throw new Error('ACI hosted page reader is unavailable');
364
+ provider = new ObjectContentProvider({ releaseId, pageRef, reader });
365
+ objectProviders.set(config, provider);
366
+ }
367
+ return provider;
368
+ }
342
369
  if (injected) {
343
370
  const releaseId = config.releaseId || process.env.ACI_RELEASE_ID || 'test';
344
371
  return new InjectedReaderProvider(releaseId, injected);
@@ -76,4 +76,5 @@ export declare function createVercelCollectionFromEnv(env?: Record<string, strin
76
76
  identityKeys: PersonalizationHostConfig['identityKeys'];
77
77
  claimsKeys: Record<string, string>;
78
78
  secureCookies: boolean;
79
+ mimicSignKey?: string;
79
80
  } | undefined;
@@ -493,6 +493,7 @@ export function createVercelCollectionFromEnv(env = process.env) {
493
493
  ...(previousKID ? { [previousKID]: previousSecret } : {}),
494
494
  },
495
495
  secureCookies: env.AUDIENCE_IDENTITY_COOKIE_SECURE !== 'false',
496
+ mimicSignKey: env.GRADIAL_PREVIEW_SIGN_KEY || env.ACI_PREVIEW_SIGN_KEY || undefined,
496
497
  };
497
498
  }
498
499
  function requiredSegment(value, label) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.52-rc.10",
3
+ "version": "0.1.52-rc.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/gradial/bare-metal-go.git",
@@ -146,11 +146,11 @@
146
146
  "zod": "^4.0.0"
147
147
  },
148
148
  "optionalDependencies": {
149
- "@gradial/aci-cli-darwin-arm64": "0.1.52-rc.10",
150
- "@gradial/aci-cli-darwin-x64": "0.1.52-rc.10",
151
- "@gradial/aci-cli-linux-arm64": "0.1.52-rc.10",
152
- "@gradial/aci-cli-linux-x64": "0.1.52-rc.10",
153
- "@gradial/aci-cli-win32-arm64": "0.1.52-rc.10",
154
- "@gradial/aci-cli-win32-x64": "0.1.52-rc.10"
149
+ "@gradial/aci-cli-darwin-arm64": "0.1.52-rc.13",
150
+ "@gradial/aci-cli-darwin-x64": "0.1.52-rc.13",
151
+ "@gradial/aci-cli-linux-arm64": "0.1.52-rc.13",
152
+ "@gradial/aci-cli-linux-x64": "0.1.52-rc.13",
153
+ "@gradial/aci-cli-win32-arm64": "0.1.52-rc.13",
154
+ "@gradial/aci-cli-win32-x64": "0.1.52-rc.13"
155
155
  }
156
156
  }
@@ -14,7 +14,7 @@
14
14
  "build:storybook": "storybook build"
15
15
  },
16
16
  "engines": {
17
- "node": ">=22",
17
+ "node": ">=24",
18
18
  "npm": ">=10"
19
19
  },
20
20
  "dependencies": {
@@ -48,8 +48,8 @@ const { title, description, canonical, siteName = 'Gradial', collectTag, assignm
48
48
  // island (@astrojs/react + @c15t/react) or any setup built on
49
49
  // c15t's createConsentManagerStore; this bridge picks it up either
50
50
  // way. Without a c15t setup the poll gives up silently and only the
51
- // JS consent API (aci('consent', …) under the manual preset) writes
52
- // envelopes.
51
+ // JS consent API (aci('consent', …)) writes envelopes — it is live
52
+ // under every preset, including c15t.
53
53
  //
54
54
  // Consents are mirrored only after a real visitor decision
55
55
  // (consentInfo set), never for pre-decision defaults, so the SDK
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "overlayResolution": {
29
29
  "type": "array",
30
- "items": { "enum": ["locale", "audience"] }
30
+ "items": { "enum": ["locale", "audience", "segment", "experiment", "experimentArm"] }
31
31
  },
32
32
  "brandHref": { "type": "string" },
33
33
  "brandLogo": {
@@ -1 +1 @@
1
- 22
1
+ 24
@@ -10,7 +10,7 @@
10
10
  "notFound": "/404/",
11
11
  "catchAll": "/error/"
12
12
  },
13
- "overlayResolution": ["locale", "audience"],
13
+ "overlayResolution": ["locale", "audience", "segment"],
14
14
  "brandHref": "/",
15
15
  "brandLogo": {
16
16
  "src": "/assets/meridian-logo.svg",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "overlayResolution": {
29
29
  "type": "array",
30
- "items": { "enum": ["locale", "audience"] }
30
+ "items": { "enum": ["locale", "audience", "segment", "experiment", "experimentArm"] }
31
31
  },
32
32
  "brandHref": { "type": "string" },
33
33
  "brandLogo": {