@everystack/server 0.4.29 → 0.4.31

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,41 @@ All notable changes to `@everystack/server` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.31] - 2026-09-16
8
+
9
+ ### Added
10
+
11
+ - a real SELECT-only role for db:query's read connection
12
+ - version-guard the Model-derived wire payloads (provision, converge-ownership, reconcile)
13
+ - automate the version-drift check — a manual note is not a gate
14
+
15
+ ### Fixed
16
+
17
+ - one shipped descent step for both lanes, bound end to end by the real CLI; trees materialize inside the project
18
+ - the compile-failure detail keeps the cause; the monorepo test skips loudly; the stash filter sits on the refuse-closed side
19
+ - owner-adoption fast-follows — identity guard, self-grant revoke, ENACT transaction
20
+ - roll the session back after a failed grant transaction
21
+ - the reader's TABLES and SEQUENCES defaults converge independently
22
+ - an E-string prefix only counts when it stands alone
23
+ - throw a private sentinel instead of calling tx.rollback() — closes a console-sandbox swallow/neutralize hole
24
+ - rds_superuser membership is not superuser — probe grant capability honestly
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
+
28
+ ### Changed
29
+
30
+ - use drizzle's tx.rollback() instead of a hand-rolled sentinel Symbol
31
+
32
+ ### Documentation
33
+
34
+ - note the accepted narrowing db:query's read-only transaction introduces
35
+
36
+ ## [0.4.30] - 2026-09-04
37
+
38
+ ### Fixed
39
+
40
+ - ADOPT resolves its master from ADMIN_DATABASE_URL on a BYO/external RDS
41
+
7
42
  ## [0.4.29] - 2026-09-04
8
43
 
9
44
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.29",
3
+ "version": "0.4.31",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -187,8 +187,8 @@
187
187
  "sst": "4.13.1",
188
188
  "ts-jest": "29.4.9",
189
189
  "typescript": "5.9.3",
190
- "@everystack/cli": "0.4.71",
191
- "@everystack/auth": "0.4.4"
190
+ "@everystack/cli": "0.4.85",
191
+ "@everystack/auth": "0.4.5"
192
192
  },
193
193
  "scripts": {
194
194
  "test": "jest",
package/src/db.ts CHANGED
@@ -1,4 +1,3 @@
1
- /// <reference path="./sst-env.d.ts" />
2
1
  /**
3
2
  * @everystack/server/db — Database and secret helpers.
4
3
  *
@@ -150,6 +149,26 @@ export function getOperatorDatabaseUrl(): { url: string; source: 'admin' | 'mast
150
149
  return null;
151
150
  }
152
151
 
152
+ /**
153
+ * The connection ADOPT (ownership reassignment) runs on: the linked SST Postgres master when one is
154
+ * present, else the `ADMIN_DATABASE_URL` operator secret.
155
+ *
156
+ * A brownfield / external-RDS app — the exact `db:pull --abilities live` audience — has NO linked
157
+ * `sst.aws.Postgres` component. It wires the database as secrets, and its `ADMIN_DATABASE_URL` is a
158
+ * privileged operator (commonly BYPASSRLS and a member of `postgres`, i.e. master-capable) that the
159
+ * ops Lambda already carries. Resolving ADOPT's master ONLY through the component dead-ended those
160
+ * apps on the missing component. This falls back to the admin secret so ADOPT has a connection to
161
+ * try. Whether that connection can ACTUALLY reassign is NOT decided here — ADOPT's live
162
+ * `master_can_act` probe decides it: a least-privilege admin resolves but the probe then refuses
163
+ * precisely (naming the grant to run), while a master-capable admin proceeds.
164
+ *
165
+ * `master` (linked component) is preferred because its presence guarantees master capability; the
166
+ * admin fallback is probe-gated. Never the least-privilege `DATABASE_URL`.
167
+ */
168
+ export function getAdoptMasterUrl(): string | null {
169
+ return getMasterDatabaseUrl() ?? getAdminDatabaseUrl();
170
+ }
171
+
153
172
  export function getJwtSecret(): Uint8Array {
154
173
  // PascalCase Secret (SST_RESOURCE_JwtSecret → { value })
155
174
  const pascal = sstLink('JwtSecret')?.value as string | undefined;
@@ -322,9 +341,29 @@ let adminFallbackWarned = false;
322
341
  * fallback — which is why the guard that refuses a privileged app credential must not treat
323
342
  * local dev as a violation. See
324
343
  * plan:the-operator-credential-is-the-ops-channel-only-doctrine-doc.
344
+ *
345
+ * ONE PHYSICAL CONNECTION, ALWAYS. This connection is pinned to `max: 1` and a caller's
346
+ * `maxConnections` is deliberately ignored on this path — two guarantees rest on there being
347
+ * exactly one backend behind it:
348
+ *
349
+ * 1. `db:query`'s `pg_advisory_unlock_all()` cleanup (plugin.ts) must run on the SAME backend
350
+ * that took the lock. A session advisory lock survives ROLLBACK, so with a pool the
351
+ * release can land on a different connection and the lock is held until the container dies.
352
+ * 2. Session state a statement leaves behind — `SET search_path`, a temp table, a prepared
353
+ * statement — must not surface in an unrelated later call. With one connection "the last
354
+ * call rolled back" is the whole story; with a pool it is a per-connection lottery.
355
+ *
356
+ * Pooling buys the ops channel nothing either way: its actions (migrate, seed, console,
357
+ * db:query) run one at a time.
325
358
  */
326
359
  export function createAdminDb<T extends Record<string, unknown>>(
327
360
  schema: T,
361
+ /**
362
+ * Accepted for signature parity with `createDb`. `maxConnections` is IGNORED for the operator
363
+ * connection — see the "ONE PHYSICAL CONNECTION" note above. It still reaches `createDb` on
364
+ * the no-operator-credential fallback below, which is the API connection, a different
365
+ * connection kind with its own rules.
366
+ */
328
367
  options?: { maxConnections?: number }
329
368
  ): {
330
369
  db: ReturnType<typeof drizzle>;
@@ -352,7 +391,9 @@ export function createAdminDb<T extends Record<string, unknown>>(
352
391
  return createDb(schema, options);
353
392
  }
354
393
 
355
- adminSqlClient = postgres(adminUrl, connectionOptions(adminUrl, options?.maxConnections));
394
+ // Pinned to 1, not `options?.maxConnections` — the advisory-lock cleanup and the
395
+ // no-session-leak guarantee both require a single physical connection. See the docblock.
396
+ adminSqlClient = postgres(adminUrl, connectionOptions(adminUrl, 1));
356
397
  adminDbInstance = drizzle(adminSqlClient, { schema });
357
398
  return { db: adminDbInstance, schema };
358
399
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The Model-derived WIRE-CONTRACT gate — the server half.
3
+ *
4
+ * `db:provision`, `db:converge-ownership` and `db:reconcile` take payloads the CLI computes FROM
5
+ * the Models barrel: owner assignments (`declaredOwners` / `declaredObjects`) and derived-object
6
+ * descriptors (`declared`). The operator's `@everystack/cli` and the one bundled into this Lambda
7
+ * are separate installs on independent release trains, so those shapes can drift between the two
8
+ * — a field renamed, retyped, or given new required meaning rides through silently and lands as
9
+ * wrong ownership or a wrong derived object on a real database.
10
+ *
11
+ * So each of those payloads carries `modelContract`, and this refuses anything but EXACT equality
12
+ * with the MODEL_WIRE_CONTRACT the bundled CLI states. Same shape of check `db:apply` has run
13
+ * against `APPLY_CONTRACT_VERSION` since M1, and deliberately a SEPARATE number: apply versions a
14
+ * persisted plan artifact, this versions an ephemeral wire payload.
15
+ *
16
+ * Two rules this file exists to make unmissable:
17
+ *
18
+ * MISSING IS A REFUSAL, never a default. A caller that does not send `modelContract` is on a
19
+ * version that predates the field, which IS the skew being guarded. Defaulting it to "probably
20
+ * fine" would reintroduce exactly the silent path the guard removes.
21
+ *
22
+ * THE REFUSAL IS PRE-DISPATCH. Callers run this before resolving a connection, before any
23
+ * master client is opened, and before any DDL — so a skewed payload never reaches the database
24
+ * at all. `__tests__/model-wire-actions.test.ts` proves that with a mocked operator connection
25
+ * that must never be called.
26
+ *
27
+ * The CLI's own half of the handshake — reading `_health` and refusing to dispatch to a stage
28
+ * that advertises a different number, or none at all — lives in
29
+ * `packages/cli/src/cli/model-wire-lane.ts`. Neither half closes the hole alone: this one cannot
30
+ * see a stage deployed BEFORE the guard existed (it has no code to refuse with), and that one is
31
+ * a client claim, which a server must never trust.
32
+ */
33
+
34
+ /**
35
+ * Null when the payload speaks this server's Model-derived wire contract; otherwise the refusal
36
+ * to return to the caller verbatim.
37
+ *
38
+ * `verb` is the action name, so the operator reads which edge refused.
39
+ */
40
+ export function modelWireRefusal(
41
+ verb: string,
42
+ declared: unknown,
43
+ enforced: number,
44
+ ): string | null {
45
+ if (typeof declared === 'number' && declared === enforced) return null;
46
+
47
+ // Three genuinely different situations, so three leads rather than one template with holes in
48
+ // it. Two formatting rules earn their keep here, both learned the hard way:
49
+ //
50
+ // ABSENT gets its own sentence. Splicing nothing into "declares v…" rendered the literal
51
+ // "declares v(none)", which reads as a broken template exactly where an operator has to trust
52
+ // what the message says.
53
+ //
54
+ // Every VERSION NUMBER renders as `v<n>`, on both sides. The first fix for the above put the
55
+ // prefix on the enforced side only, giving "declares model-wire contract 5 … enforces v1" —
56
+ // two numbers in one sentence, formatted two ways, inviting the reader to wonder whether they
57
+ // mean the same kind of thing. They do.
58
+ //
59
+ // A non-numeric claim gets neither: `v"1"` would be worse than saying plainly that it is
60
+ // malformed, and a client sending one has a bug distinct from being out of date.
61
+ const lead =
62
+ declared === undefined || declared === null
63
+ ? `this ${verb} payload declares NO model-wire contract — the everystack CLI that sent it `
64
+ + `predates the handshake — and this stage's ops Lambda enforces v${enforced}.`
65
+ : typeof declared === 'number'
66
+ ? `this ${verb} payload declares model-wire contract v${declared}, and this stage's ops `
67
+ + `Lambda enforces v${enforced}.`
68
+ : `this ${verb} payload declares a malformed model-wire contract `
69
+ + `(${JSON.stringify(declared)}); this stage's ops Lambda enforces v${enforced}.`;
70
+
71
+ // An older CLI is the overwhelmingly likely cause: a CURRENT CLI catches the skew at the
72
+ // `_health` handshake and never dispatches, so a payload that gets here at all came from one
73
+ // that does not run that preflight. Hence the remedy leads with upgrading the CLI — the same
74
+ // reasoning db:apply's refusal spells out.
75
+ return (
76
+ `${lead} The CLI that computed this Model-derived payload and the one deployed with this `
77
+ + `stage do not agree on its shape. Upgrade the CLI to match (or, if the stage was rolled `
78
+ + `back, redeploy it); nothing was applied.`
79
+ );
80
+ }
81
+
82
+ /**
83
+ * The refusal for a server that cannot even STATE its contract, because `@everystack/cli/wire`
84
+ * would not load. Fail closed: a stage that cannot name the shape it understands must never
85
+ * accept a Model-derived payload on the strength of the caller's word for it.
86
+ */
87
+ export function modelWireUnavailableRefusal(verb: string, cause: unknown): string {
88
+ return (
89
+ `${verb} cannot verify the Model-derived wire contract — this stage's ops Lambda failed to `
90
+ + `load @everystack/cli/wire (${String(cause)}), so it cannot state which payload shape it `
91
+ + `understands. Refusing rather than guessing. Redeploy the stage with a matching `
92
+ + `@everystack/cli; nothing was applied.`
93
+ );
94
+ }
@@ -188,6 +188,59 @@ export interface AdopteeProbe {
188
188
  }
189
189
 
190
190
  const OWNER_IDENT = /^[a-z_][a-z0-9_]*$/;
191
+
192
+ /** One unquoted identifier component — a schema, an object name, a type word. */
193
+ const IDENT_PART = /^[A-Za-z_][A-Za-z0-9_]*$/;
194
+ /** A double-quoted component. The catalog spells exactly two kinds this way: `"char"` and a
195
+ * mixed-case custom type. Nothing that could carry SQL is allowed inside. */
196
+ const QUOTED_PART = /^"[A-Za-z0-9_ ]+"$/;
197
+ const isPart = (s: string): boolean => IDENT_PART.test(s) || QUOTED_PART.test(s);
198
+
199
+ /** `schema.name` — exactly two components, each a bare or quoted identifier. */
200
+ function isQualifiedName(s: string): boolean {
201
+ const parts = s.split('.');
202
+ return parts.length === 2 && parts.every(isPart);
203
+ }
204
+
205
+ /**
206
+ * One argument type in the catalog's `format_type` spelling: a single word (`integer`), several
207
+ * (`character varying`, `timestamp with time zone`), a schema-qualified custom type
208
+ * (`api.my_enum`), a quoted one (`"char"`), each with any number of trailing `[]` dimensions.
209
+ */
210
+ function isArgType(s: string): boolean {
211
+ let t = s;
212
+ while (t.endsWith('[]')) t = t.slice(0, -2);
213
+ if (!t) return false;
214
+ const words = t.split(' ');
215
+ if (words.some((w) => w === '')) return false; // leading, trailing or doubled space
216
+ if (words.length > 1) return words.every((w) => IDENT_PART.test(w));
217
+ return t.includes('.') ? isQualifiedName(t) : isPart(t);
218
+ }
219
+
220
+ /**
221
+ * Is `identity` shaped like an identity everystack spells — `schema.name` for a view/matview,
222
+ * `schema.name(argtype, …)` for a function?
223
+ *
224
+ * DEFENCE IN DEPTH (BF5). `identity` is interpolated RAW into `ALTER … OWNER TO` below. Today it
225
+ * cannot be anything else: the list is barrel-sourced (the Models export, not user input) and
226
+ * resolution-gated (`to_regprocedure`/`to_regclass` return NULL for a bogus identity, and only a
227
+ * probe that EXISTS reaches the ALTER). But a raw interpolation deserves its own gate, and
228
+ * OWNER_IDENT cannot be that gate — a bare role name has no dots, parens, commas or spaces, and an
229
+ * identity legitimately has all four. So the interpolation gets a validator shaped for what it
230
+ * actually carries, and everything else is refused by name.
231
+ */
232
+ export function isValidObjectIdentity(identity: string): boolean {
233
+ const open = identity.indexOf('(');
234
+ if (open === -1) return isQualifiedName(identity);
235
+ if (!identity.endsWith(')')) return false;
236
+ if (!isQualifiedName(identity.slice(0, open))) return false;
237
+ const args = identity.slice(open + 1, -1);
238
+ if (args.includes('(') || args.includes(')')) return false;
239
+ if (args === '') return true; // `api.report()` — a function of no arguments
240
+ // The catalog joins argument types with `, `; tolerate the separating space, nothing else.
241
+ return args.split(',').every((a) => isArgType(a.startsWith(' ') ? a.slice(1) : a));
242
+ }
243
+
191
244
  const KEYWORD: Record<Adoptee['kind'], string> = {
192
245
  function: 'FUNCTION', view: 'VIEW', 'materialized view': 'MATERIALIZED VIEW',
193
246
  };
@@ -245,6 +298,13 @@ export interface AdoptionPlan {
245
298
  reassigned: Adoptee[];
246
299
  /** Precise pre-flight refusals — nothing is applied if this is non-empty (all or nothing). */
247
300
  refusals: string[];
301
+ /**
302
+ * The declared owners the master self-granted `SET` on, BY NAME. That membership exists only to
303
+ * make the ALTERs legal, so the caller revokes each one once the transaction has committed —
304
+ * otherwise it accumulates on the master across ADOPT runs. Named, not spelled as SQL, because
305
+ * only the caller knows when the commit happened.
306
+ */
307
+ selfGrantedOwners: string[];
248
308
  }
249
309
 
250
310
  /**
@@ -257,13 +317,17 @@ export interface AdoptionPlan {
257
317
  export function planAdoptionReassignment(adoptees: Adoptee[], probes: AdopteeProbe[]): AdoptionPlan {
258
318
  const byId = new Map(probes.map((p) => [p.identity, p]));
259
319
  const alters: string[] = [];
260
- const selfGrants = new Set<string>(); // one per declared owner the master must self-grant SET on
261
- const plan: AdoptionPlan = { statements: [], reassigned: [], refusals: [] };
320
+ const selfGranted = new Set<string>(); // one per declared owner the master must self-grant SET on
321
+ const plan: AdoptionPlan = { statements: [], reassigned: [], refusals: [], selfGrantedOwners: [] };
262
322
 
263
323
  for (const a of adoptees) {
264
324
  const p = byId.get(a.identity);
265
325
  if (!p || !p.exists) continue; // not in the DB — reconcile creates it owned correctly, nothing to adopt
266
326
  if (p.liveOwner === a.declaredOwner) continue; // already correct
327
+ if (!isValidObjectIdentity(a.identity)) {
328
+ plan.refusals.push(`${JSON.stringify(a.identity)}: not a valid qualified object identity (expected schema.name, or schema.name(argtype, ...) for a function) — refusing to interpolate it into ALTER ${KEYWORD[a.kind]} ... OWNER TO.`);
329
+ continue;
330
+ }
267
331
  if (p.isExtensionMember) {
268
332
  plan.refusals.push(`${a.identity}: owned by an extension — never reassigned (its OID binds to the extension).`);
269
333
  continue;
@@ -292,7 +356,7 @@ export function planAdoptionReassignment(adoptees: Adoptee[], probes: AdopteePro
292
356
  // it self-grants SET inside the transaction; otherwise it is a precise refusal naming THIS
293
357
  // conjunct (not the current-owner one), with the fix.
294
358
  if (p.masterAdminOfDeclared) {
295
- selfGrants.add(`GRANT ${a.declaredOwner} TO CURRENT_USER WITH SET TRUE, INHERIT FALSE`);
359
+ selfGranted.add(a.declaredOwner);
296
360
  } else {
297
361
  plan.refusals.push(`${a.identity}: the master cannot SET ROLE to the declared owner '${a.declaredOwner}' and lacks ADMIN OPTION on it to grant itself. As a role with admin on '${a.declaredOwner}': GRANT ${a.declaredOwner} TO <master> WITH SET TRUE; then re-run.`);
298
362
  continue;
@@ -302,6 +366,13 @@ export function planAdoptionReassignment(adoptees: Adoptee[], probes: AdopteePro
302
366
  plan.reassigned.push({ ...a, currentOwner: p.liveOwner });
303
367
  }
304
368
  // Self-grants first (they make the SET conjunct true), then the reassignments — one transaction.
305
- plan.statements = [...selfGrants, ...alters];
369
+ // A role membership granted inside a transaction is visible to the commands that follow it there,
370
+ // which is exactly why the ALTERs can use it; the matching REVOKE therefore belongs strictly
371
+ // AFTER the commit, and is the caller's job (see selfGrantedOwners).
372
+ plan.selfGrantedOwners = [...selfGranted];
373
+ plan.statements = [
374
+ ...plan.selfGrantedOwners.map((o) => `GRANT ${o} TO CURRENT_USER WITH SET TRUE, INHERIT FALSE`),
375
+ ...alters,
376
+ ];
306
377
  return plan;
307
378
  }