@gradial/aci 0.1.35 → 0.1.37

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.
@@ -1,6 +1,13 @@
1
1
  import { type NextRequest } from 'next/server.js';
2
2
  export interface GradialMiddlewareConfig {
3
- siteId: string;
3
+ /**
4
+ * Site identity for this deployment. Env-injected identity is valid —
5
+ * the platform sets ACI_SITE_ID per deployment, so passing a bare
6
+ * `process.env.ACI_SITE_ID` (which types as `string | undefined`) is
7
+ * supported. A missing value 500s cleanly at runtime
8
+ * ("ACI site id is not configured").
9
+ */
10
+ siteId: string | undefined;
4
11
  edgeConfig?: string;
5
12
  previewSignKey?: string;
6
13
  deploymentId?: string;
@@ -186,7 +186,7 @@ async function activeReleaseFromEdgeConfig(config) {
186
186
  return clampRelease(active.releaseId, active.sequence || 0, compatibility);
187
187
  }
188
188
  function siteIdForConfig(config) {
189
- return config.siteId;
189
+ return config.siteId ?? '';
190
190
  }
191
191
  async function getEdgeConfigJSON(edgeConfig, key) {
192
192
  const value = await getEdgeConfigValue(edgeConfig, key);
@@ -272,7 +272,7 @@ function normalizeRoutePath(value) {
272
272
  return pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
273
273
  }
274
274
  export const aciMiddlewareMatcher = [
275
- '/((?!_next(?:/|$)|api(?:/|$)|favicon\\.ico$|\\.gradial-dam(?:/|$)).*)',
275
+ '/((?!_next(?:/|$)|api(?:/|$)|favicon\\.ico$|\\.gradial-dam(?:/|$)|gradial/assets(?:/|$)).*)',
276
276
  ];
277
277
  export const config = {
278
278
  matcher: aciMiddlewareMatcher,
@@ -89,6 +89,19 @@ function walkFiles(dir, predicate) {
89
89
  // ---------------------------------------------------------------------------
90
90
  export function checkConfig(ctx) {
91
91
  const results = [];
92
+ // Env-only site identity: in siteIdMode 'env' the platform owns tenant
93
+ // identity (terraform injects ACI_SITE_ID per deployment), so .aci.yaml
94
+ // must not carry a siteId at all — a leftover label pins the repo to one
95
+ // site and drifts silently from the deployed identity.
96
+ if (ctx.siteIdMode === 'env') {
97
+ const aciYaml = readTextFile(join(ctx.rootDir, '.aci.yaml'));
98
+ if (aciYaml !== null) {
99
+ const pinsSiteId = /^siteId\s*:/m.test(aciYaml);
100
+ results.push(!pinsSiteId
101
+ ? ok('config', '.aci.yaml does not pin a siteId (env-only identity)')
102
+ : fail('config', '.aci.yaml does not pin a siteId (env-only identity)', '.aci.yaml declares siteId but this repo uses siteIdMode "env": the platform owns site identity (ACI_SITE_ID is injected per deployment), so the manifest must not pin a site — delete the siteId line'));
103
+ }
104
+ }
92
105
  if (ctx.framework === 'next') {
93
106
  const configFile = findFirstExisting(ctx.rootDir, [
94
107
  'next.config.ts',
@@ -242,19 +255,71 @@ export function checkMiddleware(ctx) {
242
255
  results.push(usesSDKMiddleware
243
256
  ? ok('middleware', 'middleware uses createGradialMiddleware', rel)
244
257
  : fail('middleware', 'middleware uses createGradialMiddleware', rel + ' should use createGradialMiddleware from @gradial/aci/next/middleware'));
245
- // Check if siteId is configured as a literal (no env var indirection)
246
- if (ctx.siteId) {
247
- const hasSiteIdLiteral = middlewareFile.content.includes(`siteId`) &&
248
- !middlewareFile.content.includes('process.env');
249
- results.push(hasSiteIdLiteral
250
- ? ok('middleware', 'middleware configures siteId (literal)', rel)
251
- : fail('middleware', 'middleware configures siteId (literal)', rel + ' should pass a literal siteId string to createGradialMiddleware (no env var indirection)'));
258
+ // Check that the siteId passed to createGradialMiddleware is canonical.
259
+ // Accepted forms depend on ctx.siteIdMode (default 'literal'):
260
+ //
261
+ // 'literal':
262
+ // (a) a string literal exactly equal to ctx.siteId (the tenant's
263
+ // canonical site id) — this is what the template stamps;
264
+ // (b) process.env.ACI_SITE_ID || '<literal>' (or ?? instead of ||)
265
+ // where the literal default is exactly ctx.siteId — the
266
+ // multi-tenant/dev-mirror form: the deployment env supplies the
267
+ // per-tenant identity, the default keeps local dev correct.
268
+ //
269
+ // 'env' (tenant-agnostic repos deployed to multiple sites): the ONLY
270
+ // accepted form is exactly `siteId: process.env.ACI_SITE_ID`. The
271
+ // platform owns tenant identity — terraform sets ACI_SITE_ID on every
272
+ // deployment — and the runtime 500s cleanly if it is missing, so any
273
+ // literal, fallback (|| / ??), other env var name, or computed
274
+ // expression fails.
275
+ //
276
+ // Anything else (placeholder literal, wrong/missing default, other env
277
+ // var names, computed expressions) fails: a wrong siteId means the edge
278
+ // release-pointer key never matches and every live route 503s.
279
+ const siteIdMode = ctx.siteIdMode ?? 'literal';
280
+ if (siteIdMode === 'env') {
281
+ const label = 'middleware configures canonical siteId';
282
+ // Narrow the search to the createGradialMiddleware call when present so
283
+ // an unrelated siteId reference elsewhere in the file can't satisfy us.
284
+ const callIdx = middlewareFile.content.indexOf('createGradialMiddleware(');
285
+ const region = callIdx >= 0 ? middlewareFile.content.slice(callIdx) : middlewareFile.content;
286
+ // Exactly `siteId: process.env.ACI_SITE_ID` followed by the end of the
287
+ // property (`,` or `}`) — same object-property shape the literal-mode
288
+ // regexes target, but with nothing allowed after the env access.
289
+ const bareEnv = /siteId\s*:\s*process\.env\.ACI_SITE_ID\s*(?=[,}])/.test(region);
290
+ results.push(bareEnv
291
+ ? ok('middleware', label, `${rel} (env-only siteId)`)
292
+ : fail('middleware', label, `${rel}: middleware must read siteId only from process.env.ACI_SITE_ID (no literal or fallback) — the platform owns tenant identity and injects ACI_SITE_ID per deployment`));
293
+ }
294
+ else if (ctx.siteId) {
295
+ const label = 'middleware configures canonical siteId';
296
+ const expected = ctx.siteId;
297
+ const acceptedForms = `accepted forms: siteId: '${expected}' or ` +
298
+ `siteId: process.env.ACI_SITE_ID || '${expected}' (?? also accepted)`;
299
+ // Narrow the search to the createGradialMiddleware call when present so
300
+ // an unrelated siteId reference elsewhere in the file can't satisfy us.
301
+ const callIdx = middlewareFile.content.indexOf('createGradialMiddleware(');
302
+ const region = callIdx >= 0 ? middlewareFile.content.slice(callIdx) : middlewareFile.content;
303
+ // Form (b): process.env.ACI_SITE_ID || '<default>' (or ??).
304
+ const envMatch = region.match(/siteId\s*:\s*process\.env\.ACI_SITE_ID\s*(?:\|\||\?\?)\s*(['"`])((?:(?!\1).)*)\1/);
305
+ // Form (a): bare string literal (single/double quotes or backticks).
306
+ const literalMatch = region.match(/siteId\s*:\s*(['"`])((?:(?!\1).)*)\1/);
307
+ const configured = envMatch ? envMatch[2] : literalMatch ? literalMatch[2] : null;
308
+ if (configured === expected) {
309
+ results.push(ok('middleware', label, `${rel} (${envMatch ? 'env with canonical default' : 'canonical literal'})`));
310
+ }
311
+ else if (configured !== null) {
312
+ results.push(fail('middleware', label, `${rel}: siteId ${envMatch ? 'default ' : ''}is '${configured}' but the canonical site id is '${expected}'; ${acceptedForms}`));
313
+ }
314
+ else {
315
+ results.push(fail('middleware', label, `${rel}: siteId passed to createGradialMiddleware is not an accepted form (placeholder/computed expressions, other env vars, and process.env.ACI_SITE_ID without a literal default all fail); ${acceptedForms}`));
316
+ }
252
317
  }
253
318
  // Check that the middleware matcher is inlined (not imported) and matches
254
319
  // the SDK's canonical value. Next.js requires config.matcher to be
255
320
  // statically analyzable, so importing the matcher from the SDK breaks the
256
321
  // build. Instead the template inlines the value and this check detects drift.
257
- const canonicalMatcher = "['/((?!_next(?:/|$)|api(?:/|$)|favicon\\\\.ico$|\\\\.gradial-dam(?:/|$)).*)']";
322
+ const canonicalMatcher = "['/((?!_next(?:/|$)|api(?:/|$)|favicon\\\\.ico$|\\\\.gradial-dam(?:/|$)|gradial/assets(?:/|$)).*)']";
258
323
  // Check for actual import/usage of aciMiddlewareMatcher (not just a mention
259
324
  // in a comment). Look for `matcher: aciMiddlewareMatcher` or
260
325
  // `import { ... aciMiddlewareMatcher ... }`.
@@ -56,8 +56,11 @@ function resolveChecks(input) {
56
56
  export function runConformanceChecks(options) {
57
57
  const ctx = {
58
58
  framework: options.framework,
59
- siteId: options.siteId,
59
+ // 'env' mode carries no site identity in the repo; checks never compare
60
+ // against a value, so the context uses the empty string.
61
+ siteId: options.siteId ?? '',
60
62
  rootDir: options.rootDir || process.cwd(),
63
+ siteIdMode: options.siteIdMode || 'literal',
61
64
  };
62
65
  const checks = resolveChecks(options.checks);
63
66
  const allResults = checks.flatMap((name) => {
@@ -82,8 +85,11 @@ export function conformancePreset(options) {
82
85
  test('ACI conformance', async (t) => {
83
86
  const ctx = {
84
87
  framework: options.framework,
85
- siteId: options.siteId,
88
+ // 'env' mode carries no site identity in the repo; checks never compare
89
+ // against a value, so the context uses the empty string.
90
+ siteId: options.siteId ?? '',
86
91
  rootDir: options.rootDir || process.cwd(),
92
+ siteIdMode: options.siteIdMode || 'literal',
87
93
  };
88
94
  for (const checkName of checks) {
89
95
  await t.test(checkName, () => {
@@ -8,12 +8,10 @@
8
8
  export type ConformanceFramework = 'next' | 'astro';
9
9
  /** Individual check names that can be enabled/disabled. */
10
10
  export type ConformanceCheckName = 'config' | 'registry' | 'pageEntry' | 'middleware' | 'noFileContentProvider' | 'noCustomContent' | 'noLegacyAssetRoute' | 'buildSucceeds' | 'contentCompile' | 'sdkVersion';
11
- /** Options for the conformance preset. */
12
- export interface ConformancePresetOptions {
11
+ /** Options common to both siteId modes. */
12
+ export interface ConformancePresetCommonOptions {
13
13
  /** Framework the site uses. */
14
14
  framework: ConformanceFramework;
15
- /** Expected siteId value (verified in config and middleware). */
16
- siteId: string;
17
15
  /** Root directory of the site project. Defaults to process.cwd(). */
18
16
  rootDir?: string;
19
17
  /**
@@ -22,6 +20,36 @@ export interface ConformancePresetOptions {
22
20
  */
23
21
  checks?: ConformanceCheckName[] | Partial<Record<ConformanceCheckName, boolean>>;
24
22
  }
23
+ /**
24
+ * Options for the conformance preset.
25
+ *
26
+ * The siteId requirement is keyed off `siteIdMode` (defaults to 'literal'):
27
+ *
28
+ * - 'literal': `siteId` is REQUIRED — it is the tenant's canonical site id,
29
+ * and the middleware must pass it as a string literal (or
30
+ * `process.env.ACI_SITE_ID || '<canonical>'` / `??` with the canonical
31
+ * literal as the default). This is what the template stamps for
32
+ * single-tenant repos.
33
+ * - 'env': `siteId` is FORBIDDEN — tenant-agnostic repos deployed to
34
+ * multiple sites have no site identity in the repo at all. The middleware
35
+ * must read siteId ONLY from the environment — exactly
36
+ * `siteId: process.env.ACI_SITE_ID`, with no literal default, no
37
+ * `||`/`??` fallback, no other env var names, and no computed
38
+ * expressions — and `.aci.yaml` must not pin a siteId. The platform owns
39
+ * tenant identity and injects ACI_SITE_ID per deployment; a missing env
40
+ * produces a clean 500 at runtime.
41
+ */
42
+ export type ConformancePresetOptions = ConformancePresetCommonOptions & ({
43
+ /** See ConformancePresetOptions: 'literal' expects a canonical siteId in the repo. */
44
+ siteIdMode?: 'literal';
45
+ /** Expected canonical siteId value (verified in config and middleware). */
46
+ siteId: string;
47
+ } | {
48
+ /** See ConformancePresetOptions: 'env' means the repo carries no site identity. */
49
+ siteIdMode: 'env';
50
+ /** Forbidden in env mode — the repo must not carry a site identity. */
51
+ siteId?: never;
52
+ });
25
53
  /** Result of a single conformance check. */
26
54
  export interface ConformanceCheckResult {
27
55
  name: ConformanceCheckName;
@@ -40,6 +68,12 @@ export type ConformanceCheckFn = (ctx: ConformanceContext) => ConformanceCheckRe
40
68
  /** Context passed to each check function. */
41
69
  export interface ConformanceContext {
42
70
  framework: ConformanceFramework;
71
+ /**
72
+ * Canonical siteId in 'literal' mode; '' in 'env' mode (no site identity
73
+ * in the repo — env-mode checks never compare against a value).
74
+ */
43
75
  siteId: string;
44
76
  rootDir: string;
77
+ /** See ConformancePresetOptions.siteIdMode. Defaults to 'literal'. */
78
+ siteIdMode?: 'literal' | 'env';
45
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -127,12 +127,12 @@
127
127
  "zod": "^4.0.0"
128
128
  },
129
129
  "optionalDependencies": {
130
- "@gradial/aci-cli-darwin-arm64": "0.1.35",
131
- "@gradial/aci-cli-darwin-x64": "0.1.35",
132
- "@gradial/aci-cli-linux-arm64": "0.1.35",
133
- "@gradial/aci-cli-linux-x64": "0.1.35",
134
- "@gradial/aci-cli-win32-arm64": "0.1.35",
135
- "@gradial/aci-cli-win32-x64": "0.1.35"
130
+ "@gradial/aci-cli-darwin-arm64": "0.1.37",
131
+ "@gradial/aci-cli-darwin-x64": "0.1.37",
132
+ "@gradial/aci-cli-linux-arm64": "0.1.37",
133
+ "@gradial/aci-cli-linux-x64": "0.1.37",
134
+ "@gradial/aci-cli-win32-arm64": "0.1.37",
135
+ "@gradial/aci-cli-win32-x64": "0.1.37"
136
136
  },
137
137
  "scripts": {
138
138
  "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",