@everystack/cli 0.4.51 → 0.4.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli/authz-compile.ts +6 -1
- package/src/cli/authz-derive.ts +39 -2
- package/src/cli/authz-lint.ts +33 -19
- package/src/cli/commands/db-check.ts +5 -1
- package/src/cli/commands/db-pull.ts +59 -10
- package/src/cli/index.ts +1 -1
- package/src/cli/model-render.ts +42 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.52",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"structured-headers": "1.0.1",
|
|
110
110
|
"tsx": "4.21.0",
|
|
111
111
|
"typescript": "5.9.3",
|
|
112
|
-
"@everystack/model": "0.4.
|
|
112
|
+
"@everystack/model": "0.4.14"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -555,7 +555,12 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
555
555
|
// policies. 'app' writes through its policies -> FORCE; 'worker'/'functions'
|
|
556
556
|
// write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
|
|
557
557
|
// owner is not a superuser, so a FORCEd table would block the owner's own writes).
|
|
558
|
-
rls:
|
|
558
|
+
// `rls: false` (grants-only table) declares the flag OFF — defineModel guarantees
|
|
559
|
+
// zero abilities and a non-'app' principal there. Compared against `false`, not
|
|
560
|
+
// truthiness: a descriptor minted by an older @everystack/model carries no `rls`
|
|
561
|
+
// key, and undefined must mean ENABLED — the pre-flag behavior — never a silent
|
|
562
|
+
// security downgrade via version skew.
|
|
563
|
+
rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
|
|
559
564
|
grants: compileGrants(abilities, model.privileges),
|
|
560
565
|
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
561
566
|
policies,
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -326,8 +326,19 @@ function renderColumnAbility(
|
|
|
326
326
|
* Deliberately conservative. Every branch that cannot prove what it would emit falls
|
|
327
327
|
* through to `notes` rather than guessing — the caller renders those as comments beside
|
|
328
328
|
* the model, so the human sees the real rule and decides.
|
|
329
|
+
*
|
|
330
|
+
* `governRoles` (db:pull --govern-roles) is the operator's EXPLICIT decision to transcribe a
|
|
331
|
+
* foreign role's grants into `privileges` — which GOVERNS the role, per the doctrine on
|
|
332
|
+
* GOVERNED_VOCABULARY. Never inferred: a re-pull must not silently convert a rendering
|
|
333
|
+
* decision into an access decision. Transcription is complete-or-refuse — a governed role
|
|
334
|
+
* holding a column-scoped grant THROWS, because the model cannot express it for a foreign
|
|
335
|
+
* role and governing the role would make the next plan REVOKE it.
|
|
329
336
|
*/
|
|
330
|
-
export function deriveAbilities(
|
|
337
|
+
export function deriveAbilities(
|
|
338
|
+
contract: TableContract,
|
|
339
|
+
opts: { governRoles?: ReadonlySet<string> } = {},
|
|
340
|
+
): DerivedAbilities {
|
|
341
|
+
const govern = opts.governRoles ?? new Set<string>();
|
|
331
342
|
const abilities: string[] = [];
|
|
332
343
|
const notes: string[] = [];
|
|
333
344
|
const table = contract.table;
|
|
@@ -599,6 +610,20 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
599
610
|
const renderedColumnRoles = new Set<string>();
|
|
600
611
|
for (const grantee of Object.keys(contract.columnGrants ?? {}).sort()) {
|
|
601
612
|
const byPriv = contract.columnGrants![grantee];
|
|
613
|
+
if (govern.has(grantee)) {
|
|
614
|
+
// Complete-or-refuse. Governing this role reconciles ALL its grants, and `privileges`
|
|
615
|
+
// has no column axis for a foreign role — a partial transcription would leave this
|
|
616
|
+
// grant undeclared on a governed role, and the very next plan would REVOKE it.
|
|
617
|
+
const held = Object.entries(byPriv)
|
|
618
|
+
.filter(([, cols]) => (cols ?? []).length)
|
|
619
|
+
.map(([priv, cols]) => `${priv}(${(cols ?? []).join(', ')})`);
|
|
620
|
+
throw new Error(
|
|
621
|
+
`${table}: --govern-roles ${grantee} refused — the role holds a COLUMN-scoped grant here: ${held.join('; ')}. ` +
|
|
622
|
+
`Governing a role transcribes and reconciles ALL of its grants, and the model cannot express a ` +
|
|
623
|
+
`column-scoped grant for a foreign role, so the next plan would revoke it. Normalize the grant to ` +
|
|
624
|
+
`table-wide in the database first, or leave the role ungoverned.`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
602
627
|
for (const priv of Object.keys(byPriv).sort()) {
|
|
603
628
|
const cols = byPriv[priv] ?? [];
|
|
604
629
|
if (!cols.length) continue;
|
|
@@ -621,7 +646,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
621
646
|
|
|
622
647
|
// --- roles the compiler has no vocabulary for ----------------------------------------
|
|
623
648
|
const unmappedRoles = Object.keys(contract.grants).filter(
|
|
624
|
-
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r),
|
|
649
|
+
(r) => !KNOWN_ROLES.has(r) && r !== 'PUBLIC' && r !== 'public' && !renderedColumnRoles.has(r) && !govern.has(r),
|
|
625
650
|
);
|
|
626
651
|
|
|
627
652
|
if (!abilities.length && !notes.length && !unmappedRoles.length) {
|
|
@@ -636,6 +661,18 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
|
|
|
636
661
|
privileges[role] = [...new Set([...(privileges[role] ?? []), ...privs])].sort();
|
|
637
662
|
}
|
|
638
663
|
|
|
664
|
+
// --- roles the operator chose to GOVERN (--govern-roles) ------------------------------
|
|
665
|
+
//
|
|
666
|
+
// The whole grant, verbatim — DML and beyond-CRUD alike. These are the grants a fresh
|
|
667
|
+
// build must recreate (the migrations being deleted are what used to create them); a
|
|
668
|
+
// declared grant with no policy stays subject to the naked-grant WARN, which on an
|
|
669
|
+
// rls: false table correctly names it as live, deliberate, table-wide access.
|
|
670
|
+
for (const role of Object.keys(contract.grants).sort()) {
|
|
671
|
+
if (!govern.has(role)) continue;
|
|
672
|
+
const held = (contract.grants[role] ?? []).sort();
|
|
673
|
+
if (held.length) privileges[role] = [...new Set([...(privileges[role] ?? []), ...held])].sort();
|
|
674
|
+
}
|
|
675
|
+
|
|
639
676
|
return { abilities, notes, unmappedRoles, privileges };
|
|
640
677
|
}
|
|
641
678
|
|
package/src/cli/authz-lint.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* authz-lint — the "force-RLS with no read authz" gate.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* A modeled table gets RLS enabled unless it declares `rls: false` (grants-only), and the
|
|
5
5
|
* model is default-deny: no matching `can()` means no policy. So an EXPOSED table that declares no
|
|
6
6
|
* read ability is a superuser-drop landmine — it reads fine while a bypassing role (a superuser
|
|
7
7
|
* api) is in front, then returns empty for every app role the instant that role becomes
|
|
@@ -37,11 +37,18 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
|
|
|
37
37
|
gaps.push({
|
|
38
38
|
schema: m.schema,
|
|
39
39
|
table: m.table,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// An rls: false table cannot take the "declare a read" cure — defineModel refuses
|
|
41
|
+
// abilities there — so its message names the two options that exist. db:pull never
|
|
42
|
+
// authors this shape (it renders private: true beside rls: false); only a hand
|
|
43
|
+
// author can, and this is the line that stops them.
|
|
44
|
+
message: m.rls === false
|
|
45
|
+
? `${m.schema}.${m.table} declares rls: false and is exposed to the generic data API — grants ` +
|
|
46
|
+
`are its only gate, so every granted role reads every row, unfiltered. A grants-only table ` +
|
|
47
|
+
`is operational, not API surface: mark it private(), or drop rls: false and declare abilities.`
|
|
48
|
+
: `${m.schema}.${m.table} is exposed and RLS-enabled but declares no read ability — every app role ` +
|
|
49
|
+
`reads empty once it is RLS-subject (e.g. after dropping a superuser api). Declare a read: ` +
|
|
50
|
+
`can('read') for public data, can('read', { owner: '<col>' }) for private, or mark the model ` +
|
|
51
|
+
`private() if it is not part of the data API.`,
|
|
45
52
|
});
|
|
46
53
|
}
|
|
47
54
|
return gaps;
|
|
@@ -63,12 +70,12 @@ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthz
|
|
|
63
70
|
*
|
|
64
71
|
* Severity is a WARNING, never fatal, and that is a considered narrowing of the original ruling.
|
|
65
72
|
* The ruling asked for an ERROR on a table without RLS, where a naked grant is an unrestricted
|
|
66
|
-
* table-wide privilege.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* a database the adopter is trying to adopt would make `db:pull`
|
|
71
|
-
* it exists for.
|
|
73
|
+
* table-wide privilege. Since `rls: false` landed, that case CAN arise from models — a
|
|
74
|
+
* grants-only table declares exactly that shape, deliberately: grants ARE its whole
|
|
75
|
+
* authorization, and there is no policy for the grant to be naked of. So the two RLS postures
|
|
76
|
+
* get two messages (dead grant vs live grants-only access), and both stay WARNINGs: failing CI
|
|
77
|
+
* over a faithful rendering of a database the adopter is trying to adopt would make `db:pull`
|
|
78
|
+
* unusable on exactly the schemas it exists for.
|
|
72
79
|
*/
|
|
73
80
|
export interface NakedGrant {
|
|
74
81
|
schema: string;
|
|
@@ -95,13 +102,20 @@ export function findNakedGrants(models: readonly ModelDescriptor[]): NakedGrant[
|
|
|
95
102
|
table: m.table,
|
|
96
103
|
role,
|
|
97
104
|
privileges: dml,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
// Two postures, two truths. Compared against literal `false`: a descriptor from an
|
|
106
|
+
// older @everystack/model has no rls key, and undefined means enabled (see
|
|
107
|
+
// authz-compile's identical guard).
|
|
108
|
+
message: m.rls === false
|
|
109
|
+
? `${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} with rls: false — this is LIVE, ` +
|
|
110
|
+
`unrestricted table-wide access, not a dead grant: no row filter applies to '${role}' on ` +
|
|
111
|
+
`this table. That is what a grants-only table declares, so confirm it is deliberate; if ` +
|
|
112
|
+
`'${role}' should see only some rows, drop rls: false and declare can(...) instead.`
|
|
113
|
+
: `${m.schema}.${m.table}: '${role}' holds ${dml.join(', ')} as a grant with no policy beside it. ` +
|
|
114
|
+
`It is dead while RLS is on — the role reads zero rows — and becomes an unrestricted ` +
|
|
115
|
+
`table-wide privilege the day RLS is disabled or a broad policy is added. If this came from ` +
|
|
116
|
+
`db:pull it is a faithful reading of the database; decide whether to give it a policy ` +
|
|
117
|
+
`(can(...)) or revoke it. If you wrote it by hand, you almost certainly want can() instead, ` +
|
|
118
|
+
`which decides the grant and the policy together.`,
|
|
105
119
|
});
|
|
106
120
|
}
|
|
107
121
|
}
|
|
@@ -433,7 +433,11 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
433
433
|
} else if (failed) {
|
|
434
434
|
fail('db:check FAILED — the merged declared state is not shippable as-is (findings above).');
|
|
435
435
|
} else if (ephemeral !== null) {
|
|
436
|
-
success(`db:check passed — declared state composes from scratch and lands
|
|
436
|
+
success(`db:check passed — the declared state composes from scratch and lands on its own fingerprint (${ephemeral.fingerprint.slice(0, 12)}).`);
|
|
437
|
+
// Named because it kept being read as the stronger claim (a consumer, 2026-08-07): this
|
|
438
|
+
// ring proves the checkout against ITSELF, on an empty database. Whether an existing
|
|
439
|
+
// database IS this state is a different question with a different verb.
|
|
440
|
+
info('This proves the checkout is self-consistent and buildable — not that any existing database matches it. For that claim, run db:fingerprint against the database.');
|
|
437
441
|
} else {
|
|
438
442
|
success('db:check passed (static ring only — no database provided for the ephemeral compose).');
|
|
439
443
|
}
|
|
@@ -42,7 +42,8 @@ import { introspectContract, type TableContract, type AuthzContract } from '../a
|
|
|
42
42
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership.js';
|
|
43
43
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
44
44
|
import { renderDerivedSource, renderDerivedFile, type DerivedRenderResult } from '../derived-render.js';
|
|
45
|
-
import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
45
|
+
import { renderModelSource, renderModelFiles, pullableTables, skippedInfrastructureTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
|
|
46
|
+
import { deriveAbilities } from '../authz-derive.js';
|
|
46
47
|
import type { QueryRunner } from '../authz-contract.js';
|
|
47
48
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
48
49
|
import { borrowedSessionRunner, type SessionRunner } from '../session.js';
|
|
@@ -132,6 +133,24 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
132
133
|
process.exit(1);
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
// --govern-roles: the operator's EXPLICIT decision to transcribe these foreign roles'
|
|
137
|
+
// grants into `privileges` — which governs them. Required for migration deletion when
|
|
138
|
+
// the migrations created grants to roles outside the vocabulary: a fresh build from
|
|
139
|
+
// models must recreate them. Never inferred from the database (a re-pull must not turn
|
|
140
|
+
// a rendering decision into an access decision); complete-or-refuse per role — a listed
|
|
141
|
+
// role holding a column-scoped grant fails the pull (see deriveAbilities).
|
|
142
|
+
const governRoles = (flags['govern-roles'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
143
|
+
if (governRoles.length && abilities !== 'live') {
|
|
144
|
+
fail(`--govern-roles transcribes LIVE grants, so it requires --abilities live.`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
for (const r of governRoles) {
|
|
148
|
+
if (['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) {
|
|
149
|
+
fail(`--govern-roles ${r}: the vocabulary roles and PUBLIC are always governed — name only foreign roles (an ops/connection role the migrations granted to).`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
135
154
|
let dbSource: DbSource;
|
|
136
155
|
try {
|
|
137
156
|
dbSource = resolveDbSource(flags);
|
|
@@ -196,12 +215,15 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
196
215
|
const unmapped = new Set<string>();
|
|
197
216
|
for (const t of contract.tables) {
|
|
198
217
|
for (const r of Object.keys(t.grants)) {
|
|
199
|
-
if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r)) unmapped.add(r);
|
|
218
|
+
if (!['anon', 'authenticated', 'admin', 'PUBLIC', 'public'].includes(r) && !governRoles.includes(r)) unmapped.add(r);
|
|
200
219
|
}
|
|
201
220
|
}
|
|
221
|
+
if (governRoles.length) {
|
|
222
|
+
note(`--govern-roles ${governRoles.join(', ')}: their grants are transcribed as privileges — the models now OWN them, and a fresh build recreates them.`);
|
|
223
|
+
}
|
|
202
224
|
if (unmapped.size) {
|
|
203
225
|
note(`Grants exist for ${[...unmapped].sort().join(', ')} — not rendered: abilities name the roles the model knows (anon/authenticated/admin).`);
|
|
204
|
-
detail(`These are left exactly as they are in the database. Add can(..., { role })
|
|
226
|
+
detail(`These are left exactly as they are in the database. Add can(..., { role }) to own them, or re-pull with --govern-roles to transcribe their grants as privileges.`);
|
|
205
227
|
}
|
|
206
228
|
// ADOPTION: record the foreign grantees that were already here, per stage. The
|
|
207
229
|
// reconciler exempts them from revocation, so this artifact is what stops that
|
|
@@ -209,10 +231,10 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
209
231
|
// grown, refuses at db:plan. It lands as a reviewable diff, with writes flagged,
|
|
210
232
|
// because nothing mechanical can tell a legitimate BI role from an attacker's on day
|
|
211
233
|
// one; the defence is forcing the look and making it recur.
|
|
212
|
-
// The governed set at ADOPTION is the fixed vocabulary
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
pulledExemptions = ungovernedGrants(contract, new Set(ALWAYS_GOVERNED));
|
|
234
|
+
// The governed set at ADOPTION is the fixed vocabulary plus any --govern-roles: a
|
|
235
|
+
// governed role's grants are DECLARED (transcribed as privileges), so recording them
|
|
236
|
+
// as exemptions too would double-book them — declared and exempted at once.
|
|
237
|
+
pulledExemptions = ungovernedGrants(contract, new Set([...ALWAYS_GOVERNED, ...governRoles]));
|
|
216
238
|
pulledFingerprint = fingerprintLive(current, contract).hash;
|
|
217
239
|
}
|
|
218
240
|
// --matviews-as-tables: the flip needs real fields — one extra catalog read for the
|
|
@@ -255,6 +277,33 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
255
277
|
process.exit(1);
|
|
256
278
|
}
|
|
257
279
|
|
|
280
|
+
// The denylist's consequence, stated at the moment it applies: a migration tool's
|
|
281
|
+
// bookkeeping is never modeled, so it can never enter the declared state — and a
|
|
282
|
+
// fully-declared database therefore cannot contain it.
|
|
283
|
+
const skippedInfra = skippedInfrastructureTables(current, schema);
|
|
284
|
+
if (skippedInfra.length) {
|
|
285
|
+
caution(
|
|
286
|
+
`${skippedInfra.length} migration-tool table(s) skipped, never modeled: ${skippedInfra.join(', ')} — `
|
|
287
|
+
+ `a migration journal is the tool's own state, not the app's. To reach a fully-declared database, `
|
|
288
|
+
+ `DROP them once the tool that owns them is retired.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The --govern-roles complete-or-refuse gate, run BEFORE any file is written: a refusal
|
|
293
|
+
// mid-render would leave a half-written models directory. Pure re-derivation, pulled
|
|
294
|
+
// tables only — a governed role's column grant on an UNPULLED table is safe (that table
|
|
295
|
+
// is not declared, so nothing reconciles it).
|
|
296
|
+
if (governRoles.length && liveAuthz) {
|
|
297
|
+
const pulledNames = new Set(pulled.map((t) => t.table));
|
|
298
|
+
try {
|
|
299
|
+
const govern = new Set(governRoles);
|
|
300
|
+
for (const [name, c] of liveAuthz) if (pulledNames.has(name)) deriveAbilities(c, { governRoles: govern });
|
|
301
|
+
} catch (err: any) {
|
|
302
|
+
fail(err.message);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
258
307
|
// WHO owns the tables being pulled. Nothing is FLAGGED here: flagging needs a declared
|
|
259
308
|
// write principal to contradict, and the models this pull is about to write do not exist
|
|
260
309
|
// yet. Naming the owner is the half the pull genuinely saw — and the half that vanishes
|
|
@@ -328,7 +377,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
328
377
|
if (flags.out && !flags.out.endsWith('.ts')) {
|
|
329
378
|
// A directory: one file per model + index.ts — the default shape for a real app.
|
|
330
379
|
const dir = path.resolve(flags.out);
|
|
331
|
-
const files = renderModelFiles(current, { schema, abilities, liveAuthz, derived: embeddedDerived, externalDerived });
|
|
380
|
+
const files = renderModelFiles(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived, externalDerived });
|
|
332
381
|
try {
|
|
333
382
|
await fs.mkdir(dir, { recursive: true });
|
|
334
383
|
const written = new Set(files.map((f) => f.file));
|
|
@@ -345,7 +394,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
345
394
|
source = files.map((f) => f.source).join('\n');
|
|
346
395
|
} else if (flags.out) {
|
|
347
396
|
const outPath = path.resolve(flags.out);
|
|
348
|
-
source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
397
|
+
source = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
|
|
349
398
|
try {
|
|
350
399
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
351
400
|
await fs.writeFile(outPath, source, 'utf8');
|
|
@@ -355,7 +404,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
355
404
|
}
|
|
356
405
|
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
357
406
|
} else {
|
|
358
|
-
source = renderModelSource(current, { schema, abilities, liveAuthz, derived: embeddedDerived });
|
|
407
|
+
source = renderModelSource(current, { schema, abilities, liveAuthz, governRoles, derived: embeddedDerived });
|
|
359
408
|
process.stdout.write(source);
|
|
360
409
|
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
361
410
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -359,7 +359,7 @@ Usage:
|
|
|
359
359
|
everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
|
|
360
360
|
everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
|
|
361
361
|
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
|
|
362
|
-
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
362
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
363
363
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
364
364
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
365
365
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
package/src/cli/model-render.ts
CHANGED
|
@@ -79,6 +79,14 @@ export interface RenderOptions {
|
|
|
79
79
|
* authz-derive.ts for the rule (policy presence is not effective privilege).
|
|
80
80
|
*/
|
|
81
81
|
liveAuthz?: Map<string, TableContract>;
|
|
82
|
+
/**
|
|
83
|
+
* `--govern-roles` — the operator's explicit decision to transcribe these foreign roles'
|
|
84
|
+
* grants into `privileges`, which GOVERNS them. Required for migration deletion when the
|
|
85
|
+
* migrations created grants to roles outside the vocabulary (an ops/connection role): a
|
|
86
|
+
* fresh build from models must recreate them or the deletion loses the ops lane its
|
|
87
|
+
* access. Never inferred — see deriveAbilities. Complete-or-refuse per role.
|
|
88
|
+
*/
|
|
89
|
+
governRoles?: string[];
|
|
82
90
|
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
83
91
|
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
84
92
|
derived?: DerivedRenderResult;
|
|
@@ -124,7 +132,7 @@ export function isPublicReadAbility(expr: string): boolean {
|
|
|
124
132
|
* abilities, not a regex over the joined text (a live predicate can span lines and carry its
|
|
125
133
|
* own braces). An unknown preset throws — grants are authored, never guessed.
|
|
126
134
|
*/
|
|
127
|
-
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): { text: string; publicRead: boolean } {
|
|
135
|
+
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean } {
|
|
128
136
|
if (mode === 'live') {
|
|
129
137
|
const contract = table && liveAuthz?.get(table.table);
|
|
130
138
|
if (!contract) {
|
|
@@ -137,8 +145,20 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
|
|
|
137
145
|
publicRead: false,
|
|
138
146
|
};
|
|
139
147
|
}
|
|
140
|
-
const derived = deriveAbilities(contract);
|
|
141
|
-
|
|
148
|
+
const derived = deriveAbilities(contract, { governRoles });
|
|
149
|
+
const lines = [renderDerivedAbilities(derived)];
|
|
150
|
+
// A grants-only table: live RLS is OFF and no ability rendered. Transcribe the flag —
|
|
151
|
+
// without it the compiler declares RLS enabled and the first plan after adoption
|
|
152
|
+
// proposes ENABLE ROW LEVEL SECURITY, which with zero policies denies every non-owner
|
|
153
|
+
// role a table it already uses (a consumer's ops lane, measured 2026-08-07). Never
|
|
154
|
+
// rendered beside abilities: defineModel refuses the pair, and a live-off table whose
|
|
155
|
+
// grants DO derive abilities is adopt-mode territory, not a flag transcription.
|
|
156
|
+
// The writtenBy stanza always accompanies this line (live-off is never FORCEd), which
|
|
157
|
+
// is what lets the rendered model load — rls: false with the 'app' default is refused.
|
|
158
|
+
if (!derived.abilities.length && !contract.rls.enabled) {
|
|
159
|
+
lines.push(` rls: false, // live reality: row security is OFF — authorization here is grants-only.`);
|
|
160
|
+
}
|
|
161
|
+
return { text: lines.join('\n'), publicRead: derived.abilities.some(isPublicReadAbility) };
|
|
142
162
|
}
|
|
143
163
|
if (mode === 'commented') {
|
|
144
164
|
return {
|
|
@@ -257,6 +277,21 @@ export function pullableTables(snapshot: SchemaSnapshot, schema: string | string
|
|
|
257
277
|
});
|
|
258
278
|
}
|
|
259
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The infrastructure tables a pull SKIPPED, qualified — so the command can NAME them.
|
|
282
|
+
*
|
|
283
|
+
* The denylist has a consequence nothing used to state: these tables are never modeled,
|
|
284
|
+
* so an adopter with legacy bookkeeping (a retired Rails app's `schema_migrations`) can
|
|
285
|
+
* only reach a fully-declared database by DROPPING them. A consumer did that archaeology
|
|
286
|
+
* by hand (2026-08-07); one line at pull time is what it should have cost.
|
|
287
|
+
*/
|
|
288
|
+
export function skippedInfrastructureTables(snapshot: SchemaSnapshot, schema: string | string[]): string[] {
|
|
289
|
+
const schemas = new Set(Array.isArray(schema) ? schema : [schema]);
|
|
290
|
+
return snapshot.tables
|
|
291
|
+
.map((t) => t.table)
|
|
292
|
+
.filter((table) => schemas.has(schemaOf(table)) && INFRASTRUCTURE_TABLES.has(bareName(table)));
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
/** The schema a qualified (or bare) table lives in — bare means public. */
|
|
261
296
|
function schemaOf(table: string): string {
|
|
262
297
|
const dot = table.indexOf('.');
|
|
@@ -515,7 +550,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
515
550
|
}
|
|
516
551
|
|
|
517
552
|
/** One `export const X = defineModel(...)` block for a table. */
|
|
518
|
-
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>): string {
|
|
553
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): string {
|
|
519
554
|
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
520
555
|
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
521
556
|
const validates = new Map<string, string>();
|
|
@@ -532,7 +567,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
532
567
|
// The authz decision renders FIRST — before fields — because it is the first thing a
|
|
533
568
|
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
534
569
|
// put it, proving the position is mechanical-edit-friendly).
|
|
535
|
-
const stanza = abilitiesStanza(abilities, table, liveAuthz);
|
|
570
|
+
const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
|
|
536
571
|
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
537
572
|
const softDelete = softDeleteStanza(table, stanza.publicRead);
|
|
538
573
|
|
|
@@ -654,7 +689,7 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
|
|
|
654
689
|
const known = new Set(tables.map((t) => bareName(t.table)));
|
|
655
690
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
656
691
|
|
|
657
|
-
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz));
|
|
692
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles)));
|
|
658
693
|
// The derived layer (B5): sequences + views/matviews/functions, after the models they
|
|
659
694
|
// reference, before the module wrapper that composes all three.
|
|
660
695
|
if (opts.derived?.block) blocks.push(opts.derived.block);
|
|
@@ -722,7 +757,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
722
757
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
723
758
|
|
|
724
759
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
725
|
-
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz);
|
|
760
|
+
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles));
|
|
726
761
|
const crossImports = referencedTables(t, known).map(
|
|
727
762
|
(target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
|
|
728
763
|
);
|