@everystack/cli 0.2.37 → 0.3.0
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/LICENSE +681 -0
- package/README.md +33 -6
- package/package.json +28 -9
- package/src/cli/authz-compile.ts +212 -0
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-owner-probe.ts +200 -0
- package/src/cli/authz-reconcile.ts +127 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/aws.ts +100 -0
- package/src/cli/backup.ts +99 -0
- package/src/cli/commands/db-authz.ts +292 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +133 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/db.ts +215 -5
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/commands/logs.ts +67 -33
- package/src/cli/commands/security.ts +185 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +85 -2
- package/src/cli/migration-compile.ts +69 -0
- package/src/cli/migration-generate.ts +175 -0
- package/src/cli/model-api.ts +35 -0
- package/src/cli/model-render.ts +262 -0
- package/src/cli/pg-ident.ts +59 -0
- package/src/cli/rds-snapshot.ts +47 -0
- package/src/cli/schema-compile.ts +439 -0
- package/src/cli/schema-diff.ts +605 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/security-audit.ts +405 -0
- package/src/cli/security-catalog.ts +170 -0
|
@@ -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/config.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface CliConfig {
|
|
|
24
24
|
mediaBucket?: string;
|
|
25
25
|
kvsArn?: string;
|
|
26
26
|
distributionId?: string;
|
|
27
|
+
/** RDS DB instance identifier (for db:snapshot). Absent when the DB isn't RDS or in dev mode. */
|
|
28
|
+
databaseInstanceId?: string;
|
|
29
|
+
/** Private S3 bucket holding logical backups (for db:backup:download presign). */
|
|
30
|
+
backupsBucket?: string;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** Operator invoke target — dedicated ops function when deployed, else the API function. */
|
|
@@ -44,6 +48,8 @@ interface SstOutputs {
|
|
|
44
48
|
mediaBucket?: string;
|
|
45
49
|
kvsArn?: string;
|
|
46
50
|
distributionId?: string;
|
|
51
|
+
databaseInstanceId?: string;
|
|
52
|
+
backupsBucket?: string;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
@@ -115,5 +121,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
|
115
121
|
mediaBucket: outputs.mediaBucket,
|
|
116
122
|
kvsArn: outputs.kvsArn,
|
|
117
123
|
distributionId: outputs.distributionId,
|
|
124
|
+
databaseInstanceId: outputs.databaseInstanceId,
|
|
125
|
+
backupsBucket: outputs.backupsBucket,
|
|
118
126
|
};
|
|
119
127
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { updateCommand } from './commands/update.js';
|
|
6
|
+
import { deployCommand } from './commands/deploy.js';
|
|
6
7
|
import { certsCommand } from './commands/certs.js';
|
|
7
8
|
import { channelsCommand } from './commands/channels.js';
|
|
8
9
|
import { branchesCommand } from './commands/branches.js';
|
|
9
|
-
import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand } from './commands/db.js';
|
|
10
|
+
import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDoctorCommand, dbProvisionCommand } from './commands/db.js';
|
|
11
|
+
import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwnerCommand, dbAuthzReportCommand } from './commands/db-authz.js';
|
|
12
|
+
import { dbGenerateCommand } from './commands/db-generate.js';
|
|
13
|
+
import { dbPullCommand } from './commands/db-pull.js';
|
|
14
|
+
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
15
|
+
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
10
16
|
import { consoleCommand } from './commands/console.js';
|
|
11
17
|
import { cachePurgeCommand } from './commands/cache.js';
|
|
12
18
|
import { statusCommand } from './commands/status.js';
|
|
@@ -14,6 +20,7 @@ import { diagCommand } from './commands/diag.js';
|
|
|
14
20
|
import { analyzeSSRCommand } from './commands/analyze.js';
|
|
15
21
|
import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
|
|
16
22
|
import { secretsCommand } from './commands/secrets.js';
|
|
23
|
+
import { securityAuditCommand } from './commands/security.js';
|
|
17
24
|
import { fail } from './output.js';
|
|
18
25
|
|
|
19
26
|
const args = process.argv.slice(2);
|
|
@@ -90,6 +97,9 @@ async function main() {
|
|
|
90
97
|
case 'update':
|
|
91
98
|
await updateCommand(flags);
|
|
92
99
|
break;
|
|
100
|
+
case 'deploy':
|
|
101
|
+
await deployCommand(flags);
|
|
102
|
+
break;
|
|
93
103
|
case 'certs:generate':
|
|
94
104
|
await certsCommand('generate', flags);
|
|
95
105
|
break;
|
|
@@ -114,6 +124,56 @@ async function main() {
|
|
|
114
124
|
case 'db:psql':
|
|
115
125
|
await dbPsqlCommand(flags);
|
|
116
126
|
break;
|
|
127
|
+
case 'db:doctor':
|
|
128
|
+
await dbDoctorCommand(flags);
|
|
129
|
+
break;
|
|
130
|
+
case 'db:provision':
|
|
131
|
+
await dbProvisionCommand(flags);
|
|
132
|
+
break;
|
|
133
|
+
case 'db:snapshot':
|
|
134
|
+
await dbSnapshotCommand(flags);
|
|
135
|
+
break;
|
|
136
|
+
case 'db:snapshots':
|
|
137
|
+
await dbSnapshotsCommand(flags);
|
|
138
|
+
break;
|
|
139
|
+
case 'db:backup:probe':
|
|
140
|
+
await dbBackupProbeCommand(flags);
|
|
141
|
+
break;
|
|
142
|
+
case 'db:backup':
|
|
143
|
+
await dbBackupCommand(flags);
|
|
144
|
+
break;
|
|
145
|
+
case 'db:backups':
|
|
146
|
+
await dbBackupsCommand(flags);
|
|
147
|
+
break;
|
|
148
|
+
case 'db:restore':
|
|
149
|
+
await dbRestoreCommand(flags);
|
|
150
|
+
break;
|
|
151
|
+
case 'db:backup:download': {
|
|
152
|
+
const backupId = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
153
|
+
await dbBackupDownloadCommand(flags, backupId);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case 'db:generate':
|
|
157
|
+
await dbGenerateCommand(flags);
|
|
158
|
+
break;
|
|
159
|
+
case 'db:pull':
|
|
160
|
+
await dbPullCommand(flags);
|
|
161
|
+
break;
|
|
162
|
+
case 'db:authz:pull':
|
|
163
|
+
await dbAuthzPullCommand(flags);
|
|
164
|
+
break;
|
|
165
|
+
case 'db:authz:diff':
|
|
166
|
+
await dbAuthzDiffCommand(flags);
|
|
167
|
+
break;
|
|
168
|
+
case 'db:authz:test':
|
|
169
|
+
await dbAuthzTestCommand(flags);
|
|
170
|
+
break;
|
|
171
|
+
case 'db:authz:owner':
|
|
172
|
+
await dbAuthzOwnerCommand(flags);
|
|
173
|
+
break;
|
|
174
|
+
case 'db:authz:report':
|
|
175
|
+
await dbAuthzReportCommand(flags);
|
|
176
|
+
break;
|
|
117
177
|
case 'console':
|
|
118
178
|
await consoleCommand(flags);
|
|
119
179
|
break;
|
|
@@ -143,6 +203,9 @@ async function main() {
|
|
|
143
203
|
case 'logs:query':
|
|
144
204
|
await logsQueryCommand(flags);
|
|
145
205
|
break;
|
|
206
|
+
case 'security:audit':
|
|
207
|
+
await securityAuditCommand(flags);
|
|
208
|
+
break;
|
|
146
209
|
case 'secrets': {
|
|
147
210
|
// secrets <subcommand> [positional...] [--flags]
|
|
148
211
|
// Extract positional args: everything after subcommand that isn't a flag or flag value
|
|
@@ -171,14 +234,34 @@ function printHelp() {
|
|
|
171
234
|
everystack - CLI for Expo apps on everystack
|
|
172
235
|
|
|
173
236
|
Usage:
|
|
237
|
+
everystack deploy [--stage <name>] Deploy infrastructure (wraps sst deploy — no need to know SST)
|
|
174
238
|
everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
|
|
175
239
|
everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
|
|
176
240
|
everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
|
|
177
241
|
everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
|
|
178
242
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
179
|
-
everystack db:psql
|
|
243
|
+
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
244
|
+
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
245
|
+
everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
246
|
+
everystack db:provision [--stage <name>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB)
|
|
247
|
+
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
248
|
+
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
249
|
+
everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
|
|
250
|
+
everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id)
|
|
251
|
+
everystack db:backups [--stage <name>] List logical backups (id, size, created)
|
|
252
|
+
everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
|
|
253
|
+
everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
|
|
254
|
+
everystack db:generate [--stage <name>] [--name <label>] [--models models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
|
|
255
|
+
everystack db:pull [--stage <name>] [--schema public] [--out <file>] Introspect the live DB → render field() Models (the brownfield on-ramp; stdout unless --out)
|
|
256
|
+
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
257
|
+
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
258
|
+
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|
|
259
|
+
everystack db:authz:owner [--stage <name>] [--models models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
|
|
260
|
+
everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
|
|
180
261
|
everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
|
|
181
262
|
everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
|
|
263
|
+
everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
|
|
264
|
+
everystack security:audit --stage <name> [--config <file>] Live catalog scan of a deployed stage (post-deploy)
|
|
182
265
|
everystack certs:generate [--output ./certs]
|
|
183
266
|
everystack certs:configure [--input ./certs] [--keyid main]
|
|
184
267
|
everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one generator — compose the Model into a single ordered migration.
|
|
3
|
+
*
|
|
4
|
+
* This is the unified-migration thesis made concrete: a set of Models compiles to
|
|
5
|
+
* ONE ordered list of SQL statements covering *both* layers, in dependency order:
|
|
6
|
+
*
|
|
7
|
+
* 1. CREATE TABLE … (data — fields → DDL + CHECK)
|
|
8
|
+
* 2. ALTER TABLE … FOREIGN KEY (data — references, after every table exists)
|
|
9
|
+
* 3. ENABLE/FORCE RLS · policies · grants (authz — abilities, after the
|
|
10
|
+
* columns they reference exist)
|
|
11
|
+
*
|
|
12
|
+
* It is not two pipelines: the authz statements reference columns the DDL just
|
|
13
|
+
* created, so they live in the same ordered migration. This composes the four
|
|
14
|
+
* compilers already built — compileCreateTable + foreignKeySql (data) and
|
|
15
|
+
* compileTableContract + emitReconcileSql (authz) — with no new SQL emission.
|
|
16
|
+
*
|
|
17
|
+
* This is the greenfield form (empty database → the whole schema). The brownfield
|
|
18
|
+
* form diffs against an introspected snapshot instead of the empty baseline; that
|
|
19
|
+
* lands with whole-DB introspection. The emission is identical either way.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
23
|
+
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
24
|
+
import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, type CompileTableOptions } from './schema-compile.js';
|
|
25
|
+
import { compileTableContract } from './authz-compile.js';
|
|
26
|
+
import { emitReconcileSql } from './authz-reconcile.js';
|
|
27
|
+
import { emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
28
|
+
|
|
29
|
+
/** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
|
|
30
|
+
function emptyTable(table: string): TableContract {
|
|
31
|
+
return { table, rls: { enabled: false, forced: false }, grants: {}, policies: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compile a set of Models into one ordered migration (greenfield: empty DB → full
|
|
36
|
+
* schema). Tables, then foreign keys, then authz — the order a fresh database must
|
|
37
|
+
* apply them in.
|
|
38
|
+
*/
|
|
39
|
+
export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions = {}): string[] {
|
|
40
|
+
const sql: string[] = [];
|
|
41
|
+
|
|
42
|
+
// 0. Enum types — `CREATE TYPE … AS ENUM`, before any table/column references them.
|
|
43
|
+
const enumCreates: SchemaChange[] = compileEnums(models).map((e) => ({ kind: 'createType', name: e.name, values: e.values }));
|
|
44
|
+
sql.push(...emitSchemaSql(enumCreates));
|
|
45
|
+
|
|
46
|
+
// 1. Tables (with columns, CHECKs, single/composite PK, uniques).
|
|
47
|
+
for (const model of models) sql.push(compileCreateTable(model, opts));
|
|
48
|
+
|
|
49
|
+
// 2. Foreign keys — after every table exists.
|
|
50
|
+
for (const model of models) sql.push(...foreignKeySql(model));
|
|
51
|
+
|
|
52
|
+
// 2b. Standalone indexes — after the columns they cover exist.
|
|
53
|
+
const indexCreates: SchemaChange[] = [];
|
|
54
|
+
for (const model of models) {
|
|
55
|
+
for (const ix of modelConstraintSpecs(model).indexes) {
|
|
56
|
+
indexCreates.push({ kind: 'createIndex', table: `"${model.table}"`, name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
sql.push(...emitSchemaSql(indexCreates));
|
|
60
|
+
|
|
61
|
+
// 3. Authz — RLS + policies + grants, after the columns they reference. Reuse
|
|
62
|
+
// the reconcile emitter against the empty baseline: every policy/grant is a
|
|
63
|
+
// pure creation, in the same dependency-correct order reconcile already uses.
|
|
64
|
+
const desired: AuthzContract = { tables: models.map((m) => compileTableContract(m)), functions: [] };
|
|
65
|
+
const baseline: AuthzContract = { tables: models.map((m) => emptyTable(`public.${m.table}`)), functions: [] };
|
|
66
|
+
sql.push(...emitReconcileSql(desired, baseline));
|
|
67
|
+
|
|
68
|
+
return sql;
|
|
69
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `db:generate`'s pure core — Models + live snapshot → a migration file.
|
|
3
|
+
*
|
|
4
|
+
* The brownfield generator, composed from the pieces already built and proven:
|
|
5
|
+
*
|
|
6
|
+
* compileTableSchema + compileRenames (the desired side, from the Models)
|
|
7
|
+
* → diffSchema (vs the introspected `current`)
|
|
8
|
+
* → emitSchemaSql (the ALTER/CREATE/RENAME DDL)
|
|
9
|
+
*
|
|
10
|
+
* A whole *new* table renders through the greenfield `compileCreateTable` (byte-identical
|
|
11
|
+
* to what the app ships) rather than the IR renderer; its foreign keys still arrive as the
|
|
12
|
+
* diff's `addConstraint` changes, so they land after every table exists.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure and deterministic — the clock (`when`) is injected, never read —
|
|
15
|
+
* so the command shell (commands/db-generate.ts) is a thin layer of IO over it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
19
|
+
import type { SchemaSnapshot } from './schema-introspect.js';
|
|
20
|
+
import type { AuthzContract } from './authz-contract.js';
|
|
21
|
+
import { compileTableSchema, compileRenames, compileCreateTable, compileEnums } from './schema-compile.js';
|
|
22
|
+
import { compileTableContract } from './authz-compile.js';
|
|
23
|
+
import { emitReconcileSql } from './authz-reconcile.js';
|
|
24
|
+
import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
|
|
25
|
+
|
|
26
|
+
export interface GenerateOptions {
|
|
27
|
+
/** The Postgres schema the Models live in. Default: `public`. */
|
|
28
|
+
schema?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Emit real `DROP COLUMN`/`DROP CONSTRAINT`/`DROP TABLE` for things present in the
|
|
31
|
+
* database but absent from the Models. Default `false` — those are held back as
|
|
32
|
+
* commented notices (see {@link HELD_DROP_PREFIX}), so a partial or lossy Model set
|
|
33
|
+
* never silently loses data. Destruction needs intent, the same rule renames are built on.
|
|
34
|
+
*/
|
|
35
|
+
allowDrops?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The live authorization contract (`introspectContract`). When provided, the migration
|
|
38
|
+
* carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
|
|
39
|
+
* diffed against this live state, appended after the data phase as one ordered file (the
|
|
40
|
+
* unified-migration thesis: declare once, generate both layers). Omitted → data-only, the
|
|
41
|
+
* backward-compatible default. Authz drops (`DROP POLICY`/`REVOKE`) are data-safe, so they
|
|
42
|
+
* are emitted regardless of `allowDrops` — `allowDrops` gates only data destruction.
|
|
43
|
+
*/
|
|
44
|
+
liveAuthz?: AuthzContract;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The comment prefix a held-back destructive change carries (the command greps it to count them). */
|
|
48
|
+
export const HELD_DROP_PREFIX = '-- DROP held back';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A destructive change that removes data or an integrity guard with no re-creation —
|
|
52
|
+
* the set held back unless `allowDrops`. A constraint *replace* (a `dropConstraint`
|
|
53
|
+
* paired with an `addConstraint` of the same name) is NOT a removal: it loses no data
|
|
54
|
+
* and its add depends on the drop, so it is kept intact.
|
|
55
|
+
*/
|
|
56
|
+
function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): boolean {
|
|
57
|
+
if (change.kind === 'dropColumn' || change.kind === 'dropTable' || change.kind === 'dropType' || change.kind === 'dropIndex') return true;
|
|
58
|
+
if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Comment out a destructive statement, showing the exact DDL to run by hand or with `--allow-drops`. */
|
|
63
|
+
function holdDrop(sql: string): string {
|
|
64
|
+
return `${HELD_DROP_PREFIX} (destructive — re-run db:generate with --allow-drops to emit it, or run it by hand):\n-- ${sql}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tables present in the live database that no model declares. `db:generate` leaves these
|
|
69
|
+
* untouched — they belong to another concern (auth, jobs, observability all ship their own
|
|
70
|
+
* migrations), and dropping a table must be deliberate, never a side effect of a missing
|
|
71
|
+
* model. The command reports the count so the omission is visible, not silent.
|
|
72
|
+
*/
|
|
73
|
+
export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
74
|
+
const schema = opts.schema ?? 'public';
|
|
75
|
+
const declared = new Set(models.map((m) => `${schema}.${m.table}`));
|
|
76
|
+
return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compile the Models into the ordered SQL that evolves `current` to match them. Empty when
|
|
81
|
+
* the database already matches (the no-op generate).
|
|
82
|
+
*
|
|
83
|
+
* Scoped to the tables the models DECLARE: the diff never sees a table no model owns, so it
|
|
84
|
+
* cannot propose dropping one. db:generate manages your declared schema; everything else in
|
|
85
|
+
* the database (auth, jobs, observability) is left to its own migrations.
|
|
86
|
+
*/
|
|
87
|
+
export function generateMigrationSql(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
|
|
88
|
+
const schema = opts.schema ?? 'public';
|
|
89
|
+
const desiredEnums = compileEnums(models);
|
|
90
|
+
const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums };
|
|
91
|
+
const declaredTables = new Set(desired.tables.map((t) => t.table));
|
|
92
|
+
const declaredEnums = new Set(desiredEnums.map((e) => e.name));
|
|
93
|
+
// Scope both tables and enums to what the Models declare — an undeclared table OR enum
|
|
94
|
+
// (auth, jobs, observability ship their own) is invisible to the diff, so it is never dropped.
|
|
95
|
+
const scopedCurrent: SchemaSnapshot = {
|
|
96
|
+
tables: current.tables.filter((t) => declaredTables.has(t.table)),
|
|
97
|
+
enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
|
|
98
|
+
};
|
|
99
|
+
const renames = compileRenames(models, { schema });
|
|
100
|
+
const changes = diffSchema(desired, scopedCurrent, renames);
|
|
101
|
+
|
|
102
|
+
const modelByTable = new Map(models.map((m) => [`${schema}.${m.table}`, m]));
|
|
103
|
+
const emitted = emitSchemaSql(changes, {
|
|
104
|
+
createTable: (change) => {
|
|
105
|
+
const model = modelByTable.get(change.table);
|
|
106
|
+
if (!model) throw new Error(`db:generate: no model found for new table ${change.table}`);
|
|
107
|
+
return compileCreateTable(model);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Non-destructive by default: a column/constraint/table in the database but absent
|
|
112
|
+
// from the Models would otherwise become a DROP — silent data loss for a partial or
|
|
113
|
+
// lossy Model set. Hold removals back as comments unless `allowDrops`. emitSchemaSql
|
|
114
|
+
// returns one statement per change, index-aligned, so the gate stays a pure
|
|
115
|
+
// post-pass — `diffSchema`/`emitSchemaSql` remain honest (they always emit the DROP).
|
|
116
|
+
let dataPhase = emitted;
|
|
117
|
+
if (!opts.allowDrops) {
|
|
118
|
+
const replacedConstraints = new Set(
|
|
119
|
+
changes
|
|
120
|
+
.filter((c): c is Extract<SchemaChange, { kind: 'addConstraint' }> => c.kind === 'addConstraint')
|
|
121
|
+
.map((c) => `${c.table}::${c.constraint.name}`),
|
|
122
|
+
);
|
|
123
|
+
dataPhase = emitted.map((sql, i) => (isRemoval(changes[i], replacedConstraints) ? holdDrop(sql) : sql));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Authz phase — appended after the data phase so a policy/grant references columns the
|
|
127
|
+
// data DDL just created. Emitted only when the live contract is supplied; the Models'
|
|
128
|
+
// `abilities` compile to the same contract shape introspection reads, so the two diff.
|
|
129
|
+
// Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
|
|
130
|
+
const authzPhase = opts.liveAuthz
|
|
131
|
+
? emitReconcileSql({ tables: models.map((m) => compileTableContract(m, { schema })), functions: [] }, opts.liveAuthz)
|
|
132
|
+
: [];
|
|
133
|
+
|
|
134
|
+
return [...dataPhase, ...authzPhase];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The marker drizzle migration files put between statements. */
|
|
138
|
+
const BREAKPOINT = '\n--> statement-breakpoint\n';
|
|
139
|
+
|
|
140
|
+
/** Join statements into a drizzle-compatible migration file body (trailing newline). */
|
|
141
|
+
export function formatMigrationFile(statements: string[]): string {
|
|
142
|
+
return statements.join(BREAKPOINT) + '\n';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface JournalEntry {
|
|
146
|
+
idx: number;
|
|
147
|
+
version: string;
|
|
148
|
+
when: number;
|
|
149
|
+
tag: string;
|
|
150
|
+
breakpoints: boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface Journal {
|
|
154
|
+
version: string;
|
|
155
|
+
dialect: string;
|
|
156
|
+
entries: JournalEntry[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Sanitize a migration name into the snake_case the `NNNN_<tag>.sql` convention uses. */
|
|
160
|
+
function sanitizeName(name: string): string {
|
|
161
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'generated';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Plan the next migration file: its tag, filename, and the journal with the new entry
|
|
166
|
+
* appended. Pure — `when` is passed in (the command stamps it with the clock), so the same
|
|
167
|
+
* inputs always produce the same plan. The next index is one past the highest existing
|
|
168
|
+
* `idx`, matching how the runner and drizzle order migrations.
|
|
169
|
+
*/
|
|
170
|
+
export function planMigrationFile(journal: Journal, name: string, when: number): { tag: string; filename: string; journal: Journal } {
|
|
171
|
+
const idx = journal.entries.reduce((max, e) => Math.max(max, e.idx + 1), 0);
|
|
172
|
+
const tag = `${String(idx).padStart(4, '0')}_${sanitizeName(name)}`;
|
|
173
|
+
const entry: JournalEntry = { idx, version: journal.version || '7', when, tag, breakpoints: true };
|
|
174
|
+
return { tag, filename: `${tag}.sql`, journal: { ...journal, entries: [...journal.entries, entry] } };
|
|
175
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@everystack/cli/model` — the programmatic surface of the v3 Model generator and red-team.
|
|
3
|
+
*
|
|
4
|
+
* Everything the CLI commands do (`db:generate`, `db:pull`, `db:authz:*`) is pure logic over a
|
|
5
|
+
* `QueryRunner`; this barrel exposes it so a consumer can generate and VERIFY their schema in
|
|
6
|
+
* their own tests/CI without shelling out to the CLI. The reference app dogfoods exactly this
|
|
7
|
+
* surface — its real Models are round-tripped through `compileMigration` / `generateMigrationSql`
|
|
8
|
+
* and red-teamed with the owner probe, against a real Postgres, proving the generator on a real app.
|
|
9
|
+
*
|
|
10
|
+
* The two entry shapes:
|
|
11
|
+
* - GENERATE: `compileMigration(models)` (greenfield) or `generateMigrationSql(models, snapshot,
|
|
12
|
+
* { liveAuthz })` (brownfield, both layers) → ordered SQL.
|
|
13
|
+
* - VERIFY: `introspectSchema(run)` / `introspectContract(run, …)` → diff against the Models, and
|
|
14
|
+
* `buildOwnerProbeSql` / `evaluateOwnerProbe` for behavioural owner-isolation checks.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// --- data layer: compile, introspect, diff, render -------------------------
|
|
18
|
+
export * from './schema-compile.js';
|
|
19
|
+
export * from './schema-introspect.js';
|
|
20
|
+
export * from './schema-diff.js';
|
|
21
|
+
export * from './model-render.js';
|
|
22
|
+
export * from './migration-compile.js';
|
|
23
|
+
export * from './migration-generate.js';
|
|
24
|
+
|
|
25
|
+
// --- authz layer: contract compile/introspect/diff/reconcile ---------------
|
|
26
|
+
export * from './authz-compile.js';
|
|
27
|
+
export * from './authz-contract.js';
|
|
28
|
+
export * from './authz-reconcile.js';
|
|
29
|
+
|
|
30
|
+
// --- red-team: grant enforcement + owner isolation -------------------------
|
|
31
|
+
export * from './authz-redteam.js';
|
|
32
|
+
export * from './authz-owner-probe.js';
|
|
33
|
+
|
|
34
|
+
// --- the SECDEF function catalog the contract introspection needs -----------
|
|
35
|
+
export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';
|