@everystack/server 0.4.32 → 0.4.34

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,27 @@ 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.34] - 2026-09-27
8
+
9
+ ### Added
10
+
11
+ - apply state prerequisites atomically
12
+
13
+ ### Fixed
14
+
15
+ - govern state prerequisites
16
+
17
+ ## [0.4.33] - 2026-09-24
18
+
19
+ ### BREAKING CHANGES
20
+
21
+ - a declared owner needs USAGE on its schema; db:apply refuses without it, db:provision (server 0.4.33) grants it
22
+
23
+ ### Fixed
24
+
25
+ - name USAGE with CREATE for a missing schema; review follow-ups
26
+ - reject offset-less datetime strings (ambiguous: server-local, not UTC)
27
+
7
28
  ## [0.4.32] - 2026-09-21
8
29
 
9
30
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.32",
3
+ "version": "0.4.34",
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.5",
191
- "@everystack/cli": "0.4.87"
190
+ "@everystack/cli": "0.4.98",
191
+ "@everystack/auth": "0.4.8"
192
192
  },
193
193
  "scripts": {
194
194
  "test": "jest",
@@ -11,6 +11,9 @@
11
11
  * explicit SET ROLE, never inheriting the owner's privileges ambiently.
12
12
  * 2. `GRANT CREATE ON SCHEMA <schema> TO <owner>` — so the CREATE, which runs AS the owner,
13
13
  * can place the object.
14
+ * 3. `GRANT USAGE ON SCHEMA <schema> TO <owner>` — so the owner's GRANT/REVOKE on that object,
15
+ * which name it as `schema.object`, can resolve it. CREATE alone let the CREATE through and
16
+ * then failed the object's own `REVOKE ALL … FROM PUBLIC` with `permission denied for schema`.
14
17
  *
15
18
  * These are privileged, idempotent SETUP — the same category as the role chain — so they belong
16
19
  * in db:provision, not in the least-privilege reconcile lane (which structurally cannot run them).
@@ -75,6 +78,8 @@ export interface OwnerFactRow {
75
78
  canSetRole: boolean;
76
79
  /** The owner already holds `CREATE` on the schema — no create grant needed. */
77
80
  ownerCanCreate: boolean;
81
+ /** The owner already holds `USAGE` on the schema — no usage grant needed. */
82
+ ownerCanUse: boolean;
78
83
  }
79
84
 
80
85
  /**
@@ -88,6 +93,8 @@ export interface GranterCapabilityRow {
88
93
  schema: string;
89
94
  canGrantMembership: boolean;
90
95
  canGrantCreate: boolean;
96
+ /** Can `GRANT USAGE ON SCHEMA` — owns the schema, holds USAGE WITH GRANT OPTION, or is a superuser. */
97
+ canGrantUsage: boolean;
91
98
  }
92
99
 
93
100
  /** Which connection runs a unit's statements. */
@@ -175,7 +182,8 @@ function assertIdent(name: string, what: string): void {
175
182
 
176
183
  /**
177
184
  * The connection-independent probe: for each (owner, schema), does the owner exist, does the
178
- * schema exist, can `operator` already SET ROLE the owner, and does the owner already hold CREATE.
185
+ * schema exist, can `operator` already SET ROLE the owner, and does the owner already hold CREATE
186
+ * and USAGE.
179
187
  * `has_schema_privilege` throws on an absent schema, so the schema is LEFT JOINed and the privilege
180
188
  * is evaluated only when the schema is really there. Run once, on either connection.
181
189
  */
@@ -195,7 +203,9 @@ SELECT
195
203
  (n.oid IS NOT NULL) AS schema_exists,
196
204
  CASE WHEN r.oid IS NULL THEN false ELSE pg_has_role('${operator}', r.oid, 'SET') END AS can_set_role,
197
205
  CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN false
198
- ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create
206
+ ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create,
207
+ CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN false
208
+ ELSE has_schema_privilege(r.oid, n.oid, 'USAGE') END AS owner_can_use
199
209
  FROM declared d
200
210
  LEFT JOIN pg_roles r ON r.rolname = d.owner_name
201
211
  LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
@@ -207,7 +217,8 @@ ORDER BY d.owner_name, d.schema_name
207
217
  * The connection-DEPENDENT probe: run on a connection to learn what CURRENT_USER can grant.
208
218
  * Can it `GRANT <owner> TO <operator>` — it holds ADMIN OPTION on the owner, directly or through a
209
219
  * group. Can it `GRANT CREATE ON SCHEMA <schema>` — it owns the schema or holds CREATE WITH GRANT
210
- * OPTION. A REAL superuser can do both unconditionally. Run on the operator's connection and, when
220
+ * OPTION, and `GRANT USAGE ON SCHEMA` — it owns the schema or holds USAGE WITH GRANT OPTION. A REAL
221
+ * superuser can do all three unconditionally. Run on the operator's connection and, when
211
222
  * present, on the master's; the router merges the two.
212
223
  *
213
224
  * MEMBERSHIP IN `rds_superuser` IS NOT SUPERUSER, and this probe no longer pretends it is. RDS's
@@ -238,7 +249,9 @@ SELECT
238
249
  d.schema_name AS schema,
239
250
  COALESCE(me.is_super OR pg_has_role(me.oid, r.oid, 'MEMBER WITH ADMIN OPTION'), false) AS can_grant_membership,
240
251
  COALESCE(me.is_super OR n.nspowner = me.oid
241
- OR has_schema_privilege(me.oid, n.oid, 'CREATE WITH GRANT OPTION'), false) AS can_grant_create
252
+ OR has_schema_privilege(me.oid, n.oid, 'CREATE WITH GRANT OPTION'), false) AS can_grant_create,
253
+ COALESCE(me.is_super OR n.nspowner = me.oid
254
+ OR has_schema_privilege(me.oid, n.oid, 'USAGE WITH GRANT OPTION'), false) AS can_grant_usage
242
255
  FROM declared d
243
256
  CROSS JOIN me
244
257
  LEFT JOIN pg_roles r ON r.rolname = d.owner_name
@@ -250,7 +263,8 @@ ORDER BY d.owner_name, d.schema_name
250
263
  const keyOf = (owner: string, schema: string) => `${owner}\u0000${schema}`;
251
264
 
252
265
  /**
253
- * Route the two grants each (owner, schema) may need onto the connection that can run them.
266
+ * Route the three grants each (owner, schema) may need — membership, CREATE, USAGE — onto the
267
+ * connection that can run them.
254
268
  *
255
269
  * `operator` is the least-privilege role (`migrator`). `masterCaps` is null when no master
256
270
  * connection is available — then anything the operator cannot do is a refusal, never an
@@ -305,6 +319,16 @@ export function planOwnerEnactment(
305
319
  `Run as the schema owner: ${stmt};`,
306
320
  );
307
321
  }
322
+ // 3. Usage: the owner's GRANT/REVOKE on the object name `schema.object` — resolving it needs USAGE.
323
+ if (f.schemaExists && !f.ownerCanUse) {
324
+ const stmt = `GRANT USAGE ON SCHEMA ${f.schema} TO ${f.owner}`;
325
+ if (op?.canGrantUsage) addUnit(b, subject, 'operator', [stmt]);
326
+ else if (master?.canGrantUsage) addUnit(b, subject, 'master', [stmt]);
327
+ else plan.refusals.push(
328
+ `cannot GRANT USAGE ON SCHEMA ${f.schema} TO ${f.owner}: neither connection owns the schema or holds USAGE WITH GRANT OPTION. ` +
329
+ `Run as the schema owner: ${stmt};`,
330
+ );
331
+ }
308
332
  // An absent schema is not enacted here — it is created by the reconcile builder, whose own
309
333
  // preflight then names the owner's missing CREATE. Enactment acts only on what exists.
310
334
  }
package/src/plugin.ts CHANGED
@@ -1242,7 +1242,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1242
1242
  // direct --database-url path runs, only here the connection never leaves the
1243
1243
  // Lambda. A destructive plan snapshots first (db:backup), in order.
1244
1244
  actions['db:apply'] = async (payload, ctx) => {
1245
- const { plan, contractVersion, actor, gitRef, dirty, descentVerdict, descentAttested, authorityVerdict, stage, snapshotRef } =
1245
+ const { plan, contractVersion, actor, gitRef, dirty, stateOnly, descentVerdict, descentAttested, authorityVerdict, stage, snapshotRef } =
1246
1246
  (payload ?? {}) as {
1247
1247
  plan?: any;
1248
1248
  /** The CLI's db:apply wire-contract version. Checked for EXACT equality against this
@@ -1253,11 +1253,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1253
1253
  /** The CLI applied from a dirty worktree — recorded on the schema_log witness so a
1254
1254
  * later descent ignores this row's git_ref. */
1255
1255
  dirty?: boolean;
1256
+ stateOnly?: boolean;
1256
1257
  descentVerdict?: { ok: boolean; reason?: string };
1257
1258
  /** What the CLI's descent step decided, to record in the schema_log outcome
1258
- * (`applied — <note>`): a honoured --attest-lineage-lost waiver, or a PROVEN DRIFT
1259
- * origin (`descent: drift-origin proven (never-declared | hand-edited since … | applied
1260
- * from a DIRTY worktree at …)`) — the
1259
+ * (`applied — <note>`): a honoured --attest-lineage-lost waiver, or a DRIFT
1260
+ * origin (`descent: drift-origin never-declared (…) | hand-edited since … | applied
1261
+ * from a DIRTY worktree at …`) — the
1261
1262
  * record that makes this row the origin every later apply descends from. */
1262
1263
  descentAttested?: string;
1263
1264
  authorityVerdict?: { ok: boolean; reason?: string };
@@ -1297,6 +1298,11 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1297
1298
  const result: any = await opsDb.execute(sql.raw(query));
1298
1299
  return Array.isArray(result) ? result : result?.rows ?? [];
1299
1300
  };
1301
+ const transaction = async <T>(work: (transactionRunner: (query: string) => Promise<any[]>) => Promise<T>): Promise<T> =>
1302
+ opsDb.transaction(async (tx: any) => work(async (query: string) => {
1303
+ const result: any = await tx.execute(sql.raw(query));
1304
+ return Array.isArray(result) ? result : result?.rows ?? [];
1305
+ }));
1300
1306
  // The fingerprint endpoints this apply gates on are multi-statement reads, so they
1301
1307
  // run as ONE session on this container's operator connection. Server-side that was
1302
1308
  // always one connection; saying so in the type is what stops a future caller from
@@ -1315,9 +1321,11 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1315
1321
  // in order, atomic with the lock. Authority/snapshot stay conditional (destructive-only, and
1316
1322
  // the stage lane refuses destructive outright, so they never light up on this lane).
1317
1323
  const applyOptions: StageApplyOptions = {
1324
+ transaction,
1318
1325
  actor: actor ?? null,
1319
1326
  gitRef: gitRef ?? null,
1320
1327
  dirty: dirty === true,
1328
+ stateOnly: stateOnly === true,
1321
1329
  checkDescent: async () => (descentVerdict.ok ? { ok: true as const } : { ok: false as const, reason: descentVerdict.reason ?? 'descent refused' }),
1322
1330
  // The CLI's descent record (a honoured waiver, or a proven-drift origin) lands in the
1323
1331
  // schema_log outcome; the witness query matches `applied%`, so the row stays a witness.
package/src/provision.ts CHANGED
@@ -606,6 +606,7 @@ function toFacts(rows: any[]): OwnerFactRow[] {
606
606
  schemaExists: truthy(r.schema_exists),
607
607
  canSetRole: truthy(r.can_set_role),
608
608
  ownerCanCreate: truthy(r.owner_can_create),
609
+ ownerCanUse: truthy(r.owner_can_use),
609
610
  }));
610
611
  }
611
612
 
@@ -615,6 +616,7 @@ function toCaps(rows: any[]): GranterCapabilityRow[] {
615
616
  schema: String(r.schema),
616
617
  canGrantMembership: truthy(r.can_grant_membership),
617
618
  canGrantCreate: truthy(r.can_grant_create),
619
+ canGrantUsage: truthy(r.can_grant_usage),
618
620
  }));
619
621
  }
620
622