@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.
- package/CHANGELOG.md +8 -0
- package/README.md +4 -4
- package/dist/audience-data-models.d.ts +286 -0
- package/dist/audience-data-models.js +46 -0
- package/dist/cli/compile-registry.d.mts +5 -1
- package/dist/cli/compile-registry.mjs +25 -10
- package/dist/cli/validate-content.d.mts +31 -1
- package/dist/cli/validate-content.mjs +50 -12
- package/dist/collect/aci.js +1 -0
- package/dist/collect/aci.js.sha256 +1 -0
- package/dist/collect/batcher.d.ts +2 -0
- package/dist/collect/batcher.js +18 -6
- package/dist/collect/consent/envelope.d.ts +9 -3
- package/dist/collect/consent/envelope.js +58 -40
- package/dist/collect/index.js +9 -2
- package/dist/collect/linkclicks.js +4 -2
- package/dist/collect/transport.d.ts +2 -0
- package/dist/collect/transport.js +6 -3
- package/dist/consent/portable-envelope.d.ts +9 -0
- package/dist/consent/portable-envelope.js +110 -0
- package/dist/consent-banner/index.js +2 -1
- package/dist/content/assets.d.ts +1 -0
- package/dist/content/assets.js +1 -0
- package/dist/content/resolve-slots.js +1 -0
- package/dist/content/types.d.ts +2 -0
- package/dist/next/collect-route.d.ts +2 -2
- package/dist/next/collect-route.js +90 -19
- package/dist/next/envelope-digest.d.ts +21 -0
- package/dist/next/envelope-digest.js +58 -0
- package/dist/next/middleware.d.ts +16 -2
- package/dist/next/middleware.js +190 -10
- package/dist/next/personalization-host.d.ts +16 -7
- package/dist/next/personalization-host.js +14 -33
- package/dist/next/server.d.ts +1 -0
- package/dist/next/server.js +3 -0
- package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
- package/dist/next/vercel-collect-sdk-route.js +205 -0
- package/dist/next/vercel-personalization.d.ts +3 -2
- package/dist/next/vercel-personalization.js +3 -2
- package/dist/next/vercel-s3-personalization.d.ts +25 -4
- package/dist/next/vercel-s3-personalization.js +368 -14
- package/dist/providers/s3.d.ts +19 -4
- package/dist/providers/s3.js +92 -22
- package/dist/testing/conformance/checks.js +75 -4
- package/dist/types/block-ref.d.ts +2 -0
- package/dist/types/block-ref.js +1 -0
- package/package.json +27 -16
- package/src/cli/compile-registry.mjs +24 -10
- package/src/cli/validate-content.mjs +52 -13
- package/src/types/block-ref.test.ts +45 -0
- package/src/types/block-ref.ts +2 -0
- package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
- package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
- package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
- package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
- package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
- package/templates/common/.content/pages/home/_index.json +10 -0
- package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
- package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
- package/templates/nextjs/starter-content/src/middleware.ts +31 -1
- package/templates/nextjs/template/src/app/collect/route.ts +2 -2
- package/templates/nextjs/template/src/middleware.ts +31 -1
- 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
53
|
+
credentials: awsCredentialsProvider({
|
|
54
|
+
roleArn,
|
|
55
|
+
clientConfig: { region: config.region },
|
|
56
|
+
}),
|
|
23
57
|
};
|
|
24
|
-
this.client =
|
|
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
|
|
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:
|
|
43
|
-
Body:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/providers/s3.d.ts
CHANGED
|
@@ -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 =
|
|
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 {};
|
package/dist/providers/s3.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
126
|
-
keyPrefix: config.keyPrefix
|
|
127
|
-
region: config.region
|
|
128
|
-
accessKeyId: config.accessKeyId
|
|
129
|
-
secretAccessKey: config.secretAccessKey
|
|
130
|
-
sessionToken: config.sessionToken
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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) {
|