@gradial/aci 0.1.41 → 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 (134) hide show
  1. package/CHANGELOG.md +15 -11
  2. package/README.md +4 -4
  3. package/dist/astro/index.d.ts +1 -1
  4. package/dist/astro/index.js +1 -1
  5. package/dist/astro/page.d.ts +6 -0
  6. package/dist/astro/page.js +3 -2
  7. package/dist/audience-data-models.d.ts +286 -0
  8. package/dist/audience-data-models.js +46 -0
  9. package/dist/cli/compile-registry.d.mts +6 -0
  10. package/dist/cli/compile-registry.mjs +774 -0
  11. package/dist/cli/materialize-template.d.mts +19 -0
  12. package/dist/cli/materialize-template.mjs +128 -0
  13. package/dist/cli/validate-content.d.mts +32 -0
  14. package/dist/cli/validate-content.mjs +464 -0
  15. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  16. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  17. package/dist/collect/aci.js +1 -0
  18. package/dist/collect/aci.js.sha256 +1 -0
  19. package/dist/collect/assignments.d.ts +65 -0
  20. package/dist/collect/assignments.js +138 -0
  21. package/dist/collect/batcher.d.ts +23 -0
  22. package/dist/collect/batcher.js +28 -6
  23. package/dist/collect/consent/envelope.d.ts +9 -3
  24. package/dist/collect/consent/envelope.js +58 -40
  25. package/dist/collect/declarative.d.ts +1 -1
  26. package/dist/collect/declarative.js +7 -1
  27. package/dist/collect/index.js +39 -6
  28. package/dist/collect/linkclicks.js +4 -2
  29. package/dist/collect/pageviews.d.ts +15 -3
  30. package/dist/collect/pageviews.js +28 -13
  31. package/dist/collect/transport.d.ts +4 -0
  32. package/dist/collect/transport.js +37 -2
  33. package/dist/consent/portable-envelope.d.ts +9 -0
  34. package/dist/consent/portable-envelope.js +110 -0
  35. package/dist/consent-banner/index.d.ts +33 -0
  36. package/dist/consent-banner/index.js +100 -0
  37. package/dist/content/assets.d.ts +1 -0
  38. package/dist/content/assets.js +1 -0
  39. package/dist/content/personalization.d.ts +35 -0
  40. package/dist/content/personalization.js +57 -0
  41. package/dist/content/resolve-slots.js +1 -0
  42. package/dist/content/types.d.ts +9 -1
  43. package/dist/edge/personalization-core.d.ts +11 -0
  44. package/dist/edge/personalization-core.js +265 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.js +1 -0
  47. package/dist/next/assignments-script.d.ts +14 -0
  48. package/dist/next/assignments-script.js +17 -0
  49. package/dist/next/collect-route.d.ts +19 -0
  50. package/dist/next/collect-route.js +422 -0
  51. package/dist/next/envelope-digest.d.ts +21 -0
  52. package/dist/next/envelope-digest.js +58 -0
  53. package/dist/next/index.d.ts +1 -0
  54. package/dist/next/index.js +1 -0
  55. package/dist/next/middleware.d.ts +31 -3
  56. package/dist/next/middleware.js +254 -30
  57. package/dist/next/page.js +2 -1
  58. package/dist/next/personalization-host.d.ts +127 -0
  59. package/dist/next/personalization-host.js +257 -0
  60. package/dist/next/server.d.ts +1 -0
  61. package/dist/next/server.js +9 -6
  62. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  63. package/dist/next/vercel-collect-sdk-route.js +205 -0
  64. package/dist/next/vercel-personalization.d.ts +4 -0
  65. package/dist/next/vercel-personalization.js +4 -0
  66. package/dist/next/vercel-s3-personalization.d.ts +79 -0
  67. package/dist/next/vercel-s3-personalization.js +574 -0
  68. package/dist/providers/s3.d.ts +20 -5
  69. package/dist/providers/s3.js +93 -23
  70. package/dist/react/VideoPlayer.d.ts +6 -6
  71. package/dist/react/index.d.ts +6 -6
  72. package/dist/testing/conformance/checks.js +75 -4
  73. package/dist/types/block-ref.d.ts +2 -0
  74. package/dist/types/block-ref.js +1 -0
  75. package/dist/types/page.d.ts +1 -1
  76. package/package.json +44 -15
  77. package/src/astro/ConsentBanner.astro +62 -0
  78. package/src/cli/compile-registry.mjs +24 -10
  79. package/src/cli/validate-content.mjs +52 -13
  80. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  81. package/src/consent-banner/styles.css +110 -0
  82. package/src/types/block-ref.test.ts +45 -0
  83. package/src/types/block-ref.ts +2 -0
  84. package/src/types/page.ts +1 -1
  85. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  86. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  87. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  88. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  89. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  90. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  91. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  92. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  93. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  94. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  95. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  96. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  97. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  98. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  99. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  100. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  101. package/templates/common/.content/pages/home/_index.json +124 -4
  102. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  103. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  104. package/templates/common/contracts/components/index.ts +21 -1
  105. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  106. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  107. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  108. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  109. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  110. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  111. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  112. package/templates/nextjs/starter-content/src/middleware.ts +41 -0
  113. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  114. package/templates/nextjs/template/src/middleware.ts +36 -2
  115. package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
  116. package/templates/astro/template/.aci.yaml +0 -28
  117. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  118. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  119. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  120. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  121. package/templates/common/.content/config/robots.txt +0 -2
  122. package/templates/common/.content/config/site.json +0 -197
  123. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  124. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  125. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  126. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  127. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  128. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  129. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  130. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  131. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  132. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  133. package/templates/common/.content/pages/about/_index.json +0 -51
  134. package/templates/nextjs/template/.aci.yaml +0 -26
@@ -0,0 +1,574 @@
1
+ import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
2
+ import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
3
+ import { getUncachedEdgeConfigValue } from './edge-config.js';
4
+ import { IdentityErasureFencedError } from './personalization-host.js';
5
+ const ENVELOPE_DIGEST_METADATA_KEY = 'aci-envelope-sha256';
6
+ const COLLECTION_GATE_ETAG_METADATA_KEY = 'aci-collection-gate-etag';
7
+ const COLLECTION_GATE_STATE_METADATA_KEY = 'aci-collection-gate-state';
8
+ const COLLECTION_GATE_OWNER_METADATA_KEY = 'aci-collection-gate-owner';
9
+ const COLLECTION_GATE_DIGEST_METADATA_KEY = 'aci-collection-gate-envelope-sha256';
10
+ const COLLECTION_GATE_PREFIX = 'audience/collection-gate';
11
+ const COLLECTION_WRITE_TIMEOUT_MS = 2 * 60 * 1000;
12
+ const COLLECTION_GATE_RETRY_MS = 25;
13
+ const COLLECTION_GATE_ATTEMPTS = 80;
14
+ const MAX_LAKE_OBJECT_RECORDS = 100;
15
+ const MAX_LAKE_OBJECT_WRITE_BYTES = 1024 * 1024;
16
+ const SHA256_HEX = /^[0-9a-f]{64}$/i;
17
+ const textEncoder = new TextEncoder();
18
+ export class EnvelopeIDCollisionError extends Error {
19
+ key;
20
+ existingDigest;
21
+ incomingDigest;
22
+ name = 'EnvelopeIDCollisionError';
23
+ constructor(key, existingDigest, incomingDigest) {
24
+ super(`envelope ID collision at ${key}`);
25
+ this.key = key;
26
+ this.existingDigest = existingDigest;
27
+ this.incomingDigest = incomingDigest;
28
+ }
29
+ }
30
+ /**
31
+ * Node-only Vercel collection adapter for audience lake objects and signed
32
+ * claims. The EdgeRouter never receives this adapter or AWS credentials.
33
+ * Production construction exchanges the Vercel workload OIDC token for
34
+ * scoped AWS credentials; no long-lived AWS keys are read.
35
+ */
36
+ export class VercelS3PersonalizationStore {
37
+ config;
38
+ client;
39
+ constructor(config, client) {
40
+ this.config = config;
41
+ if (client) {
42
+ this.client = client;
43
+ return;
44
+ }
45
+ const roleArn = config.roleArn?.trim() || '';
46
+ if (!roleArn) {
47
+ throw new Error('AWS role ARN is required for the Vercel S3 personalization store');
48
+ }
49
+ const s3Config = {
50
+ region: config.region,
51
+ endpoint: config.endpoint,
52
+ forcePathStyle: config.forcePathStyle,
53
+ credentials: awsCredentialsProvider({
54
+ roleArn,
55
+ clientConfig: { region: config.region },
56
+ }),
57
+ };
58
+ this.client = new S3Client(s3Config);
59
+ }
60
+ async readClaims(siteId, namespace, identityHash) {
61
+ const key = joinKey(this.config.claimsKeyPrefix || 'audience/claims/v1', requiredSegment(identityHash, 'identity hash').slice(0, 2).toLowerCase(), requiredSegment(siteId, 'site id'), requiredSegment(namespace, 'identity namespace').toLowerCase(), `${requiredSegment(identityHash, 'identity hash').toLowerCase()}.json`);
62
+ const body = await this.readObject(this.config.eventsBucket, key);
63
+ if (body === null)
64
+ return null;
65
+ const envelope = JSON.parse(body);
66
+ if (envelope.schemaVersion !== 1 || !envelope.signedValue?.trim()) {
67
+ throw new Error(`invalid claims envelope at ${key}`);
68
+ }
69
+ if (envelope.expiresAt && Date.parse(envelope.expiresAt) <= Date.now())
70
+ return null;
71
+ return envelope.signedValue;
72
+ }
73
+ async appendEnvelope(write) {
74
+ const digest = write.digest.trim().toLowerCase();
75
+ if (!SHA256_HEX.test(digest)) {
76
+ throw new Error('envelope digest must be a SHA-256 hex value');
77
+ }
78
+ if (!Array.isArray(write.records) ||
79
+ write.records.length === 0 ||
80
+ write.records.length > MAX_LAKE_OBJECT_RECORDS ||
81
+ write.records.some((record) => !record ||
82
+ typeof record !== 'object' ||
83
+ Array.isArray(record) ||
84
+ record.schemaVersion !== 2)) {
85
+ throw new Error(`envelope records must be a schemaVersion-2 array with 1-${MAX_LAKE_OBJECT_RECORDS} entries`);
86
+ }
87
+ const body = JSON.stringify(write.records);
88
+ const bodyBytes = textEncoder.encode(body).byteLength;
89
+ if (bodyBytes > MAX_LAKE_OBJECT_WRITE_BYTES) {
90
+ throw new Error(`serialized envelope is ${bodyBytes} bytes, maximum ${MAX_LAKE_OBJECT_WRITE_BYTES}`);
91
+ }
92
+ const key = cleanKey(write.key);
93
+ const gate = await this.acquireCollectionGate(write, digest);
94
+ const input = {
95
+ Bucket: this.config.eventsBucket,
96
+ Key: key,
97
+ Body: body,
98
+ ContentType: 'application/json',
99
+ IfNoneMatch: '*',
100
+ Metadata: {
101
+ [ENVELOPE_DIGEST_METADATA_KEY]: digest,
102
+ [COLLECTION_GATE_ETAG_METADATA_KEY]: gate.etag,
103
+ },
104
+ };
105
+ if (gate.resumedUncertain) {
106
+ return this.settleResumedEnvelope(input.Key, digest, gate);
107
+ }
108
+ let operationError;
109
+ let settled = false;
110
+ try {
111
+ await this.sendWithWriteDeadline(new PutObjectCommand(input));
112
+ settled = true;
113
+ }
114
+ catch (error) {
115
+ if (!isConditionalConflict(error)) {
116
+ // Unknown write outcome: retain state=collecting without expiry.
117
+ // A retry is HEAD-only and cannot release until this exact write is
118
+ // visible with the authorizing generation metadata.
119
+ throw error;
120
+ }
121
+ // S3 can report 409 for two concurrent conditional writes even when
122
+ // neither write has committed yet. Confirm the object before treating
123
+ // the conflict as the idempotent duplicate case.
124
+ let head;
125
+ try {
126
+ head = await this.client.send(new HeadObjectCommand({ Bucket: input.Bucket, Key: input.Key }));
127
+ }
128
+ catch (headError) {
129
+ // The conditional outcome remains unknown; retain the collecting
130
+ // generation exactly like an unclassified PUT failure.
131
+ throw new Error(`conditional envelope write conflicted before ${input.Key} was stored`, { cause: headError });
132
+ }
133
+ const existingDigest = envelopeDigestMetadata(head.Metadata);
134
+ const existingGateETag = metadataValue(head.Metadata, COLLECTION_GATE_ETAG_METADATA_KEY);
135
+ settled = true;
136
+ if (existingDigest === digest && (existingGateETag === gate.etag || gate.claimedFromOpen)) {
137
+ // Identical retry. A resumed uncertain generation must match the
138
+ // exact authorizing gate ETag; a gate claimed from open may observe
139
+ // an object already settled by the prior open generation.
140
+ }
141
+ else if (existingDigest !== digest) {
142
+ operationError = new EnvelopeIDCollisionError(input.Key, existingDigest, digest);
143
+ }
144
+ else {
145
+ // Keep the resumed generation collecting: the object cannot prove
146
+ // it was authorized by this uncertain writer.
147
+ throw new Error(`envelope at ${input.Key} does not match resumed collection-gate generation ${gate.etag}`);
148
+ }
149
+ }
150
+ if (!settled)
151
+ throw new Error(`envelope write did not settle at ${input.Key}`);
152
+ try {
153
+ await this.releaseCollectionGate(gate);
154
+ }
155
+ catch (releaseError) {
156
+ if (operationError) {
157
+ throw new AggregateError([operationError, releaseError], 'envelope operation and collection-gate release both failed');
158
+ }
159
+ throw releaseError;
160
+ }
161
+ if (operationError)
162
+ throw operationError;
163
+ }
164
+ async putSuppression(key, record) {
165
+ await this.client.send(new PutObjectCommand({
166
+ Bucket: this.config.eventsBucket,
167
+ Key: cleanKey(key),
168
+ Body: JSON.stringify(record),
169
+ ContentType: 'application/json',
170
+ }));
171
+ }
172
+ async acquireCollectionGate(write, requestedDigest) {
173
+ const siteId = requiredSegment(write.siteId, 'site id');
174
+ const namespace = requiredSegment(write.identityNamespace, 'identity namespace').toLowerCase();
175
+ const identityHash = requiredIdentityHash(write.identityHash);
176
+ const owner = requiredEnvelopeId(write.envelopeId);
177
+ const digest = requiredDigest(requestedDigest);
178
+ const key = joinKey(COLLECTION_GATE_PREFIX, identityHash.slice(0, 2), siteId, namespace, `${identityHash}.json`);
179
+ for (let attempt = 0; attempt < COLLECTION_GATE_ATTEMPTS; attempt += 1) {
180
+ const collecting = {
181
+ schemaVersion: 1,
182
+ state: 'collecting',
183
+ owner,
184
+ digest,
185
+ };
186
+ try {
187
+ const etag = await this.putCollectionGate(key, collecting);
188
+ return {
189
+ key,
190
+ etag,
191
+ owner,
192
+ digest,
193
+ claimedFromOpen: false,
194
+ resumedUncertain: false,
195
+ };
196
+ }
197
+ catch (error) {
198
+ if (!isConditionalConflict(error))
199
+ throw new Error(`create collection gate ${key}`, { cause: error });
200
+ }
201
+ const current = await this.readCollectionGate(key);
202
+ if (current.record.state === 'erased')
203
+ throw new IdentityErasureFencedError();
204
+ if (current.record.state === 'collecting') {
205
+ const currentOwner = requiredEnvelopeId(current.record.owner || '');
206
+ const currentDigest = requiredDigest(current.record.digest || '');
207
+ if (currentOwner === owner) {
208
+ if (currentDigest !== digest) {
209
+ throw new EnvelopeIDCollisionError(key, currentDigest, digest);
210
+ }
211
+ return {
212
+ key,
213
+ etag: current.etag,
214
+ owner,
215
+ digest,
216
+ claimedFromOpen: false,
217
+ resumedUncertain: true,
218
+ };
219
+ }
220
+ const settled = await this.collectionGenerationSettled(siteId, namespace, identityHash, currentOwner, currentDigest, current.etag);
221
+ if (settled) {
222
+ await this.releaseCollectionGate({
223
+ key,
224
+ etag: current.etag,
225
+ owner: currentOwner,
226
+ digest: currentDigest,
227
+ claimedFromOpen: false,
228
+ resumedUncertain: true,
229
+ });
230
+ continue;
231
+ }
232
+ await delay(COLLECTION_GATE_RETRY_MS);
233
+ continue;
234
+ }
235
+ else if (current.record.state !== 'open') {
236
+ throw new Error(`collection gate ${key} has unknown state ${String(current.record.state)}`);
237
+ }
238
+ try {
239
+ const etag = await this.putCollectionGate(key, collecting, current.etag);
240
+ return {
241
+ key,
242
+ etag,
243
+ owner,
244
+ digest,
245
+ claimedFromOpen: true,
246
+ resumedUncertain: false,
247
+ };
248
+ }
249
+ catch (error) {
250
+ if (!isConditionalConflict(error))
251
+ throw new Error(`claim collection gate ${key}`, { cause: error });
252
+ }
253
+ await delay(COLLECTION_GATE_RETRY_MS);
254
+ }
255
+ throw new Error(`collection gate is busy at ${key}`);
256
+ }
257
+ async releaseCollectionGate(lease) {
258
+ try {
259
+ await this.putCollectionGate(lease.key, { schemaVersion: 1, state: 'open' }, lease.etag);
260
+ return;
261
+ }
262
+ catch (error) {
263
+ let current;
264
+ try {
265
+ current = await this.readCollectionGate(lease.key);
266
+ }
267
+ catch (readError) {
268
+ throw new AggregateError([error, readError], `release collection gate and reconcile state failed at ${lease.key}`);
269
+ }
270
+ if (current.record.state === 'open')
271
+ return;
272
+ if (current.record.state === 'erased')
273
+ throw new IdentityErasureFencedError();
274
+ if (current.record.state === 'collecting' && current.etag !== lease.etag) {
275
+ return;
276
+ }
277
+ throw error;
278
+ }
279
+ }
280
+ async settleResumedEnvelope(key, digest, gate) {
281
+ for (let attempt = 0; attempt < COLLECTION_GATE_ATTEMPTS; attempt += 1) {
282
+ let head;
283
+ try {
284
+ head = await this.client.send(new HeadObjectCommand({ Bucket: this.config.eventsBucket, Key: key }));
285
+ }
286
+ catch (error) {
287
+ if (isNotFound(error)) {
288
+ await delay(COLLECTION_GATE_RETRY_MS);
289
+ continue;
290
+ }
291
+ throw new Error(`verify uncertain envelope ${key}; collection gate remains busy`, { cause: error });
292
+ }
293
+ const existingDigest = envelopeDigestMetadata(head.Metadata);
294
+ const existingGateETag = metadataValue(head.Metadata, COLLECTION_GATE_ETAG_METADATA_KEY);
295
+ if (existingDigest !== digest) {
296
+ throw new EnvelopeIDCollisionError(key, existingDigest, digest);
297
+ }
298
+ if (existingGateETag !== gate.etag) {
299
+ throw new Error(`envelope at ${key} does not match resumed collection-gate generation ${gate.etag}`);
300
+ }
301
+ await this.releaseCollectionGate(gate);
302
+ return;
303
+ }
304
+ throw new Error(`uncertain envelope ${key} is not yet visible; collection gate remains busy`);
305
+ }
306
+ async collectionGenerationSettled(siteId, namespace, identityHash, owner, digest, gateETag) {
307
+ const key = joinKey('audience/first-party', identityHash.slice(0, 2), siteId, namespace, identityHash, `${owner}.json`);
308
+ let head;
309
+ try {
310
+ head = await this.client.send(new HeadObjectCommand({ Bucket: this.config.eventsBucket, Key: key }));
311
+ }
312
+ catch (error) {
313
+ if (isNotFound(error))
314
+ return false;
315
+ throw new Error(`verify collecting generation object ${key}`, { cause: error });
316
+ }
317
+ const existingDigest = envelopeDigestMetadata(head.Metadata);
318
+ const existingGateETag = metadataValue(head.Metadata, COLLECTION_GATE_ETAG_METADATA_KEY);
319
+ if (existingDigest !== digest || existingGateETag !== gateETag) {
320
+ throw new Error(`collecting generation object ${key} has mismatched proof metadata`);
321
+ }
322
+ return true;
323
+ }
324
+ async putCollectionGate(key, record, ifMatch) {
325
+ const metadata = {
326
+ [COLLECTION_GATE_STATE_METADATA_KEY]: record.state,
327
+ };
328
+ if (record.owner)
329
+ metadata[COLLECTION_GATE_OWNER_METADATA_KEY] = record.owner;
330
+ if (record.digest)
331
+ metadata[COLLECTION_GATE_DIGEST_METADATA_KEY] = record.digest;
332
+ const output = await this.client.send(new PutObjectCommand({
333
+ Bucket: this.config.eventsBucket,
334
+ Key: cleanKey(key),
335
+ Body: JSON.stringify(record),
336
+ ContentType: 'application/json',
337
+ Metadata: metadata,
338
+ ...(ifMatch ? { IfMatch: ifMatch } : { IfNoneMatch: '*' }),
339
+ }));
340
+ const etag = output.ETag?.trim() || '';
341
+ if (!etag)
342
+ throw new Error(`collection gate PUT returned no ETag at ${key}`);
343
+ return etag;
344
+ }
345
+ async readCollectionGate(key) {
346
+ let output;
347
+ try {
348
+ output = await this.client.send(new HeadObjectCommand({ Bucket: this.config.eventsBucket, Key: cleanKey(key) }));
349
+ }
350
+ catch (error) {
351
+ throw new Error(`head collection gate ${key}`, { cause: error });
352
+ }
353
+ const etag = output.ETag?.trim() || '';
354
+ if (!etag)
355
+ throw new Error(`collection gate ${key} has no ETag`);
356
+ const metadata = lowerCaseMetadata(output.Metadata);
357
+ return {
358
+ etag,
359
+ record: {
360
+ schemaVersion: 1,
361
+ state: metadata[COLLECTION_GATE_STATE_METADATA_KEY],
362
+ owner: metadata[COLLECTION_GATE_OWNER_METADATA_KEY],
363
+ digest: metadata[COLLECTION_GATE_DIGEST_METADATA_KEY],
364
+ },
365
+ };
366
+ }
367
+ async sendWithWriteDeadline(command) {
368
+ const controller = new AbortController();
369
+ const timeout = setTimeout(() => controller.abort(), this.writeTimeoutMs());
370
+ try {
371
+ return await this.client.send(command, { abortSignal: controller.signal });
372
+ }
373
+ finally {
374
+ clearTimeout(timeout);
375
+ }
376
+ }
377
+ writeTimeoutMs() {
378
+ return positiveDuration(this.config.collectionWriteTimeoutMs, COLLECTION_WRITE_TIMEOUT_MS);
379
+ }
380
+ async readObject(bucket, key) {
381
+ try {
382
+ const output = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
383
+ if (!output.Body)
384
+ throw new Error(`empty object body at ${key}`);
385
+ if (typeof output.Body.transformToString === 'function')
386
+ return await output.Body.transformToString();
387
+ throw new Error(`unsupported object body at ${key}`);
388
+ }
389
+ catch (error) {
390
+ if (isNotFound(error))
391
+ return null;
392
+ throw error;
393
+ }
394
+ }
395
+ }
396
+ /**
397
+ * Edge Config is the provider projection for immutable release routing state.
398
+ * This tiny store is safe in Next middleware: it only fetches the route
399
+ * projection and never reads S3, claims, or event data.
400
+ */
401
+ export class VercelEdgeConfigRouteStore {
402
+ edgeConfig;
403
+ siteId;
404
+ constructor(edgeConfig, siteId) {
405
+ this.edgeConfig = edgeConfig;
406
+ this.siteId = siteId;
407
+ }
408
+ async readEdgeManifest(releaseId) {
409
+ const value = await getUncachedEdgeConfigValue(this.edgeConfig, edgeRoutesKey(this.siteId, releaseId));
410
+ if (!value)
411
+ return null;
412
+ if (typeof value === 'string')
413
+ return JSON.parse(value);
414
+ return value;
415
+ }
416
+ }
417
+ export function createVercelS3PersonalizationStore(config) {
418
+ return new VercelS3PersonalizationStore(config);
419
+ }
420
+ /**
421
+ * Builds the EdgeRouter configuration used by Next middleware. It deliberately
422
+ * has no AWS/S3 configuration: its only remote dependency is the provider's
423
+ * Edge Config projection.
424
+ */
425
+ export function createVercelPersonalizationFromEnv(env = process.env) {
426
+ if (env.ACI_PERSONALIZATION_HOST_ENABLED !== 'true')
427
+ return undefined;
428
+ const siteId = requiredEnv(env, 'ACI_SITE_ID');
429
+ const edgeConfig = requiredEnv(env, 'EDGE_CONFIG');
430
+ const currentKID = env.ACI_IDENTITY_KID || env.AUDIENCE_IDENTITY_COOKIE_KID || 'v1';
431
+ const currentSecret = env.ACI_IDENTITY_SECRET || env.AUDIENCE_IDENTITY_COOKIE_SECRET || '';
432
+ if (!currentSecret)
433
+ throw new Error('ACI_IDENTITY_SECRET is required when ACI_PERSONALIZATION_HOST_ENABLED=true');
434
+ const previousKID = env.ACI_IDENTITY_KID_PREVIOUS || env.AUDIENCE_IDENTITY_COOKIE_KID_PREVIOUS || '';
435
+ const previousSecret = env.ACI_IDENTITY_SECRET_PREVIOUS || env.AUDIENCE_IDENTITY_COOKIE_SECRET_PREVIOUS || '';
436
+ if ((previousKID === '') !== (previousSecret === '')) {
437
+ throw new Error('previous identity kid and secret must be configured together');
438
+ }
439
+ const identityKeys = {
440
+ current: { kid: currentKID, secret: currentSecret },
441
+ ...(previousKID ? { previous: { kid: previousKID, secret: previousSecret } } : {}),
442
+ };
443
+ return {
444
+ siteId,
445
+ store: new VercelEdgeConfigRouteStore(edgeConfig, siteId),
446
+ identityKeys,
447
+ claimsKeys: {
448
+ [currentKID]: currentSecret,
449
+ ...(previousKID ? { [previousKID]: previousSecret } : {}),
450
+ },
451
+ secureCookies: env.AUDIENCE_IDENTITY_COOKIE_SECURE !== 'false',
452
+ };
453
+ }
454
+ /** Builds the Node.js `/collect` configuration. This is the only Vercel
455
+ * application runtime that receives data-plane storage configuration, and it
456
+ * fails closed unless the exact-project AWS workload role is configured. */
457
+ export function createVercelCollectionFromEnv(env = process.env) {
458
+ if (env.ACI_PERSONALIZATION_HOST_ENABLED !== 'true')
459
+ return undefined;
460
+ const siteId = requiredEnv(env, 'ACI_SITE_ID');
461
+ const region = env.AWS_REGION || env.AWS_DEFAULT_REGION || '';
462
+ if (!region)
463
+ throw new Error('AWS_REGION is required for the Vercel /collect route');
464
+ const roleArn = requiredEnv(env, 'AWS_ROLE_ARN');
465
+ const currentKID = env.ACI_IDENTITY_KID || env.AUDIENCE_IDENTITY_COOKIE_KID || 'v1';
466
+ const currentSecret = env.ACI_IDENTITY_SECRET || env.AUDIENCE_IDENTITY_COOKIE_SECRET || '';
467
+ if (!currentSecret)
468
+ throw new Error('ACI_IDENTITY_SECRET is required for the Vercel /collect route');
469
+ const previousKID = env.ACI_IDENTITY_KID_PREVIOUS || env.AUDIENCE_IDENTITY_COOKIE_KID_PREVIOUS || '';
470
+ const previousSecret = env.ACI_IDENTITY_SECRET_PREVIOUS || env.AUDIENCE_IDENTITY_COOKIE_SECRET_PREVIOUS || '';
471
+ if ((previousKID === '') !== (previousSecret === '')) {
472
+ throw new Error('previous identity kid and secret must be configured together');
473
+ }
474
+ const identityKeys = {
475
+ current: { kid: currentKID, secret: currentSecret },
476
+ ...(previousKID ? { previous: { kid: previousKID, secret: previousSecret } } : {}),
477
+ };
478
+ return {
479
+ siteId,
480
+ store: createVercelS3PersonalizationStore({
481
+ region,
482
+ roleArn,
483
+ hostingBucket: requiredEnv(env, 'HOSTING_BUCKET'),
484
+ eventsBucket: requiredEnv(env, 'EVENTS_BUCKET'),
485
+ hostingKeyPrefix: env.HOSTING_KEY_PREFIX,
486
+ claimsKeyPrefix: env.AUDIENCE_CLAIMS_S3_PREFIX,
487
+ }),
488
+ identityKeys,
489
+ claimsKeys: {
490
+ [currentKID]: currentSecret,
491
+ ...(previousKID ? { [previousKID]: previousSecret } : {}),
492
+ },
493
+ secureCookies: env.AUDIENCE_IDENTITY_COOKIE_SECURE !== 'false',
494
+ };
495
+ }
496
+ function requiredSegment(value, label) {
497
+ const clean = cleanKey(value);
498
+ if (!clean || clean.includes('/'))
499
+ throw new Error(`${label} is required and must be one path segment`);
500
+ return clean;
501
+ }
502
+ function requiredIdentityHash(value) {
503
+ const hash = requiredSegment(value, 'identity hash').toLowerCase();
504
+ if (!/^[0-9a-f]{2,}$/.test(hash))
505
+ throw new Error('identity hash must be lowercase hexadecimal');
506
+ return hash;
507
+ }
508
+ function requiredEnvelopeId(value) {
509
+ const envelopeId = requiredSegment(value, 'envelope id').toUpperCase();
510
+ if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(envelopeId)) {
511
+ throw new Error('envelope id must be a normalized ULID');
512
+ }
513
+ return envelopeId;
514
+ }
515
+ function requiredDigest(value) {
516
+ const digest = value.trim().toLowerCase();
517
+ if (!SHA256_HEX.test(digest)) {
518
+ throw new Error('envelope digest must be a SHA-256 hex value');
519
+ }
520
+ return digest;
521
+ }
522
+ function requiredEnv(env, name) {
523
+ const value = env[name]?.trim() || '';
524
+ if (!value)
525
+ throw new Error(`${name} is required when ACI_PERSONALIZATION_HOST_ENABLED=true`);
526
+ return value;
527
+ }
528
+ function cleanKey(value) {
529
+ return (value || '').trim().replace(/^\/+|\/+$/g, '');
530
+ }
531
+ function joinKey(...parts) {
532
+ return parts.map(cleanKey).filter(Boolean).join('/');
533
+ }
534
+ function edgeRoutesKey(siteId, releaseId) {
535
+ return `edgeRoutes_${edgeKeySegment(siteId)}_${edgeKeySegment(releaseId)}`;
536
+ }
537
+ function edgeKeySegment(value) {
538
+ return value.replace(/[^A-Za-z0-9_-]/g, '_');
539
+ }
540
+ function isNotFound(error) {
541
+ const value = error;
542
+ return value?.$metadata?.httpStatusCode === 404 || value?.name === 'NoSuchKey' || value?.name === 'NotFound';
543
+ }
544
+ function isConditionalConflict(error) {
545
+ const value = error;
546
+ return value?.$metadata?.httpStatusCode === 409
547
+ || value?.$metadata?.httpStatusCode === 412
548
+ || value?.name === 'ConditionalRequestConflict'
549
+ || value?.name === 'PreconditionFailed';
550
+ }
551
+ function envelopeDigestMetadata(metadata) {
552
+ return metadataValue(metadata, ENVELOPE_DIGEST_METADATA_KEY).toLowerCase();
553
+ }
554
+ function metadataValue(metadata, want) {
555
+ for (const [key, value] of Object.entries(metadata || {})) {
556
+ if (key.toLowerCase() === want) {
557
+ return value.trim();
558
+ }
559
+ }
560
+ return '';
561
+ }
562
+ function lowerCaseMetadata(metadata) {
563
+ return Object.fromEntries(Object.entries(metadata || {}).map(([key, value]) => [key.toLowerCase(), value.trim()]));
564
+ }
565
+ function positiveDuration(value, fallback) {
566
+ if (value === undefined)
567
+ return fallback;
568
+ if (!Number.isFinite(value) || value <= 0)
569
+ throw new Error('collection gate durations must be positive');
570
+ return value;
571
+ }
572
+ async function delay(milliseconds) {
573
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
574
+ }
@@ -6,19 +6,34 @@ export interface S3ContentProviderConfig {
6
6
  accessKeyId?: string;
7
7
  secretAccessKey?: string;
8
8
  sessionToken?: string;
9
+ roleArn?: string;
9
10
  endpoint?: string;
10
11
  }
11
- type ResolvedS3Config = Required<S3ContentProviderConfig>;
12
+ type ResolvedS3Config = {
13
+ bucket: string;
14
+ keyPrefix: string;
15
+ region: string;
16
+ accessKeyId: string;
17
+ secretAccessKey: string;
18
+ sessionToken: string;
19
+ roleArn: string;
20
+ endpoint: string;
21
+ };
22
+ export interface S3CommandClient {
23
+ send(command: {
24
+ input: unknown;
25
+ }): Promise<any>;
26
+ }
12
27
  export declare class S3ContentProvider implements ContentProvider {
13
28
  #private;
14
29
  readonly config: ResolvedS3Config;
15
- constructor(config?: S3ContentProviderConfig);
30
+ constructor(config?: S3ContentProviderConfig, client?: S3CommandClient);
16
31
  listRoutes(): Promise<RouteEntry[]>;
17
32
  getSiteConfig<T = unknown>(): Promise<T>;
18
33
  getPage<T = unknown>(route: string): Promise<T>;
19
34
  /**
20
35
  * getPageByRef serves the exact payload the edge selected (the
21
- * X-Baremetal-Function-Ref contract): base page or variant. Only refs the
36
+ * X-Aci-Content-Ref contract): base page or variant. Only refs the
22
37
  * release manifest declares are served — the header names published
23
38
  * content, it cannot address arbitrary keys.
24
39
  */
@@ -29,6 +44,6 @@ export declare class S3ContentProvider implements ContentProvider {
29
44
  fetchRaw(key: string): Promise<Response>;
30
45
  private getJSON;
31
46
  }
32
- export declare function getS3JSON<T>(key: string, config: ResolvedS3Config): Promise<T>;
33
- export declare function getS3Raw(key: string, config: ResolvedS3Config): Promise<Response>;
47
+ export declare function getS3JSON<T>(key: string, config: ResolvedS3Config, client?: S3CommandClient): Promise<T>;
48
+ export declare function getS3Raw(key: string, config: ResolvedS3Config, client?: S3CommandClient): Promise<Response>;
34
49
  export {};