@everystack/cli 0.4.4 → 0.4.6
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/package.json +1 -1
- package/src/cli/commands/db.ts +103 -14
- package/src/cli/deploy-probe.ts +119 -0
package/package.json
CHANGED
package/src/cli/commands/db.ts
CHANGED
|
@@ -144,6 +144,69 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** The username in a postgres URL — the only part of a stored secret we ever display. */
|
|
148
|
+
function roleOfUrl(url: string | undefined): string | null {
|
|
149
|
+
const match = url?.match(/^postgres(?:ql)?:\/\/([^:@/]+)[:@]/);
|
|
150
|
+
return match ? match[1] : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ProvisionSecretPlan {
|
|
154
|
+
/** Key → URL updates, merged into the store in ONE write. */
|
|
155
|
+
updates: Record<string, string>;
|
|
156
|
+
/** Per-secret before → after role notes (roles only — never a credential). */
|
|
157
|
+
notes: string[];
|
|
158
|
+
/** Loud warnings: flipping a live secret, an unverified admin login, an old server. */
|
|
159
|
+
warnings: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide what db:provision writes and what it announces. Both spellings of each secret
|
|
164
|
+
* are written — PascalCase is the only name an `sst.Secret` component can carry (so the
|
|
165
|
+
* wiring text and the store finally agree), the raw name keeps env-style consumers and
|
|
166
|
+
* `secrets export` working. Pure, so the announcement contract is pinned by tests.
|
|
167
|
+
*/
|
|
168
|
+
export function buildProvisionSecretPlan(args: {
|
|
169
|
+
result: { loginRole: string; adminRole?: string; adminVerified?: boolean | null };
|
|
170
|
+
authUrl: string;
|
|
171
|
+
adminUrl?: string;
|
|
172
|
+
existing: Record<string, string>;
|
|
173
|
+
}): ProvisionSecretPlan {
|
|
174
|
+
const { result, authUrl, adminUrl, existing } = args;
|
|
175
|
+
const updates: Record<string, string> = {
|
|
176
|
+
DATABASE_URL: authUrl,
|
|
177
|
+
DatabaseUrl: authUrl,
|
|
178
|
+
};
|
|
179
|
+
const notes: string[] = [];
|
|
180
|
+
const warnings: string[] = [];
|
|
181
|
+
|
|
182
|
+
const prevAuthRole = roleOfUrl(existing.DATABASE_URL ?? existing.DatabaseUrl);
|
|
183
|
+
notes.push(`DATABASE_URL / DatabaseUrl: ${prevAuthRole ?? 'unset'} → ${result.loginRole}`);
|
|
184
|
+
if (prevAuthRole && prevAuthRole !== result.loginRole) {
|
|
185
|
+
warnings.push(
|
|
186
|
+
`DATABASE_URL was already set (role '${prevAuthRole}') — any function linked to it connects as '${result.loginRole}' after its next cold start. Ensure grants are in place: everystack db:reconcile && everystack db:doctor.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (result.adminRole && adminUrl) {
|
|
191
|
+
updates.ADMIN_DATABASE_URL = adminUrl;
|
|
192
|
+
updates.AdminDatabaseUrl = adminUrl;
|
|
193
|
+
const prevAdminRole = roleOfUrl(existing.AdminDatabaseUrl ?? existing.ADMIN_DATABASE_URL);
|
|
194
|
+
const verified = result.adminVerified === true ? 'login verified' : 'login NOT verified';
|
|
195
|
+
notes.push(`AdminDatabaseUrl / ADMIN_DATABASE_URL: ${prevAdminRole ?? 'unset'} → ${result.adminRole} (${verified})`);
|
|
196
|
+
if (result.adminVerified !== true) {
|
|
197
|
+
warnings.push(
|
|
198
|
+
`The '${result.adminRole}' login could not be verified from the ops function — confirm operator connectivity before relying on it: everystack db:doctor.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
warnings.push(
|
|
203
|
+
'No operator login was created — this @everystack/server predates the both-sides split (<0.3.7). Operator tasks stay on the linked Postgres master; upgrade the server pin and re-run db:provision for the full credential split.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { updates, notes, warnings };
|
|
208
|
+
}
|
|
209
|
+
|
|
147
210
|
export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
|
|
148
211
|
if (!flags.stage) {
|
|
149
212
|
fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
|
|
@@ -163,26 +226,32 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
163
226
|
info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
|
|
164
227
|
step('Provisioning roles...');
|
|
165
228
|
|
|
166
|
-
// Generate
|
|
167
|
-
// the secret store and
|
|
229
|
+
// Generate BOTH login passwords and keep them in memory only — they are written straight
|
|
230
|
+
// into the secret store and are NEVER printed, logged, or returned to a human.
|
|
168
231
|
const { randomBytes } = await import('node:crypto');
|
|
169
232
|
const authPassword = randomBytes(24).toString('hex');
|
|
233
|
+
const adminPassword = randomBytes(24).toString('hex');
|
|
170
234
|
|
|
171
235
|
let result: any;
|
|
172
236
|
try {
|
|
173
|
-
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
|
|
237
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
|
|
174
238
|
} catch (err: any) {
|
|
175
239
|
fail(`db:provision failed: ${err.message}`);
|
|
176
240
|
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
177
241
|
process.exit(1);
|
|
178
242
|
}
|
|
179
243
|
if (result?.error) {
|
|
244
|
+
// Includes the admin-verification refusal: the action set NO authenticator password,
|
|
245
|
+
// so nothing here rewrites DATABASE_URL — the working credentials are untouched.
|
|
180
246
|
fail(`db:provision failed: ${result.error}`);
|
|
181
247
|
process.exit(1);
|
|
182
248
|
}
|
|
183
249
|
success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
|
|
250
|
+
if (result.adminRole) {
|
|
251
|
+
success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${result.adminVerified === true ? 'connectivity verified' : 'connectivity NOT verified'}`);
|
|
252
|
+
}
|
|
184
253
|
|
|
185
|
-
// Resolve host/port/db (not secret) to build the connection
|
|
254
|
+
// Resolve host/port/db (not secret) to build the connection strings in memory.
|
|
186
255
|
let conn: any;
|
|
187
256
|
try {
|
|
188
257
|
conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
|
|
@@ -194,28 +263,48 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
194
263
|
info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
|
|
195
264
|
process.exit(1);
|
|
196
265
|
}
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
266
|
+
const authUrl = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
|
|
267
|
+
const adminUrl = result.adminRole
|
|
268
|
+
? `postgresql://${result.adminRole}:${adminPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`
|
|
269
|
+
: undefined;
|
|
270
|
+
|
|
271
|
+
// Write the secrets directly to the SST secret store — never display them. One blob
|
|
272
|
+
// write updates every key together, so there is no window where the API side is the
|
|
273
|
+
// authenticator but the operator side is still unset.
|
|
274
|
+
step('Writing the database secrets (not displayed)...');
|
|
275
|
+
let plan: ProvisionSecretPlan;
|
|
201
276
|
try {
|
|
202
277
|
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
|
|
203
278
|
const { parseAppName } = await import('../discover.js');
|
|
204
279
|
const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
205
280
|
const secrets = await loadSstSecrets(ctx);
|
|
206
|
-
|
|
281
|
+
plan = buildProvisionSecretPlan({ result, authUrl, adminUrl, existing: secrets });
|
|
282
|
+
Object.assign(secrets, plan.updates);
|
|
207
283
|
await putSstSecrets(ctx, secrets);
|
|
208
284
|
} catch (err: any) {
|
|
209
|
-
fail(`Failed to write the
|
|
285
|
+
fail(`Failed to write the database secrets: ${err.message}`);
|
|
210
286
|
info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
|
|
211
287
|
process.exit(1);
|
|
212
288
|
}
|
|
213
289
|
|
|
214
|
-
success(`
|
|
290
|
+
success(`Secrets written for stage "${flags.stage}". No credential was displayed.`);
|
|
291
|
+
for (const note of plan.notes) info(` ${note}`);
|
|
292
|
+
for (const warning of plan.warnings) info(` ⚠ ${warning}`);
|
|
215
293
|
console.log('');
|
|
216
|
-
info('Finish the wiring in sst.config.ts (one-time):
|
|
217
|
-
info('
|
|
218
|
-
info('
|
|
294
|
+
info('Finish the wiring in sst.config.ts (one-time):');
|
|
295
|
+
info(' API — PRECONDITION: your handler must escalate per request (pgSettings + SET ROLE /');
|
|
296
|
+
info(' withRole). The authenticator holds no privileges of its own (NOINHERIT); a handler');
|
|
297
|
+
info(' that never escalates cannot see the schema — every endpoint fails "relation does');
|
|
298
|
+
info(' not exist". If unsure, do NOT flip the API yet.');
|
|
299
|
+
info(' Flip in TWO deploys: (1) declare `new sst.Secret("DatabaseUrl")` and link it to the');
|
|
300
|
+
info(' Api function, KEEPING the raw Postgres component linked (the explicit secret already');
|
|
301
|
+
info(' outranks it; the component is your instant rollback). Verify with real requests +');
|
|
302
|
+
info(` db:doctor. (2) Then remove the raw Postgres component from the Api link (keep it on`);
|
|
303
|
+
info(' the ops/worker functions) and redeploy.');
|
|
304
|
+
if (result.adminRole) {
|
|
305
|
+
info(' Ops: declare `new sst.Secret("AdminDatabaseUrl", "")` and link it to the Ops function —');
|
|
306
|
+
info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
|
|
307
|
+
}
|
|
219
308
|
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
220
309
|
}
|
|
221
310
|
|
package/src/cli/deploy-probe.ts
CHANGED
|
@@ -99,6 +99,104 @@ async function probeFunction(region: string, functionName: string): Promise<Prob
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** A minimal APIGW v2 GET event — what a Function URL delivers for a real request. */
|
|
103
|
+
export function syntheticGetEvent(path: string, queryString = ''): {
|
|
104
|
+
version: string;
|
|
105
|
+
routeKey: string;
|
|
106
|
+
rawPath: string;
|
|
107
|
+
rawQueryString: string;
|
|
108
|
+
headers: Record<string, string>;
|
|
109
|
+
requestContext: { http: { method: string; path: string; protocol: string; sourceIp: string; userAgent: string } };
|
|
110
|
+
isBase64Encoded: boolean;
|
|
111
|
+
} {
|
|
112
|
+
return {
|
|
113
|
+
version: '2.0',
|
|
114
|
+
routeKey: '$default',
|
|
115
|
+
rawPath: path,
|
|
116
|
+
rawQueryString: queryString,
|
|
117
|
+
headers: { 'user-agent': 'everystack-deploy-probe' },
|
|
118
|
+
requestContext: {
|
|
119
|
+
http: { method: 'GET', path, protocol: 'HTTP/1.1', sourceIp: '127.0.0.1', userAgent: 'everystack-deploy-probe' },
|
|
120
|
+
},
|
|
121
|
+
isBase64Encoded: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Classify one table GET driven through the api function. `_health` proves BOOT; it
|
|
127
|
+
* touches no tables — a stage whose API role cannot see the schema (a NOINHERIT
|
|
128
|
+
* authenticator behind a handler that never escalates) boots fine and 500s on every
|
|
129
|
+
* real request. A consumer shipped exactly that dark site; this is the check that
|
|
130
|
+
* catches it at deploy time, with the failure named.
|
|
131
|
+
*/
|
|
132
|
+
export interface ReadProbeVerdict {
|
|
133
|
+
verdict: 'reads-ok' | 'auth-gated' | 'dark-site' | 'failed' | 'no-surface';
|
|
134
|
+
detail?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function classifyReadProbe(response: { statusCode?: number; body?: string }): ReadProbeVerdict {
|
|
138
|
+
const status = response.statusCode ?? 0;
|
|
139
|
+
if (status >= 200 && status < 300) return { verdict: 'reads-ok' };
|
|
140
|
+
if (status === 401 || status === 403) return { verdict: 'auth-gated' };
|
|
141
|
+
if (status === 404) return { verdict: 'no-surface' };
|
|
142
|
+
const body = response.body ?? '';
|
|
143
|
+
if (status >= 500 && /relation .* does not exist/i.test(body)) {
|
|
144
|
+
return {
|
|
145
|
+
verdict: 'dark-site',
|
|
146
|
+
detail:
|
|
147
|
+
'the API role cannot see the schema — the handler never escalates. The authenticator holds no '
|
|
148
|
+
+ 'privileges of its own (NOINHERIT); the handler must SET ROLE per request (pgSettings + withRole). '
|
|
149
|
+
+ `Raw response: ${body}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return { verdict: 'failed', detail: body || `HTTP ${status}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Invoke the api function with a synthetic HTTP GET; null when the invoke itself failed. */
|
|
156
|
+
async function invokeHttpGet(
|
|
157
|
+
region: string,
|
|
158
|
+
functionName: string,
|
|
159
|
+
path: string,
|
|
160
|
+
queryString = '',
|
|
161
|
+
): Promise<{ statusCode?: number; body?: string } | null> {
|
|
162
|
+
try {
|
|
163
|
+
const { LambdaClient, InvokeCommand } = await import('@aws-sdk/client-lambda');
|
|
164
|
+
const client = new LambdaClient({ region });
|
|
165
|
+
const response = await client.send(new InvokeCommand({
|
|
166
|
+
FunctionName: functionName,
|
|
167
|
+
Payload: new TextEncoder().encode(JSON.stringify(syntheticGetEvent(path, queryString))),
|
|
168
|
+
}));
|
|
169
|
+
if (response.FunctionError || !response.Payload) return null;
|
|
170
|
+
const parsed = JSON.parse(new TextDecoder().decode(response.Payload));
|
|
171
|
+
if (parsed && typeof parsed === 'object' && 'statusCode' in parsed) {
|
|
172
|
+
return { statusCode: parsed.statusCode, body: parsed.body };
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Drive one real table read through the api function: GET / (the public table listing)
|
|
182
|
+
* names an exposed table, GET /<table>?limit=1 exercises role → schema → RLS. Best-effort:
|
|
183
|
+
* anything unprobeable stays neutral — only a positively classified failure is fatal.
|
|
184
|
+
*/
|
|
185
|
+
async function probeApiRead(region: string, functionName: string): Promise<ReadProbeVerdict | null> {
|
|
186
|
+
const listing = await invokeHttpGet(region, functionName, '/');
|
|
187
|
+
if (!listing || listing.statusCode !== 200 || !listing.body) return null;
|
|
188
|
+
let tables: string[];
|
|
189
|
+
try {
|
|
190
|
+
tables = (JSON.parse(listing.body) as { tables?: string[] }).tables ?? [];
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (tables.length === 0) return null;
|
|
195
|
+
const read = await invokeHttpGet(region, functionName, `/${tables[0]}`, 'limit=1');
|
|
196
|
+
if (!read) return null;
|
|
197
|
+
return classifyReadProbe(read);
|
|
198
|
+
}
|
|
199
|
+
|
|
102
200
|
/**
|
|
103
201
|
* Probe the stage's deployed functions and report. Returns whether deploy must fail: a function
|
|
104
202
|
* that cannot boot (or answers unhealthy) is fatal; a probe that couldn't run is a warning.
|
|
@@ -137,6 +235,27 @@ export async function probeDeployedFunctions(
|
|
|
137
235
|
info(`Could not probe ${target.label} function (${target.functionName}): ${result.detail}`);
|
|
138
236
|
break;
|
|
139
237
|
}
|
|
238
|
+
|
|
239
|
+
// Boot is necessary, not sufficient: drive one table read through the api function so a
|
|
240
|
+
// schema-invisible role (the dark-site shape) fails HERE, named, not on the first user.
|
|
241
|
+
if (target.label === 'api' && (result.verdict === 'healthy' || result.verdict === 'boot-ok')) {
|
|
242
|
+
const read = await probeApiRead(config.region, target.functionName);
|
|
243
|
+
if (read === null) {
|
|
244
|
+
// Nothing conclusive to probe (listing empty/unreadable) — stay neutral.
|
|
245
|
+
} else if (read.verdict === 'reads-ok') {
|
|
246
|
+
success('api read path OK (table read through the handler)');
|
|
247
|
+
} else if (read.verdict === 'auth-gated') {
|
|
248
|
+
info('api read path is auth-gated — boot verified, deeper read needs credentials');
|
|
249
|
+
} else if (read.verdict === 'no-surface') {
|
|
250
|
+
info('api exposes no tables to probe — skipped the read check');
|
|
251
|
+
} else if (read.verdict === 'dark-site') {
|
|
252
|
+
fatal = true;
|
|
253
|
+
fail(`api function is DARK: ${read.detail}`);
|
|
254
|
+
} else {
|
|
255
|
+
fatal = true;
|
|
256
|
+
fail(`api read probe failed: ${read.detail}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
140
259
|
}
|
|
141
260
|
return { fatal };
|
|
142
261
|
}
|