@everystack/model 0.4.9 → 0.4.10
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/README.md +13 -1
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/model.ts +58 -0
package/README.md
CHANGED
|
@@ -96,7 +96,19 @@ The table-level surface, composed in `constraints: [...]`, for what a column-sco
|
|
|
96
96
|
- `{ where: 'deleted_at IS NULL' }` — a column/scalar predicate, compiled to the policy's `USING` clause.
|
|
97
97
|
- `{ sql: … }` — a verbatim RLS predicate escape hatch.
|
|
98
98
|
|
|
99
|
-
These compile to RLS policies and grants (via `@everystack/cli`'s `compileTableContract`, what `db:generate` emits). `can('manage', …)` covers all actions
|
|
99
|
+
These compile to RLS policies and grants (via `@everystack/cli`'s `compileTableContract`, what `db:generate` emits). `can('manage', …)` covers all four DML actions: `SELECT`, `INSERT`, `UPDATE`, `DELETE`.
|
|
100
|
+
|
|
101
|
+
Exactly those four, and no more. A verb that expanded to "every privilege" would hand out `TRUNCATE`, which no `DELETE` policy filters. The other table privileges are named one at a time:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
defineModel('posts', {
|
|
105
|
+
fields: { … },
|
|
106
|
+
abilities: [can('read'), can('manage', { role: 'admin' })],
|
|
107
|
+
privileges: { admin: ['REFERENCES', 'TRIGGER', 'TRUNCATE'] },
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`privileges` takes only those three, and it is additive — it widens what a role holds and can never narrow it. DML stays in `can()`, where the grant and the row policy are decided together. `db:pull --abilities live` writes this key from the live grants, so an existing database that grants its admin role all three keeps them.
|
|
100
112
|
|
|
101
113
|
## Relations
|
|
102
114
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -22,8 +22,8 @@ export type { Ability, AbilityCondition, Action } from './can';
|
|
|
22
22
|
export { belongsTo, hasMany } from './relations';
|
|
23
23
|
export type { Relation } from './relations';
|
|
24
24
|
|
|
25
|
-
export { defineModel, defineMaterializedTable } from './model';
|
|
26
|
-
export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, MaterializedTableMarker, MaterializedTableDefinition } from './model';
|
|
25
|
+
export { defineModel, defineMaterializedTable, EXTRA_PRIVILEGES } from './model';
|
|
26
|
+
export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, ExtraPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
|
|
27
27
|
|
|
28
28
|
export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
|
|
29
29
|
export type { Module, ModuleDefinition } from './module';
|
package/src/model.ts
CHANGED
|
@@ -50,6 +50,23 @@ export interface ModelHooks {
|
|
|
50
50
|
*/
|
|
51
51
|
export type WrittenBy = 'app' | 'worker' | 'functions';
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* A table privilege that is NOT one of the four CRUD verbs — the ones `can()` has no word for.
|
|
55
|
+
*
|
|
56
|
+
* `can('manage')` means exactly INSERT/SELECT/UPDATE/DELETE, and that is deliberate: a verb
|
|
57
|
+
* that quietly expanded to "every privilege" would hand out TRUNCATE, which is not a DELETE
|
|
58
|
+
* and is not filtered by a DELETE policy. So these three are named one at a time, by the
|
|
59
|
+
* author, in {@link ModelDefinition.privileges}.
|
|
60
|
+
*
|
|
61
|
+
* Why the vocabulary stops here: a real database routinely grants its admin role REFERENCES,
|
|
62
|
+
* TRIGGER and TRUNCATE, and everystack had no way to SAY that — so the first plan against an
|
|
63
|
+
* existing schema revoked all three on every table. That is our spelling, not their bug.
|
|
64
|
+
*/
|
|
65
|
+
export type ExtraPrivilege = 'REFERENCES' | 'TRIGGER' | 'TRUNCATE';
|
|
66
|
+
|
|
67
|
+
/** The declarable privilege set — the one definition; the CLI's classifier reads it too. */
|
|
68
|
+
export const EXTRA_PRIVILEGES: readonly ExtraPrivilege[] = ['REFERENCES', 'TRIGGER', 'TRUNCATE'];
|
|
69
|
+
|
|
53
70
|
export interface ModelDefinition {
|
|
54
71
|
fields: Record<string, FieldBuilder>;
|
|
55
72
|
abilities?: Ability[];
|
|
@@ -59,6 +76,21 @@ export interface ModelDefinition {
|
|
|
59
76
|
constraints?: TableConstraint[];
|
|
60
77
|
/** The write principal — drives RLS posture. Default `'app'`. See {@link WrittenBy}. */
|
|
61
78
|
writtenBy?: WrittenBy;
|
|
79
|
+
/**
|
|
80
|
+
* Privileges a role holds on this table BEYOND the CRUD an ability compiles to —
|
|
81
|
+
* `privileges: { admin: ['REFERENCES', 'TRIGGER', 'TRUNCATE'] }`.
|
|
82
|
+
*
|
|
83
|
+
* Only {@link ExtraPrivilege} values are accepted. The four DML privileges are NOT
|
|
84
|
+
* declarable here on purpose: `SELECT`/`INSERT`/`UPDATE`/`DELETE` come from `can()`, which
|
|
85
|
+
* decides the grant and the RLS policy together. A grant declared without its policy is a
|
|
86
|
+
* privilege with no row gate — the exact shape `db:pull` refuses to render.
|
|
87
|
+
*
|
|
88
|
+
* ADDITIVE, never subtractive: these are unioned with what the abilities grant, so this key
|
|
89
|
+
* can widen a role's privileges and can never narrow them. Naming a role here GOVERNS it —
|
|
90
|
+
* same rule as `can(…, { role })` — so a role that appears only in this map has its other
|
|
91
|
+
* privileges reconciled everywhere, not just on this table.
|
|
92
|
+
*/
|
|
93
|
+
privileges?: Record<string, ExtraPrivilege[]>;
|
|
62
94
|
/**
|
|
63
95
|
* `true` excludes soft-deleted rows (`deleted_at IS NULL`) from the public read policies
|
|
64
96
|
* and from the generic data API. **Required — not inferred — on any model that has a
|
|
@@ -132,6 +164,8 @@ export interface ModelDescriptor {
|
|
|
132
164
|
constraints: TableConstraint[];
|
|
133
165
|
/** The resolved write principal (default `'app'`) — drives RLS posture. See {@link WrittenBy}. */
|
|
134
166
|
writtenBy: WrittenBy;
|
|
167
|
+
/** Beyond-CRUD privileges per role, unioned with what the abilities grant. See {@link ModelDefinition.privileges}. */
|
|
168
|
+
privileges: Record<string, ExtraPrivilege[]>;
|
|
135
169
|
/** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
|
|
136
170
|
softDelete: boolean;
|
|
137
171
|
/** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
|
|
@@ -207,6 +241,26 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
207
241
|
);
|
|
208
242
|
}
|
|
209
243
|
|
|
244
|
+
// A privilege outside the beyond-CRUD set is refused rather than passed through. The DML
|
|
245
|
+
// four are the dangerous ones: declared here they would compile to a GRANT with no policy
|
|
246
|
+
// beside it, which is a privilege nobody row-gated. They come from can(), where the grant
|
|
247
|
+
// and the policy are decided in one place.
|
|
248
|
+
for (const [role, privs] of Object.entries(def.privileges ?? {})) {
|
|
249
|
+
if (!Array.isArray(privs)) {
|
|
250
|
+
throw new Error(`model '${table}': privileges['${role}'] must be an array of ${EXTRA_PRIVILEGES.join(' | ')}.`);
|
|
251
|
+
}
|
|
252
|
+
for (const p of privs) {
|
|
253
|
+
if (EXTRA_PRIVILEGES.includes(p)) continue;
|
|
254
|
+
const dml = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].includes(String(p).toUpperCase());
|
|
255
|
+
throw new Error(
|
|
256
|
+
`model '${table}': privileges['${role}'] contains '${p}', which is not one of ${EXTRA_PRIVILEGES.join(', ')}. `
|
|
257
|
+
+ (dml
|
|
258
|
+
? `Declare ${String(p).toUpperCase()} with can() — that is what decides the row policy beside the grant. A grant declared here would have no policy.`
|
|
259
|
+
: `This key names only the table privileges can() has no verb for.`),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
210
264
|
validateTriggersFor('table', table, def.triggers ?? []);
|
|
211
265
|
|
|
212
266
|
// A rename and a move in one increment can't be composed from two independent markers
|
|
@@ -230,6 +284,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
230
284
|
primaryKey,
|
|
231
285
|
constraints: def.constraints ?? [],
|
|
232
286
|
writtenBy: def.writtenBy ?? 'app',
|
|
287
|
+
privileges: def.privileges ?? {},
|
|
233
288
|
softDelete: def.softDelete ?? false,
|
|
234
289
|
schema: def.schema ?? 'public',
|
|
235
290
|
private: def.private ?? false,
|
|
@@ -266,6 +321,8 @@ export interface MaterializedTableDefinition {
|
|
|
266
321
|
writtenBy?: WrittenBy;
|
|
267
322
|
private?: boolean;
|
|
268
323
|
renamedFrom?: string;
|
|
324
|
+
/** Beyond-CRUD privileges per role. See {@link ModelDefinition.privileges}. */
|
|
325
|
+
privileges?: Record<string, ExtraPrivilege[]>;
|
|
269
326
|
}
|
|
270
327
|
|
|
271
328
|
export function defineMaterializedTable(table: string, def: MaterializedTableDefinition): ModelDescriptor {
|
|
@@ -282,6 +339,7 @@ export function defineMaterializedTable(table: string, def: MaterializedTableDef
|
|
|
282
339
|
constraints: def.constraints,
|
|
283
340
|
writtenBy: def.writtenBy ?? 'worker',
|
|
284
341
|
private: def.private,
|
|
342
|
+
...(def.privileges ? { privileges: def.privileges } : {}),
|
|
285
343
|
...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
|
|
286
344
|
});
|
|
287
345
|
model.materialized = {
|