@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.
- package/CHANGELOG.md +16 -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/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/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/package.json +27 -16
- package/src/cli/validate-content.mjs +0 -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,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
|
-
|
|
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
|
|
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
|
-
|
|
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 {};
|