@everystack/model 0.4.7 → 0.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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/derive.ts CHANGED
@@ -83,8 +83,9 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
83
83
  };
84
84
  }
85
85
 
86
- // Convention: a `deletedAt` field opts the table into soft-delete.
87
- if (SOFT_DELETE_COLUMN in model.fields) softDeleteTables.push(table);
86
+ // Declared, not inferred — one `softDelete` decision drives both the API filter and the
87
+ // RLS guard, so the two can never disagree about what a soft-deleted row is.
88
+ if (model.softDelete) softDeleteTables.push(table);
88
89
 
89
90
  if (model.hooks) hooks[table] = model.hooks;
90
91
 
package/src/model.ts CHANGED
@@ -59,6 +59,20 @@ export interface ModelDefinition {
59
59
  constraints?: TableConstraint[];
60
60
  /** The write principal — drives RLS posture. Default `'app'`. See {@link WrittenBy}. */
61
61
  writtenBy?: WrittenBy;
62
+ /**
63
+ * `true` excludes soft-deleted rows (`deleted_at IS NULL`) from the public read policies
64
+ * and from the generic data API. **Required — not inferred — on any model that has a
65
+ * `deletedAt` field and a public read.**
66
+ *
67
+ * It used to be inferred from the field's mere presence. A column named `deleted_at` is
68
+ * not a statement of visibility intent: it is carried for audit, for app-layer filtering,
69
+ * for half-finished migrations. Inferring the predicate made the compiler author a
70
+ * security rule nobody wrote — and because `db:pull` renders `deletedAt` straight from the
71
+ * live column, a brownfield model acquired it automatically and the FIRST plan narrowed a
72
+ * policy the adopter had written. `createHandler`'s own `softDelete: { column, tables }`
73
+ * was always explicit; this matches it. Convention may propose it, never enforce it.
74
+ */
75
+ softDelete?: boolean;
62
76
  /**
63
77
  * The Postgres schema the table lives in. Default `'public'`. Package-owned
64
78
  * tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
@@ -118,6 +132,8 @@ export interface ModelDescriptor {
118
132
  constraints: TableConstraint[];
119
133
  /** The resolved write principal (default `'app'`) — drives RLS posture. See {@link WrittenBy}. */
120
134
  writtenBy: WrittenBy;
135
+ /** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
136
+ softDelete: boolean;
121
137
  /** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
122
138
  schema: string;
123
139
  /** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
@@ -174,6 +190,23 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
174
190
  }
175
191
  }
176
192
 
193
+ // The soft-delete guard used to be inferred from the field's presence, which meant a model
194
+ // could gain — or, once the inference was removed, silently LOSE — a predicate that decides
195
+ // what anonymous users can see, with nothing in any diff to review. Neither default is safe,
196
+ // so there is no default: on the exact set of models where the answer changes behaviour (a
197
+ // `deletedAt` field plus a public read), it must be declared. `softDelete: true` is what the
198
+ // inference used to do.
199
+ const hasPublicRead = (def.abilities ?? []).some(
200
+ (a) => a.action === 'read' && !a.condition.role && !a.condition.owner && !a.condition.via,
201
+ );
202
+ if ('deletedAt' in def.fields && hasPublicRead && def.softDelete === undefined) {
203
+ throw new Error(
204
+ `model '${table}': has a 'deletedAt' field and a public read, so it must declare softDelete. `
205
+ + `softDelete: true excludes soft-deleted rows from the public read policies (what the compiler used to infer from the field's presence); `
206
+ + `softDelete: false leaves them readable. This is a visibility decision, so it is declared, not guessed.`,
207
+ );
208
+ }
209
+
177
210
  validateTriggersFor('table', table, def.triggers ?? []);
178
211
 
179
212
  // A rename and a move in one increment can't be composed from two independent markers
@@ -197,6 +230,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
197
230
  primaryKey,
198
231
  constraints: def.constraints ?? [],
199
232
  writtenBy: def.writtenBy ?? 'app',
233
+ softDelete: def.softDelete ?? false,
200
234
  schema: def.schema ?? 'public',
201
235
  private: def.private ?? false,
202
236
  ...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),