@everystack/model 0.4.27 → 0.4.28

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 CHANGED
@@ -4,6 +4,12 @@ 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.28] - 2026-11-05
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - refuse a manage ability whose condition would be dropped
12
+
7
13
  ## [0.4.27] - 2026-11-04
8
14
 
9
15
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.27",
3
+ "version": "0.4.28",
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/index.ts CHANGED
@@ -53,6 +53,7 @@ export type {
53
53
 
54
54
  export { deriveHandlerConfig, deriveForceRlsCarveouts, deriveFunctionSchemas } from './derive';
55
55
  export { assertNoLegacySoftDelete } from './legacy-soft-delete';
56
+ export { assertManageScope } from './manage-scope';
56
57
  export type { DerivedHandlerConfig, RelationConfig } from './derive';
57
58
 
58
59
  export { ENSURE_DESTROY_LOG_SQL, DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH } from './destroy-log';
@@ -0,0 +1,30 @@
1
+ import type { Ability } from './can';
2
+
3
+ const CONDITION_KEYS = ['where', 'sql', 'check'] as const;
4
+
5
+ /**
6
+ * A `manage` with no owner and no via compiles to an ALL policy USING true for its role, or, for
7
+ * an authenticated manage on a row-scoped model, to the model's own row scope. Neither path
8
+ * reads the ability's where / sql / check, so `can('manage', { role: 'editor', where })` would
9
+ * let the editor manage every row. Refuse it, whatever the value (`sql\`true\`` included).
10
+ *
11
+ * defineModel calls this, and so does compileTableContract, for a descriptor that never went
12
+ * through defineModel. One message, two callers.
13
+ */
14
+ export function assertManageScope(table: string, abilities: readonly Ability[] | undefined): void {
15
+ for (const a of abilities ?? []) {
16
+ if (a.action !== 'manage' || a.condition.owner || a.condition.via) continue;
17
+ const keys = CONDITION_KEYS.filter((k) => a.condition[k] !== undefined);
18
+ if (keys.length === 0) continue;
19
+ const key = keys.join(', ');
20
+ const role = a.condition.role ?? 'authenticated';
21
+ throw new Error(
22
+ `model '${table}': can('manage', { role: '${role}', ${key} }) would grant every row: ` +
23
+ `a manage without owner or via ignores ${key}. ` +
24
+ // The resolved role, never a bare placeholder: a copied `can('read', { where })` with no
25
+ // role is a public read, wider than the manage it replaces.
26
+ `Declare the four abilities instead (can('read'|'create'|'update'|'delete', { role: '${role}', ${key} })), ` +
27
+ 'or scope it with owner or via.',
28
+ );
29
+ }
30
+ }
package/src/model.ts CHANGED
@@ -22,6 +22,7 @@ import { lifecycleCascadeHarm } from './dependents';
22
22
  import type { RawSql } from './raw-sql';
23
23
  import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
24
24
  import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
25
+ import { assertManageScope } from './manage-scope';
25
26
 
26
27
  import { assertDeleteHookNames, assertDestroyHooks, type AfterLifecycleHook, type BeforeLifecycleHook, type DestroyHook } from './lifecycle-hooks';
27
28
  export type { AfterLifecycleHook, BeforeLifecycleHook, DestroyContext, DestroyHook, HookVeto, LifecycleContext } from './lifecycle-hooks';
@@ -467,6 +468,7 @@ function assertNoSoftDelete(table: string, def: ModelDefinition): void {
467
468
 
468
469
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
469
470
  assertNoSoftDelete(table, def);
471
+ assertManageScope(table, def.abilities);
470
472
  assertDeleteHookNames(table, def.hooks);
471
473
  assertDestroyHooks(table, def.hooks, isLifecycle(def));
472
474
  // Refuse first, then inject: the author-declared collisions are checked against what the author wrote.