@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 +12 -0
- package/package.json +2 -2
- package/src/cli/commands/audit.ts +2 -2
- package/src/cli/commands/bundle.ts +2 -2
- package/src/cli/commands/cache.ts +13 -6
- package/src/cli/commands/db-snapshot.ts +2 -0
- package/src/cli/commands/db.ts +22 -1
- package/src/cli/commands/diag.ts +4 -4
- package/src/cli/commands/lighthouse.ts +2 -2
- package/src/cli/commands/security-probe.ts +2 -2
- package/src/cli/commands/update.ts +17 -3
- package/src/cli/config.ts +30 -2
- package/src/cli/discover-distribution.ts +58 -22
- package/src/cli/discover.ts +48 -6
- package/src/cli/fk-orphan-advisory.ts +9 -7
- package/src/cli/host-url.ts +127 -0
- package/src/cli/index.ts +1 -41
- package/src/cli/security-audit.ts +14 -2
- package/src/cli/swap-snapshot.ts +2 -1
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.
|
|
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.
|
|
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
|
|
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))
|
|
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
|
|
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
|
-
|
|
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 = `${
|
|
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 ${
|
|
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);
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/cli/commands/diag.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ${
|
|
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
|
|
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
|
|
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.
|
|
11
|
-
* 3.
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
87
|
+
export async function discoverDistributionMatch(
|
|
54
88
|
region: string,
|
|
55
89
|
prefix: string,
|
|
56
90
|
hints: DistributionHints = {},
|
|
57
|
-
): Promise<
|
|
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
|
|
73
|
-
if (
|
|
106
|
+
const byHost = matchByHost(items, hints.routerHost);
|
|
107
|
+
if (byHost) return byHost;
|
|
74
108
|
|
|
75
|
-
// Tag pass — authoritative
|
|
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
|
|
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
|
-
|
|
128
|
+
// Legacy setups whose distribution carries the {app}-{stage}- prefix in its Comment.
|
|
129
|
+
return matchByComment(items, prefix);
|
|
94
130
|
}
|
package/src/cli/discover.ts
CHANGED
|
@@ -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 {
|
|
314
|
+
const { discoverDistributionMatch } = await import('./discover-distribution');
|
|
289
315
|
const routerHost = lambdaResult.routerUrl
|
|
290
316
|
? new URL(lambdaResult.routerUrl).host
|
|
291
317
|
: undefined;
|
|
292
|
-
|
|
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
|
-
|
|
324
|
-
if (
|
|
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
|
|
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 =
|
|
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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
+
|
|
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.
|
|
274
|
+
+ `running DDL. ${fix}`,
|
|
263
275
|
);
|
|
264
276
|
severity = raise(severity, 'warn');
|
|
265
277
|
}
|
package/src/cli/swap-snapshot.ts
CHANGED
|
@@ -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
|
|
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
|
}
|