@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
@@ -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 {};
@@ -1,4 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
+ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
3
+ import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
2
4
  import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError } from '../content/provider.js';
3
5
  import { normalizeRoute } from '../content/routes.js';
4
6
  // ---------------------------------------------------------------------------
@@ -7,8 +9,10 @@ import { normalizeRoute } from '../content/routes.js';
7
9
  export class S3ContentProvider {
8
10
  config;
9
11
  #manifest;
10
- constructor(config = {}) {
12
+ #client;
13
+ constructor(config = {}, client) {
11
14
  this.config = resolveS3Config(config);
15
+ this.#client = client || oidcS3Client(this.config);
12
16
  }
13
17
  async listRoutes() {
14
18
  const manifest = await this.manifest();
@@ -83,24 +87,18 @@ export class S3ContentProvider {
83
87
  return await this.#manifest;
84
88
  }
85
89
  async fetchRaw(key) {
86
- return await getS3Raw(key, this.config);
90
+ return await getS3Raw(key, this.config, this.#client);
87
91
  }
88
92
  async getJSON(key) {
89
- return await getS3JSON(joinKey(this.config.keyPrefix, key), this.config);
93
+ return await getS3JSON(joinKey(this.config.keyPrefix, key), this.config, this.#client);
90
94
  }
91
95
  }
92
96
  // ---------------------------------------------------------------------------
93
97
  // S3 fetch with AWS SigV4 signing
94
98
  // ---------------------------------------------------------------------------
95
- export async function getS3JSON(key, config) {
99
+ export async function getS3JSON(key, config, client) {
96
100
  const startTime = Date.now();
97
- const url = s3ObjectURL(config, key);
98
- const signedHeaders = signedS3Headers('GET', url, config);
99
- const res = await fetch(url, {
100
- method: 'GET',
101
- headers: signedHeaders,
102
- cache: 'no-store'
103
- });
101
+ const res = await getS3Raw(key, config, client);
104
102
  const duration = Date.now() - startTime;
105
103
  console.log(`[ACI S3] GET ${key} - ${res.status} in ${duration}ms`);
106
104
  if (!res.ok) {
@@ -108,7 +106,9 @@ export async function getS3JSON(key, config) {
108
106
  }
109
107
  return (await res.json());
110
108
  }
111
- export async function getS3Raw(key, config) {
109
+ export async function getS3Raw(key, config, client) {
110
+ if (client)
111
+ return await getS3RawWithClient(key, config, client);
112
112
  const url = s3ObjectURL(config, key);
113
113
  const signedHeaders = signedS3Headers('GET', url, config);
114
114
  return await fetch(url, {
@@ -122,20 +122,90 @@ export async function getS3Raw(key, config) {
122
122
  // ---------------------------------------------------------------------------
123
123
  function resolveS3Config(config) {
124
124
  const resolved = {
125
- bucket: config.bucket || process.env.ACI_S3_BUCKET || '',
126
- keyPrefix: config.keyPrefix || process.env.ACI_S3_KEY_PREFIX || '',
127
- region: config.region || process.env.AWS_REGION || 'us-east-1',
128
- accessKeyId: config.accessKeyId || process.env.AWS_ACCESS_KEY_ID || '',
129
- secretAccessKey: config.secretAccessKey || process.env.AWS_SECRET_ACCESS_KEY || '',
130
- sessionToken: config.sessionToken || process.env.AWS_SESSION_TOKEN || '',
131
- endpoint: trimSlash(config.endpoint || process.env.ACI_S3_ENDPOINT || '')
125
+ bucket: configuredValue(config.bucket, process.env.ACI_S3_BUCKET),
126
+ keyPrefix: configuredValue(config.keyPrefix, process.env.ACI_S3_KEY_PREFIX),
127
+ region: configuredValue(config.region, process.env.AWS_REGION) || 'us-east-1',
128
+ accessKeyId: configuredValue(config.accessKeyId, process.env.AWS_ACCESS_KEY_ID),
129
+ secretAccessKey: configuredValue(config.secretAccessKey, process.env.AWS_SECRET_ACCESS_KEY),
130
+ sessionToken: configuredValue(config.sessionToken, process.env.AWS_SESSION_TOKEN),
131
+ roleArn: configuredValue(config.roleArn, process.env.AWS_ROLE_ARN),
132
+ endpoint: trimSlash(configuredValue(config.endpoint, process.env.ACI_S3_ENDPOINT))
132
133
  };
133
- for (const key of ['bucket', 'accessKeyId', 'secretAccessKey']) {
134
- if (!resolved[key])
135
- throw new Error(`S3 config missing ${key}`);
134
+ if (!resolved.bucket)
135
+ throw new Error('S3 config missing bucket');
136
+ if (!resolved.roleArn) {
137
+ if (resolved.accessKeyId && !resolved.secretAccessKey) {
138
+ throw new Error('S3 config missing secretAccessKey');
139
+ }
140
+ if (!resolved.accessKeyId && resolved.secretAccessKey) {
141
+ throw new Error('S3 config missing accessKeyId');
142
+ }
143
+ if (!resolved.accessKeyId) {
144
+ throw new Error('S3 config missing credential mode: set AWS_ROLE_ARN or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY');
145
+ }
136
146
  }
137
147
  return resolved;
138
148
  }
149
+ function oidcS3Client(config) {
150
+ if (!config.roleArn)
151
+ return undefined;
152
+ return new S3Client({
153
+ region: config.region,
154
+ ...(config.endpoint ? {
155
+ endpoint: config.endpoint,
156
+ forcePathStyle: true,
157
+ } : {}),
158
+ credentials: awsCredentialsProvider({
159
+ roleArn: config.roleArn,
160
+ clientConfig: { region: config.region },
161
+ }),
162
+ });
163
+ }
164
+ async function getS3RawWithClient(key, config, client) {
165
+ try {
166
+ const output = await client.send(new GetObjectCommand({
167
+ Bucket: config.bucket,
168
+ Key: key,
169
+ }));
170
+ if (!output.Body?.transformToWebStream) {
171
+ throw new Error(`S3 GET ${key} returned an unsupported object body`);
172
+ }
173
+ const headers = new Headers();
174
+ if (output.ContentType)
175
+ headers.set('content-type', output.ContentType);
176
+ if (output.ContentLength !== undefined) {
177
+ headers.set('content-length', String(output.ContentLength));
178
+ }
179
+ if (output.ETag)
180
+ headers.set('etag', output.ETag);
181
+ if (output.LastModified) {
182
+ headers.set('last-modified', output.LastModified.toUTCString());
183
+ }
184
+ return new Response(output.Body.transformToWebStream(), {
185
+ status: 200,
186
+ headers,
187
+ });
188
+ }
189
+ catch (error) {
190
+ const status = s3ErrorStatus(error);
191
+ if (status) {
192
+ return new Response(error instanceof Error ? error.message : String(error), { status });
193
+ }
194
+ throw new Error(`S3 GET ${key} failed`, { cause: error });
195
+ }
196
+ }
197
+ function s3ErrorStatus(error) {
198
+ if (!error || typeof error !== 'object')
199
+ return undefined;
200
+ const status = error
201
+ .$metadata?.httpStatusCode;
202
+ return typeof status === 'number' && status >= 400 && status <= 599
203
+ ? status
204
+ : undefined;
205
+ }
206
+ function configuredValue(configured, environment) {
207
+ return (configured === undefined ? environment : configured)?.trim() || '';
208
+ }
139
209
  function s3ObjectURL(config, key) {
140
210
  const encodedKey = encodeKey(key);
141
211
  if (config.endpoint) {