@gradial/aci 0.1.44 → 0.1.46

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.
@@ -2,6 +2,7 @@ import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from
2
2
  import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
3
3
  import { getUncachedEdgeConfigValue } from './edge-config.js';
4
4
  import { IdentityErasureFencedError } from './personalization-host.js';
5
+ import { decodeEdgeRouteProjectionV1, edgeRoutesV1Key, } from './vercel-edge-projection-v1.js';
5
6
  const ENVELOPE_DIGEST_METADATA_KEY = 'aci-envelope-sha256';
6
7
  const COLLECTION_GATE_ETAG_METADATA_KEY = 'aci-collection-gate-etag';
7
8
  const COLLECTION_GATE_STATE_METADATA_KEY = 'aci-collection-gate-state';
@@ -406,12 +407,13 @@ export class VercelEdgeConfigRouteStore {
406
407
  this.siteId = siteId;
407
408
  }
408
409
  async readEdgeManifest(releaseId) {
409
- const value = await getUncachedEdgeConfigValue(this.edgeConfig, edgeRoutesKey(this.siteId, releaseId));
410
- if (!value)
410
+ const value = await getUncachedEdgeConfigValue(this.edgeConfig, edgeRoutesV1Key(this.siteId, releaseId));
411
+ if (value == null)
411
412
  return null;
412
- if (typeof value === 'string')
413
- return JSON.parse(value);
414
- return value;
413
+ return decodeEdgeRouteProjectionV1(value, {
414
+ siteId: this.siteId,
415
+ releaseId,
416
+ });
415
417
  }
416
418
  }
417
419
  export function createVercelS3PersonalizationStore(config) {
@@ -531,12 +533,6 @@ function cleanKey(value) {
531
533
  function joinKey(...parts) {
532
534
  return parts.map(cleanKey).filter(Boolean).join('/');
533
535
  }
534
- function edgeRoutesKey(siteId, releaseId) {
535
- return `edgeRoutes_${edgeKeySegment(siteId)}_${edgeKeySegment(releaseId)}`;
536
- }
537
- function edgeKeySegment(value) {
538
- return value.replace(/[^A-Za-z0-9_-]/g, '_');
539
- }
540
536
  function isNotFound(error) {
541
537
  const value = error;
542
538
  return value?.$metadata?.httpStatusCode === 404 || value?.name === 'NoSuchKey' || value?.name === 'NotFound';
@@ -32,10 +32,8 @@ export declare class S3ContentProvider implements ContentProvider {
32
32
  getSiteConfig<T = unknown>(): Promise<T>;
33
33
  getPage<T = unknown>(route: string): Promise<T>;
34
34
  /**
35
- * getPageByRef serves the exact payload the edge selected (the
36
- * X-Aci-Content-Ref contract): base page or variant. Only refs the
37
- * release manifest declares are served — the header names published
38
- * content, it cannot address arbitrary keys.
35
+ * getPageByRef serves an exact payload the manifest declares. The stricter
36
+ * route-bound selection used by server rendering stays package-internal.
39
37
  */
40
38
  getPageByRef<T = unknown>(payloadRef: string): Promise<T>;
41
39
  getFragment<T = unknown>(id: string): Promise<T>;
@@ -1,8 +1,9 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
3
3
  import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
4
- import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError } from '../content/provider.js';
4
+ import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError, } from '../content/provider.js';
5
5
  import { normalizeRoute } from '../content/routes.js';
6
+ import { createVerifiedPageReceipt, selectVerifiedPage, validateVerifiedPageReceipt, } from '../internal/render-proof.js';
6
7
  // ---------------------------------------------------------------------------
7
8
  // S3 content provider — reads compiled content files from S3
8
9
  // ---------------------------------------------------------------------------
@@ -23,29 +24,21 @@ export class S3ContentProvider {
23
24
  return await this.getJSON(manifest.siteConfigRef);
24
25
  }
25
26
  async getPage(route) {
26
- const manifest = await this.manifest();
27
- const normalized = normalizeRoute(route);
28
- const entry = Object.values(manifest.routes).find((candidate) => normalizeRoute(candidate.path) === normalized);
29
- if (!entry) {
30
- throw new PageNotFoundError(route);
31
- }
32
- try {
33
- return await this.getJSON(entry.payloadRef);
34
- }
35
- catch (error) {
36
- throw new PageNotFoundError(route, error);
37
- }
27
+ return (await this.#selectPage({
28
+ route,
29
+ contentRef: '',
30
+ contentRefPresent: false,
31
+ variantKey: '',
32
+ variantKeyPresent: false,
33
+ })).page;
38
34
  }
39
35
  /**
40
- * getPageByRef serves the exact payload the edge selected (the
41
- * X-Aci-Content-Ref contract): base page or variant. Only refs the
42
- * release manifest declares are served — the header names published
43
- * content, it cannot address arbitrary keys.
36
+ * getPageByRef serves an exact payload the manifest declares. The stricter
37
+ * route-bound selection used by server rendering stays package-internal.
44
38
  */
45
39
  async getPageByRef(payloadRef) {
46
40
  const manifest = await this.manifest();
47
- const known = Object.values(manifest.routes).some((entry) => entry.payloadRef === payloadRef ||
48
- Object.values(entry.variants ?? {}).some((variant) => variant.payloadRef === payloadRef));
41
+ const known = Object.values(manifest.routes).some((entry) => routeContainsRef(entry, payloadRef));
49
42
  if (!known) {
50
43
  throw new PageNotFoundError(payloadRef);
51
44
  }
@@ -56,6 +49,20 @@ export class S3ContentProvider {
56
49
  throw new PageNotFoundError(payloadRef, error);
57
50
  }
58
51
  }
52
+ /** @internal */
53
+ async [selectVerifiedPage](request) {
54
+ const selection = await this.#selectPage(request, request.releaseId);
55
+ return Object.freeze({
56
+ page: selection.page,
57
+ receipt: createVerifiedPageReceipt({
58
+ releaseId: request.releaseId,
59
+ route: selection.route,
60
+ contentRef: selection.contentRef,
61
+ contentSha256: selection.contentSha256,
62
+ variantKey: selection.variantKey,
63
+ }),
64
+ });
65
+ }
59
66
  async getFragment(id) {
60
67
  const manifest = await this.manifest();
61
68
  const entry = manifest.fragments[id];
@@ -92,6 +99,149 @@ export class S3ContentProvider {
92
99
  async getJSON(key) {
93
100
  return await getS3JSON(joinKey(this.config.keyPrefix, key), this.config, this.#client);
94
101
  }
102
+ async #selectPage(request, releaseId) {
103
+ if (request.variantKeyPresent && !request.contentRefPresent) {
104
+ throw pageIntegrityError('variant header requires a content ref');
105
+ }
106
+ const manifest = await this.manifest();
107
+ const normalized = normalizeRoute(request.route);
108
+ const routeMatches = Object.values(manifest.routes).filter((candidate) => normalizeRoute(candidate.path) === normalized);
109
+ if (routeMatches.length === 0) {
110
+ if (request.contentRefPresent) {
111
+ const elsewhere = Object.values(manifest.routes)
112
+ .some((candidate) => routeContainsRef(candidate, request.contentRef));
113
+ const reason = elsewhere ? 'cross-route content ref' : 'undeclared route content ref';
114
+ throw pageIntegrityError(`${reason}: ${request.contentRef}`);
115
+ }
116
+ throw new PageNotFoundError(request.route);
117
+ }
118
+ if (routeMatches.length !== 1) {
119
+ throw pageIntegrityError(`route ${normalized} is ambiguous`);
120
+ }
121
+ const entry = routeMatches[0];
122
+ const route = normalizeRoute(entry.path);
123
+ const tuples = [
124
+ {
125
+ contentRef: entry.payloadRef,
126
+ digest: entry.digest,
127
+ variantKey: '',
128
+ isVariant: false,
129
+ },
130
+ ...Object.values(entry.variants ?? {}).map(pageTupleForVariant),
131
+ ];
132
+ let tuple;
133
+ if (!request.contentRefPresent) {
134
+ tuple = tuples[0];
135
+ }
136
+ else {
137
+ const matches = tuples.filter((candidate) => {
138
+ if (candidate.contentRef !== request.contentRef)
139
+ return false;
140
+ if (!request.variantKeyPresent)
141
+ return true;
142
+ return candidate.isVariant && candidate.variantKey === request.variantKey;
143
+ });
144
+ if (matches.length === 0) {
145
+ const elsewhere = Object.values(manifest.routes)
146
+ .filter((candidate) => candidate !== entry)
147
+ .some((candidate) => routeContainsRef(candidate, request.contentRef));
148
+ const reason = elsewhere ? 'cross-route content ref' : 'undeclared route content ref';
149
+ throw pageIntegrityError(`${reason}: ${request.contentRef}`);
150
+ }
151
+ if (matches.length !== 1) {
152
+ throw pageIntegrityError(`ambiguous route content ref: ${request.contentRef}`);
153
+ }
154
+ tuple = matches[0];
155
+ }
156
+ const digestMatch = /^sha256:([0-9a-f]{64})$/.exec(tuple.digest);
157
+ if (!digestMatch) {
158
+ throw pageIntegrityError(`invalid manifest digest for ${tuple.contentRef}`);
159
+ }
160
+ const contentSha256 = digestMatch[1];
161
+ if (releaseId !== undefined) {
162
+ validateVerifiedPageReceipt({
163
+ releaseId,
164
+ route,
165
+ contentRef: tuple.contentRef,
166
+ contentSha256,
167
+ variantKey: tuple.variantKey,
168
+ });
169
+ }
170
+ const key = joinManifestContentKey(this.config.keyPrefix, tuple.contentRef);
171
+ let response;
172
+ try {
173
+ response = await getS3Raw(key, this.config, this.#client);
174
+ }
175
+ catch (cause) {
176
+ throw pageIntegrityError(`fetch failed for ${tuple.contentRef}`, cause);
177
+ }
178
+ if (!response.ok) {
179
+ const detail = await safeResponseText(response);
180
+ throw pageIntegrityError(`fetch failed for ${tuple.contentRef} with status ${response.status}${detail ? `: ${detail}` : ''}`);
181
+ }
182
+ let bytes;
183
+ try {
184
+ bytes = new Uint8Array(await response.arrayBuffer());
185
+ }
186
+ catch (cause) {
187
+ throw pageIntegrityError(`response body failed for ${tuple.contentRef}`, cause);
188
+ }
189
+ const actualDigest = crypto.createHash('sha256').update(bytes).digest('hex');
190
+ if (actualDigest !== contentSha256) {
191
+ throw pageIntegrityError(`digest mismatch for ${tuple.contentRef}: got ${actualDigest}, want ${contentSha256}`);
192
+ }
193
+ let source = '';
194
+ try {
195
+ source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
196
+ }
197
+ catch (cause) {
198
+ throw pageIntegrityError(`invalid UTF-8 for ${tuple.contentRef}`, cause);
199
+ }
200
+ let page;
201
+ try {
202
+ page = JSON.parse(source);
203
+ }
204
+ catch (cause) {
205
+ throw pageIntegrityError(`invalid JSON for ${tuple.contentRef}`, cause);
206
+ }
207
+ return Object.freeze({
208
+ page,
209
+ route,
210
+ contentRef: tuple.contentRef,
211
+ contentSha256,
212
+ variantKey: tuple.variantKey,
213
+ });
214
+ }
215
+ }
216
+ function pageTupleForVariant(variant) {
217
+ if (typeof variant.variantKey !== 'string' || variant.variantKey === '') {
218
+ throw pageIntegrityError('variant manifest tuple has an empty variant key');
219
+ }
220
+ return {
221
+ contentRef: variant.payloadRef,
222
+ digest: variant.digest,
223
+ variantKey: variant.variantKey,
224
+ isVariant: true,
225
+ };
226
+ }
227
+ function routeContainsRef(entry, contentRef) {
228
+ return entry.payloadRef === contentRef
229
+ || Object.values(entry.variants ?? {})
230
+ .some((variant) => variant.payloadRef === contentRef);
231
+ }
232
+ function pageIntegrityError(message, cause) {
233
+ const error = new Error(`ACI S3 page integrity error: ${message}`);
234
+ if (cause !== undefined)
235
+ error.cause = cause;
236
+ return error;
237
+ }
238
+ async function safeResponseText(response) {
239
+ try {
240
+ return await response.text();
241
+ }
242
+ catch {
243
+ return '';
244
+ }
95
245
  }
96
246
  // ---------------------------------------------------------------------------
97
247
  // S3 fetch with AWS SigV4 signing
@@ -278,3 +428,7 @@ function joinKey(...parts) {
278
428
  .filter(Boolean)
279
429
  .join('/');
280
430
  }
431
+ function joinManifestContentKey(prefix, contentRef) {
432
+ const normalizedPrefix = joinKey(prefix);
433
+ return normalizedPrefix ? `${normalizedPrefix}/${contentRef}` : contentRef;
434
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/gradial/bare-metal-go.git",
@@ -114,7 +114,7 @@
114
114
  }
115
115
  },
116
116
  "scripts": {
117
- "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node scripts/build-collect.mjs",
117
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node scripts/assert-render-proof-public-surface.mjs && node scripts/build-collect.mjs",
118
118
  "test": "node --import tsx --test \"src/**/*.test.ts\"",
119
119
  "build:collect": "node scripts/build-collect.mjs",
120
120
  "build:cli": "cd ../.. && ./scripts/build-cli.sh",
@@ -164,11 +164,11 @@
164
164
  "zod": "^4.0.0"
165
165
  },
166
166
  "optionalDependencies": {
167
- "@gradial/aci-cli-darwin-arm64": "0.1.44",
168
- "@gradial/aci-cli-darwin-x64": "0.1.44",
169
- "@gradial/aci-cli-linux-arm64": "0.1.44",
170
- "@gradial/aci-cli-linux-x64": "0.1.44",
171
- "@gradial/aci-cli-win32-arm64": "0.1.44",
172
- "@gradial/aci-cli-win32-x64": "0.1.44"
167
+ "@gradial/aci-cli-darwin-arm64": "0.1.45",
168
+ "@gradial/aci-cli-darwin-x64": "0.1.45",
169
+ "@gradial/aci-cli-linux-arm64": "0.1.45",
170
+ "@gradial/aci-cli-linux-x64": "0.1.45",
171
+ "@gradial/aci-cli-win32-arm64": "0.1.45",
172
+ "@gradial/aci-cli-win32-x64": "0.1.45"
173
173
  }
174
174
  }
@@ -71,6 +71,12 @@ checkMissing(
71
71
  'The root catch-all handles release-scoped rewrites. Do not create a separate __r route folder.',
72
72
  );
73
73
 
74
+ checkMissing(
75
+ 'platform-owned collect route is absent',
76
+ 'src/app/collect/route.ts',
77
+ 'ACI injects the reserved /collect function during Vercel Build Output composition; customer source must not own it.',
78
+ );
79
+
74
80
  checkFile(
75
81
  'next.config uses withAci',
76
82
  'next.config.mjs',
@@ -24,7 +24,8 @@
24
24
  "assetId": "ast_site_about_hero",
25
25
  "alt": "Meridian network coverage map with soft gradient"
26
26
  }
27
- }
27
+ },
28
+ "$type": "component"
28
29
  },
29
30
  {
30
31
  "id": "about-actions",
@@ -41,10 +42,12 @@
41
42
  "variant": "primary",
42
43
  "icon": "arrow_forward",
43
44
  "iconPosition": "after"
44
- }
45
+ },
46
+ "$type": "component"
45
47
  }
46
48
  ]
47
- }
49
+ },
50
+ "$type": "component"
48
51
  }
49
52
  ]
50
53
  }
@@ -24,7 +24,8 @@
24
24
  "assetId": "ast_site_home_hero",
25
25
  "alt": "Light abstract hero background with satellite constellation"
26
26
  }
27
- }
27
+ },
28
+ "$type": "component"
28
29
  },
29
30
  {
30
31
  "id": "why-meridian",
@@ -40,7 +41,8 @@
40
41
  "icon": "globe",
41
42
  "title": "Global coverage",
42
43
  "description": "A constellation of low-orbit satellites keeps you connected across continents, oceans, and remote terrain."
43
- }
44
+ },
45
+ "$type": "component"
44
46
  },
45
47
  {
46
48
  "id": "low-latency",
@@ -49,7 +51,8 @@
49
51
  "icon": "speed",
50
52
  "title": "Low latency",
51
53
  "description": "Sub-50ms latency means video calls, gaming, and real-time control feel local, even in the middle of nowhere."
52
- }
54
+ },
55
+ "$type": "component"
53
56
  },
54
57
  {
55
58
  "id": "easy-setup",
@@ -58,22 +61,41 @@
58
61
  "icon": "setup",
59
62
  "title": "Easy setup",
60
63
  "description": "A compact terminal and a phone app. Point at the sky, power on, and get online in minutes."
61
- }
64
+ },
65
+ "$type": "component"
62
66
  }
63
67
  ]
64
- }
68
+ },
69
+ "$type": "component"
65
70
  },
66
71
  {
67
72
  "id": "stats",
68
73
  "component": "stats_bar",
69
74
  "props": {
70
75
  "stats": [
71
- { "label": "Network uptime", "value": "99.9", "suffix": "%" },
72
- { "label": "Average latency", "value": "<50", "suffix": "ms" },
73
- { "label": "Countries covered", "value": "40", "suffix": "+" },
74
- { "label": "Orbital satellites", "value": "2,400", "suffix": "" }
76
+ {
77
+ "label": "Network uptime",
78
+ "value": "99.9",
79
+ "suffix": "%"
80
+ },
81
+ {
82
+ "label": "Average latency",
83
+ "value": "<50",
84
+ "suffix": "ms"
85
+ },
86
+ {
87
+ "label": "Countries covered",
88
+ "value": "40",
89
+ "suffix": "+"
90
+ },
91
+ {
92
+ "label": "Orbital satellites",
93
+ "value": "2,400",
94
+ "suffix": ""
95
+ }
75
96
  ]
76
- }
97
+ },
98
+ "$type": "component"
77
99
  },
78
100
  {
79
101
  "id": "cta",
@@ -91,7 +113,8 @@
91
113
  "variant": "primary",
92
114
  "icon": "arrow_forward",
93
115
  "iconPosition": "after"
94
- }
116
+ },
117
+ "$type": "component"
95
118
  },
96
119
  {
97
120
  "id": "cta-secondary",
@@ -102,10 +125,12 @@
102
125
  "variant": "outline",
103
126
  "icon": "arrow_forward",
104
127
  "iconPosition": "after"
105
- }
128
+ },
129
+ "$type": "component"
106
130
  }
107
131
  ]
108
- }
132
+ },
133
+ "$type": "component"
109
134
  }
110
135
  ]
111
136
  }
@@ -1,18 +0,0 @@
1
- import {
2
- createPersonalizationCollectRoute,
3
- createVercelCollectionFromEnv,
4
- } from '@gradial/aci/next/vercel-personalization';
5
-
6
- export const runtime = 'nodejs';
7
-
8
- const personalization = createVercelCollectionFromEnv();
9
- const unavailable = async () => new Response('personalization host is not configured', {
10
- status: 503,
11
- headers: { 'Cache-Control': 'private, no-store' },
12
- });
13
- const collect = personalization
14
- ? createPersonalizationCollectRoute(personalization)
15
- : unavailable;
16
-
17
- export const POST = collect;
18
- export const OPTIONS = collect;