@everystack/model 0.4.6 → 0.4.8

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.6",
3
+ "version": "0.4.8",
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/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/index.ts CHANGED
@@ -25,7 +25,7 @@ export type { Relation } from './relations';
25
25
  export { defineModel, defineMaterializedTable } from './model';
26
26
  export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, MaterializedTableMarker, MaterializedTableDefinition } from './model';
27
27
 
28
- export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived } from './module';
28
+ export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
29
29
  export type { Module, ModuleDefinition } from './module';
30
30
 
31
31
  export {
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 } : {}),
package/src/module.ts CHANGED
@@ -41,6 +41,28 @@ export interface ModuleDefinition {
41
41
  * deployed by `db:reconcile`, never migrated. Supersedes the `functions` thunk.
42
42
  */
43
43
  derived?: DerivedDescriptor[];
44
+ /**
45
+ * Roles this module's authz GOVERNS, beyond the ones its models already name.
46
+ *
47
+ * The reconciler treats declared authz as authoritative over the roles it governs and
48
+ * leaves every other grantee alone. Without that, a live role the model vocabulary
49
+ * cannot express — a migrator, an ETL account, a BI reader that predates everystack —
50
+ * has every privilege REVOKED the first time a plan is applied, because the reconciler
51
+ * unions declared ∪ live grantees and finds no declaration. On the first brownfield
52
+ * schema this was measured against, that was 32 revokes that would have broken the
53
+ * adopter's deploy pipeline mid-deploy.
54
+ *
55
+ * This list only ever WIDENS the governed set: `PUBLIC` and the fixed vocabulary
56
+ * (`anon`, `authenticated`, `admin`) plus every role the models name are always
57
+ * governed, and naming them here changes nothing. You cannot use this to stop the
58
+ * reconciler governing a role your models grant to — that would be a way to hide a
59
+ * grant from the very thing that audits it.
60
+ *
61
+ * An ungoverned grantee is NOT ignored: every plan, check and dry-run enumerates it by
62
+ * name with its exact privileges, and a grantee that is not in the stage's adoption
63
+ * baseline fails the gate. Exempt from reconciliation, never exempt from the report.
64
+ */
65
+ governedRoles?: string[];
44
66
  /**
45
67
  * Package-owned SQL emitted after the tables — SECURITY DEFINER functions, triggers, etc.
46
68
  * A thunk so it is generated lazily and can take run-specific input (the app's event-trigger
@@ -60,6 +82,8 @@ export interface Module {
60
82
  sequences: SequenceDescriptor[];
61
83
  /** Derived (compute) objects — reconciled, never migrated. */
62
84
  derived: DerivedDescriptor[];
85
+ /** Roles this module's authz governs beyond the ones its models name. Widen-only. */
86
+ governedRoles: string[];
63
87
  /** Function-SQL thunks, normalized to an array so composing modules just concatenates. */
64
88
  functions: Array<() => string>;
65
89
  }
@@ -95,6 +119,7 @@ export function defineModule(def: ModuleDefinition): Module {
95
119
  extensions: def.extensions ?? [],
96
120
  sequences: def.sequences ?? [],
97
121
  derived: def.derived ?? [],
122
+ governedRoles: [...new Set(def.governedRoles ?? [])].sort(),
98
123
  functions: def.functions ? [def.functions] : [],
99
124
  };
100
125
  }
@@ -124,3 +149,8 @@ export function moduleSequences(modules: Module[]): SequenceDescriptor[] {
124
149
  export function moduleDerived(modules: Module[]): DerivedDescriptor[] {
125
150
  return modules.flatMap((m) => m.derived);
126
151
  }
152
+
153
+ /** The union of every module's governed roles — deduped and sorted. Widen-only by construction. */
154
+ export function moduleGovernedRoles(modules: Module[]): string[] {
155
+ return [...new Set(modules.flatMap((m) => m.governedRoles))].sort();
156
+ }