@everystack/model 0.4.19 → 0.4.20

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.19",
3
+ "version": "0.4.20",
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
@@ -138,6 +138,23 @@ export function isPublicReadAbility(ability: Ability): boolean {
138
138
  && (!ability.condition.role || ability.condition.role === 'anon');
139
139
  }
140
140
 
141
+ /**
142
+ * The no-session audience a condition names, spelled as declared — or null.
143
+ *
144
+ * `role: 'anon'` IS the caller with no session; `policyRoles: 'public'` admits that caller. An
145
+ * `owner`/`via` scope compares the row to the caller's JWT claim, which neither can supply, so
146
+ * the pair never does what it says: under anon it matches no row, and under PUBLIC the compiler
147
+ * drops the open audience and emits the signed-in owner policy. ONE definition, because two
148
+ * surfaces enforce it: `defineModel` refuses the pair and `db:pull` never renders it.
149
+ */
150
+ export function sessionlessAudience(
151
+ condition: Pick<AbilityCondition, 'role' | 'policyRoles'>,
152
+ ): "role: 'anon'" | "policyRoles: 'public'" | null {
153
+ if (condition.role === 'anon') return "role: 'anon'";
154
+ if (condition.policyRoles === 'public') return "policyRoles: 'public'";
155
+ return null;
156
+ }
157
+
141
158
  /**
142
159
  * Does this ability hand out DELETE?
143
160
  *
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ export type { TableConstraint } from './constraints';
20
20
  export { sql } from './raw-sql';
21
21
  export type { RawSql } from './raw-sql';
22
22
 
23
- export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete } from './can';
23
+ export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
24
24
  export type { SoftDeleteFacts } from './can';
25
25
  export type { Ability, AbilityCondition, Action } from './can';
26
26
 
package/src/model.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { FieldBuilder } from './field';
15
15
  import type { Ability } from './can';
16
- import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete } from './can';
16
+ import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
17
17
  import type { Relation } from './relations';
18
18
  import type { TableConstraint } from './constraints';
19
19
  import type { RawSql } from './raw-sql';
@@ -317,6 +317,22 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
317
317
  }
318
318
  }
319
319
 
320
+ // `owner`/`via` compare a row column to the caller's JWT claim; a no-session audience has
321
+ // none. Truthiness matches the compiler, which treats `owner: ''` as no row scope.
322
+ const rowScope = a.condition.owner ? 'owner' : a.condition.via ? 'via' : null;
323
+ const audience = sessionlessAudience(a.condition);
324
+ if (rowScope && audience) {
325
+ const outcome = audience === "role: 'anon'"
326
+ ? 'can never match a row: anon has no session'
327
+ : 'is narrower than declared: PUBLIC admits callers with no session, so the open audience is dropped';
328
+ throw new Error(
329
+ `model '${table}': can('${a.action}', { ${audience}, ${rowScope}: '${a.condition[rowScope]}' }) ${outcome}. ` +
330
+ `${rowScope} compares the row to the signed-in user's claim. ` +
331
+ `Either drop ${audience} (the ability then applies to signed-in callers, their own rows), ` +
332
+ `or drop ${rowScope} and scope the anonymous read with columns or where.`,
333
+ );
334
+ }
335
+
320
336
  // `policyRoles` decouples the policy's AUDIENCE from its GRANTEES, and it is deliberately
321
337
  // amputated to one open set. Anything else would be a second spelling of the `role:` axis
322
338
  // and could name a role no grant backs — a policy that reads as access which does not