@everystack/cli 0.4.86 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.87] - 2026-09-21
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - cache:purge --stage works without CDN_URL on the API Lambda; update --stage now refuses when no HOST_URL is found, export HOST_URL to override
12
+
13
+ ### Fixed
14
+
15
+ - the FK-orphan advisory doubled its own '!' on top of warn()'s
16
+ - db:provision detects PUBLIC's CREATE on schema public, revokes it under --confirm
17
+ - the db:provision pointer swallowed every other grantee's revoke line
18
+
7
19
  ## [0.4.86] - 2026-09-16
8
20
 
9
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.86",
3
+ "version": "0.4.87",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -182,7 +182,7 @@
182
182
  "react": "19.2.0",
183
183
  "ts-jest": "29.4.9",
184
184
  "zod": "3.25.67",
185
- "@everystack/server": "0.4.31"
185
+ "@everystack/server": "0.4.32"
186
186
  },
187
187
  "scripts": {
188
188
  "test": "jest",
@@ -8,7 +8,7 @@
8
8
  * the heavier `audit:lighthouse` stay separate commands (run them explicitly).
9
9
  */
10
10
 
11
- import { resolveConfig, opsFunction } from '../config';
11
+ import { resolveConfig, opsFunction, probeUrl } from '../config';
12
12
  import { runBundleAudit, runBundleWeightAudit, reportBundleAudit } from './bundle';
13
13
  import { runUiAudit, reportUiAudit } from './ui-audit';
14
14
  import { checkRunbook } from './runbook';
@@ -28,7 +28,7 @@ export async function auditCommand(flags: Record<string, string>): Promise<void>
28
28
  fail(err.message);
29
29
  process.exit(1);
30
30
  }
31
- const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
31
+ const base = (flags.host || probeUrl(config)).replace(/\/+$/, '');
32
32
  if (!base) {
33
33
  fail('No router URL to audit. Pass --host or ensure the stage has a deployed URL.');
34
34
  process.exit(1);
@@ -48,7 +48,7 @@ import {
48
48
  DEFAULT_UI_BLOAT_BUDGET,
49
49
  type UiBloatBudget,
50
50
  } from '../ui-bloat';
51
- import { resolveConfig } from '../config';
51
+ import { resolveConfig, probeUrl } from '../config';
52
52
  import { fail, info, step, success, warn } from '../output';
53
53
 
54
54
  // Expo Router web export puts client chunks under client/_expo/...; a plain
@@ -430,7 +430,7 @@ export async function bundleAuditCommand(
430
430
  let target = positional || flags.url;
431
431
  if (!target) {
432
432
  try {
433
- target = (await resolveConfig(flags.stage)).baseUrl;
433
+ target = probeUrl(await resolveConfig(flags.stage));
434
434
  } catch (err: any) {
435
435
  fail(err.message);
436
436
  info('Pass a URL positionally or use --stage <name>.');
@@ -9,7 +9,7 @@
9
9
  * Writes directly to CloudFront KVS — no Lambda invoke.
10
10
  */
11
11
 
12
- import { resolveConfig } from '../config';
12
+ import { resolveConfig, probeUrl } from '../config';
13
13
  import { putKvsKey, createInvalidation, getDistributionStatus, getFromS3 } from '../aws';
14
14
  import { step, success, warn, fail, info } from '../output';
15
15
 
@@ -37,7 +37,7 @@ export async function cachePurgeCommand(flags: Record<string, string>): Promise<
37
37
  // Safety gate: verify the origin is serving the expected release and the distribution is stable.
38
38
  if (!force) {
39
39
  const channel = flags.channel || flags.stage || 'production';
40
- const blocked = await preflight(config.region, config.baseUrl, config.updatesBucket, channel, config.distributionId);
40
+ const blocked = await preflight(config.region, probeUrl(config), config.updatesBucket, channel, config.distributionId);
41
41
  if (blocked) {
42
42
  fail(blocked);
43
43
  info('Pass --force to override this check.');
@@ -111,7 +111,7 @@ export async function cachePurgeCommand(flags: Record<string, string>): Promise<
111
111
  */
112
112
  export async function preflight(
113
113
  region: string,
114
- baseUrl: string,
114
+ siteUrl: string,
115
115
  updatesBucket: string,
116
116
  channel: string,
117
117
  distributionId?: string,
@@ -134,19 +134,26 @@ export async function preflight(
134
134
  }
135
135
 
136
136
  // 2. Probe the origin for the serving release.
137
+ if (!siteUrl) {
138
+ return (
139
+ 'No site URL for this stage, so the origin cannot be probed. ' +
140
+ 'Set CDN_URL on the API function and redeploy, or tag the distribution sst:app/sst:stage; ' +
141
+ 'then re-run with --refresh to discover the stage again. Or pass --force to skip this check.'
142
+ );
143
+ }
137
144
  try {
138
- const probe = `${baseUrl}?_probe=${Date.now()}`;
145
+ const probe = `${siteUrl}?_probe=${Date.now()}`;
139
146
  const res = await fetch(probe, { redirect: 'follow' });
140
147
  const serving = res.headers.get('x-everystack-release');
141
148
  if (serving !== expectedUpdateId) {
142
149
  return (
143
150
  `Origin is serving release ${serving ? serving.slice(0, 12) + '...' : '(none)'} ` +
144
- `but the channel pointer is ${expectedUpdateId.slice(0, 12)}.... ` +
151
+ `but the channel pointer is ${expectedUpdateId.slice(0, 12)}.... Probed ${siteUrl}. ` +
145
152
  `Purging now would expose stale containers to uncached traffic.`
146
153
  );
147
154
  }
148
155
  } catch (err: any) {
149
- return `Could not reach origin at ${baseUrl}: ${err.message}`;
156
+ return `Could not reach origin at ${siteUrl}: ${err.message}`;
150
157
  }
151
158
 
152
159
  // 3. Check distribution status (if discoverable).
@@ -26,6 +26,8 @@ function resolveInstanceId(config: CliConfig, flags: Record<string, string>): st
26
26
  ' - If your DB is RDS: add `databaseInstanceId: database.id` to the outputs return block in\n' +
27
27
  ' sst.config.ts and redeploy (run db:snapshot from the app directory so .sst/outputs.json is\n' +
28
28
  ' readable), or pass --instance <id>.\n' +
29
+ ' - .sst/outputs.json is used only when it is THIS stage\'s deploy (its apiFunctionName names the\n' +
30
+ ' stage). If the last `sst deploy` was another stage, redeploy this one, or pass --instance <id>.\n' +
29
31
  ' - If your DB is not RDS (Aurora, a container, self-hosted): use `everystack db:backup` for a portable logical backup.',
30
32
  );
31
33
  process.exit(1);
@@ -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,
@@ -116,11 +116,13 @@ export interface FkOrphanAdvisoryOptions {
116
116
  /**
117
117
  * The advisory, as lines. Empty when nothing is unproven.
118
118
  *
119
- * Every HEADLINE it returns is a problem line — it starts with `!`, the same mark and the same
120
- * severity tier the ownership report's findings use — so the caller prints whatever comes back
121
- * without having to classify it: at the default verbosity that is headlines only, and `verbose`
122
- * adds the indented detail under each one. The headline states the CONSEQUENCE — enforced
123
- * forward, N rows unchecked — because a bare count reads as an error nobody can size.
119
+ * Every HEADLINE it returns is a problem line, the same severity tier the ownership report's
120
+ * findings use — so the caller `warn()`s whatever comes back without having to classify it: at
121
+ * the default verbosity that is headlines only, and `verbose` adds the indented detail under
122
+ * each one. `warn()` (output.ts) already prepends the `!` mark, so these strings carry none of
123
+ * their own — a caller that printed one with `line()`/`info()` instead of `warn()` would be the
124
+ * bug, not this module doubling the mark on top of it. The headline states the CONSEQUENCE —
125
+ * enforced forward, N rows unchecked — because a bare count reads as an error nobody can size.
124
126
  *
125
127
  * Findings whose column also decides row access get their OWN headline. Same severity, same
126
128
  * non-blocking behaviour: what changes is that the reader is told this is not only a data
@@ -144,7 +146,7 @@ export function renderFkOrphanAdvisory(
144
146
  if (plain.length > 0) {
145
147
  const rows = plain.reduce((sum, f) => sum + f.orphans, 0);
146
148
  lines.push(
147
- `! ${plain.length} foreign ${plural(plain.length, 'key stays', 'keys stay')} NOT VALID: new writes enforced, `
149
+ `${plain.length} foreign ${plural(plain.length, 'key stays', 'keys stay')} NOT VALID: new writes enforced, `
148
150
  + `${count(rows)} existing ${plural(rows, 'row', 'rows')} unchecked `
149
151
  + `(--verbose for detail; ${declareIt}).`,
150
152
  );
@@ -157,7 +159,7 @@ export function renderFkOrphanAdvisory(
157
159
  .map((f) => `${f.table}.${gatingColumns(f, ownership).join('/')}`)
158
160
  .join(', ');
159
161
  lines.push(
160
- `! ROW SECURITY — ${gating.length} unproven foreign ${plural(gating.length, 'key also decides', 'keys also decide')} `
162
+ `ROW SECURITY — ${gating.length} unproven foreign ${plural(gating.length, 'key also decides', 'keys also decide')} `
161
163
  + `who may read or write the row (${where}): `
162
164
  + `${count(rows)} existing ${plural(rows, 'row', 'rows')} point at a parent that is gone, so the owner check `
163
165
  + `on ${plural(gating.length, 'it', 'them')} matches nobody `
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Auto-detect HOST_URL from SST outputs.
3
+ * SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
4
+ * If HOST_URL isn't already set, use the routerUrl from the last deploy.
5
+ *
6
+ * When a stage is specified, checks the stage-specific cached config first.
7
+ * .sst/outputs.json reflects the last deploy (any stage), so with --stage it is used only when
8
+ * its apiFunctionName says that deploy WAS this stage (outputsDescribeStage). Otherwise HOST_URL
9
+ * stays unset: a wrong-stage URL would be baked into the OTA bundle by `update`.
10
+ */
11
+
12
+ import fs from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { createRequire } from 'node:module';
15
+
16
+ export async function loadSstOutputs(stage?: string): Promise<void> {
17
+ if (process.env.HOST_URL) return;
18
+
19
+ // When --stage is provided, try stage-specific cache first
20
+ if (stage) {
21
+ const stagePath = path.resolve('.sst', `outputs.${stage}.json`);
22
+ try {
23
+ const raw = await fs.readFile(stagePath, 'utf8');
24
+ const cached = JSON.parse(raw);
25
+ if (cached.routerUrl) {
26
+ process.env.HOST_URL = cached.routerUrl;
27
+ return;
28
+ }
29
+ } catch {
30
+ // No cached config for this stage — fall through to default
31
+ }
32
+ }
33
+
34
+ const outputsPath = path.resolve('.sst', 'outputs.json');
35
+ try {
36
+ const raw = await fs.readFile(outputsPath, 'utf8');
37
+ const outputs = JSON.parse(raw);
38
+ if (stage && !(await outputsBelongToStage(outputs, stage))) return;
39
+ if (outputs.routerUrl) {
40
+ process.env.HOST_URL = outputs.routerUrl;
41
+ }
42
+ } catch {
43
+ // No .sst/outputs.json — user must set HOST_URL manually or use app.config.js defaults
44
+ }
45
+ }
46
+
47
+ /** Provenance gate for the --stage fall-through; an unreadable app name means unknown, so no. */
48
+ async function outputsBelongToStage(outputs: { apiFunctionName?: string }, stage: string): Promise<boolean> {
49
+ try {
50
+ const { parseAppName, outputsDescribeStage } = await import('./discover');
51
+ return outputsDescribeStage(outputs, await parseAppName(), stage);
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The @expo/env `expo export` will load, found the way it finds it: expo -> @expo/cli -> @expo/env
59
+ * from the project (a package manager that does not hoist still resolves it), else a direct
60
+ * @expo/env. Undefined when the project has no Expo.
61
+ */
62
+ function resolveExpoEnv(from: string): string | undefined {
63
+ const at = (dir: string, id: string) => createRequire(path.join(dir, 'noop.js')).resolve(id);
64
+ try {
65
+ const cli = at(path.dirname(at(from, 'expo/package.json')), '@expo/cli/package.json');
66
+ return at(path.dirname(cli), '@expo/env');
67
+ } catch {
68
+ // fall through
69
+ }
70
+ try {
71
+ return at(from, '@expo/env');
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Does the dotenv loading `expo export` will run set a non-empty HOST_URL? It runs Expo's own
79
+ * loader (@expo/env parseProjectEnv), not a copy of it: file set, precedence,
80
+ * EXPO_NO_DOTENV and variable expansion are Expo's (the mode is fixed to production, as in export), so this sees what the bundle will bake.
81
+ * Non-mutating. Only a boolean escapes; no value is kept or printed. Fails closed: no Expo, or
82
+ * a loader that throws, means no.
83
+ */
84
+ async function dotenvSetsHostUrl(
85
+ cwd: string,
86
+ env: Record<string, string | undefined>,
87
+ resolveFrom: string,
88
+ ): Promise<boolean> {
89
+ const resolved = resolveExpoEnv(resolveFrom);
90
+ if (!resolved) return false;
91
+ try {
92
+ const expoEnv = createRequire(resolved)(resolved) as {
93
+ parseProjectEnv(
94
+ root: string,
95
+ options: { mode?: string; silent?: boolean; systemEnv?: Record<string, string | undefined> },
96
+ ): { env: Record<string, string | undefined> };
97
+ };
98
+ // Upstream behaviour, re-verify on every Expo upgrade: @expo/cli build/src/export/exportApp.js
99
+ // exportAppAsync sets `process.env.NODE_ENV = dev ? 'development' : 'production'`
100
+ // unconditionally, and everystack never passes --dev. So the mode is production, not env.NODE_ENV.
101
+ const parsed = expoEnv.parseProjectEnv(cwd, { mode: 'production', silent: true, systemEnv: env });
102
+ return Boolean(parsed.env.HOST_URL);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Why `update --stage` must stop: nothing supplied a HOST_URL, so the OTA bundle would bake the
110
+ * app's default (often localhost) as updates.url. Devices could then never fetch the recovery
111
+ * publish. Undefined when HOST_URL is set (env or dotenv) or no stage was given. The message
112
+ * names the ways out, including the stage's own site URL when discovery found one.
113
+ */
114
+ export async function missingHostUrlError(
115
+ stage: string | undefined,
116
+ opts: { env?: Record<string, string | undefined>; cwd?: string; siteUrl?: string; resolveFrom?: string } = {},
117
+ ): Promise<string | undefined> {
118
+ const { env = process.env, cwd = process.cwd(), siteUrl, resolveFrom = cwd } = opts;
119
+ if (!stage || env.HOST_URL || (await dotenvSetsHostUrl(cwd, env, resolveFrom))) return undefined;
120
+ const offer = siteUrl ? ` This stage's site URL is ${siteUrl}; export it as HOST_URL if that is its public URL.` : '';
121
+ return (
122
+ `No HOST_URL for stage "${stage}", so the bundle would ship the app's default URL as updates.url.${offer} ` +
123
+ 'Otherwise export HOST_URL=<the stage\'s public URL>, or set CDN_URL on the API function and re-run with --refresh. ' +
124
+ '(A HOST_URL in a dotenv file counts only when the project\'s Expo can be found to read it the way `expo export` does.) ' +
125
+ `(.sst/outputs.json is used only when its apiFunctionName is "<app>-${stage}-ApiFunction-...".)`
126
+ );
127
+ }
package/src/cli/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
3
+ import { loadSstOutputs } from './host-url';
5
4
  import { updateCommand } from './commands/update';
6
5
  import { deployCommand } from './commands/deploy';
7
6
  import { certsCommand } from './commands/certs';
@@ -51,45 +50,6 @@ import { parseFlags } from './parse-flags';
51
50
  const args = process.argv.slice(2);
52
51
  const command = args[0];
53
52
 
54
- /**
55
- * Auto-detect HOST_URL from SST outputs.
56
- * SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
57
- * If HOST_URL isn't already set, use the routerUrl from the last deploy.
58
- *
59
- * When a stage is specified, checks the stage-specific cached config first.
60
- * .sst/outputs.json reflects the last deploy (any stage) and may be wrong
61
- * when publishing to a different stage.
62
- */
63
- async function loadSstOutputs(stage?: string): Promise<void> {
64
- if (process.env.HOST_URL) return;
65
-
66
- // When --stage is provided, try stage-specific cache first
67
- if (stage) {
68
- const stagePath = path.resolve('.sst', `outputs.${stage}.json`);
69
- try {
70
- const raw = await fs.readFile(stagePath, 'utf8');
71
- const cached = JSON.parse(raw);
72
- if (cached.routerUrl) {
73
- process.env.HOST_URL = cached.routerUrl;
74
- return;
75
- }
76
- } catch {
77
- // No cached config for this stage — fall through to default
78
- }
79
- }
80
-
81
- const outputsPath = path.resolve('.sst', 'outputs.json');
82
- try {
83
- const raw = await fs.readFile(outputsPath, 'utf8');
84
- const outputs = JSON.parse(raw);
85
- if (outputs.routerUrl) {
86
- process.env.HOST_URL = outputs.routerUrl;
87
- }
88
- } catch {
89
- // No .sst/outputs.json — user must set HOST_URL manually or use app.config.js defaults
90
- }
91
- }
92
-
93
53
  async function main() {
94
54
  if (!command || command === '--help' || command === '-h') {
95
55
  printHelp();
@@ -248,18 +248,30 @@ export function classifySchema(schema: SchemaDescriptor, waivers: Waivers = {}):
248
248
 
249
249
  if (broad.length > 0) {
250
250
  const who = broad.join(', ');
251
+ // The fix is PER GRANTEE, and the grantees are PARTITIONED — never collapsed into one line.
252
+ //
253
+ // `db:provision --confirm` revokes exactly ONE thing: PUBLIC's CREATE on schema `public`
254
+ // (reviewably — it reports the gap without the flag). Every other (grantee, schema) pair is hand
255
+ // SQL. So the pointer stands in for PUBLIC's own revoke and for nothing else: a schema where BOTH
256
+ // PUBLIC and `authenticated` hold CREATE must still name the second revoke, or an operator runs
257
+ // the one command offered, re-audits, and is still writable with nothing saying why.
258
+ const isPublicPseudoRole = (g: string) => key === 'public' && g.toLowerCase() === 'public';
259
+ const fixes = broad.map((g) => (isPublicPseudoRole(g)
260
+ ? 'REVOKE CREATE ON SCHEMA public FROM PUBLIC — run `everystack db:provision --stage <stage> --confirm`, which does exactly this revoke and verifies it'
261
+ : `REVOKE CREATE ON SCHEMA ${key} FROM ${g}`));
262
+ const fix = `${fixes.join('. ')}.`;
251
263
  if (schema.unpinnedDefinerFunctions > 0) {
252
264
  reasons.push(
253
265
  `${who} can CREATE in schema "${key}", which holds ${schema.unpinnedDefinerFunctions} unpinned SECURITY DEFINER function(s) — `
254
266
  + `that is a complete search_path hijack chain: plant a shadow object, the unpinned function resolves through it and runs it with the owner's rights. `
255
- + `REVOKE CREATE ON SCHEMA ${key} FROM ${who}, and pin those functions.`,
267
+ + `${fix.replace(/\.$/, '')}, and pin those functions.`,
256
268
  );
257
269
  severity = 'red';
258
270
  } else {
259
271
  reasons.push(
260
272
  `${who} can CREATE in schema "${key}" — the pre-PG15 default, which PG15 removed. No unpinned SECURITY DEFINER function is here, `
261
273
  + `but a writable schema on a search path threatens every privileged session that resolves through it, including the build credential `
262
- + `running DDL. REVOKE CREATE ON SCHEMA ${key} FROM ${who}.`,
274
+ + `running DDL. ${fix}`,
263
275
  );
264
276
  severity = raise(severity, 'warn');
265
277
  }
@@ -113,7 +113,8 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
113
113
  mode: 'refuse',
114
114
  reason: 'A physical snapshot needs the RDS instance id, and this stage does not expose one. '
115
115
  + 'Add `databaseInstanceId: database.id` to the outputs return block in sst.config.ts and redeploy '
116
- + '(run db:swap from the app directory so .sst/outputs.json is readable), or pass --snapshot logical '
116
+ + '(run db:swap from the app directory so .sst/outputs.json is readable; it counts only when the last '
117
+ + '`sst deploy` was THIS stage, otherwise redeploy this stage), or pass --snapshot logical '
117
118
  + 'for a pg_dump rollback point instead.',
118
119
  };
119
120
  }