@everystack/model 0.4.25 → 0.4.26

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,21 @@ All notable changes to `@everystack/model` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.26] - 2026-10-31
8
+
9
+ ### Added
10
+
11
+ - declare request roles with defineModule({ roles: { request } })
12
+
13
+ ### Fixed
14
+
15
+ - a granted definer role is not a request role in the lifecycle check
16
+
17
+ ### Documentation
18
+
19
+ - say why a granted owner counts, and document roles.request
20
+ - state what an older server skips under the role declaration
21
+
7
22
  ## [0.4.25] - 2026-10-30
8
23
 
9
24
  ### BREAKING CHANGES
package/README.md CHANGED
@@ -294,6 +294,11 @@ An explicit field `onDelete: 'cascade'` into a lifecycle table is refused; use t
294
294
  running app hooks. `dependent: 'destroy'` has not shipped.
295
295
  AFTER DELETE audit triggers are supported; they do not run on a soft deletion.
296
296
 
297
+ A trigger function on a lifecycle table must be owned by a role no request can become. The check counts
298
+ every role the models grant to as a request role. If a definer role owns such a function and holds grants,
299
+ declare the request roles: `defineModule({ roles: { request: [...] } })`. `moduleRequestRoles(modules)`
300
+ returns their union. See [docs/lifecycle.md](../../docs/lifecycle.md#owner-definer-and-request-roles).
301
+
297
302
  #### Refusing some deletes: `deleteWhen`
298
303
 
299
304
  `lifecycle: { deleteWhen: sql\`<predicate>\` }` is lifecycle with a guard: a row may be deleted only when the predicate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/derived.ts CHANGED
@@ -461,6 +461,9 @@ export interface FunctionDescriptor {
461
461
  * Same rule as @everystack/server's withRole.
462
462
  */
463
463
  const OWNER_ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
464
+ /** The bare role-name rule, for every other place a module names a role (defineModule's roles.request). */
465
+ export const BARE_ROLE_NAME = OWNER_ROLE_NAME;
466
+ export const isBareRoleName = (role: string): boolean => OWNER_ROLE_NAME.test(role);
464
467
 
465
468
  /**
466
469
  * Refuse an owner that is not a bare lowercase role name. ONE implementation across
package/src/index.ts CHANGED
@@ -36,8 +36,8 @@ export { veto, isVeto } from './lifecycle-hooks';
36
36
  export { defineModel, defineMaterializedTable, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
37
37
  export type { ModelDescriptor, ModelDefinition, ModelHooks, LifecycleOptions, DestroyContext, DestroyHook, LifecycleContext, HookVeto, BeforeLifecycleHook, AfterLifecycleHook, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
38
38
 
39
- export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
40
- export type { Module, ModuleDefinition } from './module';
39
+ export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles, moduleRequestRoles } from './module';
40
+ export type { Module, ModuleDefinition, ModuleRoles } from './module';
41
41
 
42
42
  export {
43
43
  defineView, defineMaterializedView, defineFunction, defineSql, defineSequence,
package/src/module.ts CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from './model';
23
23
  import type { DerivedDescriptor, SequenceDescriptor } from './derived';
24
- import { rejectHoles, functionArgSignature } from './derived';
24
+ import { rejectHoles, functionArgSignature, isBareRoleName, BARE_ROLE_NAME } from './derived';
25
25
  import { deriveFunctionSchemas } from './derive';
26
26
  import { parseQualified } from './qualified';
27
27
  import { linkModels } from './link';
@@ -66,6 +66,20 @@ export interface ModuleDefinition {
66
66
  * baseline fails the gate. Exempt from reconciliation, never exempt from the report.
67
67
  */
68
68
  governedRoles?: string[];
69
+ /**
70
+ * What the database's roles are FOR, where the grants alone cannot tell.
71
+ *
72
+ * `request` — the roles a request can become: the roles the API sets for anon, signed-in and
73
+ * admin requests, and the login the application connects as. The lifecycle catalog check treats
74
+ * every role the models grant to as one a request can be. A least-privilege SECURITY DEFINER
75
+ * role that does not own the tables must still be granted DML, so without this list it counts
76
+ * as a request role, and every trigger function it owns on a lifecycle table is refused.
77
+ * Declared, a declared `owner:` that no request role can reach (and that cannot log in) no
78
+ * longer counts. Undeclared, the check runs as before.
79
+ *
80
+ * Refused: an empty list, `PUBLIC`, and `everystack_lifecycle`.
81
+ */
82
+ roles?: ModuleRoles;
69
83
  /**
70
84
  * Package-owned SQL emitted after the tables — SECURITY DEFINER functions, triggers, etc.
71
85
  * A thunk so it is generated lazily and can take run-specific input (the app's event-trigger
@@ -87,10 +101,39 @@ export interface Module {
87
101
  derived: DerivedDescriptor[];
88
102
  /** Roles this module's authz governs beyond the ones its models name. Widen-only. */
89
103
  governedRoles: string[];
104
+ /** What the roles are for — see {@link ModuleDefinition.roles}. Absent when not declared. */
105
+ roles?: ModuleRoles;
90
106
  /** Function-SQL thunks, normalized to an array so composing modules just concatenates. */
91
107
  functions: Array<() => string>;
92
108
  }
93
109
 
110
+ /** {@link ModuleDefinition.roles}. On a Module, `request` is deduped and sorted. */
111
+ export interface ModuleRoles {
112
+ /** The roles a request can become. */
113
+ request?: string[];
114
+ }
115
+
116
+ /** Validate and normalize `roles.request`: non-empty, real role names, never PUBLIC or the lifecycle role. */
117
+ function normalizeRoles(roles: ModuleRoles): ModuleRoles {
118
+ if (roles.request === undefined) return {};
119
+ if (roles.request.length === 0) {
120
+ throw new Error('defineModule: roles.request is empty. Name every role a request can become (the API\'s request roles and the '
121
+ + 'login the application connects as), or leave roles.request out; an empty list would treat no role as a request role.');
122
+ }
123
+ for (const role of roles.request) {
124
+ if (role === 'PUBLIC' || role === 'public') {
125
+ throw new Error(`defineModule: roles.request names '${role}'. PUBLIC is every role, not one a request becomes; name the roles themselves.`);
126
+ }
127
+ if (role === LIFECYCLE_OWNER_ROLE) {
128
+ throw new Error(`defineModule: roles.request names '${role}'. That role is reserved for the lifecycle soft delete; a request must never become it.`);
129
+ }
130
+ if (!isBareRoleName(role)) {
131
+ throw new Error(`defineModule: roles.request names '${role}', which is not a bare lowercase role name (${BARE_ROLE_NAME}).`);
132
+ }
133
+ }
134
+ return { request: [...new Set(roles.request)].sort() };
135
+ }
136
+
94
137
  /** Declare a package's database slice. Empty fields default to nothing — a models-only module is fine. */
95
138
  export function defineModule(def: ModuleDefinition): Module {
96
139
  // The loud undefined gate — a missing import composes as a hole; name it here,
@@ -124,6 +167,7 @@ export function defineModule(def: ModuleDefinition): Module {
124
167
  sequences: def.sequences ?? [],
125
168
  derived: def.derived ?? [],
126
169
  governedRoles: [...new Set(def.governedRoles ?? [])].sort(),
170
+ ...(def.roles ? { roles: normalizeRoles(def.roles) } : {}),
127
171
  functions: def.functions ? [def.functions] : [],
128
172
  };
129
173
  }
@@ -232,3 +276,13 @@ export function moduleGovernedRoles(modules: Module[]): string[] {
232
276
  const lifecycle = modules.some((m) => m.models.some((model) => model.lifecycle));
233
277
  return [...new Set([...modules.flatMap((m) => m.governedRoles), ...(lifecycle ? [LIFECYCLE_OWNER_ROLE] : [])])].sort();
234
278
  }
279
+
280
+ /**
281
+ * The union of every module's request roles, sorted. Undefined when no module declares any, so a
282
+ * caller can tell "not declared" (the lifecycle check runs as before) from a declared list.
283
+ */
284
+ export function moduleRequestRoles(modules: Module[]): string[] | undefined {
285
+ const declared = modules.filter((m) => m.roles?.request !== undefined);
286
+ if (declared.length === 0) return undefined;
287
+ return [...new Set(declared.flatMap((m) => m.roles!.request!))].sort();
288
+ }
package/src/wire.ts CHANGED
@@ -245,6 +245,19 @@ export interface ReconcileWirePayload extends ModelWireEnvelope {
245
245
  * declared object is lifecycle-owned = the reconcile refuses (fail closed).
246
246
  */
247
247
  applicationRoles?: string[];
248
+ /**
249
+ * The roles a request can become (`roles: { request }` on the modules), and every owner a
250
+ * declared derived object names. The catalog check reads them beside `applicationRoles`, so a
251
+ * definer role the models grant to is not mistaken for a request role.
252
+ *
253
+ * OPTIONAL, and added without a contract bump on purpose: absent (an older CLI), the server
254
+ * checks exactly as before. An older server ignores them and runs the check from before they
255
+ * existed: it refuses more, but it skips the live checks on the request roles (missing,
256
+ * superuser, BYPASSRLS). db:apply's CLI-side gates still run those; `db:reconcile --apply
257
+ * --stage` has none, so against an older server it does not. See wire-shape.test.ts.
258
+ */
259
+ requestRoles?: string[];
260
+ declaredOwners?: string[];
248
261
  actor?: string | null;
249
262
  gitRef?: string | null;
250
263
  }