@gradial/aci 0.1.42 → 0.1.44

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 (44) hide show
  1. package/CHANGELOG.md +16 -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/collect/aci.js +1 -0
  6. package/dist/collect/aci.js.sha256 +1 -0
  7. package/dist/collect/batcher.d.ts +2 -0
  8. package/dist/collect/batcher.js +18 -6
  9. package/dist/collect/consent/envelope.d.ts +9 -3
  10. package/dist/collect/consent/envelope.js +58 -40
  11. package/dist/collect/index.js +9 -2
  12. package/dist/collect/linkclicks.js +4 -2
  13. package/dist/collect/transport.d.ts +2 -0
  14. package/dist/collect/transport.js +6 -3
  15. package/dist/consent/portable-envelope.d.ts +9 -0
  16. package/dist/consent/portable-envelope.js +110 -0
  17. package/dist/consent-banner/index.js +2 -1
  18. package/dist/content/assets.d.ts +1 -0
  19. package/dist/content/assets.js +1 -0
  20. package/dist/next/collect-route.d.ts +2 -2
  21. package/dist/next/collect-route.js +90 -19
  22. package/dist/next/envelope-digest.d.ts +21 -0
  23. package/dist/next/envelope-digest.js +58 -0
  24. package/dist/next/middleware.d.ts +16 -2
  25. package/dist/next/middleware.js +190 -10
  26. package/dist/next/personalization-host.d.ts +16 -7
  27. package/dist/next/personalization-host.js +14 -33
  28. package/dist/next/server.d.ts +1 -0
  29. package/dist/next/server.js +3 -0
  30. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  31. package/dist/next/vercel-collect-sdk-route.js +205 -0
  32. package/dist/next/vercel-personalization.d.ts +3 -2
  33. package/dist/next/vercel-personalization.js +3 -2
  34. package/dist/next/vercel-s3-personalization.d.ts +25 -4
  35. package/dist/next/vercel-s3-personalization.js +368 -14
  36. package/dist/providers/s3.d.ts +19 -4
  37. package/dist/providers/s3.js +92 -22
  38. package/dist/testing/conformance/checks.js +75 -4
  39. package/package.json +27 -16
  40. package/src/cli/validate-content.mjs +0 -0
  41. package/templates/nextjs/starter-content/src/middleware.ts +31 -1
  42. package/templates/nextjs/template/src/app/collect/route.ts +2 -2
  43. package/templates/nextjs/template/src/middleware.ts +31 -1
  44. package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
@@ -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;
@@ -1,6 +1,32 @@
1
1
  import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
2
2
  import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
3
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
+ }
4
30
  /**
5
31
  * Node-only Vercel collection adapter for audience lake objects and signed
6
32
  * claims. The EdgeRouter never receives this adapter or AWS credentials.
@@ -12,16 +38,24 @@ export class VercelS3PersonalizationStore {
12
38
  client;
13
39
  constructor(config, client) {
14
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
+ }
15
49
  const s3Config = {
16
50
  region: config.region,
17
51
  endpoint: config.endpoint,
18
52
  forcePathStyle: config.forcePathStyle,
19
- ...(config.roleArn ? { credentials: awsCredentialsProvider({
20
- roleArn: config.roleArn,
21
- clientConfig: { region: config.region },
22
- }) } : {}),
53
+ credentials: awsCredentialsProvider({
54
+ roleArn,
55
+ clientConfig: { region: config.region },
56
+ }),
23
57
  };
24
- this.client = client || new S3Client(s3Config);
58
+ this.client = new S3Client(s3Config);
25
59
  }
26
60
  async readClaims(siteId, namespace, identityHash) {
27
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`);
@@ -36,30 +70,96 @@ export class VercelS3PersonalizationStore {
36
70
  return null;
37
71
  return envelope.signedValue;
38
72
  }
39
- async appendEvent(key, record) {
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);
40
94
  const input = {
41
95
  Bucket: this.config.eventsBucket,
42
- Key: cleanKey(key),
43
- Body: JSON.stringify(record),
96
+ Key: key,
97
+ Body: body,
44
98
  ContentType: 'application/json',
45
99
  IfNoneMatch: '*',
100
+ Metadata: {
101
+ [ENVELOPE_DIGEST_METADATA_KEY]: digest,
102
+ [COLLECTION_GATE_ETAG_METADATA_KEY]: gate.etag,
103
+ },
46
104
  };
105
+ if (gate.resumedUncertain) {
106
+ return this.settleResumedEnvelope(input.Key, digest, gate);
107
+ }
108
+ let operationError;
109
+ let settled = false;
47
110
  try {
48
- await this.client.send(new PutObjectCommand(input));
111
+ await this.sendWithWriteDeadline(new PutObjectCommand(input));
112
+ settled = true;
49
113
  }
50
114
  catch (error) {
51
- if (!isConditionalConflict(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.
52
119
  throw error;
120
+ }
53
121
  // S3 can report 409 for two concurrent conditional writes even when
54
122
  // neither write has committed yet. Confirm the object before treating
55
123
  // the conflict as the idempotent duplicate case.
124
+ let head;
56
125
  try {
57
- await this.client.send(new HeadObjectCommand({ Bucket: input.Bucket, Key: input.Key }));
126
+ head = await this.client.send(new HeadObjectCommand({ Bucket: input.Bucket, Key: input.Key }));
58
127
  }
59
128
  catch (headError) {
60
- throw new Error(`conditional event write conflicted before ${input.Key} was stored`, { cause: 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 });
61
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);
62
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;
63
163
  }
64
164
  async putSuppression(key, record) {
65
165
  await this.client.send(new PutObjectCommand({
@@ -69,6 +169,214 @@ export class VercelS3PersonalizationStore {
69
169
  ContentType: 'application/json',
70
170
  }));
71
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
+ }
72
380
  async readObject(bucket, key) {
73
381
  try {
74
382
  const output = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
@@ -144,7 +452,8 @@ export function createVercelPersonalizationFromEnv(env = process.env) {
144
452
  };
145
453
  }
146
454
  /** Builds the Node.js `/collect` configuration. This is the only Vercel
147
- * application runtime that receives data-plane storage configuration. */
455
+ * application runtime that receives data-plane storage configuration, and it
456
+ * fails closed unless the exact-project AWS workload role is configured. */
148
457
  export function createVercelCollectionFromEnv(env = process.env) {
149
458
  if (env.ACI_PERSONALIZATION_HOST_ENABLED !== 'true')
150
459
  return undefined;
@@ -152,6 +461,7 @@ export function createVercelCollectionFromEnv(env = process.env) {
152
461
  const region = env.AWS_REGION || env.AWS_DEFAULT_REGION || '';
153
462
  if (!region)
154
463
  throw new Error('AWS_REGION is required for the Vercel /collect route');
464
+ const roleArn = requiredEnv(env, 'AWS_ROLE_ARN');
155
465
  const currentKID = env.ACI_IDENTITY_KID || env.AUDIENCE_IDENTITY_COOKIE_KID || 'v1';
156
466
  const currentSecret = env.ACI_IDENTITY_SECRET || env.AUDIENCE_IDENTITY_COOKIE_SECRET || '';
157
467
  if (!currentSecret)
@@ -169,7 +479,7 @@ export function createVercelCollectionFromEnv(env = process.env) {
169
479
  siteId,
170
480
  store: createVercelS3PersonalizationStore({
171
481
  region,
172
- roleArn: env.AWS_ROLE_ARN?.trim() || undefined,
482
+ roleArn,
173
483
  hostingBucket: requiredEnv(env, 'HOSTING_BUCKET'),
174
484
  eventsBucket: requiredEnv(env, 'EVENTS_BUCKET'),
175
485
  hostingKeyPrefix: env.HOSTING_KEY_PREFIX,
@@ -189,6 +499,26 @@ function requiredSegment(value, label) {
189
499
  throw new Error(`${label} is required and must be one path segment`);
190
500
  return clean;
191
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
+ }
192
522
  function requiredEnv(env, name) {
193
523
  const value = env[name]?.trim() || '';
194
524
  if (!value)
@@ -218,3 +548,27 @@ function isConditionalConflict(error) {
218
548
  || value?.name === 'ConditionalRequestConflict'
219
549
  || value?.name === 'PreconditionFailed';
220
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,13 +6,28 @@ 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>;
@@ -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 {};