@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.
@@ -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 [--stage <name>] -c <command> Execute SQL query via Lambda (private RDS)
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';