@everystack/cli 0.4.56 → 0.4.57
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 +4 -3
- package/src/cli/authz-derive.ts +46 -5
- package/src/cli/commands/db-apply.ts +11 -0
- package/src/cli/commands/db-plan.ts +20 -3
- package/src/cli/git-descent.ts +91 -19
- package/src/cli/model-api.ts +24 -0
- package/src/cli/model-render.ts +72 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.57",
|
|
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.16"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
|
@@ -159,7 +159,8 @@
|
|
|
159
159
|
"drizzle-orm": "0.41.0",
|
|
160
160
|
"jest": "29.7.0",
|
|
161
161
|
"react": "19.2.0",
|
|
162
|
-
"ts-jest": "29.4.9"
|
|
162
|
+
"ts-jest": "29.4.9",
|
|
163
|
+
"@everystack/server": "0.4.20"
|
|
163
164
|
},
|
|
164
165
|
"scripts": {
|
|
165
166
|
"test": "jest",
|
package/src/cli/authz-derive.ts
CHANGED
|
@@ -260,6 +260,44 @@ function effective(contract: TableContract, role: string, priv: string): boolean
|
|
|
260
260
|
return policiesFor(contract, role, priv).some((p) => p.permissive);
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
/** The four CRUD privileges, in the order the deriver reports them. */
|
|
264
|
+
const CRUD_PRIVS = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Classify every (role, privilege) surface ONCE: may pull render it as an ABILITY, or must it
|
|
268
|
+
* be transcribed as a grant?
|
|
269
|
+
*
|
|
270
|
+
* A DIFFERENT question from {@link effective}, and conflating them is what broke the RLS-off
|
|
271
|
+
* shape. `effective` answers "does this grant reach rows?" — with RLS off the honest answer is
|
|
272
|
+
* YES, unrestricted. But the RENDERER is asking whether it may say `can(...)`, and an ability
|
|
273
|
+
* compiles to a policy. On a table with row security disabled, rendering one makes the model
|
|
274
|
+
* propose `ALTER TABLE … ENABLE ROW LEVEL SECURITY` plus `CREATE POLICY …` against a database
|
|
275
|
+
* that has neither, and REVOKE whatever privilege the ability has no room for — three changes
|
|
276
|
+
* emitted by a command that only READ the database. That is the G1 class (a manufactured
|
|
277
|
+
* policy) reached from the adoption side, and the round-trip gate refuses it.
|
|
278
|
+
*
|
|
279
|
+
* So with RLS OFF nothing is an ability; the grants are still declared, through `privileges`,
|
|
280
|
+
* which is precisely what the governed-role path already did. That asymmetry — foreign roles
|
|
281
|
+
* transcribed, vocabulary roles turned into abilities — WAS the bug; there is one rule now.
|
|
282
|
+
*
|
|
283
|
+
* Computed as one pass returning a tag per surface, rather than a predicate each site calls,
|
|
284
|
+
* so a future ability-emitting site cannot quietly skip the question (Fable, 2026-08-11).
|
|
285
|
+
*
|
|
286
|
+
* Lives here, in the pull/derive direction only. The COMPILER must never consult it: compile
|
|
287
|
+
* turns declarations into SQL and has no business knowing how they were rendered.
|
|
288
|
+
*/
|
|
289
|
+
function classifyAbilitySurfaces(contract: TableContract): ReadonlySet<string> {
|
|
290
|
+
const surfaces = new Set<string>();
|
|
291
|
+
// Grants-only table: every surface is a grant, none is an ability.
|
|
292
|
+
if (!contract.rls.enabled) return surfaces;
|
|
293
|
+
for (const role of Object.keys(contract.grants)) {
|
|
294
|
+
for (const priv of CRUD_PRIVS) {
|
|
295
|
+
if (effective(contract, role, priv)) surfaces.add(`${role}:${priv}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return surfaces;
|
|
299
|
+
}
|
|
300
|
+
|
|
263
301
|
/** A sql`…` fragment literal, or null when the predicate is vacuous. */
|
|
264
302
|
function sqlFragment(pred: string | null): string | null {
|
|
265
303
|
if (!pred) return null;
|
|
@@ -339,6 +377,9 @@ export function deriveAbilities(
|
|
|
339
377
|
opts: { governRoles?: ReadonlySet<string> } = {},
|
|
340
378
|
): DerivedAbilities {
|
|
341
379
|
const govern = opts.governRoles ?? new Set<string>();
|
|
380
|
+
/** One classification pass; every ability-vs-grant decision below reads this tag. */
|
|
381
|
+
const abilitySurfaces = classifyAbilitySurfaces(contract);
|
|
382
|
+
const isAbilitySurface = (role: string, priv: string): boolean => abilitySurfaces.has(`${role}:${priv}`);
|
|
342
383
|
const abilities: string[] = [];
|
|
343
384
|
const notes: string[] = [];
|
|
344
385
|
const table = contract.table;
|
|
@@ -365,7 +406,7 @@ export function deriveAbilities(
|
|
|
365
406
|
// non-owner, and RLS binds non-owners whenever `enabled` is true. `forced` is irrelevant here.
|
|
366
407
|
const CRUD = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
|
|
367
408
|
const adminGrants = CRUD.filter((p) => granted(contract, 'admin', p));
|
|
368
|
-
const adminLive = adminGrants.filter((p) =>
|
|
409
|
+
const adminLive = adminGrants.filter((p) => isAbilitySurface('admin', p));
|
|
369
410
|
const adminDead = adminGrants.filter((p) => !adminLive.includes(p));
|
|
370
411
|
|
|
371
412
|
if (adminLive.length === CRUD.length) {
|
|
@@ -402,7 +443,7 @@ export function deriveAbilities(
|
|
|
402
443
|
// declared (so the plan does not REVOKE a live privilege) and never rendered as an ability
|
|
403
444
|
// (so the plan does not CREATE the policy the database is missing).
|
|
404
445
|
for (const role of ['authenticated', 'anon']) {
|
|
405
|
-
const dead = CRUD.filter((p) => granted(contract, role, p) && !
|
|
446
|
+
const dead = CRUD.filter((p) => granted(contract, role, p) && !isAbilitySurface(role, p));
|
|
406
447
|
if (!dead.length) continue;
|
|
407
448
|
deadGrants[role] = dead;
|
|
408
449
|
notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
|
|
@@ -444,8 +485,8 @@ export function deriveAbilities(
|
|
|
444
485
|
// `effective`, not `granted`: a SELECT grant with no policy behind it renders can('read'),
|
|
445
486
|
// which compiles to CREATE POLICY … USING (true) — manufacturing a policy the database does
|
|
446
487
|
// not have. Same defect as the admin branch above; the round-trip gate found it here.
|
|
447
|
-
const anonRead =
|
|
448
|
-
const authedRead =
|
|
488
|
+
const anonRead = isAbilitySurface('anon', 'SELECT');
|
|
489
|
+
const authedRead = isAbilitySurface('authenticated', 'SELECT');
|
|
449
490
|
|
|
450
491
|
if (anonRead) {
|
|
451
492
|
// Public read. If the live policy narrows it (a soft-delete guard, a published flag),
|
|
@@ -569,7 +610,7 @@ export function deriveAbilities(
|
|
|
569
610
|
// --- writes --------------------------------------------------------------------------
|
|
570
611
|
for (const [command, action] of Object.entries(DML)) {
|
|
571
612
|
if (action === 'read') continue;
|
|
572
|
-
if (!
|
|
613
|
+
if (!isAbilitySurface('authenticated', command)) continue;
|
|
573
614
|
|
|
574
615
|
const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
|
|
575
616
|
?? policiesFor(contract, 'authenticated', command)[0];
|
|
@@ -75,6 +75,10 @@ export function descentVerdictForApply(verdict: DescentVerdict): { ok: true } |
|
|
|
75
75
|
return { ok: true };
|
|
76
76
|
case 'diverged':
|
|
77
77
|
case 'drift':
|
|
78
|
+
// `indeterminate` refuses like the others, and must: the search was not exhaustive, so
|
|
79
|
+
// the fast-forward rule is unproven. Its REASON is the difference — it points at this
|
|
80
|
+
// run, not at the database. Never fold it into `drift`.
|
|
81
|
+
case 'indeterminate':
|
|
78
82
|
return { ok: false, reason: verdict.reason };
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -178,6 +182,12 @@ async function applyPlanViaStage(
|
|
|
178
182
|
case 'diverged':
|
|
179
183
|
case 'drift':
|
|
180
184
|
break; // the reason travels to the action's refusal + the memoir
|
|
185
|
+
case 'indeterminate':
|
|
186
|
+
// Said out loud here because the operator's next move is "run it again", not
|
|
187
|
+
// "reconcile your models" — and a refusal that reads like drift sends them the
|
|
188
|
+
// wrong way. The reason still travels to the refusal.
|
|
189
|
+
warn(`descent: ${verdict.unevaluated.length} historical tree(s) could not be extracted — the search was not exhaustive.`);
|
|
190
|
+
break;
|
|
181
191
|
}
|
|
182
192
|
descentVerdict = descentVerdictForApply(verdict);
|
|
183
193
|
}
|
|
@@ -437,6 +447,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
437
447
|
return { ok: true };
|
|
438
448
|
case 'diverged':
|
|
439
449
|
case 'drift':
|
|
450
|
+
case 'indeterminate':
|
|
440
451
|
return { ok: false, reason: verdict.reason };
|
|
441
452
|
}
|
|
442
453
|
},
|
|
@@ -279,9 +279,26 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
279
279
|
// work — say so on the review surface, where a human still reads it.
|
|
280
280
|
if (plan.statements.length > 0) {
|
|
281
281
|
const verdict = await verifyDescent(modelsPath, { snapshot, contract }, {});
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
// A switch, not an `if (diverged || drift)`. That form silently ignored every verdict
|
|
283
|
+
// it did not name, so `indeterminate` would have minted a plan with no warning at all
|
|
284
|
+
// and then been refused at apply time with no hint of why. Exhaustive here means a new
|
|
285
|
+
// verdict cannot be forgotten.
|
|
286
|
+
switch (verdict.status) {
|
|
287
|
+
case 'ok':
|
|
288
|
+
case 'fresh-target':
|
|
289
|
+
case 'no-git':
|
|
290
|
+
break;
|
|
291
|
+
case 'diverged':
|
|
292
|
+
case 'drift':
|
|
293
|
+
warn(`descent: ${verdict.reason}`);
|
|
294
|
+
warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
|
|
295
|
+
break;
|
|
296
|
+
case 'indeterminate':
|
|
297
|
+
// NOT "rebase first" — nothing is wrong with the checkout or the database. The
|
|
298
|
+
// search failed, so the rule is unproven either way.
|
|
299
|
+
warn(`descent: ${verdict.reason}`);
|
|
300
|
+
warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
|
|
301
|
+
break;
|
|
285
302
|
}
|
|
286
303
|
}
|
|
287
304
|
|
package/src/cli/git-descent.ts
CHANGED
|
@@ -49,6 +49,14 @@ export interface DescentOptions {
|
|
|
49
49
|
/** The Postgres schema the Models default to. Default: `public`. */
|
|
50
50
|
schema?: string;
|
|
51
51
|
loader?: ModelsLoader;
|
|
52
|
+
/**
|
|
53
|
+
* How a tree is extracted. Default: {@link materializeTree}.
|
|
54
|
+
*
|
|
55
|
+
* Injected for the same reason `loader` is: an in-module call goes through the local
|
|
56
|
+
* binding, so it cannot be spied on, and extraction failure is a condition that MUST be
|
|
57
|
+
* covered by a test — it is the one that used to be misreported as database drift.
|
|
58
|
+
*/
|
|
59
|
+
materialize?: (tree: string, dest: string, cwd: string) => void;
|
|
52
60
|
/**
|
|
53
61
|
* Where historical trees materialize. Default: a fresh temp dir under the
|
|
54
62
|
* repo's node_modules — bare imports (`@everystack/model`) must resolve
|
|
@@ -69,7 +77,15 @@ export type DescentVerdict =
|
|
|
69
77
|
/** The declaring commits exist but none is an ancestor of HEAD — rebase first. */
|
|
70
78
|
| { status: 'diverged'; commits: string[]; tree: string; reason: string }
|
|
71
79
|
/** No committed models state declares the live fingerprint — operator decision. */
|
|
72
|
-
| { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string }
|
|
80
|
+
| { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string }
|
|
81
|
+
/**
|
|
82
|
+
* One or more trees could not be EVALUATED at all — extraction or IO failed, so "no tree
|
|
83
|
+
* declares this state" is not a conclusion we are entitled to draw. Distinct from `drift`
|
|
84
|
+
* because the operator's next move is different: retry, not `db:pull`.
|
|
85
|
+
*
|
|
86
|
+
* `unevaluated` names the trees and why each failed.
|
|
87
|
+
*/
|
|
88
|
+
| { status: 'indeterminate'; scannedTrees: number; unevaluated: string[]; reason: string };
|
|
73
89
|
|
|
74
90
|
export interface ModelTreeCandidate {
|
|
75
91
|
/** The models directory's tree oid — the identity of a declared state. */
|
|
@@ -134,14 +150,33 @@ export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCa
|
|
|
134
150
|
return order.map((tree) => ({ tree, commits: byTree.get(tree)! }));
|
|
135
151
|
}
|
|
136
152
|
|
|
137
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Extract a tree into `dest`, via a temporary archive FILE — never a pipe.
|
|
155
|
+
*
|
|
156
|
+
* This used to buffer `git archive` into memory (512MB `maxBuffer`) and hand that buffer to
|
|
157
|
+
* `tar` as `input`. `execFileSync` writes the whole buffer to the child's stdin, so if the
|
|
158
|
+
* child is not draining it the write fails with **EPIPE** — and under a loaded machine it
|
|
159
|
+
* does. Measured 2026-08-11 on a full-repo run: `spawnSync tar EPIPE`, recorded against a
|
|
160
|
+
* historical tree, twice in four runs.
|
|
161
|
+
*
|
|
162
|
+
* It failed badly, not loudly: `verifyDescent` recorded it as a COMPILE failure and returned
|
|
163
|
+
* `drift`, telling the operator their database had been hand-edited. See the `indeterminate`
|
|
164
|
+
* verdict for that half of the fix; this is the half that stops the failure happening.
|
|
165
|
+
*
|
|
166
|
+
* `git archive -o` writes the archive itself and `tar -xf <file>` reads it — two independent
|
|
167
|
+
* processes, no shared pipe, no backpressure to lose, and no multi-hundred-MB buffer through
|
|
168
|
+
* the parent. Retrying the pipe harder would only have made it rarer.
|
|
169
|
+
*/
|
|
138
170
|
export function materializeTree(tree: string, dest: string, cwd: string): void {
|
|
139
171
|
fs.mkdirSync(dest, { recursive: true });
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
172
|
+
// Sibling of `dest`, not inside it — a file under `dest` would be extracted over.
|
|
173
|
+
const archivePath = `${dest}.tar`;
|
|
174
|
+
try {
|
|
175
|
+
execFileSync('git', ['archive', '--format=tar', '-o', archivePath, tree], { cwd });
|
|
176
|
+
execFileSync('tar', ['-xf', archivePath, '-C', dest]);
|
|
177
|
+
} finally {
|
|
178
|
+
fs.rmSync(archivePath, { force: true });
|
|
179
|
+
}
|
|
145
180
|
}
|
|
146
181
|
|
|
147
182
|
/**
|
|
@@ -181,39 +216,60 @@ export async function verifyDescent(
|
|
|
181
216
|
?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
|
|
182
217
|
const ownsRoot = !opts.materializeRoot;
|
|
183
218
|
const loader = opts.loader ?? defaultLoader;
|
|
219
|
+
const materialize = opts.materialize ?? materializeTree;
|
|
184
220
|
|
|
185
221
|
const compileFailures: string[] = [];
|
|
222
|
+
const unevaluated: string[] = [];
|
|
186
223
|
const declaringButDiverged: ModelTreeCandidate[] = [];
|
|
187
224
|
try {
|
|
188
225
|
for (const candidate of candidates) {
|
|
189
226
|
const dest = path.join(materializeRoot, candidate.tree);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
// a transient materialize/import failure (fd pressure, module-loader contention under
|
|
194
|
-
// a parallel test run or a busy CI box) is not — so a failed candidate gets exactly
|
|
195
|
-
// one retry, from a clean materialization, before it is recorded as a compile failure.
|
|
227
|
+
const where = `${short(candidate.tree)} (at ${short(candidate.commits[0])})`;
|
|
228
|
+
|
|
229
|
+
// TWO KINDS OF FAILURE, and conflating them is what made this path lie.
|
|
196
230
|
//
|
|
231
|
+
// EXTRACTION failing says nothing about the tree — we never saw it. It was recorded as a
|
|
232
|
+
// "compile failure" and the verdict fell through to `drift`, which tells an operator
|
|
233
|
+
// their database was hand-edited. Observed for real: `spawnSync tar EPIPE` under a loaded
|
|
234
|
+
// machine. Those trees now go to `unevaluated` and produce `indeterminate`.
|
|
235
|
+
//
|
|
236
|
+
// COMPILING failing IS a property of the tree — a models file that no longer builds
|
|
237
|
+
// against today's @everystack/model is genuinely unusable, deterministically, and
|
|
238
|
+
// skipping it is correct. Those keep `compileFailures` and still allow `drift`.
|
|
239
|
+
//
|
|
240
|
+
// Each gets ONE retry from a clean materialization first, because either can also be
|
|
241
|
+
// transient under load.
|
|
242
|
+
try {
|
|
243
|
+
if (!fs.existsSync(dest)) materialize(candidate.tree, dest, repoRoot);
|
|
244
|
+
} catch {
|
|
245
|
+
try {
|
|
246
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
247
|
+
materialize(candidate.tree, dest, repoRoot);
|
|
248
|
+
} catch (err: any) {
|
|
249
|
+
unevaluated.push(`${where}: could not extract — ${err.message}`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
197
254
|
// The declares-test is GOVERNED on both sides, per candidate: the commit's
|
|
198
255
|
// models define the governed set, and the live hash is filtered through the
|
|
199
256
|
// SAME set as the prediction — comparing a governed prediction against the
|
|
200
257
|
// raw live hash can never match on a brownfield target with foreign roles.
|
|
201
|
-
const
|
|
202
|
-
if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
|
|
258
|
+
const evaluate = async (): Promise<boolean> => {
|
|
203
259
|
const models = await loader(path.join(dest, barrel));
|
|
204
260
|
const governedRoles = governedRolesForModels(models);
|
|
205
261
|
const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema, governedRoles });
|
|
206
262
|
return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
|
|
207
263
|
};
|
|
264
|
+
let declares: boolean;
|
|
208
265
|
try {
|
|
209
266
|
try {
|
|
210
|
-
declares = await
|
|
267
|
+
declares = await evaluate();
|
|
211
268
|
} catch {
|
|
212
|
-
|
|
213
|
-
declares = await attempt();
|
|
269
|
+
declares = await evaluate();
|
|
214
270
|
}
|
|
215
271
|
} catch (err: any) {
|
|
216
|
-
compileFailures.push(`${
|
|
272
|
+
compileFailures.push(`${where}: ${err.message}`);
|
|
217
273
|
continue;
|
|
218
274
|
}
|
|
219
275
|
if (!declares) continue;
|
|
@@ -254,6 +310,22 @@ export async function verifyDescent(
|
|
|
254
310
|
};
|
|
255
311
|
}
|
|
256
312
|
|
|
313
|
+
// Nothing matched — but if a tree was never evaluated, "nothing matched" is a claim we
|
|
314
|
+
// cannot make. The unevaluated one could be the declaring tree. Reported BEFORE drift
|
|
315
|
+
// because drift accuses the database, and this condition is about our own run.
|
|
316
|
+
if (unevaluated.length > 0) {
|
|
317
|
+
return {
|
|
318
|
+
status: 'indeterminate',
|
|
319
|
+
scannedTrees: candidates.length,
|
|
320
|
+
unevaluated,
|
|
321
|
+
reason:
|
|
322
|
+
`${unevaluated.length} of ${candidates.length} historical tree(s) could not be extracted, ` +
|
|
323
|
+
'so the search was not exhaustive and one of them may be the commit that declares this state. ' +
|
|
324
|
+
'This is a fault in THIS run, not evidence about the database — retry, and if it persists ' +
|
|
325
|
+
`check disk space and open-file limits. Details: ${unevaluated.join('; ')}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
257
329
|
const failureNote = compileFailures.length > 0
|
|
258
330
|
? ` (${compileFailures.length} historical tree(s) no longer compile and were skipped)`
|
|
259
331
|
: '';
|
package/src/cli/model-api.ts
CHANGED
|
@@ -35,6 +35,30 @@ export * from './authz-owner-probe.js';
|
|
|
35
35
|
// --- the SECDEF function catalog the contract introspection needs -----------
|
|
36
36
|
export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';
|
|
37
37
|
|
|
38
|
+
// --- how you HAND a database to the VERIFY calls ---------------------------
|
|
39
|
+
// `introspectSchema` and `introspectContract` take a `SessionRunner`, not a `QueryRunner`:
|
|
40
|
+
// their queries must describe ONE moment, so they run as one transaction on one connection.
|
|
41
|
+
// This barrel exported neither the type nor a way to build one, which left a consumer able
|
|
42
|
+
// to CALL those functions with nothing legitimate to pass them. That gap is not academic —
|
|
43
|
+
// the reference app's own dogfood suite kept passing a `QueryRunner` long after the
|
|
44
|
+
// signature moved, and Postgres reported it as `syntax error at or near ","` (the array of
|
|
45
|
+
// statements stringified) rather than as the type error it was.
|
|
46
|
+
//
|
|
47
|
+
// `sessionRunnerOver(sql)` wraps a postgres.js client whose pool is a SINGLE connection.
|
|
48
|
+
// `INTROSPECTION_SESSION` is the options both introspections expect — read only, repeatable
|
|
49
|
+
// read, canonical `search_path`. Pass it through; do not re-derive it.
|
|
50
|
+
export { sessionRunnerOver } from './db-source.js';
|
|
51
|
+
export {
|
|
52
|
+
INTROSPECTION_SESSION,
|
|
53
|
+
borrowedSessionRunner,
|
|
54
|
+
isSessionError,
|
|
55
|
+
rowsOrEmpty,
|
|
56
|
+
type SessionRunner,
|
|
57
|
+
type SessionStatement,
|
|
58
|
+
type SessionOptions,
|
|
59
|
+
type SessionResult,
|
|
60
|
+
} from './session.js';
|
|
61
|
+
|
|
38
62
|
// --- databases FROM the declared state: ephemeral test DBs + the builder ----
|
|
39
63
|
// `createEphemeralDatabase(adminUrl, models, { sources })` is the packaged
|
|
40
64
|
// create-sync-drop shape for consumer test suites: a fresh database at the
|
package/src/cli/model-render.ts
CHANGED
|
@@ -21,6 +21,10 @@ import type { TableContract } from './authz-contract.js';
|
|
|
21
21
|
import { deriveAbilities, renderDerivedAbilities } from './authz-derive.js';
|
|
22
22
|
import { normalizeDefault, normalizeCheck } from './schema-diff.js';
|
|
23
23
|
import { generatedIndexName, generatedForeignKeyName } from './schema-compile.js';
|
|
24
|
+
// The soft-delete RULE itself — one boolean combination, shared with `defineModel`'s refusal.
|
|
25
|
+
// Only the FACT EXTRACTION is duplicated here (this renderer holds text, not descriptors);
|
|
26
|
+
// the rule is not, so the two surfaces cannot drift on when a declaration is required.
|
|
27
|
+
import { needsSoftDeleteDeclaration, privilegesGrantDelete } from '@everystack/model';
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
|
|
@@ -127,13 +131,30 @@ export function isPublicReadAbility(expr: string): boolean {
|
|
|
127
131
|
return !role || role[1] === 'anon';
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A rendered ability that hands out DELETE — the TEXT form of `isDeleteAbility` from
|
|
136
|
+
* `@everystack/model`.
|
|
137
|
+
*
|
|
138
|
+
* The second fact the soft-delete rule turns on, and the one the old guard missed entirely.
|
|
139
|
+
* `manage` counts: it is "all actions" and compiles to the admin-bypass ALL policy, so it
|
|
140
|
+
* destroys rows exactly as `can('delete')` does.
|
|
141
|
+
*
|
|
142
|
+
* Same two-representations situation as its sibling above, for the same reason — this renderer
|
|
143
|
+
* only ever holds rendered source. The BOOLEAN RULE both facts feed is not duplicated here: it
|
|
144
|
+
* lives once in `needsSoftDeleteDeclaration`. Only the fact extraction is representation-
|
|
145
|
+
* specific, and the pin test drives both extractors over one ability matrix.
|
|
146
|
+
*/
|
|
147
|
+
export function isDeleteAbility(expr: string): boolean {
|
|
148
|
+
return /^can\('(delete|manage)'/.test(expr.trim());
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
/**
|
|
131
152
|
* The scaffold stanza for one model, plus whether it declares a public read — the renderer
|
|
132
153
|
* needs the second fact to decide the `softDelete` line, and it must come from the STRUCTURED
|
|
133
154
|
* abilities, not a regex over the joined text (a live predicate can span lines and carry its
|
|
134
155
|
* own braces). An unknown preset throws — grants are authored, never guessed.
|
|
135
156
|
*/
|
|
136
|
-
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean } {
|
|
157
|
+
function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean; grantsDelete: boolean } {
|
|
137
158
|
if (mode === 'live') {
|
|
138
159
|
const contract = table && liveAuthz?.get(table.table);
|
|
139
160
|
if (!contract) {
|
|
@@ -144,6 +165,7 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
|
|
|
144
165
|
' // private: true,',
|
|
145
166
|
].join('\n'),
|
|
146
167
|
publicRead: false,
|
|
168
|
+
grantsDelete: false,
|
|
147
169
|
};
|
|
148
170
|
}
|
|
149
171
|
const derived = deriveAbilities(contract, { governRoles });
|
|
@@ -159,7 +181,13 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
|
|
|
159
181
|
if (!derived.abilities.length && !contract.rls.enabled) {
|
|
160
182
|
lines.push(` rls: false, // live reality: row security is OFF — authorization here is grants-only.`);
|
|
161
183
|
}
|
|
162
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
text: lines.join('\n'),
|
|
186
|
+
publicRead: derived.abilities.some(isPublicReadAbility),
|
|
187
|
+
// Both spellings. `privileges` is where pull puts a live DELETE grant no policy covers,
|
|
188
|
+
// which is precisely the shape that can hard-delete on an rls: false table.
|
|
189
|
+
grantsDelete: derived.abilities.some(isDeleteAbility) || privilegesGrantDelete(derived.privileges),
|
|
190
|
+
};
|
|
163
191
|
}
|
|
164
192
|
if (mode === 'commented') {
|
|
165
193
|
return {
|
|
@@ -171,13 +199,18 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
|
|
|
171
199
|
].join('\n'),
|
|
172
200
|
// Nothing is stamped uncommented, so the model declares no read at all yet.
|
|
173
201
|
publicRead: false,
|
|
202
|
+
grantsDelete: false,
|
|
174
203
|
};
|
|
175
204
|
}
|
|
176
205
|
const preset = ABILITY_PRESETS[mode];
|
|
177
206
|
if (!preset) {
|
|
178
207
|
throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
|
|
179
208
|
}
|
|
180
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
text: ` abilities: [${preset.join(', ')}],`,
|
|
211
|
+
publicRead: preset.some(isPublicReadAbility),
|
|
212
|
+
grantsDelete: preset.some(isDeleteAbility),
|
|
213
|
+
};
|
|
181
214
|
}
|
|
182
215
|
|
|
183
216
|
/**
|
|
@@ -210,10 +243,41 @@ function writtenByStanza(table: TableSchema, liveAuthz?: Map<string, TableContra
|
|
|
210
243
|
+ `'app' (the default) would FORCE it — a change to who bypasses row security, not an adoption.\n`;
|
|
211
244
|
}
|
|
212
245
|
|
|
213
|
-
function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
|
|
214
|
-
const
|
|
215
|
-
if (!
|
|
216
|
-
|
|
246
|
+
function softDeleteStanza(table: TableSchema, publicRead: boolean, grantsDelete: boolean): string {
|
|
247
|
+
const hasDeletedAtField = table.columns.some((c) => c.name === 'deleted_at');
|
|
248
|
+
if (!needsSoftDeleteDeclaration({ hasDeletedAtField, hasPublicRead: publicRead, grantsDelete })) return '';
|
|
249
|
+
|
|
250
|
+
// A PUBLIC read forces `false`, and the constraint is the fingerprint, not a preference:
|
|
251
|
+
// `true` AND-s `deleted_at IS NULL` into the anon policy, so rendering it would transcribe a
|
|
252
|
+
// predicate no live policy carries and the pulled checkout would MISMATCH the database it
|
|
253
|
+
// was pulled from. Live reality is what a pull is for.
|
|
254
|
+
if (publicRead) {
|
|
255
|
+
return ` softDelete: false, // live reality: no policy filters deleted_at, so false is what this database does.\n`
|
|
256
|
+
+ ` // true would AND deleted_at IS NULL into the public read policy — a predicate no live\n`
|
|
257
|
+
+ ` // policy carries, so it would MISMATCH the database this was pulled from. It would also\n`
|
|
258
|
+
+ ` // soften DELETE and hide marked rows from the data API. Change it deliberately, with a plan.\n`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// No public read: the flag adds no POLICY (the compiler reads its guard only on the
|
|
262
|
+
// public-read branch), so either value fingerprints identically and introspection cannot
|
|
263
|
+
// recover which the app intended — a hard DELETE and a soft one leave the same schema.
|
|
264
|
+
//
|
|
265
|
+
// Fingerprint-neutral is NOT behaviour-neutral, and the comment must not imply it is: the
|
|
266
|
+
// generic data API also FILTERS soft-deleted rows out of reads. On a table where `deleted_at`
|
|
267
|
+
// is carried for audit rather than for deletion, adopting with `true` quietly drops rows from
|
|
268
|
+
// every listing — which is the same class of harm as the bug this rule exists to prevent,
|
|
269
|
+
// pointed the other way. So the comment states the full blast radius and asks the one
|
|
270
|
+
// question introspection cannot answer.
|
|
271
|
+
//
|
|
272
|
+
// `true` and not `false` because the worst cases are not symmetric: `true` hides rows
|
|
273
|
+
// recoverably (flip the flag; they were never destroyed, and they are still there over raw
|
|
274
|
+
// SQL), while `false` destroys data on the next DELETE and re-exposes rows the old
|
|
275
|
+
// application already treated as deleted. Irreversibility loses.
|
|
276
|
+
return ` softDelete: true, // ADOPTION DECISION — does deleted_at here mean DELETED, or merely audited?\n`
|
|
277
|
+
+ ` // true: DELETE marks deleted_at instead of destroying the row, AND the data API\n`
|
|
278
|
+
+ ` // hides marked rows from reads (every role, unless ?deleted=include|only)\n`
|
|
279
|
+
+ ` // and from updates. No RLS policy changes either way — there is no public read.\n`
|
|
280
|
+
+ ` // false: DELETE is permanent, marked rows stay visible. Set it if deleted_at is audit-only.\n`;
|
|
217
281
|
}
|
|
218
282
|
|
|
219
283
|
// The inverse type map lives with the vocabulary it inverts (`@everystack/model`),
|
|
@@ -594,7 +658,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
594
658
|
// put it, proving the position is mechanical-edit-friendly).
|
|
595
659
|
const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
|
|
596
660
|
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
597
|
-
const softDelete = softDeleteStanza(table, stanza.publicRead);
|
|
661
|
+
const softDelete = softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
|
|
598
662
|
|
|
599
663
|
// A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
|
|
600
664
|
// qualification cannot ride in the first argument (that would make the table literally
|