@everystack/cli 0.4.85 → 0.4.87

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.
@@ -1117,7 +1117,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1117
1117
  // The reader's read grants ride this same step, for the same reason ADOPT does: they can stall
1118
1118
  // on a lock, and nothing that can stall may sit between the password swap and the secret write.
1119
1119
  const readerRole = result.readerRole as string | undefined;
1120
- if (!declaredOwners.length && !declaredObjects.length && !readerRole) return;
1120
+ // NO early return on "nothing declared". Convergence also carries the schema "public" posture
1121
+ // check — a property of the DATABASE, not of the Models — and the brownfield app that declares
1122
+ // no owners is exactly the one most likely to carry the pre-PG15 PUBLIC CREATE grant. Gating
1123
+ // this on the declarations made the check unreachable for it.
1121
1124
  const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole, ...(readerRole ? { readerRole, searchPath } : {}) };
1122
1125
  let owned: any = {};
1123
1126
  try {
@@ -1177,6 +1180,24 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1177
1180
  // then fail on every table. Same non-zero exit as an un-converged owner.
1178
1181
  for (const r of reader.refusals) { warn(`Read-only grants: ${r}`); ownershipIncomplete = true; }
1179
1182
  }
1183
+ // The schema "public" posture. Reported only when there IS a gap: a PG15+ database (or one
1184
+ // already hardened) is the expected state and says nothing, exactly like a converged owner.
1185
+ // Absent entirely on a server that predates this check — also silent, never narrated as clean.
1186
+ const posture = owned.publicSchemaPosture;
1187
+ if (posture?.publicCanCreate) {
1188
+ if (posture.revoked) {
1189
+ // The SQL is spelled out rather than imported from @everystack/server: that package is a
1190
+ // lazily-loaded peer here (the ops-Lambda venue never loads it), so a static import for a
1191
+ // display string would be a new hard dependency for a sentence.
1192
+ success('Schema posture: REVOKE CREATE ON SCHEMA public FROM PUBLIC — the pre-PG15 default, which PG15 removed, is gone. Verified by re-reading the catalog.');
1193
+ } else {
1194
+ // NOT counted as ownershipIncomplete, deliberately. This is an advisory hardening on a live
1195
+ // database, and `--confirm` is a shared gate (an operator passes it for ADOPT). Failing a
1196
+ // provisioning run whose credentials succeeded, over a posture the operator has not been
1197
+ // asked about yet, would break every existing pre-PG15 deploy pipeline on upgrade.
1198
+ for (const r of posture.refusals) warn(`Schema posture: ${r}`);
1199
+ }
1200
+ }
1180
1201
  };
1181
1202
 
1182
1203
  // Additive-only: if the login roles already existed as live logins, provision changed no
@@ -13,7 +13,7 @@
13
13
  * everystack diag [url] --hydration # Analyze SSR hydration issues
14
14
  */
15
15
 
16
- import { resolveConfig } from '../config';
16
+ import { resolveConfig, probeUrl } from '../config';
17
17
  import { getFromS3 } from '../aws';
18
18
  import { step, success, fail, info, warn } from '../output';
19
19
  import { analyzeHtml, generateHydrationReport } from '../hydration-analyzer';
@@ -53,11 +53,11 @@ export async function diagCommand(
53
53
  // Build the URL: positional arg > baseUrl + --path
54
54
  let url = positionalUrl;
55
55
  if (!url) {
56
- if (!config.baseUrl) {
57
- fail('No URL provided and no baseUrl in config. Pass a URL or use --stage.');
56
+ url = probeUrl(config);
57
+ if (!url) {
58
+ fail('No URL provided and no site URL for this stage. Pass a URL, set CDN_URL on the API function, or use --stage on a deployed stage.');
58
59
  process.exit(1);
59
60
  }
60
- url = config.baseUrl;
61
61
  }
62
62
  // Strip trailing slash from base URL before appending path
63
63
  url = url.replace(/\/+$/, '');
@@ -21,7 +21,7 @@ import {
21
21
  type ExtractedRun,
22
22
  type LhReport,
23
23
  } from '../lighthouse';
24
- import { resolveConfig } from '../config';
24
+ import { resolveConfig, probeUrl } from '../config';
25
25
  import { step, info, fail, warn } from '../output';
26
26
 
27
27
  function runLighthouse(url: string, outPath: string): LhReport {
@@ -69,7 +69,7 @@ export async function auditLighthouseCommand(flags: Record<string, string>): Pro
69
69
  if (!host) {
70
70
  try {
71
71
  const config = await resolveConfig(flags.stage);
72
- host = config.baseUrl;
72
+ host = probeUrl(config);
73
73
  } catch (err: any) {
74
74
  fail(err.message);
75
75
  info('Pass --host <url> to audit a specific URL without resolving a stage.');
@@ -26,7 +26,7 @@ import {
26
26
  type CatalogGrant,
27
27
  type ProbeResult,
28
28
  } from '../security-probe';
29
- import { resolveConfig, opsFunction } from '../config';
29
+ import { resolveConfig, opsFunction, probeUrl } from '../config';
30
30
  import { invokeAction } from '../aws';
31
31
  import { step, info, fail, success, warn } from '../output';
32
32
 
@@ -188,7 +188,7 @@ export async function securityProbeCommand(flags: Record<string, string>): Promi
188
188
  fail(err.message);
189
189
  process.exit(1);
190
190
  }
191
- const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
191
+ const base = (flags.host || probeUrl(config)).replace(/\/+$/, '');
192
192
  if (!base) {
193
193
  fail('No router URL to probe. Pass --host or ensure the stage has a deployed URL.');
194
194
  process.exit(1);
@@ -5,7 +5,8 @@ import zlib from 'node:zlib';
5
5
  import { pipeline } from 'node:stream/promises';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { spawn } from 'node:child_process';
8
- import { resolveConfig, type CliConfig } from '../config';
8
+ import { resolveConfig, probeUrl, type CliConfig } from '../config';
9
+ import { missingHostUrlError } from '../host-url';
9
10
  import { uploadToS3, headS3, invokeAction, rollingRestart } from '../aws';
10
11
  import { step, success, warn, fail, info } from '../output';
11
12
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
@@ -128,6 +129,13 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
128
129
  warn(`Could not load SST secrets: ${err.message}`);
129
130
  info('Secrets may be unavailable during export. Provide env vars from the caller if needed.');
130
131
  }
132
+
133
+ // After the secrets step: a secret may supply HOST_URL.
134
+ const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl });
135
+ if (noHostUrl) {
136
+ fail(noHostUrl);
137
+ process.exit(1);
138
+ }
131
139
  }
132
140
 
133
141
  const branch = flags.branch;
@@ -639,13 +647,17 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
639
647
 
640
648
  // 7. Verify the origin is serving the new release.
641
649
  step('Verifying origin serves new release...');
642
- const verified = await verifyOriginRelease(config.baseUrl, updateId);
650
+ const siteUrl = probeUrl(config);
651
+ const verified = await verifyOriginRelease(siteUrl, updateId);
643
652
  if (verified) {
644
653
  success(`Origin confirmed: ${updateId.slice(0, 12)}...`);
654
+ } else if (!siteUrl) {
655
+ warn('No site URL for this stage, so the origin was not checked.');
656
+ info('Set CDN_URL on the API function, or check the site by hand for the x-everystack-release header.');
645
657
  } else {
646
658
  warn('Origin did not confirm the new release within the timeout.');
647
659
  info('The release may still be propagating. Check manually:');
648
- info(` curl -sI ${config.baseUrl} | grep x-everystack-release`);
660
+ info(` curl -sI ${siteUrl} | grep x-everystack-release`);
649
661
  }
650
662
 
651
663
  // Clean up archive
@@ -822,6 +834,8 @@ export async function verifyOriginRelease(
822
834
  baseUrl: string,
823
835
  expectedUpdateId: string,
824
836
  ): Promise<boolean> {
837
+ // No site URL: every probe would throw and be swallowed, burning the whole timeout.
838
+ if (!baseUrl) return false;
825
839
  const deadline = Date.now() + VERIFY_TIMEOUT_MS;
826
840
  let consecutive = 0;
827
841
 
package/src/cli/config.ts CHANGED
@@ -12,7 +12,10 @@ import path from 'node:path';
12
12
  import { step } from './output';
13
13
 
14
14
  export interface CliConfig {
15
+ /** CDN_URL / routerUrl as deployed. update pins it into OTA bundles: never derived. */
15
16
  baseUrl: string;
17
+ /** What cache:purge and update's release check probe. Falls back to baseUrl when absent. */
18
+ siteUrl?: string;
16
19
  region: string;
17
20
  apiFunctionName: string;
18
21
  /** Dedicated operator function (db/console actions). Falls back to apiFunctionName. */
@@ -30,6 +33,14 @@ export interface CliConfig {
30
33
  backupsBucket?: string;
31
34
  }
32
35
 
36
+ /**
37
+ * The URL commands PROBE (release checks, audits, lighthouse): siteUrl, else baseUrl, else ''.
38
+ * Never use it to write a URL into anything shipped; that is baseUrl's job (HOST_URL).
39
+ */
40
+ export function probeUrl(config: Pick<CliConfig, 'baseUrl' | 'siteUrl'>): string {
41
+ return config.siteUrl || config.baseUrl || '';
42
+ }
43
+
33
44
  /** Operator invoke target — dedicated ops function when deployed, else the API function. */
34
45
  export function opsFunction(config: CliConfig): string {
35
46
  return config.opsFunctionName ?? config.apiFunctionName;
@@ -81,6 +92,22 @@ async function readLocalOutputs(): Promise<Partial<SstOutputs> | null> {
81
92
  }
82
93
  }
83
94
 
95
+ /**
96
+ * The local outputs.json, only if its deploy WAS this stage. It is the last deploy on ANY stage,
97
+ * and databaseInstanceId picks db:apply/db:swap's confirmed rollback snapshot, so another
98
+ * stage's file must never reach a stage's config. Unknown means no.
99
+ */
100
+ async function readOutputsFor(stage: string): Promise<Partial<SstOutputs> | null> {
101
+ const outputs = await readLocalOutputs();
102
+ if (!outputs) return null;
103
+ try {
104
+ const { parseAppName, outputsDescribeStage } = await import('./discover');
105
+ return outputsDescribeStage(outputs, await parseAppName(), stage) ? outputs : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
84
111
  export async function resolveConfig(stage?: string): Promise<CliConfig> {
85
112
  const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
86
113
 
@@ -95,7 +122,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
95
122
 
96
123
  // Check cache first
97
124
  const cached = await getCachedConfig(stage);
98
- if (cached) return enrichConfigFromOutputs(cached, await readLocalOutputs());
125
+ if (cached) return enrichConfigFromOutputs(cached, await readOutputsFor(stage));
99
126
 
100
127
  step(`Discovering resources for stage "${stage}"...`);
101
128
  const appName = await parseAppName();
@@ -103,7 +130,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
103
130
 
104
131
  // Cache for future fast lookups
105
132
  await setCachedConfig(stage, config);
106
- return enrichConfigFromOutputs(config, await readLocalOutputs());
133
+ return enrichConfigFromOutputs(config, await readOutputsFor(stage));
107
134
  }
108
135
 
109
136
  // Default: read .sst/outputs.json (unchanged behavior)
@@ -140,6 +167,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
140
167
 
141
168
  return {
142
169
  baseUrl: outputs.routerUrl,
170
+ siteUrl: outputs.routerUrl,
143
171
  region,
144
172
  apiFunctionName: outputs.apiFunctionName,
145
173
  opsFunctionName: outputs.opsFunctionName,
@@ -7,8 +7,8 @@
7
7
  * SST Routers do not put the app/stage in the distribution Comment, so
8
8
  * matching runs in order of reliability:
9
9
  * 1. DomainName / Aliases match the discovered router URL host
10
- * 2. Comment contains the {app}-{stage}- prefix (legacy setups)
11
- * 3. sst:app + sst:stage tags (authoritative, but one API call per candidate)
10
+ * 2. sst:app + sst:stage tags (authoritative, but one API call per candidate)
11
+ * 3. Comment contains the {app}-{stage}- prefix (legacy setups; a substring, so a last resort)
12
12
  */
13
13
 
14
14
  export interface DistributionHints {
@@ -26,35 +26,69 @@ interface DistributionSummaryLite {
26
26
  Aliases?: { Items?: string[] };
27
27
  }
28
28
 
29
- /** Pure matcher for the cheap passes (domain, alias, legacy comment). Exported for tests. */
30
- export function matchDistribution(
31
- items: readonly DistributionSummaryLite[],
32
- prefix: string,
33
- routerHost?: string,
34
- ): string | undefined {
29
+ /**
30
+ * A distribution and how it was found. `host` and `tag` are confirmed matches; `comment` is a
31
+ * substring guess (prefix `app-dev-` also matches the Comment of `my-app`'s dev stage), good
32
+ * enough for --invalidate but not to name the site a purge probes.
33
+ */
34
+ export interface DistributionMatch {
35
+ id: string;
36
+ /** cloudfront.net domain, no scheme — the host this distribution always answers on. */
37
+ domain?: string;
38
+ by: 'host' | 'comment' | 'tag';
39
+ }
40
+
41
+ function toMatch(dist: DistributionSummaryLite, by: DistributionMatch['by']): DistributionMatch | undefined {
42
+ return dist.Id ? { id: dist.Id, domain: dist.DomainName, by } : undefined;
43
+ }
44
+
45
+ function matchByHost(items: readonly DistributionSummaryLite[], routerHost?: string): DistributionMatch | undefined {
35
46
  const host = routerHost?.toLowerCase();
36
- if (host) {
37
- for (const dist of items) {
38
- if (dist.DomainName?.toLowerCase() === host) return dist.Id;
39
- if (dist.Aliases?.Items?.some((alias) => alias.toLowerCase() === host)) return dist.Id;
40
- }
47
+ if (!host) return undefined;
48
+ for (const dist of items) {
49
+ const hit =
50
+ dist.DomainName?.toLowerCase() === host ||
51
+ dist.Aliases?.Items?.some((alias) => alias.toLowerCase() === host);
52
+ if (hit) return toMatch(dist, 'host');
41
53
  }
54
+ return undefined;
55
+ }
56
+
57
+ function matchByComment(items: readonly DistributionSummaryLite[], prefix: string): DistributionMatch | undefined {
42
58
  const needle = prefix.toLowerCase();
43
59
  for (const dist of items) {
44
- if ((dist.Comment || '').toLowerCase().includes(needle)) return dist.Id;
60
+ if ((dist.Comment || '').toLowerCase().includes(needle)) return toMatch(dist, 'comment');
45
61
  }
46
62
  return undefined;
47
63
  }
48
64
 
65
+ /** Pure matcher for the non-tag passes (domain, alias, legacy comment). Exported for tests. */
66
+ export function matchDistributionBy(
67
+ items: readonly DistributionSummaryLite[],
68
+ prefix: string,
69
+ routerHost?: string,
70
+ ): DistributionMatch | undefined {
71
+ return matchByHost(items, routerHost) ?? matchByComment(items, prefix);
72
+ }
73
+
74
+ /** The distribution id only. Exported for tests. */
75
+ export function matchDistribution(
76
+ items: readonly DistributionSummaryLite[],
77
+ prefix: string,
78
+ routerHost?: string,
79
+ ): string | undefined {
80
+ return matchDistributionBy(items, prefix, routerHost)?.id;
81
+ }
82
+
49
83
  /**
50
- * Find the CloudFront distribution for a stage.
84
+ * Find the CloudFront distribution for a stage, and how it was matched.
51
85
  * Best-effort: returns undefined if not found.
52
86
  */
53
- export async function discoverDistribution(
87
+ export async function discoverDistributionMatch(
54
88
  region: string,
55
89
  prefix: string,
56
90
  hints: DistributionHints = {},
57
- ): Promise<string | undefined> {
91
+ ): Promise<DistributionMatch | undefined> {
58
92
  const { CloudFrontClient, ListDistributionsCommand, ListTagsForResourceCommand } =
59
93
  await import('@aws-sdk/client-cloudfront');
60
94
  const client = new CloudFrontClient({ region });
@@ -69,10 +103,11 @@ export async function discoverDistribution(
69
103
  marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined;
70
104
  } while (marker);
71
105
 
72
- const cheap = matchDistribution(items, prefix, hints.routerHost);
73
- if (cheap) return cheap;
106
+ const byHost = matchByHost(items, hints.routerHost);
107
+ if (byHost) return byHost;
74
108
 
75
- // Tag pass — authoritative but one call per distribution, so it runs last.
109
+ // Tag pass — authoritative, so it runs before the Comment guess (a Comment substring can name
110
+ // another app's stage). One call per distribution; SST tags every distribution it makes.
76
111
  if (hints.appName && hints.stage) {
77
112
  for (const dist of items) {
78
113
  if (!dist.ARN) continue;
@@ -82,7 +117,7 @@ export async function discoverDistribution(
82
117
  (res.Tags?.Items || []).map((t) => [t.Key, t.Value]),
83
118
  );
84
119
  if (tags.get('sst:app') === hints.appName && tags.get('sst:stage') === hints.stage) {
85
- return dist.Id;
120
+ return toMatch(dist, 'tag');
86
121
  }
87
122
  } catch {
88
123
  // Tag read denied or transient — keep scanning
@@ -90,5 +125,6 @@ export async function discoverDistribution(
90
125
  }
91
126
  }
92
127
 
93
- return undefined;
128
+ // Legacy setups whose distribution carries the {app}-{stage}- prefix in its Comment.
129
+ return matchByComment(items, prefix);
94
130
  }
@@ -17,7 +17,16 @@ import { importOptionalAws } from './aws';
17
17
 
18
18
  /** Mirrors CliConfig from config.ts — defined locally to avoid circular imports. */
19
19
  interface DiscoveredConfig {
20
+ /**
21
+ * CDN_URL from the API Lambda, or ''. update pins this into shipped OTA bundles (HOST_URL),
22
+ * so it is NEVER derived from anything else.
23
+ */
20
24
  baseUrl: string;
25
+ /**
26
+ * The URL cache:purge and update's release check probe: baseUrl, else the domain of the stage's
27
+ * tag-confirmed CloudFront distribution, else ''. Probe-only; never shipped.
28
+ */
29
+ siteUrl: string;
21
30
  region: string;
22
31
  apiFunctionName: string;
23
32
  opsFunctionName?: string;
@@ -62,6 +71,22 @@ export async function parseAppName(configPath?: string): Promise<string> {
62
71
  return match[1];
63
72
  }
64
73
 
74
+ /**
75
+ * Does a deploy-written .sst/outputs.json describe this stage? The file is the last deploy on ANY
76
+ * stage and has no stage field, but its apiFunctionName is `<app>-<stage>-ApiFunction-<hash>`
77
+ * (the convention discovery matches on). `.sst/stage` is NOT evidence: it is SST's personal-stage
78
+ * marker and can name a different stage than the last deploy. Unknown means no.
79
+ */
80
+ export function outputsDescribeStage(
81
+ outputs: { apiFunctionName?: string } | null | undefined,
82
+ appName: string,
83
+ stage: string,
84
+ ): boolean {
85
+ const fn = outputs?.apiFunctionName;
86
+ if (!fn) return false;
87
+ return fn.toLowerCase().startsWith(`${appName}-${stage}-ApiFunction-`.toLowerCase());
88
+ }
89
+
65
90
  // ---------------------------------------------------------------------------
66
91
  // CloudFront function discovery (→ KVS ARN)
67
92
  // ---------------------------------------------------------------------------
@@ -284,20 +309,30 @@ export async function discoverConfig(
284
309
 
285
310
  // Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
286
311
  let distributionId: string | undefined;
312
+ let distributionDomain: string | undefined;
287
313
  try {
288
- const { discoverDistribution } = await import('./discover-distribution');
314
+ const { discoverDistributionMatch } = await import('./discover-distribution');
289
315
  const routerHost = lambdaResult.routerUrl
290
316
  ? new URL(lambdaResult.routerUrl).host
291
317
  : undefined;
292
- distributionId = await discoverDistribution(region, prefix, {
318
+ const match = await discoverDistributionMatch(region, prefix, {
293
319
  routerHost,
294
320
  appName,
295
321
  stage,
296
322
  });
323
+ distributionId = match?.id;
324
+ // A Comment-substring match can name another app's stage; only a confirmed one may say
325
+ // which site a purge probes.
326
+ if (match && match.by !== 'comment') distributionDomain = match.domain;
297
327
  } catch {
298
328
  // Non-fatal
299
329
  }
300
330
 
331
+ // CDN_URL is optional on the API Lambda. Without it the stage's own distribution supplies the
332
+ // probe URL — and only that: baseUrl stays as deployed (see DiscoveredConfig).
333
+ const baseUrl = lambdaResult.routerUrl || '';
334
+ const siteUrl = baseUrl || (distributionDomain ? `https://${distributionDomain}` : '');
335
+
301
336
  if (!bucketsResult.updatesBucket) {
302
337
  throw new Error(
303
338
  `Could not find S3 bucket "${prefix}updatesbucket-*". ` +
@@ -320,15 +355,19 @@ export async function discoverConfig(
320
355
  try {
321
356
  const raw = await fs.readFile(path.resolve('.sst', 'outputs.json'), 'utf8');
322
357
  const outputs = JSON.parse(raw);
323
- if (!mediaBucket && outputs.mediaBucket) mediaBucket = outputs.mediaBucket;
324
- if (!backupsBucket && outputs.backupsBucket) backupsBucket = outputs.backupsBucket;
358
+ // outputs.json is the last deploy on ANY stage: adopt its buckets only if it was this one.
359
+ if (outputsDescribeStage(outputs, appName, stage)) {
360
+ if (!mediaBucket && outputs.mediaBucket) mediaBucket = outputs.mediaBucket;
361
+ if (!backupsBucket && outputs.backupsBucket) backupsBucket = outputs.backupsBucket;
362
+ }
325
363
  } catch {
326
364
  // outputs.json not available — non-fatal
327
365
  }
328
366
  }
329
367
 
330
368
  return {
331
- baseUrl: lambdaResult.routerUrl || '',
369
+ baseUrl,
370
+ siteUrl,
332
371
  region,
333
372
  apiFunctionName: lambdaResult.functionName,
334
373
  opsFunctionName: opsFn,
@@ -348,12 +387,13 @@ export async function discoverConfig(
348
387
  // ---------------------------------------------------------------------------
349
388
 
350
389
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
351
- const CACHE_VERSION = 5; // Bump when CachedOutputs schema changes to invalidate stale caches (5: +backupsBucket)
390
+ const CACHE_VERSION = 6; // Bump when CachedOutputs schema changes to invalidate stale caches (5: +backupsBucket, 6: +siteUrl; getCachedConfig drops v5 files, an empty siteUrl is cached as a fact)
352
391
 
353
392
  interface CachedOutputs {
354
393
  _cachedAt: number;
355
394
  _version?: number;
356
395
  routerUrl?: string;
396
+ siteUrl?: string;
357
397
  apiFunctionName?: string;
358
398
  opsFunctionName?: string;
359
399
  imageFunctionName?: string;
@@ -381,6 +421,7 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
381
421
  }
382
422
  return {
383
423
  baseUrl: cached.routerUrl || '',
424
+ siteUrl: cached.siteUrl ?? cached.routerUrl ?? '',
384
425
  region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
385
426
  apiFunctionName: cached.apiFunctionName,
386
427
  opsFunctionName: cached.opsFunctionName,
@@ -458,6 +499,7 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
458
499
  _cachedAt: Date.now(),
459
500
  _version: CACHE_VERSION,
460
501
  routerUrl: config.baseUrl,
502
+ siteUrl: config.siteUrl,
461
503
  apiFunctionName: config.apiFunctionName,
462
504
  opsFunctionName: config.opsFunctionName,
463
505
  imageFunctionName: config.imageFunctionName,
@@ -26,9 +26,9 @@
26
26
 
27
27
  import { createHash } from 'node:crypto';
28
28
  import type { ModelDescriptor } from '@everystack/model';
29
- import type { SchemaSnapshot } from './schema-introspect';
29
+ import type { SchemaSnapshot, TableSchema } from './schema-introspect';
30
30
  import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
31
- import { ownSequenceName } from './schema-diff';
31
+ import { ownSequenceName, foreignKeyContentKey } from './schema-diff';
32
32
  import { generateMigrationSql, unmodeledTables } from './migration-generate';
33
33
  import { compileTableContract } from './authz-compile';
34
34
  import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
@@ -73,8 +73,19 @@ export const PLAN_VERSION = 2;
73
73
  * stage would skip the preflight and run the VALIDATE inside the edge — a silently dropped gate
74
74
  * and a different failure mode, both of which the BUMP RULE covers. Peers refuse until the stage
75
75
  * is redeployed.
76
+ *
77
+ * v5 (validation-pending stops refusing the whole edge): `ApplyPlanResult` gained `pendingIsSafe`
78
+ * and `unappliedStatements` — the CLIENT now decides, from `pendingIsSafe`, whether a
79
+ * `validation-pending` result exits 0 (a withheld or ordinary-orphan cause) or 1 (a cut-off or
80
+ * unrecognised cause, a real anomaly). A v4 ops Lambda's `executeApplyPlan` never sets
81
+ * `pendingIsSafe` at all, so a v5 client reading its response would see it absent, treat every
82
+ * pending result as UNSAFE by the same fail-closed rule that protects the in-version case, and
83
+ * exit 1 on a perfectly safe withheld key — the exact false CI failure this whole feature exists
84
+ * to remove, reintroduced silently across a version skew instead of refused cleanly. The BUMP
85
+ * RULE covers exactly this: a field the CLIENT now depends on to decide something is part of the
86
+ * contract. Peers refuse until the stage is redeployed.
76
87
  */
77
- export const APPLY_CONTRACT_VERSION = 4;
88
+ export const APPLY_CONTRACT_VERSION = 5;
78
89
 
79
90
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
80
91
  export interface PlanClassification {
@@ -225,6 +236,36 @@ export function predictLiveFingerprint(
225
236
  return predictLiveState(models, snapshot, contract, opts).hash;
226
237
  }
227
238
 
239
+ /**
240
+ * One declared table with its `validate: false` foreign keys' VALIDITY taken from live.
241
+ *
242
+ * A key the model declared `validate: false` compiles to `notValid: true`, and that is right for
243
+ * a from-scratch build: nothing validates it, so the built key really is NOT VALID. But the
244
+ * PREDICTED endpoint is not a build, it is "the live database after this edge" — and the edge
245
+ * emits nothing at all for such a key when the live one already content-matches (see
246
+ * `diffConstraints`: a live NOT VALID key needs no statement, and a live FULLY-VALID key is the
247
+ * accept-either case). Predicting NOT VALID over a key the database has already validated would
248
+ * make the plan's own verify-after fail on an edge that touched nothing, on every apply, forever.
249
+ *
250
+ * So the prediction copies what the key IS. Untouched for every other key: absent live table,
251
+ * absent live counterpart, or a declaration that never said `validate: false` — those predict
252
+ * exactly what they always did.
253
+ */
254
+ function carryFkValidityThrough(declared: TableSchema, live: TableSchema | undefined): TableSchema {
255
+ if (!live || !declared.foreignKeys.some((fk) => fk.notValid)) return declared;
256
+ const liveByContent = new Map(live.foreignKeys.map((fk) => [foreignKeyContentKey(fk), fk]));
257
+ return {
258
+ ...declared,
259
+ foreignKeys: declared.foreignKeys.map((fk) => {
260
+ if (!fk.notValid) return fk;
261
+ const counterpart = liveByContent.get(foreignKeyContentKey(fk));
262
+ if (!counterpart || counterpart.notValid) return fk;
263
+ const { notValid: _dropped, ...rest } = fk;
264
+ return rest;
265
+ }),
266
+ };
267
+ }
268
+
228
269
  /**
229
270
  * The predicted endpoint as a full {@link Fingerprint} (hash AND canonical form). The hash is
230
271
  * the plan's `to` and the declares-test; the canonical form lets a MISMATCH report name WHICH
@@ -268,16 +309,6 @@ export function predictLiveState(
268
309
  !declaredTables.has(table) && !pendingRenameSources.has(table) && !pendingMoveSources.has(table);
269
310
  const declaredEnumList = declared.snapshot.enums ?? [];
270
311
  const declaredEnums = new Set(declaredEnumList.map((e) => e.name));
271
- const mergedSnapshot: SchemaSnapshot = {
272
- tables: [
273
- ...declared.snapshot.tables,
274
- ...snapshot.tables.filter((t) => ridesThrough(t.table)),
275
- ],
276
- enums: [
277
- ...declaredEnumList,
278
- ...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
279
- ],
280
- };
281
312
  // The live counterpart of a declared table: its rename or move source when the edge brings it
282
313
  // in under a new name, else its own name. Grants, policies and owned sequences survive RENAME
283
314
  // TABLE and SET SCHEMA alike (they attach by OID), so the foreign authz carries across both.
@@ -290,6 +321,17 @@ export function predictLiveState(
290
321
  !currentNames.has(declaredTable) && currentNames.has(priorNameOf[declaredTable] ?? '')
291
322
  ? priorNameOf[declaredTable]
292
323
  : declaredTable;
324
+ const liveSnapshotByTable = new Map(snapshot.tables.map((t) => [t.table, t]));
325
+ const mergedSnapshot: SchemaSnapshot = {
326
+ tables: [
327
+ ...declared.snapshot.tables.map((t) => carryFkValidityThrough(t, liveSnapshotByTable.get(liveNameFor(t.table)))),
328
+ ...snapshot.tables.filter((t) => ridesThrough(t.table)),
329
+ ],
330
+ enums: [
331
+ ...declaredEnumList,
332
+ ...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
333
+ ],
334
+ };
293
335
  const liveByTable = new Map(contract.tables.map((t) => [t.table, t]));
294
336
  const declaredColumnsByTable = new Map(
295
337
  declared.snapshot.tables.map((t) => [t.table, new Set(t.columns.map((c) => c.name))]),