@everystack/server 0.4.30 → 0.4.32
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 +35 -0
- package/package.json +3 -3
- package/src/db.ts +23 -2
- package/src/model-wire.ts +94 -0
- package/src/owner-adoption.ts +75 -4
- package/src/owner-enactment.ts +355 -20
- package/src/plugin.ts +273 -55
- package/src/provision.ts +406 -11
- package/src/role-chain.ts +114 -0
- package/src/sql-lex.ts +217 -0
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.32] - 2026-09-21
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- db:provision detects PUBLIC's CREATE on schema public, revokes it under --confirm
|
|
12
|
+
|
|
13
|
+
## [0.4.31] - 2026-09-16
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- a real SELECT-only role for db:query's read connection
|
|
18
|
+
- version-guard the Model-derived wire payloads (provision, converge-ownership, reconcile)
|
|
19
|
+
- automate the version-drift check — a manual note is not a gate
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- one shipped descent step for both lanes, bound end to end by the real CLI; trees materialize inside the project
|
|
24
|
+
- the compile-failure detail keeps the cause; the monorepo test skips loudly; the stash filter sits on the refuse-closed side
|
|
25
|
+
- owner-adoption fast-follows — identity guard, self-grant revoke, ENACT transaction
|
|
26
|
+
- roll the session back after a failed grant transaction
|
|
27
|
+
- the reader's TABLES and SEQUENCES defaults converge independently
|
|
28
|
+
- an E-string prefix only counts when it stands alone
|
|
29
|
+
- throw a private sentinel instead of calling tx.rollback() — closes a console-sandbox swallow/neutralize hole
|
|
30
|
+
- rds_superuser membership is not superuser — probe grant capability honestly
|
|
31
|
+
- the shape-pin also covers enum value domains, not just field names
|
|
32
|
+
- symmetric v-prefix in the mismatch message, restore db:provision's --direct detail
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- use drizzle's tx.rollback() instead of a hand-rolled sentinel Symbol
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
- note the accepted narrowing db:query's read-only transaction introduces
|
|
41
|
+
|
|
7
42
|
## [0.4.30] - 2026-09-04
|
|
8
43
|
|
|
9
44
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.32",
|
|
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/auth": "0.4.
|
|
191
|
-
"@everystack/cli": "0.4.
|
|
190
|
+
"@everystack/auth": "0.4.5",
|
|
191
|
+
"@everystack/cli": "0.4.87"
|
|
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
|
*
|
|
@@ -342,9 +341,29 @@ let adminFallbackWarned = false;
|
|
|
342
341
|
* fallback — which is why the guard that refuses a privileged app credential must not treat
|
|
343
342
|
* local dev as a violation. See
|
|
344
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.
|
|
345
358
|
*/
|
|
346
359
|
export function createAdminDb<T extends Record<string, unknown>>(
|
|
347
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
|
+
*/
|
|
348
367
|
options?: { maxConnections?: number }
|
|
349
368
|
): {
|
|
350
369
|
db: ReturnType<typeof drizzle>;
|
|
@@ -372,7 +391,9 @@ export function createAdminDb<T extends Record<string, unknown>>(
|
|
|
372
391
|
return createDb(schema, options);
|
|
373
392
|
}
|
|
374
393
|
|
|
375
|
-
|
|
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));
|
|
376
397
|
adminDbInstance = drizzle(adminSqlClient, { schema });
|
|
377
398
|
return { db: adminDbInstance, schema };
|
|
378
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
|
+
}
|
package/src/owner-adoption.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|