@everystack/model 0.4.15 → 0.4.17
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 +50 -1
- package/package.json +3 -1
- package/src/can.ts +74 -0
- package/src/derived.ts +17 -0
- package/src/index.ts +2 -1
- package/src/model.ts +48 -11
package/README.md
CHANGED
|
@@ -110,6 +110,50 @@ defineModel('posts', {
|
|
|
110
110
|
|
|
111
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.
|
|
112
112
|
|
|
113
|
+
## Soft delete
|
|
114
|
+
|
|
115
|
+
A `deletedAt` column is a column. `softDelete` is the decision about what it MEANS, and it sits
|
|
116
|
+
on the model beside `fields`:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
export const Upload = defineModel('uploads', {
|
|
120
|
+
fields: {
|
|
121
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
122
|
+
accountId: field.uuid().notNull().references(() => Account),
|
|
123
|
+
s3Key: field.text().notNull(),
|
|
124
|
+
deletedAt: field.timestamptz(),
|
|
125
|
+
},
|
|
126
|
+
softDelete: true,
|
|
127
|
+
abilities: [can('read', { owner: 'accountId' }), can('delete', { owner: 'accountId' })],
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
It changes two different things, and neither default is safe:
|
|
132
|
+
|
|
133
|
+
- **Durability, on every model** — `true` makes a `DELETE` mark `deleted_at` instead of removing
|
|
134
|
+
the row, and hides marked rows from the data API's reads and updates. `false` makes `DELETE`
|
|
135
|
+
permanent.
|
|
136
|
+
- **Visibility, only where there is a public read** — `true` AND-s `deleted_at IS NULL` into the
|
|
137
|
+
anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either way,
|
|
138
|
+
so durability is the axis that matters there.
|
|
139
|
+
|
|
140
|
+
**`defineModel` refuses to guess** when a model has `deletedAt` AND either a public read or a way
|
|
141
|
+
to DELETE (`can('delete')`, `can('manage')`, or a `privileges` DELETE grant). Omit the line on
|
|
142
|
+
such a model and it throws at import, naming which branch it tripped. Where nothing reads
|
|
143
|
+
publicly and nothing deletes, the flag provably changes neither the SQL nor the handler, so
|
|
144
|
+
nothing is required.
|
|
145
|
+
|
|
146
|
+
Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or purge
|
|
147
|
+
job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
|
|
148
|
+
audit-only and a `DELETE` really should remove the row. It is not cosmetic: a table whose S3
|
|
149
|
+
cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should have purged if
|
|
150
|
+
the row is hard-deleted instead.
|
|
151
|
+
|
|
152
|
+
A column named `deleted_at` is not a statement of intent — it is carried for audit, for
|
|
153
|
+
app-layer filtering, for half-finished migrations. This used to be inferred from the field's
|
|
154
|
+
presence, which made the compiler author a security rule nobody wrote. `db:pull` writes the
|
|
155
|
+
flag explicitly for the same reason.
|
|
156
|
+
|
|
113
157
|
## Relations
|
|
114
158
|
|
|
115
159
|
```typescript
|
|
@@ -137,13 +181,18 @@ everystack db:pull --stage dev # reverse an existing database into model s
|
|
|
137
181
|
|
|
138
182
|
### Handler config
|
|
139
183
|
|
|
140
|
-
`deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership) — so the HTTP handler and the database enforce the *same* declared rules, with no second source of truth.
|
|
184
|
+
`deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership, **`softDelete.tables`**) — so the HTTP handler and the database enforce the *same* declared rules, with no second source of truth.
|
|
141
185
|
|
|
142
186
|
```typescript
|
|
143
187
|
import { deriveHandlerConfig } from '@everystack/model';
|
|
144
188
|
const handler = createHandler(db, schema, deriveHandlerConfig(models));
|
|
145
189
|
```
|
|
146
190
|
|
|
191
|
+
Do not hand-write the per-table keys beside it. Each one is a view of the models, and a
|
|
192
|
+
hand-kept copy drifts silently: `softDelete.tables` losing two entries is what let a table start
|
|
193
|
+
hard-deleting rows whose S3 objects a purge job keyed on `deleted_at IS NOT NULL`. Handler-level
|
|
194
|
+
options that are not per-table — `auth`, `pgSettings`, `rpc`, limits — stay explicit.
|
|
195
|
+
|
|
147
196
|
## Programmatic API (`@everystack/cli/model`)
|
|
148
197
|
|
|
149
198
|
The same compiler the CLI uses is importable for your own CI and tests — point it at your live DB and assert the declaration and the database agree, or red-team the deployed authorization:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/model",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
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": {
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
],
|
|
13
13
|
"exports": {
|
|
14
14
|
".": {
|
|
15
|
+
"source": "./src/index.ts",
|
|
15
16
|
"types": "./src/index.ts",
|
|
16
17
|
"default": "./src/index.ts"
|
|
17
18
|
},
|
|
18
19
|
"./drizzle": {
|
|
20
|
+
"source": "./src/drizzle.ts",
|
|
19
21
|
"types": "./src/drizzle.ts",
|
|
20
22
|
"default": "./src/drizzle.ts"
|
|
21
23
|
}
|
package/src/can.ts
CHANGED
|
@@ -138,6 +138,80 @@ export function isPublicReadAbility(ability: Ability): boolean {
|
|
|
138
138
|
&& (!ability.condition.role || ability.condition.role === 'anon');
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Does this ability hand out DELETE?
|
|
143
|
+
*
|
|
144
|
+
* `manage` is "all actions" and compiles to the admin-bypass ALL policy, so it carries DELETE
|
|
145
|
+
* even though nothing spells it. Counting only the literal `delete` verb would leave the
|
|
146
|
+
* soft-delete hazard one grant over — which is exactly the shape `@everystack/auth`'s own
|
|
147
|
+
* user model has: `deletedAt`, no public read, no `can('delete')`, and an admin `manage` that
|
|
148
|
+
* can still destroy the row.
|
|
149
|
+
*/
|
|
150
|
+
export function isDeleteAbility(ability: Ability): boolean {
|
|
151
|
+
return ability.action === 'delete' || ability.action === 'manage';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Does a `privileges` map hand out DELETE?
|
|
156
|
+
*
|
|
157
|
+
* The second spelling of a reachable DELETE, and the one that slipped the first version of
|
|
158
|
+
* this rule. `can()` decides a grant AND a policy together; `privileges` is the key for a
|
|
159
|
+
* grant that has no policy — which is exactly what `db:pull` renders when it finds a live
|
|
160
|
+
* DELETE grant no policy covers. On a `rls: false` table that grant is unrestricted, so the
|
|
161
|
+
* table can be destroyed row by row without a single `can('delete')` in the model.
|
|
162
|
+
*
|
|
163
|
+
* Takes the map rather than a descriptor because both callers already hold one in the same
|
|
164
|
+
* shape (a model's `privileges`, and `db:pull`'s `DerivedAbilities.privileges`), so unlike the
|
|
165
|
+
* ability axis this one needs no second representation and cannot drift at all.
|
|
166
|
+
*/
|
|
167
|
+
export function privilegesGrantDelete(privileges: Record<string, readonly string[]> | undefined): boolean {
|
|
168
|
+
return Object.values(privileges ?? {}).some((privs) => privs.includes('DELETE'));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The three facts the soft-delete rule turns on. See {@link needsSoftDeleteDeclaration}. */
|
|
172
|
+
export interface SoftDeleteFacts {
|
|
173
|
+
/** A `deletedAt` / `deleted_at` field is present. */
|
|
174
|
+
hasDeletedAtField: boolean;
|
|
175
|
+
/** Some ability is a PUBLIC read — see {@link isPublicReadAbility}. */
|
|
176
|
+
hasPublicRead: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* A DELETE can reach this table — via an ability ({@link isDeleteAbility}) or via a
|
|
179
|
+
* `privileges` grant ({@link privilegesGrantDelete}). The SPELLING of the grant was never
|
|
180
|
+
* load-bearing; what matters is that rows can be destroyed.
|
|
181
|
+
*/
|
|
182
|
+
grantsDelete: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* THE rule deciding when `softDelete` must be declared rather than defaulted.
|
|
187
|
+
*
|
|
188
|
+
* `softDelete` changes observable behaviour on TWO axes, and the refusal has to cover both:
|
|
189
|
+
*
|
|
190
|
+
* - **visibility** — on a model with a public read, `true` AND-s `deleted_at IS NULL` into the
|
|
191
|
+
* anon policy. What a caller with no session can see.
|
|
192
|
+
* - **durability** — on ANY model that can be deleted from, `true` makes the handler mark
|
|
193
|
+
* `deleted_at` instead of removing the row. What a DELETE destroys.
|
|
194
|
+
*
|
|
195
|
+
* The guard originally asked only the visibility question, so a model with an owner-scoped
|
|
196
|
+
* read and a `delete` ability sailed past it and silently defaulted to `false`. That is how
|
|
197
|
+
* `uploads` and `image_variants` began hard-deleting rows, orphaning their S3 objects forever
|
|
198
|
+
* — on those models `softDelete: true` adds no policy predicate at all, so visibility was
|
|
199
|
+
* never the axis that mattered.
|
|
200
|
+
*
|
|
201
|
+
* Deliberately NOT "any model with a `deletedAt` field": where nothing reads publicly and
|
|
202
|
+
* nothing deletes, the flag provably changes neither SQL nor handler behaviour, and refusing
|
|
203
|
+
* there would be ceremony charged to every brownfield pull for no hazard.
|
|
204
|
+
*
|
|
205
|
+
* Stated over FACTS, not over a descriptor, because it has two callers holding two
|
|
206
|
+
* representations: `defineModel` has structured {@link Ability} objects, and `db:pull`'s
|
|
207
|
+
* renderer only ever holds rendered source text. Both extract the same three facts and share
|
|
208
|
+
* this one boolean, so the two surfaces cannot drift on the rule itself — the failure that
|
|
209
|
+
* already bit this guard once, when the refusal and the renderer disagreed about `role: 'anon'`.
|
|
210
|
+
*/
|
|
211
|
+
export function needsSoftDeleteDeclaration(facts: SoftDeleteFacts): boolean {
|
|
212
|
+
return facts.hasDeletedAtField && (facts.hasPublicRead || facts.grantsDelete);
|
|
213
|
+
}
|
|
214
|
+
|
|
141
215
|
/** Declare one authorization rule. */
|
|
142
216
|
export function can(action: Action, condition: AbilityCondition = {}): Ability {
|
|
143
217
|
return { action, condition };
|
package/src/derived.ts
CHANGED
|
@@ -416,6 +416,15 @@ export interface FunctionDefinition {
|
|
|
416
416
|
* PUBLIC-executable. Opinion lives in the linter, never in the parser.
|
|
417
417
|
*/
|
|
418
418
|
privileges?: Record<string, string[]>;
|
|
419
|
+
/**
|
|
420
|
+
* Owner-only by design — no app role may ever EXECUTE this function.
|
|
421
|
+
*
|
|
422
|
+
* Parallel to `private` on views/matviews. An `internal` function is a helper called only
|
|
423
|
+
* by other SECURITY DEFINER functions running as the owner. The compiler still emits
|
|
424
|
+
* `REVOKE ALL … FROM PUBLIC`; `internal` tells the lint (`db:check`) the absence of callers
|
|
425
|
+
* is reviewed and permanent, not an oversight. Contradicts `abilities` and `privileges`.
|
|
426
|
+
*/
|
|
427
|
+
internal?: boolean;
|
|
419
428
|
comment?: string;
|
|
420
429
|
body: RawSql;
|
|
421
430
|
}
|
|
@@ -436,6 +445,7 @@ export interface FunctionDescriptor {
|
|
|
436
445
|
abilities: Ability[];
|
|
437
446
|
/** Recorded EXECUTE grants — see {@link FunctionDefinition.privileges}. */
|
|
438
447
|
privileges: Record<string, string[]>;
|
|
448
|
+
internal: boolean;
|
|
439
449
|
comment?: string;
|
|
440
450
|
body: string;
|
|
441
451
|
}
|
|
@@ -487,6 +497,12 @@ export function defineFunction(name: string, def: FunctionDefinition): FunctionD
|
|
|
487
497
|
if (seen.has(a.name)) throw new Error(`function '${name}': duplicate argument '${a.name}'.`);
|
|
488
498
|
seen.add(a.name);
|
|
489
499
|
}
|
|
500
|
+
if (def.internal && (def.abilities?.length ?? 0) > 0) {
|
|
501
|
+
throw new Error(`function '${name}': internal: true declares it owner-only — it cannot also declare abilities. Drop one.`);
|
|
502
|
+
}
|
|
503
|
+
if (def.internal && Object.keys(def.privileges ?? {}).length > 0) {
|
|
504
|
+
throw new Error(`function '${name}': internal: true declares it owner-only — it cannot also declare privileges. Drop one.`);
|
|
505
|
+
}
|
|
490
506
|
for (const a of def.abilities ?? []) {
|
|
491
507
|
if (a.action !== 'execute') {
|
|
492
508
|
throw new Error(`function '${name}': can('${a.action}') is not a function ability — functions carry can('execute', { role }) only.`);
|
|
@@ -526,6 +542,7 @@ export function defineFunction(name: string, def: FunctionDefinition): FunctionD
|
|
|
526
542
|
dependsOn: def.dependsOn ?? [],
|
|
527
543
|
abilities: def.abilities ?? [],
|
|
528
544
|
privileges,
|
|
545
|
+
internal: def.internal ?? false,
|
|
529
546
|
...(def.comment ? { comment: def.comment } : {}),
|
|
530
547
|
body: def.body.sql,
|
|
531
548
|
};
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,8 @@ 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 } from './can';
|
|
23
|
+
export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete } from './can';
|
|
24
|
+
export type { SoftDeleteFacts } from './can';
|
|
24
25
|
export type { Ability, AbilityCondition, Action } from './can';
|
|
25
26
|
|
|
26
27
|
export { belongsTo, hasMany } from './relations';
|
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 } from './can';
|
|
16
|
+
import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete } from './can';
|
|
17
17
|
import type { Relation } from './relations';
|
|
18
18
|
import type { TableConstraint } from './constraints';
|
|
19
19
|
import type { RawSql } from './raw-sql';
|
|
@@ -125,9 +125,27 @@ export interface ModelDefinition {
|
|
|
125
125
|
*/
|
|
126
126
|
privileges?: Record<string, TablePrivilege[]>;
|
|
127
127
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
128
|
+
* What a soft delete MEANS on this table. It drives two different machines, and both
|
|
129
|
+
* consequences are real — a model declaring it should expect all of this:
|
|
130
|
+
*
|
|
131
|
+
* - **The generic data API** (`deriveHandlerConfig` → `createHandler`): `DELETE` becomes
|
|
132
|
+
* `UPDATE SET deleted_at = now()` instead of removing the row; and soft-deleted rows are
|
|
133
|
+
* then filtered out of GET (for EVERY role, unless `?deleted=include|only`), out of
|
|
134
|
+
* embedded relation reads, and out of UPDATE. This half applies to every model, whatever
|
|
135
|
+
* its audience.
|
|
136
|
+
* - **RLS**: `deleted_at IS NULL` is AND-ed into the PUBLIC read policies. This half applies
|
|
137
|
+
* only where there is a public read — on an owner-scoped or admin-only model the compiled
|
|
138
|
+
* contract is byte-identical either way (pinned in cli's `public-read-pin.test.ts`).
|
|
139
|
+
*
|
|
140
|
+
* **Required — not inferred — on any model with a `deletedAt` field that either has a public
|
|
141
|
+
* read OR grants DELETE** (`can('delete')`, or `can('manage')`, which is "all actions"). See
|
|
142
|
+
* {@link needsSoftDeleteDeclaration}, the one rule this and `db:pull` share.
|
|
143
|
+
*
|
|
144
|
+
* The guard used to ask only about the public read, which is the visibility axis. That let a
|
|
145
|
+
* model with an owner-scoped read and a `delete` ability default to `false` and begin
|
|
146
|
+
* destroying rows — with an S3 purge job keyed on `deleted_at IS NOT NULL`, that orphaned
|
|
147
|
+
* every file it should have cleaned up. On those models the flag adds no policy at all, so
|
|
148
|
+
* visibility was never the axis that mattered.
|
|
131
149
|
*
|
|
132
150
|
* It used to be inferred from the field's mere presence. A column named `deleted_at` is
|
|
133
151
|
* not a statement of visibility intent: it is carried for audit, for app-layer filtering,
|
|
@@ -345,9 +363,12 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
345
363
|
// The soft-delete guard used to be inferred from the field's presence, which meant a model
|
|
346
364
|
// could gain — or, once the inference was removed, silently LOSE — a predicate that decides
|
|
347
365
|
// what anonymous users can see, with nothing in any diff to review. Neither default is safe,
|
|
348
|
-
// so there is no default: on the exact set of models where the answer changes behaviour
|
|
349
|
-
//
|
|
350
|
-
//
|
|
366
|
+
// so there is no default: on the exact set of models where the answer changes behaviour, it
|
|
367
|
+
// must be declared. `softDelete: true` is what the inference used to do.
|
|
368
|
+
//
|
|
369
|
+
// That set is NOT "has a public read". It is the union of two axes — see
|
|
370
|
+
// `needsSoftDeleteDeclaration`, which is the whole rule and is shared with db:pull so the two
|
|
371
|
+
// surfaces cannot drift on it.
|
|
351
372
|
// `isPublicReadAbility`, not a local copy: this refusal and db:pull's renderer were asking
|
|
352
373
|
// the same question — "can a caller with no session see these rows?" — and answering it
|
|
353
374
|
// differently. The renderer already counted `role: 'anon'`; this refusal did not, so a table
|
|
@@ -356,15 +377,31 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
356
377
|
// spelled. Explicit `softDelete: false` remains the one-line answer, and pull writes that
|
|
357
378
|
// line into every model it renders, so a pulled checkout keeps loading.
|
|
358
379
|
const hasPublicRead = (def.abilities ?? []).some(isPublicReadAbility);
|
|
380
|
+
// Either spelling of a reachable DELETE. `privileges` matters most on an `rls: false` table,
|
|
381
|
+
// where the grant is unrestricted and no policy stands between it and every row.
|
|
382
|
+
const grantsDelete = (def.abilities ?? []).some(isDeleteAbility) || privilegesGrantDelete(def.privileges);
|
|
359
383
|
// Both spellings of the field key. The guard used to test `'deletedAt' in def.fields`
|
|
360
384
|
// literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
|
|
361
385
|
// schema pulls as — carried a soft-delete column past a guard that never looked at it.
|
|
362
386
|
const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
|
|
363
|
-
if (softDeleteField
|
|
387
|
+
if (needsSoftDeleteDeclaration({ hasDeletedAtField: softDeleteField, hasPublicRead, grantsDelete }) && def.softDelete === undefined) {
|
|
388
|
+
// DURABILITY FIRST. The old message called this "a visibility decision", which is false
|
|
389
|
+
// for exactly the models that broke: with no public read, `softDelete: true` adds no
|
|
390
|
+
// policy predicate at all. Naming the wrong consequence teaches the wrong fix, so each
|
|
391
|
+
// axis is stated only when it actually fired.
|
|
392
|
+
const why = grantsDelete
|
|
393
|
+
? `grants DELETE (via can('delete'), can('manage') — which is "all actions" — or a privileges DELETE grant), `
|
|
394
|
+
+ `so it must declare softDelete. `
|
|
395
|
+
+ `This is a durability decision: softDelete: true makes a DELETE mark deleted_at instead of destroying the row; `
|
|
396
|
+
+ `softDelete: false makes DELETE permanent.`
|
|
397
|
+
: `has a public read, so it must declare softDelete.`;
|
|
398
|
+
const visibility = hasPublicRead
|
|
399
|
+
? ` It is also a visibility decision: softDelete: true excludes soft-deleted rows from the public read policies `
|
|
400
|
+
+ `(what the compiler used to infer from the field's presence); softDelete: false leaves them readable.`
|
|
401
|
+
: '';
|
|
364
402
|
throw new Error(
|
|
365
|
-
`model '${table}': has a 'deletedAt' field and
|
|
366
|
-
+ `
|
|
367
|
-
+ `softDelete: false leaves them readable. This is a visibility decision, so it is declared, not guessed.`,
|
|
403
|
+
`model '${table}': has a 'deletedAt' field and ${why}${visibility} `
|
|
404
|
+
+ `What a DELETE destroys is declared, not guessed — add softDelete: true or softDelete: false.`,
|
|
368
405
|
);
|
|
369
406
|
}
|
|
370
407
|
|