@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 +15 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/derived.ts +3 -0
- package/src/index.ts +2 -2
- package/src/module.ts +55 -1
- package/src/wire.ts +13 -0
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
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
|
}
|