@everystack/cli 0.2.36 → 0.2.39

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/README.md CHANGED
@@ -286,4 +286,8 @@ The handler implements the full Expo Updates manifest protocol:
286
286
 
287
287
  ## License
288
288
 
289
- MIT
289
+ [AGPL-3.0-only](https://www.gnu.org/licenses/agpl-3.0.html) © Scalable Technology, Inc.
290
+
291
+ A commercial license is available for organizations that cannot or do not wish to
292
+ comply with the AGPL-3.0 terms. For commercial licensing, contact
293
+ licensing@scalable.technology.
package/package.json CHANGED
@@ -1,8 +1,18 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.36",
3
+ "version": "0.2.39",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
+ "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/scalable-technology/everystack.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "homepage": "https://github.com/scalable-technology/everystack#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/scalable-technology/everystack/issues"
15
+ },
6
16
  "publishConfig": {
7
17
  "access": "public"
8
18
  },
@@ -43,12 +53,6 @@
43
53
  "bin": {
44
54
  "everystack": "./src/cli/index.ts"
45
55
  },
46
- "scripts": {
47
- "test": "jest",
48
- "build": "tsc --build",
49
- "prepublishOnly": "tsc --module commonjs --moduleResolution node --target ES2022 --esModuleInterop --skipLibCheck --declaration false --outDir src src/env.ts",
50
- "lint": "tsc --noEmit"
51
- },
52
56
  "dependencies": {
53
57
  "@aws-sdk/client-cloudfront": "3.1053.0",
54
58
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
@@ -119,5 +123,10 @@
119
123
  "react": "19.2.6",
120
124
  "ts-jest": "29.4.9",
121
125
  "typescript": "5.9.3"
126
+ },
127
+ "scripts": {
128
+ "test": "jest",
129
+ "build": "tsc --build",
130
+ "lint": "tsc --noEmit"
122
131
  }
123
- }
132
+ }
@@ -9,6 +9,34 @@ import { resolveConfig, opsFunction } from '../config.js';
9
9
  import { invokeAction } from '../aws.js';
10
10
  import { step, success, fail, info, warn } from '../output.js';
11
11
 
12
+ /** Secret-store keys that may hold the privileged (operator) connection URL,
13
+ * in precedence order. Names vary by deployment; --secret-name overrides. */
14
+ const ADMIN_URL_KEYS = ['ADMIN_DATABASE_URL', 'AdminDatabaseUrl', 'ADMIN_URL'];
15
+
16
+ /**
17
+ * Parse a postgres connection URL into psql's PG* environment variables.
18
+ *
19
+ * Passing the credential via env (not argv) keeps the password out of the
20
+ * process table and shell history. Pure and side-effect free — it never logs;
21
+ * callers must never log the result either (it contains PGPASSWORD).
22
+ */
23
+ export function pgEnvFromUrl(url: string): Record<string, string> {
24
+ const u = new URL(url);
25
+ if (!/^postgres(ql)?:$/.test(u.protocol)) {
26
+ throw new Error(`Not a postgres connection URL (protocol: ${u.protocol})`);
27
+ }
28
+ const env: Record<string, string> = {};
29
+ if (u.hostname) env.PGHOST = decodeURIComponent(u.hostname);
30
+ if (u.port) env.PGPORT = u.port;
31
+ if (u.username) env.PGUSER = decodeURIComponent(u.username);
32
+ if (u.password) env.PGPASSWORD = decodeURIComponent(u.password);
33
+ const database = u.pathname.replace(/^\//, '');
34
+ if (database) env.PGDATABASE = decodeURIComponent(database);
35
+ const sslmode = u.searchParams.get('sslmode');
36
+ if (sslmode) env.PGSSLMODE = sslmode;
37
+ return env;
38
+ }
39
+
12
40
  export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
13
41
  step('Resolving deployed config...');
14
42
  let config;
@@ -115,6 +143,130 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
115
143
  }
116
144
  }
117
145
 
146
+ export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
147
+ if (!flags.stage) {
148
+ fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
149
+ process.exit(1);
150
+ }
151
+
152
+ step('Resolving deployed config...');
153
+ let config;
154
+ try {
155
+ config = await resolveConfig(flags.stage);
156
+ } catch (err: any) {
157
+ fail(err.message);
158
+ process.exit(1);
159
+ }
160
+
161
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
162
+ info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
163
+ step('Provisioning roles...');
164
+
165
+ // Generate the login password and keep it in memory only — it is written straight into
166
+ // the secret store and is NEVER printed, logged, or returned to a human.
167
+ const { randomBytes } = await import('node:crypto');
168
+ const authPassword = randomBytes(24).toString('hex');
169
+
170
+ let result: any;
171
+ try {
172
+ result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
173
+ } catch (err: any) {
174
+ fail(`db:provision failed: ${err.message}`);
175
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
176
+ process.exit(1);
177
+ }
178
+ if (result?.error) {
179
+ fail(`db:provision failed: ${result.error}`);
180
+ process.exit(1);
181
+ }
182
+ success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
183
+
184
+ // Resolve host/port/db (not secret) to build the connection string in memory.
185
+ let conn: any;
186
+ try {
187
+ conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
188
+ } catch {
189
+ /* handled below */
190
+ }
191
+ if (!conn?.host) {
192
+ fail('Could not resolve the database host to build DATABASE_URL.');
193
+ info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
194
+ process.exit(1);
195
+ }
196
+ const url = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
197
+
198
+ // Write the secret directly to the SST secret store — never display it.
199
+ step('Writing the DATABASE_URL secret (not displayed)...');
200
+ try {
201
+ const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
202
+ const { parseAppName } = await import('../discover.js');
203
+ const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
204
+ const secrets = await loadSstSecrets(ctx);
205
+ secrets.DATABASE_URL = url;
206
+ await putSstSecrets(ctx, secrets);
207
+ } catch (err: any) {
208
+ fail(`Failed to write the DATABASE_URL secret: ${err.message}`);
209
+ info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
210
+ process.exit(1);
211
+ }
212
+
213
+ success(`DATABASE_URL secret set for stage "${flags.stage}" → role ${result.loginRole}. No credential was displayed.`);
214
+ console.log('');
215
+ info('Finish the wiring in sst.config.ts (one-time): declare `new sst.Secret("DatabaseUrl")`,');
216
+ info('link it to the Api function, and remove the raw Postgres component from the Api link');
217
+ info('(keep it on the ops/worker functions). Then redeploy.');
218
+ info(`Verify: everystack db:doctor --stage ${flags.stage}`);
219
+ }
220
+
221
+ export async function dbDoctorCommand(flags: Record<string, string>): Promise<void> {
222
+ step('Resolving deployed config...');
223
+ let config;
224
+ try {
225
+ config = await resolveConfig(flags.stage);
226
+ } catch (err: any) {
227
+ fail(err.message);
228
+ process.exit(1);
229
+ }
230
+
231
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
232
+ step('Probing database connections (api + operator)...');
233
+
234
+ let report: any;
235
+ try {
236
+ report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
237
+ } catch (err: any) {
238
+ fail(`db:doctor failed: ${err.message}`);
239
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
240
+ process.exit(1);
241
+ }
242
+
243
+ if (report?.error) {
244
+ fail(`db:doctor failed: ${report.error}`);
245
+ process.exit(1);
246
+ }
247
+
248
+ console.log('');
249
+ info(`api connection (DATABASE_URL): role=${report.api.role} superuser=${report.api.isSuperuser} bypassrls=${report.api.bypassRls}`);
250
+ info(`operator connection (ADMIN_URL): role=${report.admin.role} superuser=${report.admin.isSuperuser} bypassrls=${report.admin.bypassRls}`);
251
+ console.log('');
252
+
253
+ for (const c of report.checks as Array<{ name: string; status: string; detail: string; remediation?: string }>) {
254
+ if (c.status === 'pass') success(`${c.name} — ${c.detail}`);
255
+ else if (c.status === 'warn') warn(`${c.name} — ${c.detail}`);
256
+ else fail(`${c.name} — ${c.detail}`);
257
+ if (c.status === 'fail' && c.remediation) info(` fix: ${c.remediation}`);
258
+ }
259
+
260
+ console.log('');
261
+ if (report.ok) {
262
+ success('db:doctor — database is least-privilege and RLS-subject');
263
+ process.exit(0);
264
+ } else {
265
+ fail('db:doctor — the api connection is NOT correctly least-privilege (see failures above)');
266
+ process.exit(1);
267
+ }
268
+ }
269
+
118
270
  export async function dbPsqlCommand(flags: Record<string, string>): Promise<void> {
119
271
  step('Resolving deployed config...');
120
272
  let config;
@@ -180,9 +332,67 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
180
332
  }
181
333
  }
182
334
 
183
- // Interactive mode: not supported for private RDS
184
- fail('Interactive psql mode not supported for private RDS instances.');
185
- info('Use -c flag to execute SQL queries via Lambda: everystack db:psql -c "SELECT * FROM users;"');
186
- info('The Lambda function executes queries from within the VPC where it can reach the database.');
187
- process.exit(1);
335
+ // Interactive mode break-glass admin psql.
336
+ //
337
+ // Authorization IS your AWS identity: resolving the admin URL requires reading
338
+ // the SST secret store (SSM passphrase + S3 blob + KMS decrypt). If your
339
+ // profile can't, you don't get a session — the permission is the policy, no
340
+ // confirmation prompt. CloudTrail records the decrypt, so the access is
341
+ // audited for free. The URL is resolved in-process and handed to psql via PG*
342
+ // env vars: it never touches your terminal, argv, clipboard, or psql history.
343
+ if (!flags.stage) {
344
+ fail('--stage is required for an interactive session. Example: everystack db:psql --stage production');
345
+ info('Or run a one-off query via Lambda: everystack db:psql -c "SELECT 1" --stage production');
346
+ process.exit(1);
347
+ }
348
+
349
+ step('Resolving the admin connection from the secret store...');
350
+ let secrets: Record<string, string>;
351
+ try {
352
+ const { loadSstSecrets } = await import('../utils/secrets.js');
353
+ const { parseAppName } = await import('../discover.js');
354
+ const appName = await parseAppName();
355
+ secrets = await loadSstSecrets({ appName, stage: flags.stage, region: config.region });
356
+ } catch (err: any) {
357
+ fail('Could not read the secret store — your AWS profile lacks secrets access.');
358
+ info('db:psql opens an ADMIN session; it requires SSM + S3 + KMS access to the SST state.');
359
+ info('If you should have it, check your IAM permissions; otherwise you should not be in psql.');
360
+ process.exit(1);
361
+ }
362
+
363
+ const keyName = flags['secret-name'] || ADMIN_URL_KEYS.find((k) => secrets[k]);
364
+ const adminUrl = keyName ? secrets[keyName] : undefined;
365
+ if (!adminUrl) {
366
+ fail(`No admin database URL found for stage "${flags.stage}".`);
367
+ // Names only — never values.
368
+ info(`Looked for: ${ADMIN_URL_KEYS.join(', ')}. Available secret names: ${Object.keys(secrets).join(', ') || '(none)'}.`);
369
+ info('Provision one with `everystack db:provision`, or point at an existing key with --secret-name <KEY>.');
370
+ process.exit(1);
371
+ }
372
+
373
+ let pgEnv: Record<string, string>;
374
+ try {
375
+ pgEnv = pgEnvFromUrl(adminUrl);
376
+ } catch {
377
+ fail(`The resolved ${keyName} is not a valid postgres connection URL.`);
378
+ process.exit(1);
379
+ }
380
+
381
+ // Confirm the target (host/user/db are infra identifiers, not the credential);
382
+ // the password is in PGPASSWORD and is never displayed.
383
+ success(
384
+ `Admin psql → ${flags.stage} as ${pgEnv.PGUSER ?? '?'}@${pgEnv.PGHOST ?? '?'}/${pgEnv.PGDATABASE ?? ''} (credential not displayed)`,
385
+ );
386
+
387
+ const { spawn } = await import('node:child_process');
388
+ const child = spawn('psql', [], { stdio: 'inherit', env: { ...process.env, ...pgEnv } });
389
+ child.on('error', (err: any) => {
390
+ if (err.code === 'ENOENT') {
391
+ fail('psql not found on PATH. Install the PostgreSQL client to use interactive mode.');
392
+ } else {
393
+ fail(`Failed to launch psql: ${err.message}`);
394
+ }
395
+ process.exit(1);
396
+ });
397
+ child.on('close', (code) => process.exit(code ?? 0));
188
398
  }
@@ -133,6 +133,62 @@ function resolveFunctionForOrigin(
133
133
  }
134
134
  }
135
135
 
136
+ /** One FilterLogEvents page (the subset of the AWS shape this command reads). */
137
+ interface FilterLogEventsPage {
138
+ events?: Array<{ eventId?: string; timestamp?: number; message?: string }>;
139
+ nextToken?: string;
140
+ }
141
+
142
+ /** Carries the tail window across polls so a re-query can't reprint events. */
143
+ export interface TailState {
144
+ /** Highest event timestamp printed so far. Only moves forward. */
145
+ lastTimestamp: number;
146
+ /** eventId → timestamp for events at the boundary millisecond — the only ones
147
+ * an inclusive-startTime re-query can return again. Pruned each drain so it
148
+ * never grows past one millisecond's worth of events. */
149
+ seen: Map<string, number>;
150
+ }
151
+
152
+ /**
153
+ * Drain one poll's worth of pages, printing only events not seen before and
154
+ * advancing the window forward.
155
+ *
156
+ * The loop bug this fixes: FilterLogEvents `startTime` is INCLUSIVE, so a poll
157
+ * with `startTime = lastTimestamp` re-returns every event sharing that newest
158
+ * millisecond. The old code advanced `lastTimestamp` only on a strictly-greater
159
+ * comparison and never deduped, so the boundary batch reprinted on every 2s poll
160
+ * forever. Here we dedup by `eventId` and advance to the max timestamp, then
161
+ * prune `seen` to just the boundary millisecond (the only ids that can recur).
162
+ *
163
+ * Exported for unit testing without the AWS SDK or the setTimeout poll loop.
164
+ */
165
+ export async function drainLogEvents(
166
+ send: (params: { startTime: number; nextToken?: string }) => Promise<FilterLogEventsPage>,
167
+ state: TailState,
168
+ emit: (line: string) => void,
169
+ ): Promise<void> {
170
+ let nextToken: string | undefined;
171
+ do {
172
+ const page = await send({ startTime: state.lastTimestamp, nextToken });
173
+ for (const event of page.events ?? []) {
174
+ const id = event.eventId;
175
+ const ts = event.timestamp ?? state.lastTimestamp;
176
+ if (id && state.seen.has(id)) continue; // already printed this one
177
+ const timestamp = event.timestamp ? new Date(event.timestamp).toISOString() : '';
178
+ emit(`[${timestamp}] ${(event.message || '').trimEnd()}`);
179
+ if (id) state.seen.set(id, ts);
180
+ if (ts > state.lastTimestamp) state.lastTimestamp = ts;
181
+ }
182
+ nextToken = page.nextToken;
183
+ } while (nextToken);
184
+
185
+ // Only events at exactly lastTimestamp can be returned again (startTime is
186
+ // inclusive); drop everything older so `seen` stays bounded.
187
+ for (const [id, ts] of state.seen) {
188
+ if (ts < state.lastTimestamp) state.seen.delete(id);
189
+ }
190
+ }
191
+
136
192
  export async function logsTailCommand(flags: Record<string, string>): Promise<void> {
137
193
  step('Resolving deployed config...');
138
194
  let config;
@@ -174,42 +230,20 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
174
230
 
175
231
  success(`Streaming logs from ${logGroupName}...\n`);
176
232
 
177
- // Poll for new logs every 2 seconds
178
- let nextToken: string | undefined;
179
- let lastTimestamp = startTime;
233
+ // Poll for new logs every 2 seconds. State carries the window + dedup set
234
+ // across polls see drainLogEvents for why both are required.
235
+ const state: TailState = { lastTimestamp: startTime, seen: new Map() };
236
+ const send = (params: { startTime: number; nextToken?: string }) =>
237
+ client.send(new FilterLogEventsCommand({
238
+ logGroupName,
239
+ startTime: params.startTime,
240
+ filterPattern,
241
+ ...(params.nextToken ? { nextToken: params.nextToken } : {}),
242
+ }));
180
243
 
181
244
  const poll = async () => {
182
245
  try {
183
- const params: any = {
184
- logGroupName,
185
- startTime: lastTimestamp,
186
- filterPattern,
187
- };
188
-
189
- if (nextToken) {
190
- params.nextToken = nextToken;
191
- }
192
-
193
- const response = await client.send(new FilterLogEventsCommand(params));
194
-
195
- if (response.events && response.events.length > 0) {
196
- for (const event of response.events) {
197
- if (event.timestamp && event.timestamp > lastTimestamp) {
198
- lastTimestamp = event.timestamp;
199
- }
200
-
201
- const timestamp = event.timestamp
202
- ? new Date(event.timestamp).toISOString()
203
- : '';
204
- const message = event.message || '';
205
-
206
- console.log(`[${timestamp}] ${message.trimEnd()}`);
207
- }
208
- }
209
-
210
- nextToken = response.nextToken;
211
-
212
- // Continue polling
246
+ await drainLogEvents(send, state, (line) => console.log(line));
213
247
  setTimeout(poll, 2000);
214
248
  } catch (err: any) {
215
249
  if (err.name === 'ResourceNotFoundException') {
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `everystack security:audit` — the gate behind the two-surface security model.
3
+ *
4
+ * Goes RED (non-zero exit) on any data-returning SECURITY DEFINER function or any
5
+ * public view that exposes a non-public column, so CI fails before the leak ships.
6
+ * Two instruments over one classifier:
7
+ *
8
+ * everystack security:audit static scan of migration SQL (pre-merge)
9
+ * everystack security:audit --stage <name> live catalog scan of a deployed stage
10
+ *
11
+ * See docs/security.md ("Auditing the surface") and docs/plans/nothing-skips-rls.md.
12
+ */
13
+
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import {
17
+ audit,
18
+ parseFunctionsFromSql,
19
+ parseViewsAndGrantsFromSql,
20
+ type Waivers,
21
+ type AuditReport,
22
+ type FunctionDescriptor,
23
+ type ViewDescriptor,
24
+ } from '../security-audit.js';
25
+ import {
26
+ FUNCTIONS_SQL,
27
+ RELATIONS_SQL,
28
+ catalogFunctionToDescriptor,
29
+ catalogRelationToDescriptor,
30
+ } from '../security-catalog.js';
31
+ import { resolveConfig, opsFunction } from '../config.js';
32
+ import { invokeAction } from '../aws.js';
33
+ import { step, success, fail, info, warn } from '../output.js';
34
+
35
+ const MIGRATION_DIRS = ['drizzle', 'db/migrations', 'migrations', 'db/drizzle'];
36
+ const WAIVER_FILES = ['security-audit.json', '.security-audit.json'];
37
+
38
+ export async function securityAuditCommand(flags: Record<string, string>): Promise<void> {
39
+ const waivers = await loadWaivers(flags.config);
40
+
41
+ const useCatalog = flags.catalog === 'true' || typeof flags.stage === 'string';
42
+
43
+ let report: AuditReport;
44
+ try {
45
+ report = useCatalog
46
+ ? await runCatalogAudit(flags, waivers)
47
+ : await runStaticAudit(flags, waivers);
48
+ } catch (err: any) {
49
+ fail(err.message);
50
+ process.exit(1);
51
+ }
52
+
53
+ printReport(report, useCatalog ? 'catalog' : 'static');
54
+ process.exit(report.red > 0 ? 1 : 0);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Static instrument — parse migration SQL, no database.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ async function runStaticAudit(flags: Record<string, string>, waivers: Waivers): Promise<AuditReport> {
62
+ const dir = await resolveMigrationDir(flags.dir);
63
+ step(`Scanning migration SQL in ${dir} ...`);
64
+
65
+ const entries = (await fs.readdir(dir)).filter((f) => f.endsWith('.sql')).sort();
66
+ if (entries.length === 0) {
67
+ throw new Error(`No .sql files found in ${dir}. Pass --dir <path> to point at your migrations.`);
68
+ }
69
+
70
+ let sql = '';
71
+ for (const entry of entries) {
72
+ sql += '\n' + (await fs.readFile(path.join(dir, entry), 'utf8'));
73
+ }
74
+
75
+ const functions = parseFunctionsFromSql(sql);
76
+ const views = parseViewsAndGrantsFromSql(sql);
77
+ info(`Found ${functions.length} function(s), ${views.length} view(s) across ${entries.length} migration file(s).`);
78
+ return audit(functions, views, waivers);
79
+ }
80
+
81
+ async function resolveMigrationDir(flag?: string): Promise<string> {
82
+ if (flag) {
83
+ const abs = path.resolve(flag);
84
+ await fs.access(abs);
85
+ return abs;
86
+ }
87
+ for (const candidate of MIGRATION_DIRS) {
88
+ const abs = path.resolve(candidate);
89
+ try {
90
+ const stat = await fs.stat(abs);
91
+ if (stat.isDirectory()) return abs;
92
+ } catch {
93
+ // try next
94
+ }
95
+ }
96
+ throw new Error(`No migration directory found (looked for ${MIGRATION_DIRS.join(', ')}). Pass --dir <path>.`);
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Catalog instrument — introspect the live database via the ops Lambda.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ async function runCatalogAudit(flags: Record<string, string>, waivers: Waivers): Promise<AuditReport> {
104
+ step('Resolving deployed config...');
105
+ const config = await resolveConfig(flags.stage);
106
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
107
+
108
+ step('Introspecting database catalog (functions + relations)...');
109
+ const fnRows = await catalogQuery(config.region, opsFunction(config), FUNCTIONS_SQL);
110
+ const relRows = await catalogQuery(config.region, opsFunction(config), RELATIONS_SQL);
111
+
112
+ const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
113
+ const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
114
+ info(`Catalog: ${functions.length} function(s), ${views.length} relation(s).`);
115
+ return audit(functions, views, waivers);
116
+ }
117
+
118
+ async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
119
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
120
+ if (result?.error) throw new Error(`Catalog query failed: ${result.error}`);
121
+ return result?.rows ?? [];
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Waivers + reporting.
126
+ // ---------------------------------------------------------------------------
127
+
128
+ async function loadWaivers(flag?: string): Promise<Waivers> {
129
+ const candidates = flag ? [flag] : WAIVER_FILES;
130
+ for (const candidate of candidates) {
131
+ const abs = path.resolve(candidate);
132
+ try {
133
+ const raw = await fs.readFile(abs, 'utf8');
134
+ const parsed = JSON.parse(raw);
135
+ info(`Loaded waivers from ${candidate}.`);
136
+ return { secdef: parsed.secdef ?? {}, publicColumns: parsed.publicColumns ?? {} };
137
+ } catch (err: any) {
138
+ if (err.code !== 'ENOENT') throw new Error(`Could not read waiver config ${candidate}: ${err.message}`);
139
+ }
140
+ }
141
+ return {};
142
+ }
143
+
144
+ function printReport(report: AuditReport, instrument: 'static' | 'catalog'): void {
145
+ const reds = [
146
+ ...report.functions.filter((f) => f.severity === 'red'),
147
+ ...report.views.filter((v) => v.severity === 'red'),
148
+ ];
149
+ const warns = [
150
+ ...report.functions.filter((f) => f.severity === 'warn'),
151
+ ...report.views.filter((v) => v.severity === 'warn'),
152
+ ];
153
+ const waived = report.functions.filter((f) => f.waived);
154
+
155
+ if (reds.length > 0) {
156
+ console.log('');
157
+ info('RED — must fix or waive with justification:');
158
+ for (const f of reds) {
159
+ fail(`${f.key}`);
160
+ for (const r of f.reasons) info(` ${r}`);
161
+ }
162
+ }
163
+
164
+ if (warns.length > 0) {
165
+ console.log('');
166
+ info('WARN — review (does not fail the build):');
167
+ for (const f of warns) {
168
+ warn(`${f.key}`);
169
+ for (const r of f.reasons) info(` ${r}`);
170
+ }
171
+ }
172
+
173
+ if (waived.length > 0) {
174
+ console.log('');
175
+ info(`Waived (${waived.length}): ${waived.map((f) => f.key).join(', ')}`);
176
+ }
177
+
178
+ console.log('');
179
+ const summary = `security:audit (${instrument}) — ${report.red} red, ${report.warn} warn`;
180
+ if (report.red > 0) {
181
+ fail(summary);
182
+ } else {
183
+ success(summary);
184
+ }
185
+ }
package/src/cli/index.ts CHANGED
@@ -6,7 +6,7 @@ import { updateCommand } from './commands/update.js';
6
6
  import { certsCommand } from './commands/certs.js';
7
7
  import { channelsCommand } from './commands/channels.js';
8
8
  import { branchesCommand } from './commands/branches.js';
9
- import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand } from './commands/db.js';
9
+ import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDoctorCommand, dbProvisionCommand } from './commands/db.js';
10
10
  import { consoleCommand } from './commands/console.js';
11
11
  import { cachePurgeCommand } from './commands/cache.js';
12
12
  import { statusCommand } from './commands/status.js';
@@ -14,6 +14,7 @@ import { diagCommand } from './commands/diag.js';
14
14
  import { analyzeSSRCommand } from './commands/analyze.js';
15
15
  import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
16
16
  import { secretsCommand } from './commands/secrets.js';
17
+ import { securityAuditCommand } from './commands/security.js';
17
18
  import { fail } from './output.js';
18
19
 
19
20
  const args = process.argv.slice(2);
@@ -114,6 +115,12 @@ async function main() {
114
115
  case 'db:psql':
115
116
  await dbPsqlCommand(flags);
116
117
  break;
118
+ case 'db:doctor':
119
+ await dbDoctorCommand(flags);
120
+ break;
121
+ case 'db:provision':
122
+ await dbProvisionCommand(flags);
123
+ break;
117
124
  case 'console':
118
125
  await consoleCommand(flags);
119
126
  break;
@@ -143,6 +150,9 @@ async function main() {
143
150
  case 'logs:query':
144
151
  await logsQueryCommand(flags);
145
152
  break;
153
+ case 'security:audit':
154
+ await securityAuditCommand(flags);
155
+ break;
146
156
  case 'secrets': {
147
157
  // secrets <subcommand> [positional...] [--flags]
148
158
  // Extract positional args: everything after subcommand that isn't a flag or flag value
@@ -176,9 +186,14 @@ Usage:
176
186
  everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
177
187
  everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
178
188
  everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
179
- everystack db:psql [--stage <name>] -c <command> Execute SQL query via Lambda (private RDS)
189
+ everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
190
+ everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
191
+ everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
192
+ everystack db:provision [--stage <name>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB)
180
193
  everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
181
194
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
195
+ everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
196
+ everystack security:audit --stage <name> [--config <file>] Live catalog scan of a deployed stage (post-deploy)
182
197
  everystack certs:generate [--output ./certs]
183
198
  everystack certs:configure [--input ./certs] [--keyid main]
184
199
  everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
@@ -10,6 +10,8 @@
10
10
  * {prefix}/events/YYYY/MM/DD/HH/{ts}-{id}.ndjson (one JSON LogEvent per line)
11
11
  * Hour partitions are UTC. Canonical writer + query semantics:
12
12
  * packages/logging/src/service/s3-log-storage.ts
13
+ * A contract test fails loudly if the writer drifts from what this reader assumes:
14
+ * packages/logging/__tests__/service/cli-reader-format.contract.test.ts
13
15
  *
14
16
  * We read the format rather than import the class because @everystack/logging
15
17
  * pulls React Native (admin/ui) through its peer graph — wrong for a standalone CLI.