@everystack/cli 0.4.81 → 0.4.83

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/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.83] - 2026-09-16
8
+
9
+ ### Added
10
+
11
+ - db:pull --derived-out takes a directory, split by kind and schema
12
+ - a real SELECT-only role for db:query's read connection
13
+ - version-guard the Model-derived wire payloads (provision, converge-ownership, reconcile)
14
+ - two symbols and plain words — the default output for db:plan/db:apply/db:check
15
+ - db:apply does the whole job — the plan, then the functions and views
16
+
17
+ ### Fixed
18
+
19
+ - derive USAGE only on serial sequences (least privilege) (#11)
20
+ - close the closing-gate findings on --derived-out <dir>
21
+ - owner-adoption fast-follows — identity guard, self-grant revoke, ENACT transaction
22
+ - mirror the reader fields in ServerProvision, and pin that no credential prints
23
+ - an E-string prefix only counts when it stands alone
24
+ - throw a private sentinel instead of calling tx.rollback() — closes a console-sandbox swallow/neutralize hole
25
+ - the shape-pin also covers enum value domains, not just field names
26
+ - symmetric v-prefix in the mismatch message, restore db:provision's --direct detail
27
+ - ADD CONSTRAINT/PRIMARY KEY are not additive — they can fail against existing data
28
+ - the lease covers the catch-up, the receipt marks a strip, the preflight never exits mid-chain
29
+ - the catch-up's refusal mode belongs to the caller, and the failure line says why
30
+ - the catch-up failure names two subjects, quotes its cause once, and nobody inherits 'exit'
31
+ - db:apply refuses a malformed plan `statements` instead of throwing
32
+ - what a statement IS gets read once, and a comment can neither hide a revoke nor invent a drop
33
+ - the declared side rides on the plan, so a strip reads the same before and after
34
+ - one statement, one bucket — the summary's two halves read the same declared side
35
+ - the plan's optional grantee set is refused at load, not dereferenced after the write
36
+
37
+ ### Documentation
38
+
39
+ - the workflow is three commands — db:plan, db:apply, db:check
40
+
41
+ ## [0.4.82] - 2026-09-08
42
+
43
+ ### Fixed
44
+
45
+ - four compiler-hardening throws close the A22/F-A10/F-A12 tail
46
+ - column-path check disclosure parity closes the F1 gap
47
+
7
48
  ## [0.4.81] - 2026-09-08
8
49
 
9
50
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.81",
3
+ "version": "0.4.83",
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>",
@@ -48,6 +48,11 @@
48
48
  "types": "./src/cli/db-source.ts",
49
49
  "default": "./src/cli/db-source.ts"
50
50
  },
51
+ "./wire": {
52
+ "source": "./src/wire.ts",
53
+ "types": "./src/wire.ts",
54
+ "default": "./src/wire.ts"
55
+ },
51
56
  "./reconcile": {
52
57
  "source": "./src/reconcile.ts",
53
58
  "types": "./src/reconcile.ts",
@@ -120,10 +125,12 @@
120
125
  "@aws-sdk/client-rds": "3.1053.0",
121
126
  "@aws-sdk/client-s3": "3.1053.0",
122
127
  "@aws-sdk/client-ssm": "3.1053.0",
128
+ "@aws-sdk/credential-provider-node": "3.972.44",
123
129
  "@aws-sdk/lib-storage": "3.1053.0",
124
130
  "@aws-sdk/client-sts": "3.1053.0",
125
131
  "@aws-sdk/signature-v4a": "3.1048.0",
126
132
  "glob": "13.0.6",
133
+ "minimatch": "10.2.5",
127
134
  "node-forge": "1.4.0",
128
135
  "structured-headers": "1.0.1",
129
136
  "tsx": "4.21.0",
@@ -174,6 +181,7 @@
174
181
  "jest": "29.7.0",
175
182
  "react": "19.2.0",
176
183
  "ts-jest": "29.4.9",
184
+ "zod": "3.25.67",
177
185
  "@everystack/server": "0.4.30"
178
186
  },
179
187
  "scripts": {
@@ -133,16 +133,48 @@ function resolveSchema(model: ModelDescriptor, opts: CompileOptions): string {
133
133
  return model.schema ?? opts.schema ?? 'public';
134
134
  }
135
135
 
136
- /** Resolve the owner column (SQL name + type + claim) from the first owner ability. */
136
+ /**
137
+ * The identity of a row scope — owner column, claim, and accessor — as one string, so two
138
+ * owner abilities can be compared for agreement. A model may declare `owner` on several
139
+ * abilities (a read and each write); they MUST all name the same scope, because the compiler
140
+ * folds them into ONE `rowPred`. It used to take the first silently, so a second ability with
141
+ * a different `owner:` compiled a policy scoped to a column nobody meant (A22).
142
+ */
143
+ function ownerScopeKey(a: Ability): string {
144
+ const expr = (a.condition.ownerExpr as { sql?: unknown } | undefined)?.sql;
145
+ return `${a.condition.owner}\u0000${a.condition.userField ?? 'sub'}\u0000${typeof expr === 'string' ? expr : ''}`;
146
+ }
147
+
148
+ /**
149
+ * Resolve the owner column (SQL name + type + claim) from the owner abilities.
150
+ *
151
+ * Every ability that declares `owner:` must agree on the SAME column, userField and ownerExpr —
152
+ * the compiler has one `rowPred` per model, so disagreeing owners cannot both be honored. Taking
153
+ * the first silently meant a `can('read', { owner: 'authorId' })` beside a
154
+ * `can('update', { owner: 'editorId' })` scoped every write to `author_id` with no error (A22).
155
+ * A repeat of the SAME owner is fine — that is the normal shape (one owner, many verbs).
156
+ */
137
157
  function resolveOwner(model: ModelDescriptor): OwnerRef | null {
138
- const ownerAbility = model.abilities.find((a) => a.condition.owner);
139
- if (!ownerAbility) return null;
140
- const fieldKey = ownerAbility.condition.owner!;
158
+ const ownerAbilities = model.abilities.filter((a) => a.condition.owner);
159
+ if (!ownerAbilities.length) return null;
160
+ const first = ownerAbilities[0];
161
+ const firstKey = ownerScopeKey(first);
162
+ const clash = ownerAbilities.find((a) => ownerScopeKey(a) !== firstKey);
163
+ if (clash) {
164
+ throw new Error(
165
+ `${model.table}: two owner abilities declare DIFFERENT row scopes — `
166
+ + `can('${first.action}', { owner: '${first.condition.owner}'${first.condition.userField ? `, userField: '${first.condition.userField}'` : ''} }) `
167
+ + `vs can('${clash.action}', { owner: '${clash.condition.owner}'${clash.condition.userField ? `, userField: '${clash.condition.userField}'` : ''} }). `
168
+ + `A model has ONE owner row scope; every owner ability must name the same owner, userField and ownerExpr. `
169
+ + `Declare the owner once and reuse it across the verbs.`,
170
+ );
171
+ }
172
+ const fieldKey = first.condition.owner!;
141
173
  return {
142
174
  sqlColumn: toSnakeCase(fieldKey),
143
175
  type: model.fields[fieldKey]?.spec.type ?? 'uuid',
144
- claim: ownerAbility.condition.userField ?? 'sub',
145
- expr: rawExpr(ownerAbility.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
176
+ claim: first.condition.userField ?? 'sub',
177
+ expr: rawExpr(first.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
146
178
  };
147
179
  }
148
180
 
@@ -167,6 +199,19 @@ interface ViaRef {
167
199
  function resolveVia(model: ModelDescriptor): ViaRef | null {
168
200
  const viaAbility = model.abilities.find((a) => a.condition.via);
169
201
  if (!viaAbility) return null;
202
+ // A model scopes its rows EITHER directly (`owner:`) OR transitively (`via:`) — never both.
203
+ // The compiler keeps one `rowPred`, and with both declared `owner` won and `via` vanished
204
+ // silently (A22). Two definitions of "mine" on one model is a declaration bug, not a merge.
205
+ const ownerAbility = model.abilities.find((a) => a.condition.owner);
206
+ if (ownerAbility) {
207
+ throw new Error(
208
+ `${model.table}: a model declares BOTH an owner row scope and a via: row scope — `
209
+ + `can('${ownerAbility.action}', { owner: '${ownerAbility.condition.owner}' }) `
210
+ + `and can('${viaAbility.action}', { via: '${viaAbility.condition.via}' }). `
211
+ + `They are two definitions of which rows are the caller's, and the compiler can honor only one. `
212
+ + `Use a single row scope — a direct owner OR a transitive via — for the whole model.`,
213
+ );
214
+ }
170
215
  const fieldKey = viaAbility.condition.via!;
171
216
  const ref = model.fields[fieldKey]?.spec.references;
172
217
  if (!ref) {
@@ -263,6 +308,7 @@ function rawPredicate(value: unknown, where: string): string | null {
263
308
  `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
264
309
  );
265
310
  }
311
+ assertBalancedParens(text.trim(), where);
266
312
  // Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
267
313
  // (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
268
314
  // differ from the live one by a single layer of parens — enough for the reconciler to
@@ -284,9 +330,69 @@ function rawExpr(value: unknown, where: string): string | null {
284
330
  `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — the expression that decides who owns a row is authored, never stringly assembled.`,
285
331
  );
286
332
  }
333
+ assertBalancedParens(text.trim(), where);
287
334
  return text.trim();
288
335
  }
289
336
 
337
+ /**
338
+ * Refuse a fragment whose parentheses do not balance WITHIN it (quote-aware).
339
+ *
340
+ * A compiled predicate is assembled by templates — `andPredicates` joins terms with ` AND ` and
341
+ * `ownerPredicate` writes `(col = …)`. A fragment that closes a paren it never opened, or opens
342
+ * one it never closes, RE-ASSOCIATES against the terms around it: `sql\`true) OR (true\`` ANDed
343
+ * beside an owner gate becomes `(owner AND (true) OR (true))`, and AND binds tighter than OR, so
344
+ * the whole policy collapses to `USING (true)` — the owner term defeated (F-A10). pg_get_expr
345
+ * always deparses balanced predicates, so a pulled fragment is never rejected here; only a
346
+ * hand-authored fragment with a stray paren is, and that is the one this catches.
347
+ *
348
+ * Quote-aware because a paren INSIDE a string literal (`status = ')'`) or a quoted identifier
349
+ * is data, not structure — counting it would false-reject a fragment that composes cleanly.
350
+ * Dollar-quoted strings ($tag$…$tag$) are skipped whole for the same reason.
351
+ */
352
+ function assertBalancedParens(text: string, where: string): void {
353
+ const fail = (): never => {
354
+ throw new Error(
355
+ `${where}: the predicate has unbalanced parentheses. A fragment whose parens do not close `
356
+ + `within it re-associates against the terms it is composed beside — `
357
+ + `sql\`true) OR (true\` ANDed next to an owner gate becomes USING (true), defeating the gate. `
358
+ + `Parenthesize the fragment so every '(' it opens closes inside it.`,
359
+ );
360
+ };
361
+ let depth = 0;
362
+ let i = 0;
363
+ const n = text.length;
364
+ while (i < n) {
365
+ const c = text[i];
366
+ // A single-quoted string or double-quoted identifier — parens inside are literal text.
367
+ if (c === "'" || c === '"') {
368
+ const q = c;
369
+ i++;
370
+ while (i < n) {
371
+ if (text[i] === q) {
372
+ if (text[i + 1] === q) { i += 2; continue; } // '' / "" — an escaped quote, stay in
373
+ i++;
374
+ break;
375
+ }
376
+ i++;
377
+ }
378
+ continue;
379
+ }
380
+ // A dollar-quoted string ($tag$ … $tag$) — skip it whole, any paren inside is data.
381
+ if (c === '$') {
382
+ const m = /^\$[A-Za-z0-9_]*\$/.exec(text.slice(i));
383
+ if (m) {
384
+ const close = text.indexOf(m[0], i + m[0].length);
385
+ i = close === -1 ? n : close + m[0].length;
386
+ continue;
387
+ }
388
+ }
389
+ if (c === '(') depth++;
390
+ else if (c === ')') { if (--depth < 0) fail(); }
391
+ i++;
392
+ }
393
+ if (depth !== 0) fail();
394
+ }
395
+
290
396
  /** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
291
397
  function andPredicates(...parts: (string | null)[]): string | null {
292
398
  const real = parts.filter((p): p is string => Boolean(p) && p !== '(true)' && p !== 'true');
@@ -586,6 +692,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
586
692
  }
587
693
  }
588
694
 
695
+ // The `privileges` map's KEYS are grantees too, emitted unquoted into GRANT just like a
696
+ // `role:` (see compileGrants). The role-name guard above only saw ability roles, so a
697
+ // fold/split grantee here — `privileges: { AUTHENTICATED: [...] }` — slid straight through and
698
+ // GRANTed a beyond-CRUD privilege to the wrong role. Same refusal, same exemption for PUBLIC.
699
+ for (const role of Object.keys(model.privileges ?? {})) {
700
+ if (!isPseudoPublic(role) && !/^[a-z_][a-z0-9_]*$/.test(role)) {
701
+ throw new Error(
702
+ `${table}: privileges['${role}'] — a grantee is emitted unquoted, so it must be a bare `
703
+ + `lowercase identifier ([a-z_][a-z0-9_]*). '${role}' would be case-folded or split by Postgres and the grant `
704
+ + `would land on a different role. Use the exact lowercase role name (or 'public').`,
705
+ );
706
+ }
707
+ }
708
+
589
709
  const WRITE_VERBS = [
590
710
  { action: 'create' as const, word: 'insert', command: 'INSERT' as PolicyCommand },
591
711
  { action: 'update' as const, word: 'update', command: 'UPDATE' as PolicyCommand },
@@ -385,9 +385,11 @@ export interface SequenceGrantContract {
385
385
  /**
386
386
  * The owning column is `GENERATED … AS IDENTITY`, not `serial` — and that changes who needs
387
387
  * what. A serial column's value comes from a DEFAULT `nextval(...)` evaluated with the
388
- * CALLER's privileges, so an INSERTing role must hold USAGE/SELECT/UPDATE on the sequence or
389
- * every insert fails `permission denied for sequence`. An identity column's value is supplied
390
- * by the system as part of the column definition; no caller privilege is consulted at all.
388
+ * CALLER's privileges, so an INSERTing role must hold USAGE on the sequence (all `nextval`
389
+ * needs) or every insert fails `permission denied for sequence`. `SELECT`/`UPDATE` are NOT
390
+ * required for an INSERT — they are administrative (see SEQUENCE_PRIVILEGES_FOR_INSERT). An
391
+ * identity column's value is supplied by the system as part of the column definition; no
392
+ * caller privilege is consulted at all.
391
393
  *
392
394
  * Measured, not assumed: a role with INSERT on the table and NOTHING on the sequence inserts
393
395
  * successfully into an identity table and fails on a serial one. So the A6 inheritance rule
@@ -364,15 +364,24 @@ function renderColumnAbility(
364
364
  grantee: string,
365
365
  columns: readonly string[],
366
366
  policy: PolicyContract,
367
+ notes: string[],
367
368
  ): string | null {
368
369
  const using = policy.using ?? null;
369
370
  const owner = parseOwnerPredicate(using);
370
371
  const parts: string[] = [];
371
372
  const role = policyRoleForGrantee(grantee);
372
373
 
373
- // Owner-only uses the model's authenticated default. Every other grantee must be named so
374
- // the compiler emits the policy TO the live role, rather than a role-less public ability.
375
- if (!owner || role !== 'authenticated') parts.push(`role: '${role}'`);
374
+ // A distinct WITH CHECK on a column UPDATE must ride as `check:`, and the compiler REFUSES a
375
+ // role-less column ability that carries any row predicate (F-A12: a column ability has nowhere
376
+ // row-level to put one). So a differing check forces the NAMED-role spelling — `role:
377
+ // 'authenticated'` — which routes to the compiler's named-role column path: that path ANDs the
378
+ // owner back into BOTH halves and honors the check, where the role-less shape would throw.
379
+ const distinctCheck = action === 'update' && policy.check != null && policy.check !== using;
380
+
381
+ // Owner-only uses the model's authenticated default. Every other grantee must be named so the
382
+ // compiler emits the policy TO the live role, not a role-less public ability — and a differing
383
+ // check names it too, to escape the F-A12 refusal above.
384
+ if (!owner || role !== 'authenticated' || distinctCheck) parts.push(`role: '${role}'`);
376
385
  if (owner) {
377
386
  parts.push(`owner: '${modelFieldKey(owner.column)}'`);
378
387
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
@@ -382,10 +391,26 @@ function renderColumnAbility(
382
391
  if (fragment) parts.push(`sql: ${fragment}`);
383
392
  }
384
393
 
385
- if (action === 'update' && policy.check && policy.check !== using) {
386
- const fragment = sqlFragment(policy.check);
394
+ if (distinctCheck) {
395
+ // Emit only the guard BEYOND the owner gate. The live WITH CHECK restates the owner term
396
+ // (the compiler's named-role column path ANDs it back in), so carrying the whole predicate
397
+ // would double the owner on recompile and churn the plan. Strip it exactly as the row-level
398
+ // write path does; a check with no owner conjunct rides whole (guard === the check).
399
+ const split = owner ? splitOwnerPredicate(policy.check) : null;
400
+ const guard = split ? split.guard : policy.check;
401
+ const fragment = sqlFragment(guard);
387
402
  if (!fragment) return null;
388
403
  parts.push(`check: ${fragment}`);
404
+ // Disclosure parity with the row-level write path. When USING is a PURE owner predicate but
405
+ // the live WITH CHECK does NOT restate the owner, the compiler ANDs the owner back into the
406
+ // rendered check — TIGHTENING it (the F-A11 "wider write half" the model cannot express). Say
407
+ // so, rather than let an unexplained conjunct surface in the next db:plan, which an operator
408
+ // might "fix" back toward the wider live shape by hand — the exact spelling the compiler refuses.
409
+ if (split && split.owner == null) {
410
+ notes.push(`policy "${policy.name}" (UPDATE) has a WITH CHECK that does not restate the owner gate in its USING —`);
411
+ notes.push(` it is wider than a column owner ability can express, so the rendered model TIGHTENS it to the owner. Live WITH CHECK:`);
412
+ notes.push(` ${policy.check}`);
413
+ }
389
414
  }
390
415
  parts.push(`columns: [${columns.map((column) => `'${modelFieldKey(column)}'`).join(', ')}]`);
391
416
  return `can('${action}', { ${parts.join(', ')} })`;
@@ -739,7 +764,7 @@ export function deriveAbilities(
739
764
  if (!cols.length) continue;
740
765
  const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
741
766
  const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
742
- const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy) : null;
767
+ const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy, notes) : null;
743
768
  if (ability) {
744
769
  abilities.push(ability);
745
770
  renderedColumnRoles.add(grantee);
@@ -277,20 +277,23 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
277
277
  * Measured on a consumer's database: every INSERT into a serial-PK table failed
278
278
  * `permission denied for sequence`, and two of their 563 tests were exactly that.
279
279
  *
280
- * The rule is INSERT on the table → USAGE + SELECT + UPDATE on its sequence, and all three
281
- * matter. USAGE alone permits `nextval`, but `setval` needs UPDATE and reading `last_value`
282
- * needs SELECT, so the common `rU` shape breaks the moment anything reseeds the counter.
280
+ * The rule is INSERT on the table → USAGE on its sequence, and ONLY USAGE. A serial column's
281
+ * value comes from a DEFAULT `nextval(...)`; USAGE is exactly the privilege `nextval` (and
282
+ * `currval`) requires. `setval` (reseed the counter) needs UPDATE and reading `last_value`
283
+ * directly needs SELECT — but neither is part of an INSERT. They are administrative operations,
284
+ * so an INSERT-holder must not inherit them.
283
285
  *
284
- * Signed off after measurement, not assumed: the consumer checked every role against every
285
- * serial/identity sequence and found zero cases of INSERT-without-sequence-USAGE and zero of
286
- * USAGE-without-UPDATE, so this under-grants nobody. Their one divergence — `authenticated`
287
- * holding USAGE on all 27 sequences while able to INSERT into none — was a vestigial 2023
288
- * blanket grant they are revoking, so their database converges to this rule rather than the
289
- * reverse. The pull ADVISORY names that shape wherever it appears.
286
+ * Least privilege, corrected after a regression: an earlier form derived SELECT + UPDATE + USAGE
287
+ * and handed every INSERT-holder `setval` control of a surrogate key. It widened a consumer's
288
+ * governed prod migrator by 28 UPDATE grants nobody asked for — governance ADDED privileges the
289
+ * role had worked fine without. The fix returns to the minimum INSERT needs: USAGE. A role that
290
+ * genuinely reseeds a counter is administrative and is either ungoverned (the reconciler leaves
291
+ * it alone) or must declare the grant deliberately; it is never inferred from INSERT.
290
292
  *
291
- * Derived, never declared: there is no `sequence:` key in the model and nobody wants one.
293
+ * Derived, never declared: there is no `sequence:` key in the model. Declaring sequence UPDATE
294
+ * for a governed role is a deliberately deferred feature — added when a real need names it.
292
295
  */
293
- export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['SELECT', 'UPDATE', 'USAGE'] as const;
296
+ export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['USAGE'] as const;
294
297
 
295
298
  export function inheritedSequenceGrants(table: TableContract): Record<string, string[]> {
296
299
  const out: Record<string, string[]> = {};
@@ -332,10 +335,10 @@ function reconcileSequenceGrants(
332
335
  // NOTHING on the sequence inserts fine into an identity table and fails on a serial one.
333
336
  //
334
337
  // Applying the rule here was a real WIDENING, caught by the round-trip oracle the day
335
- // identity columns became declarable: the rebuild handed an INSERTing role SELECT + UPDATE
336
- // + USAGE — direct `setval` control of a surrogate key — that the original database never
337
- // granted and never needed. Nothing is revoked either: a grant somebody made deliberately
338
- // is not ours to remove, and no declaration says it should not be there.
338
+ // identity columns became declarable: the rebuild handed an INSERTing role USAGE on the
339
+ // identity sequence that the original database never granted and never needed. Nothing is
340
+ // revoked either: a grant somebody made deliberately is not ours to remove, and no
341
+ // declaration says it should not be there.
339
342
  if (seq.identity) continue;
340
343
  const target = quoteQualified(seq.sequence);
341
344
  const grantees = new Set([...Object.keys(declared), ...Object.keys(seq.grants)]);
package/src/cli/aws.ts CHANGED
@@ -290,7 +290,23 @@ export async function resolveFunctionName(
290
290
  * credential-free introspection path. Every deployed-stage read verb
291
291
  * (db:plan, db:apply --stage's descent probe, …) shares it: catalog SQL runs
292
292
  * inside AWS on the operator connection, and the row set comes back over IAM.
293
- * `db:query` refuses writes, so this can only read.
293
+ *
294
+ * WHAT MAKES THIS READ-ONLY: not this file, and no longer a keyword blocklist in the
295
+ * action. `db:query` runs its single statement inside an explicit PostgreSQL `READ ONLY`
296
+ * transaction that always rolls back, so the ENGINE refuses a write — after parsing,
297
+ * where no leading comment, quoted identifier, writable CTE, `SELECT ... INTO`,
298
+ * `EXPLAIN ANALYZE <write>`, `COPY`, `MERGE`, `CALL` or `DO` block reaches. (A red team
299
+ * against a real PostgreSQL 16 defeated the blocklist that stood here before; see the
300
+ * comment on the `db:query` action in `@everystack/server`'s plugin.ts.)
301
+ *
302
+ * WHAT IT DOES NOT COVER: a read-only transaction stops DATA writes, not every effect.
303
+ * Still reachable by anyone who may invoke this action — `CLUSTER`, `REINDEX`,
304
+ * `LOCK TABLE ... ACCESS EXCLUSIVE`, `pg_sleep`, `NOTIFY`, and session advisory locks:
305
+ * availability and side-channel effects, not data change. An out-of-transaction escape
306
+ * exists via `dblink_exec` / `pg_background` IF those extensions are installed AND
307
+ * executable by the operator role. Closing those needs a real SELECT-only role wired in
308
+ * as the query connection (`readDatabaseUrl`) — a separate, already-filed follow-up, and
309
+ * an infrastructure decision rather than a code change here.
294
310
  */
295
311
  export function lambdaQueryRunner(
296
312
  region: string,