@everystack/model 0.4.8 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
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/can.ts CHANGED
@@ -28,6 +28,26 @@ export interface AbilityCondition {
28
28
  via?: string;
29
29
  /** Field in the JWT claims holding the owner id. Default: `sub`. */
30
30
  userField?: string;
31
+ /**
32
+ * The SQL expression that yields the signed-in user's id — the RIGHT side of the owner
33
+ * comparison. Default: the JWT claim read inline,
34
+ * `((current_setting('request.jwt.claims'::text, true))::jsonb ->> '<userField>'::text)`,
35
+ * cast to the owner column's type.
36
+ *
37
+ * Declare it when the database already has its own accessor — `auth.user_id()` is the
38
+ * common brownfield shape — so the compiled policy is the one that is already live
39
+ * rather than a semantically-similar rewrite of it. That matters more than it sounds:
40
+ * predicates are diffed as TEXT, so an equivalent-but-different expression is a
41
+ * DROP + CREATE on every owner policy in the schema, which is exactly the churn a
42
+ * first plan against an existing database must not produce.
43
+ *
44
+ * Deliberately per-ability, beside `userField`, and with no project-wide default: a
45
+ * single setting that silently repoints every owner policy in a schema is a worse
46
+ * security surface than saying it where it applies. When declared, the expression is
47
+ * emitted VERBATIM — no cast is added, because the accessor already returns the
48
+ * column's type (`auth.user_id() RETURNS integer` against an integer owner).
49
+ */
50
+ ownerExpr?: unknown;
31
51
  /**
32
52
  * Column-scoped exposure for a `read` ability. Names the field keys a role may read
33
53
  * (compiled to a `GRANT SELECT (cols)` column grant, not a table grant) instead of the
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 = {