@gradial/aci 0.1.42 → 0.1.43

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 (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/dist/audience-data-models.d.ts +286 -0
  4. package/dist/audience-data-models.js +46 -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 -0
  10. package/dist/collect/aci.js.sha256 +1 -0
  11. package/dist/collect/batcher.d.ts +2 -0
  12. package/dist/collect/batcher.js +18 -6
  13. package/dist/collect/consent/envelope.d.ts +9 -3
  14. package/dist/collect/consent/envelope.js +58 -40
  15. package/dist/collect/index.js +9 -2
  16. package/dist/collect/linkclicks.js +4 -2
  17. package/dist/collect/transport.d.ts +2 -0
  18. package/dist/collect/transport.js +6 -3
  19. package/dist/consent/portable-envelope.d.ts +9 -0
  20. package/dist/consent/portable-envelope.js +110 -0
  21. package/dist/consent-banner/index.js +2 -1
  22. package/dist/content/assets.d.ts +1 -0
  23. package/dist/content/assets.js +1 -0
  24. package/dist/content/resolve-slots.js +1 -0
  25. package/dist/content/types.d.ts +2 -0
  26. package/dist/next/collect-route.d.ts +2 -2
  27. package/dist/next/collect-route.js +90 -19
  28. package/dist/next/envelope-digest.d.ts +21 -0
  29. package/dist/next/envelope-digest.js +58 -0
  30. package/dist/next/middleware.d.ts +16 -2
  31. package/dist/next/middleware.js +190 -10
  32. package/dist/next/personalization-host.d.ts +16 -7
  33. package/dist/next/personalization-host.js +14 -33
  34. package/dist/next/server.d.ts +1 -0
  35. package/dist/next/server.js +3 -0
  36. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  37. package/dist/next/vercel-collect-sdk-route.js +205 -0
  38. package/dist/next/vercel-personalization.d.ts +3 -2
  39. package/dist/next/vercel-personalization.js +3 -2
  40. package/dist/next/vercel-s3-personalization.d.ts +25 -4
  41. package/dist/next/vercel-s3-personalization.js +368 -14
  42. package/dist/providers/s3.d.ts +19 -4
  43. package/dist/providers/s3.js +92 -22
  44. package/dist/testing/conformance/checks.js +75 -4
  45. package/dist/types/block-ref.d.ts +2 -0
  46. package/dist/types/block-ref.js +1 -0
  47. package/package.json +27 -16
  48. package/src/cli/compile-registry.mjs +24 -10
  49. package/src/cli/validate-content.mjs +52 -13
  50. package/src/types/block-ref.test.ts +45 -0
  51. package/src/types/block-ref.ts +2 -0
  52. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  53. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  54. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  55. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  56. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  57. package/templates/common/.content/pages/home/_index.json +10 -0
  58. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  59. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  60. package/templates/nextjs/starter-content/src/middleware.ts +31 -1
  61. package/templates/nextjs/template/src/app/collect/route.ts +2 -2
  62. package/templates/nextjs/template/src/middleware.ts +31 -1
  63. package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
@@ -0,0 +1,205 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
3
+ import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
4
+ import { getUncachedEdgeConfigValue } from './edge-config.js';
5
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
6
+ /**
7
+ * Creates the Node-only stable collect-SDK route used by ACI's injected
8
+ * Vercel Function. The provider pointer selects the immutable release on every
9
+ * uncached invocation, so an old code deployment remains correct while a
10
+ * canonical activation converges through Edge Config and deployment promotion.
11
+ */
12
+ export function createVercelCollectSDKRoute(input, dependencies = {}) {
13
+ const config = validateConfig(input);
14
+ const readEdgeConfig = dependencies.readEdgeConfig || getUncachedEdgeConfigValue;
15
+ const client = dependencies.s3Client || new S3Client({
16
+ region: config.region,
17
+ credentials: awsCredentialsProvider({
18
+ roleArn: config.roleArn,
19
+ clientConfig: { region: config.region },
20
+ }),
21
+ });
22
+ const onError = dependencies.onError || ((error) => {
23
+ console.error('[ACI collect SDK] route failed', error);
24
+ });
25
+ return async (request) => {
26
+ const method = request.method.toUpperCase();
27
+ if (method !== 'GET' && method !== 'HEAD') {
28
+ return new Response('method not allowed', {
29
+ status: 405,
30
+ headers: {
31
+ Allow: 'GET, HEAD',
32
+ 'Cache-Control': 'private, no-store',
33
+ },
34
+ });
35
+ }
36
+ try {
37
+ const active = parseActiveReleasePointer(await readEdgeConfig(config.edgeConfig, activeReleaseKey(config.siteId)));
38
+ const projection = parseProjection(await readEdgeConfig(config.edgeConfig, edgeRoutesKey(config.siteId, active.releaseId)));
39
+ if (projection.siteId !== config.siteId || projection.releaseId !== active.releaseId) {
40
+ throw new Error(`collect SDK projection identity mismatch: got ${projection.siteId}/${projection.releaseId}, `
41
+ + `want ${config.siteId}/${active.releaseId}`);
42
+ }
43
+ if (!Object.prototype.hasOwnProperty.call(projection, 'collectSdk')) {
44
+ return new Response(method === 'HEAD' ? null : 'collect SDK is not enabled for this release', {
45
+ status: 404,
46
+ headers: { 'Cache-Control': 'no-store' },
47
+ });
48
+ }
49
+ const entry = validateCollectSDKEntry(projection.collectSdk);
50
+ const body = await readSDKObject(client, config.hostingBucket, joinKey(config.hostingKeyPrefix, 'releases', active.releaseId, entry.path));
51
+ const actualHash = createHash('sha256').update(body).digest('hex');
52
+ if (actualHash !== entry.hash) {
53
+ throw new Error(`collect SDK digest mismatch: got ${actualHash}, want ${entry.hash}`);
54
+ }
55
+ const etag = `"${entry.hash}"`;
56
+ const headers = new Headers({
57
+ 'Content-Type': 'text/javascript; charset=utf-8',
58
+ 'Cache-Control': 'public, max-age=300, must-revalidate',
59
+ ETag: etag,
60
+ });
61
+ if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {
62
+ return new Response(null, { status: 304, headers });
63
+ }
64
+ headers.set('Content-Length', String(body.byteLength));
65
+ const responseBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
66
+ return new Response(method === 'HEAD' ? null : responseBody, { status: 200, headers });
67
+ }
68
+ catch (error) {
69
+ onError(error);
70
+ return new Response(method === 'HEAD' ? null : 'collect SDK is temporarily unavailable', {
71
+ status: 503,
72
+ headers: { 'Cache-Control': 'private, no-store' },
73
+ });
74
+ }
75
+ };
76
+ }
77
+ export function createVercelCollectSDKRouteFromEnv(env = process.env, dependencies = {}) {
78
+ try {
79
+ return createVercelCollectSDKRoute({
80
+ siteId: requiredEnv(env, 'ACI_SITE_ID'),
81
+ edgeConfig: requiredEnv(env, 'EDGE_CONFIG'),
82
+ region: env.AWS_REGION || env.AWS_DEFAULT_REGION || '',
83
+ roleArn: requiredEnv(env, 'AWS_ROLE_ARN'),
84
+ hostingBucket: requiredEnv(env, 'HOSTING_BUCKET'),
85
+ hostingKeyPrefix: env.HOSTING_KEY_PREFIX || env.GRADIAL_S3_KEY_PREFIX,
86
+ }, dependencies);
87
+ }
88
+ catch (error) {
89
+ (dependencies.onError || ((value) => {
90
+ console.error('[ACI collect SDK] configuration failed', value);
91
+ }))(error);
92
+ return async (request) => new Response(request.method.toUpperCase() === 'HEAD' ? null : 'collect SDK is temporarily unavailable', { status: 503, headers: { 'Cache-Control': 'private, no-store' } });
93
+ }
94
+ }
95
+ function validateConfig(input) {
96
+ const siteId = requiredPathSegment(input.siteId, 'ACI site ID');
97
+ const edgeConfig = input.edgeConfig.trim();
98
+ const region = input.region.trim();
99
+ const roleArn = input.roleArn.trim();
100
+ const hostingBucket = input.hostingBucket.trim();
101
+ if (!edgeConfig)
102
+ throw new Error('Edge Config connection is required');
103
+ if (!region)
104
+ throw new Error('AWS region is required');
105
+ if (!roleArn)
106
+ throw new Error('AWS role ARN is required');
107
+ if (!hostingBucket)
108
+ throw new Error('hosting bucket is required');
109
+ return {
110
+ siteId,
111
+ edgeConfig,
112
+ region,
113
+ roleArn,
114
+ hostingBucket,
115
+ hostingKeyPrefix: cleanKey(input.hostingKeyPrefix || ''),
116
+ };
117
+ }
118
+ function parseActiveReleasePointer(value) {
119
+ const parsed = parseRecord(value, 'active release pointer');
120
+ return { releaseId: requiredPathSegment(parsed.releaseId || '', 'active release ID') };
121
+ }
122
+ function parseProjection(value) {
123
+ const parsed = parseRecord(value, 'collect SDK route projection');
124
+ const projection = {
125
+ releaseId: requiredPathSegment(parsed.releaseId || '', 'projection release ID'),
126
+ siteId: requiredPathSegment(parsed.siteId || '', 'projection site ID'),
127
+ };
128
+ if (Object.prototype.hasOwnProperty.call(parsed, 'collectSdk')) {
129
+ projection.collectSdk = parsed.collectSdk;
130
+ }
131
+ return projection;
132
+ }
133
+ function parseRecord(value, label) {
134
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
135
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
136
+ throw new Error(`${label} is unavailable or malformed`);
137
+ }
138
+ return parsed;
139
+ }
140
+ function validateCollectSDKEntry(value) {
141
+ const entry = parseRecord(value, 'collect SDK metadata');
142
+ if (typeof entry.hash !== 'string' || typeof entry.path !== 'string') {
143
+ throw new Error('collect SDK metadata must contain string hash and path fields');
144
+ }
145
+ const hash = entry.hash.trim().toLowerCase();
146
+ const path = cleanKey(entry.path);
147
+ if (!SHA256_HEX.test(hash))
148
+ throw new Error('collect SDK hash must be lowercase SHA-256');
149
+ const expectedPath = `_aci/aci.${hash}.js`;
150
+ if (path !== expectedPath || path !== entry.path) {
151
+ throw new Error(`collect SDK path ${JSON.stringify(entry.path)} must equal ${JSON.stringify(expectedPath)}`);
152
+ }
153
+ return { hash, path };
154
+ }
155
+ async function readSDKObject(client, bucket, key) {
156
+ const output = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
157
+ if (output.Body?.transformToByteArray)
158
+ return await output.Body.transformToByteArray();
159
+ if (output.Body?.transformToString) {
160
+ return new TextEncoder().encode(await output.Body.transformToString());
161
+ }
162
+ throw new Error(`collect SDK object ${key} returned an unsupported body`);
163
+ }
164
+ function ifNoneMatchSatisfied(value, etag) {
165
+ const header = (value || '').trim();
166
+ if (!header)
167
+ return false;
168
+ if (header === '*')
169
+ return true;
170
+ return header.split(',').some((candidate) => {
171
+ const normalized = candidate.trim().replace(/^W\//, '');
172
+ return normalized === etag;
173
+ });
174
+ }
175
+ function activeReleaseKey(siteId) {
176
+ return `activeRelease_${edgeKeySegment(siteId)}`;
177
+ }
178
+ function edgeRoutesKey(siteId, releaseId) {
179
+ return `edgeRoutes_${edgeKeySegment(siteId)}_${edgeKeySegment(releaseId)}`;
180
+ }
181
+ function edgeKeySegment(value) {
182
+ return value.replace(/[^A-Za-z0-9_-]/g, '_');
183
+ }
184
+ function requiredPathSegment(value, label) {
185
+ const clean = value.trim();
186
+ if (!clean || clean === '.' || clean === '..' || clean.includes('/') || clean.includes('\\')) {
187
+ throw new Error(`${label} is required and must be one path segment`);
188
+ }
189
+ return clean;
190
+ }
191
+ function cleanKey(value) {
192
+ const segments = value.replaceAll('\\', '/').split('/').filter((segment) => segment && segment !== '.');
193
+ if (segments.some((segment) => segment === '..'))
194
+ throw new Error('S3 key traversal is not allowed');
195
+ return segments.join('/');
196
+ }
197
+ function joinKey(...parts) {
198
+ return parts.map((part) => cleanKey(part || '')).filter(Boolean).join('/');
199
+ }
200
+ function requiredEnv(env, key) {
201
+ const value = env[key]?.trim() || '';
202
+ if (!value)
203
+ throw new Error(`${key} is required for the Vercel collect SDK route`);
204
+ return value;
205
+ }
@@ -1,3 +1,4 @@
1
- export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, resolvePersonalizationRequest, type EdgeRouteManifest, type PersonalizationBlobStore, type PersonalizationCollectionStore, type PersonalizationHostConfig, type PersonalizationRouteStore, type PersonalizationResolution, } from './personalization-host.js';
2
1
  export { createPersonalizationCollectRoute, type PersonalizationCollectRouteConfig, } from './collect-route.js';
3
- export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, VercelS3PersonalizationStore, type VercelS3PersonalizationConfig, } from './vercel-s3-personalization.js';
2
+ export { computeEnvelopeDigest, type EnvelopeDigestInput, } from './envelope-digest.js';
3
+ export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, IdentityErasureFencedError, resolvePersonalizationRequest, type EdgeRouteManifest, type PersonalizationBlobStore, type PersonalizationCollectionStore, type PersonalizationEnvelopeWrite, type PersonalizationHostConfig, type PersonalizationRouteStore, type PersonalizationResolution, } from './personalization-host.js';
4
+ export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, EnvelopeIDCollisionError, VercelS3PersonalizationStore, type VercelS3PersonalizationConfig, } from './vercel-s3-personalization.js';
@@ -1,3 +1,4 @@
1
- export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, resolvePersonalizationRequest, } from './personalization-host.js';
2
1
  export { createPersonalizationCollectRoute, } from './collect-route.js';
3
- export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, VercelS3PersonalizationStore, } from './vercel-s3-personalization.js';
2
+ export { computeEnvelopeDigest, } from './envelope-digest.js';
3
+ export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, IdentityErasureFencedError, resolvePersonalizationRequest, } from './personalization-host.js';
4
+ export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, EnvelopeIDCollisionError, VercelS3PersonalizationStore, } from './vercel-s3-personalization.js';
@@ -1,4 +1,4 @@
1
- import type { EdgeRouteManifest, PersonalizationCollectionStore, PersonalizationHostConfig, PersonalizationRouteStore } from './personalization-host.js';
1
+ import type { EdgeRouteManifest, PersonalizationCollectionStore, PersonalizationHostConfig, PersonalizationEnvelopeWrite, PersonalizationRouteStore } from './personalization-host.js';
2
2
  export interface VercelS3PersonalizationConfig {
3
3
  region: string;
4
4
  roleArn?: string;
@@ -8,12 +8,22 @@ export interface VercelS3PersonalizationConfig {
8
8
  claimsKeyPrefix?: string;
9
9
  endpoint?: string;
10
10
  forcePathStyle?: boolean;
11
+ collectionWriteTimeoutMs?: number;
11
12
  }
12
13
  export interface S3CommandClient {
13
14
  send(command: {
14
15
  input: unknown;
16
+ }, options?: {
17
+ abortSignal?: AbortSignal;
15
18
  }): Promise<any>;
16
19
  }
20
+ export declare class EnvelopeIDCollisionError extends Error {
21
+ readonly key: string;
22
+ readonly existingDigest: string;
23
+ readonly incomingDigest: string;
24
+ readonly name = "EnvelopeIDCollisionError";
25
+ constructor(key: string, existingDigest: string, incomingDigest: string);
26
+ }
17
27
  /**
18
28
  * Node-only Vercel collection adapter for audience lake objects and signed
19
29
  * claims. The EdgeRouter never receives this adapter or AWS credentials.
@@ -25,8 +35,16 @@ export declare class VercelS3PersonalizationStore implements PersonalizationColl
25
35
  private readonly client;
26
36
  constructor(config: VercelS3PersonalizationConfig, client?: S3CommandClient);
27
37
  readClaims(siteId: string, namespace: string, identityHash: string): Promise<string | null>;
28
- appendEvent(key: string, record: unknown): Promise<void>;
38
+ appendEnvelope(write: PersonalizationEnvelopeWrite): Promise<void>;
29
39
  putSuppression(key: string, record: unknown): Promise<void>;
40
+ private acquireCollectionGate;
41
+ private releaseCollectionGate;
42
+ private settleResumedEnvelope;
43
+ private collectionGenerationSettled;
44
+ private putCollectionGate;
45
+ private readCollectionGate;
46
+ private sendWithWriteDeadline;
47
+ private writeTimeoutMs;
30
48
  private readObject;
31
49
  }
32
50
  /**
@@ -40,7 +58,9 @@ export declare class VercelEdgeConfigRouteStore implements PersonalizationRouteS
40
58
  constructor(edgeConfig: string, siteId: string);
41
59
  readEdgeManifest(releaseId: string): Promise<EdgeRouteManifest | null>;
42
60
  }
43
- export declare function createVercelS3PersonalizationStore(config: VercelS3PersonalizationConfig): VercelS3PersonalizationStore;
61
+ export declare function createVercelS3PersonalizationStore(config: VercelS3PersonalizationConfig & {
62
+ roleArn: string;
63
+ }): VercelS3PersonalizationStore;
44
64
  /**
45
65
  * Builds the EdgeRouter configuration used by Next middleware. It deliberately
46
66
  * has no AWS/S3 configuration: its only remote dependency is the provider's
@@ -48,7 +68,8 @@ export declare function createVercelS3PersonalizationStore(config: VercelS3Perso
48
68
  */
49
69
  export declare function createVercelPersonalizationFromEnv(env?: Record<string, string | undefined>): PersonalizationHostConfig | undefined;
50
70
  /** Builds the Node.js `/collect` configuration. This is the only Vercel
51
- * application runtime that receives data-plane storage configuration. */
71
+ * application runtime that receives data-plane storage configuration, and it
72
+ * fails closed unless the exact-project AWS workload role is configured. */
52
73
  export declare function createVercelCollectionFromEnv(env?: Record<string, string | undefined>): {
53
74
  siteId: string;
54
75
  store: PersonalizationCollectionStore;