@avelonjs/core 0.1.0

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.
@@ -0,0 +1,77 @@
1
+ import type { HttpRequest } from '../adapter'
2
+ import { Forbidden, Invalid } from '../errors'
3
+ import type { GateActor } from './gate'
4
+ import { Gate } from './gate'
5
+
6
+ /** Minimal Zod-like schema surface used by request validation. */
7
+ export interface RequestSchema<TOutput> {
8
+ /** Parses unknown input into a typed value or throws. */
9
+ parse(input: unknown): TOutput
10
+ }
11
+
12
+ /** Optional authorize hook evaluated before schema parsing. */
13
+ export interface FormRequestDefinition<TOutput> {
14
+ /** Zod (or Zod-compatible) schema. */
15
+ schema: RequestSchema<TOutput>
16
+ /** When it returns false, validation throws `Forbidden`. */
17
+ authorize?: (request: HttpRequest, actor: GateActor) => boolean | Promise<boolean>
18
+ }
19
+
20
+ function extractFields(error: unknown): Record<string, readonly string[]> {
21
+ if (
22
+ typeof error === 'object' &&
23
+ error !== null &&
24
+ 'issues' in error &&
25
+ Array.isArray((error as { issues: unknown }).issues)
26
+ ) {
27
+ const fields: Record<string, string[]> = {}
28
+ for (const issue of (error as { issues: Array<{ path?: unknown[]; message?: string }> })
29
+ .issues) {
30
+ const key = Array.isArray(issue.path) ? issue.path.map(String).join('.') || '_form' : '_form'
31
+ const bucket = fields[key] ?? []
32
+ bucket.push(issue.message ?? 'Invalid')
33
+ fields[key] = bucket
34
+ }
35
+ return fields
36
+ }
37
+ return { _form: ['Invalid'] }
38
+ }
39
+
40
+ /** Validation helper that maps schema failures to `Invalid`. */
41
+ export function validateRequest<TOutput>(
42
+ request: HttpRequest,
43
+ schema: RequestSchema<TOutput>,
44
+ ): TOutput {
45
+ try {
46
+ return schema.parse(request.body)
47
+ } catch (error) {
48
+ throw new Invalid('The given data was invalid.', {
49
+ metadata: { fields: extractFields(error) },
50
+ cause: error,
51
+ })
52
+ }
53
+ }
54
+
55
+ /** Form request with Zod parsing and an optional authorize hook. */
56
+ export interface FormRequest<TOutput> {
57
+ validate(request: HttpRequest, actor?: GateActor): Promise<TOutput>
58
+ }
59
+
60
+ /** Creates a request validator matching `UpdatePostRequest.validate(request)`. */
61
+ export function defineRequest<TOutput>(
62
+ definition: FormRequestDefinition<TOutput>,
63
+ ): FormRequest<TOutput> {
64
+ return {
65
+ async validate(request, actor = Gate.actor()) {
66
+ if (definition.authorize !== undefined) {
67
+ const allowed = await definition.authorize(request, actor)
68
+ if (!allowed) {
69
+ throw new Forbidden('This action is unauthorized.', {
70
+ metadata: { ability: 'request', resource: 'form' },
71
+ })
72
+ }
73
+ }
74
+ return validateRequest(request, definition.schema)
75
+ },
76
+ }
77
+ }
@@ -0,0 +1,45 @@
1
+ type HeldTask = () => Promise<void>
2
+
3
+ let depth = 0
4
+ const held: HeldTask[] = []
5
+
6
+ /** Reports whether an enclosing transaction boundary is open. */
7
+ export function isInTransaction(): boolean {
8
+ return depth > 0
9
+ }
10
+
11
+ /** Holds a task until the outermost transaction commits; drops it on rollback. */
12
+ export function holdUntilCommit(task: HeldTask): void {
13
+ held.push(task)
14
+ }
15
+
16
+ /**
17
+ * Runs `callback` inside a commit boundary used by `afterCommit` event dispatch.
18
+ *
19
+ * Nested calls share one boundary. Tasks held with {@link holdUntilCommit} run after the outermost
20
+ * callback resolves, and are discarded if it throws.
21
+ */
22
+ export async function runInTransaction<TResult>(
23
+ callback: () => Promise<TResult>,
24
+ ): Promise<TResult> {
25
+ depth += 1
26
+ try {
27
+ const result = await callback()
28
+ depth -= 1
29
+ if (depth === 0) {
30
+ const tasks = held.splice(0, held.length)
31
+ for (const task of tasks) await task()
32
+ }
33
+ return result
34
+ } catch (error) {
35
+ depth -= 1
36
+ if (depth === 0) held.length = 0
37
+ throw error
38
+ }
39
+ }
40
+
41
+ /** Clears transaction state. Intended for tests. */
42
+ export function resetTransaction(): void {
43
+ depth = 0
44
+ held.length = 0
45
+ }
@@ -0,0 +1,362 @@
1
+ import type { Predicate, QueryIR } from '../query'
2
+ import { Invalid } from '../errors'
3
+ import type { GateActor } from './gate'
4
+
5
+ /** Value a ward ability may return: IR predicate, boolean, or object shorthand. */
6
+ export type WardInput = Predicate | boolean | WardShorthand
7
+
8
+ /** Object shorthand compiled into a portable predicate. */
9
+ export interface WardShorthand {
10
+ readonly or?: readonly WardInput[]
11
+ readonly and?: readonly WardInput[]
12
+ readonly not?: WardInput
13
+ readonly [column: string]: unknown
14
+ }
15
+
16
+ /** Ward abilities declared once and enforced as IR injection plus SQL policy. */
17
+ export interface WardAbilities {
18
+ read: (actor: GateActor) => WardInput
19
+ insert: (actor: GateActor) => WardInput
20
+ update: (actor: GateActor) => WardInput
21
+ delete: (actor: GateActor) => WardInput
22
+ }
23
+
24
+ export type WardAction = keyof WardAbilities
25
+
26
+ type ResourceKey = string | { readonly name: string; readonly table?: string }
27
+
28
+ const wards = new Map<string, { table: string; abilities: WardAbilities }>()
29
+
30
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
31
+
32
+ const REFUSED = [
33
+ 'empty objects',
34
+ 'objects that mix `kind` with shorthand keys',
35
+ 'objects that mix `or`/`and`/`not` with column compares',
36
+ 'non-identifier column names',
37
+ 'arrays, functions, and symbols as predicates',
38
+ ] as const
39
+
40
+ /** Predicate shapes the compiler refuses rather than approximating. */
41
+ export const refusedWardShapes: readonly string[] = REFUSED
42
+
43
+ function resourceName(resource: ResourceKey): string {
44
+ return typeof resource === 'string' ? resource : resource.name
45
+ }
46
+
47
+ function resourceTable(resource: ResourceKey): string {
48
+ if (typeof resource === 'string') return resource
49
+ return resource.table ?? resource.name
50
+ }
51
+
52
+ function isPredicate(value: unknown): value is Predicate {
53
+ return typeof value === 'object' && value !== null && 'kind' in value
54
+ }
55
+
56
+ /**
57
+ * Compiles a ward return value into a portable predicate.
58
+ *
59
+ * `{ published: true }` becomes a compare. `{ or: [...] }` becomes an `or` node. `true`/`false`
60
+ * become `const`. Already-portable predicates pass through.
61
+ */
62
+ export function compileWardShorthand(input: WardInput): Predicate {
63
+ if (typeof input === 'boolean') return { kind: 'const', value: input }
64
+ if (isPredicate(input)) return input
65
+ if (input === null || typeof input !== 'object') {
66
+ throw refused('arrays, functions, and symbols as predicates')
67
+ }
68
+ const keys = Object.keys(input)
69
+ if (keys.length === 0) throw refused('empty objects')
70
+ if ('kind' in input) {
71
+ throw refused('objects that mix `kind` with shorthand keys')
72
+ }
73
+ const combinators = keys.filter((key) => key === 'or' || key === 'and' || key === 'not')
74
+ if (combinators.length > 0 && combinators.length !== keys.length) {
75
+ throw refused('objects that mix `or`/`and`/`not` with column compares')
76
+ }
77
+ if (keys.length === 1 && keys[0] === 'or') {
78
+ const branches = Reflect.get(input, 'or')
79
+ if (!Array.isArray(branches))
80
+ throw refused('objects that mix `or`/`and`/`not` with column compares')
81
+ return {
82
+ kind: 'or',
83
+ predicates: branches.map((branch) => compileWardShorthand(branch as WardInput)),
84
+ }
85
+ }
86
+ if (keys.length === 1 && keys[0] === 'and') {
87
+ const branches = Reflect.get(input, 'and')
88
+ if (!Array.isArray(branches))
89
+ throw refused('objects that mix `or`/`and`/`not` with column compares')
90
+ return {
91
+ kind: 'and',
92
+ predicates: branches.map((branch) => compileWardShorthand(branch as WardInput)),
93
+ }
94
+ }
95
+ if (keys.length === 1 && keys[0] === 'not') {
96
+ return { kind: 'not', predicate: compileWardShorthand((input as { not: WardInput }).not) }
97
+ }
98
+ const compares: Predicate[] = []
99
+ for (const [column, value] of Object.entries(input)) {
100
+ if (!IDENTIFIER.test(column)) throw refused('non-identifier column names')
101
+ compares.push({ kind: 'compare', column, op: '=', value })
102
+ }
103
+ return compares.length === 1 ? (compares[0] as Predicate) : { kind: 'and', predicates: compares }
104
+ }
105
+
106
+ function refused(shape: (typeof REFUSED)[number]): never {
107
+ throw new Invalid(`The ward compiler refuses ${shape}.`, {
108
+ metadata: { fields: { ward: [`Refused shape: ${shape}`] } },
109
+ })
110
+ }
111
+
112
+ /** Registers a ward for a model class or table name. */
113
+ export function defineWard(resource: ResourceKey, abilities: WardAbilities): void {
114
+ wards.set(resourceName(resource), { table: resourceTable(resource), abilities })
115
+ }
116
+
117
+ /** Clears registered wards. Intended for tests. */
118
+ export function resetWards(): void {
119
+ wards.clear()
120
+ }
121
+
122
+ /** Resolves a registered ward ability for an actor into a portable predicate. */
123
+ export function resolveWard(
124
+ resource: ResourceKey,
125
+ action: WardAction,
126
+ actor: GateActor,
127
+ ): Predicate {
128
+ const entry = wards.get(resourceName(resource))
129
+ if (entry === undefined) {
130
+ throw new Invalid(`No ward is registered for ${resourceName(resource)}.`, {
131
+ metadata: { fields: { ward: [`Missing ward for ${resourceName(resource)}.`] } },
132
+ })
133
+ }
134
+ return compileWardShorthand(entry.abilities[action](actor))
135
+ }
136
+
137
+ /** Injects a ward predicate into a query IR without mutating the original. */
138
+ export function injectWard(query: QueryIR, ward: WardInput): QueryIR {
139
+ return { ...query, ward: compileWardShorthand(ward) }
140
+ }
141
+
142
+ /** Alias matching the earlier runtime name. */
143
+ export const withWard = injectWard
144
+
145
+ type TruthValue = boolean | null
146
+
147
+ function not(value: TruthValue): TruthValue {
148
+ return value === null ? null : !value
149
+ }
150
+
151
+ function and(values: readonly TruthValue[]): TruthValue {
152
+ if (values.includes(false)) return false
153
+ if (values.includes(null)) return null
154
+ return true
155
+ }
156
+
157
+ function or(values: readonly TruthValue[]): TruthValue {
158
+ if (values.includes(true)) return true
159
+ if (values.includes(null)) return null
160
+ return false
161
+ }
162
+
163
+ function like(left: unknown, right: unknown, insensitive: boolean): boolean {
164
+ if (typeof left !== 'string' || typeof right !== 'string') return false
165
+ const escaped = right.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
166
+ const pattern = `^${escaped.replaceAll('%', '.*').replaceAll('_', '.')}$`
167
+ return new RegExp(pattern, insensitive ? 'i' : undefined).test(left)
168
+ }
169
+
170
+ function compare(
171
+ left: unknown,
172
+ op: Extract<Predicate, { kind: 'compare' }>['op'],
173
+ right: unknown,
174
+ ): TruthValue {
175
+ if (left === null || left === undefined || right === null || right === undefined) return null
176
+ switch (op) {
177
+ case '=':
178
+ return left === right
179
+ case '!=':
180
+ return left !== right
181
+ case '<':
182
+ case '<=':
183
+ case '>':
184
+ case '>=': {
185
+ if (typeof left === 'number' && typeof right === 'number') {
186
+ const diff = left - right
187
+ if (op === '<') return diff < 0
188
+ if (op === '<=') return diff <= 0
189
+ if (op === '>') return diff > 0
190
+ return diff >= 0
191
+ }
192
+ if (typeof left === 'string' && typeof right === 'string') {
193
+ const diff = left.localeCompare(right)
194
+ if (op === '<') return diff < 0
195
+ if (op === '<=') return diff <= 0
196
+ if (op === '>') return diff > 0
197
+ return diff >= 0
198
+ }
199
+ return false
200
+ }
201
+ case 'like':
202
+ return like(left, right, false)
203
+ case 'ilike':
204
+ return like(left, right, true)
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Evaluates a predicate against a row using SQL three-valued logic.
210
+ *
211
+ * `true` means the row matches. `false` means it does not. `null` is unknown, which row security
212
+ * and `WHERE` treat as not visible.
213
+ */
214
+ export function evaluatePredicate(
215
+ predicate: Predicate,
216
+ row: Readonly<Record<string, unknown>>,
217
+ ): TruthValue {
218
+ switch (predicate.kind) {
219
+ case 'const':
220
+ return predicate.value
221
+ case 'compare':
222
+ return compare(row[predicate.column], predicate.op, predicate.value)
223
+ case 'null': {
224
+ const result = row[predicate.column] === null || row[predicate.column] === undefined
225
+ return predicate.negated ? !result : result
226
+ }
227
+ case 'in': {
228
+ if (predicate.values.length === 0) return predicate.negated
229
+ const value = row[predicate.column]
230
+ if (value === null || value === undefined) return null
231
+ if (predicate.values.some((candidate) => candidate === value)) return !predicate.negated
232
+ const result: TruthValue = predicate.values.some((candidate) => candidate === null)
233
+ ? null
234
+ : false
235
+ return predicate.negated ? not(result) : result
236
+ }
237
+ case 'and':
238
+ return and(predicate.predicates.map((entry) => evaluatePredicate(entry, row)))
239
+ case 'or':
240
+ return or(predicate.predicates.map((entry) => evaluatePredicate(entry, row)))
241
+ case 'not':
242
+ return not(evaluatePredicate(predicate.predicate, row))
243
+ }
244
+ }
245
+
246
+ /** Whether a row is visible under a ward. Unknown (`null`) is not visible. */
247
+ export function rowAllowedByWard(
248
+ predicate: Predicate,
249
+ row: Readonly<Record<string, unknown>>,
250
+ ): boolean {
251
+ return evaluatePredicate(predicate, row) === true
252
+ }
253
+
254
+ export interface CompiledSql {
255
+ /** Parameterized SQL boolean expression. */
256
+ sql: string
257
+ /** Ordered parameter values referenced as `$1`, `$2`, ... */
258
+ params: readonly unknown[]
259
+ }
260
+
261
+ function quoteIdent(identifier: string): string {
262
+ if (!IDENTIFIER.test(identifier)) throw refused('non-identifier column names')
263
+ return `"${identifier}"`
264
+ }
265
+
266
+ function emit(predicate: Predicate, params: unknown[]): string {
267
+ switch (predicate.kind) {
268
+ case 'const':
269
+ return predicate.value ? 'TRUE' : 'FALSE'
270
+ case 'compare': {
271
+ params.push(predicate.value)
272
+ const placeholder = `$${params.length}`
273
+ const op =
274
+ predicate.op === 'ilike' ? 'ILIKE' : predicate.op === 'like' ? 'LIKE' : predicate.op
275
+ return `${quoteIdent(predicate.column)} ${op} ${placeholder}`
276
+ }
277
+ case 'null':
278
+ return `${quoteIdent(predicate.column)} IS${predicate.negated ? ' NOT' : ''} NULL`
279
+ case 'in': {
280
+ if (predicate.values.length === 0) return predicate.negated ? 'TRUE' : 'FALSE'
281
+ const placeholders = predicate.values.map((value) => {
282
+ params.push(value)
283
+ return `$${params.length}`
284
+ })
285
+ const expr = `${quoteIdent(predicate.column)} IN (${placeholders.join(', ')})`
286
+ return predicate.negated ? `NOT (${expr})` : expr
287
+ }
288
+ case 'and': {
289
+ if (predicate.predicates.length === 0) return 'TRUE'
290
+ return `(${predicate.predicates.map((entry) => emit(entry, params)).join(' AND ')})`
291
+ }
292
+ case 'or': {
293
+ if (predicate.predicates.length === 0) return 'FALSE'
294
+ return `(${predicate.predicates.map((entry) => emit(entry, params)).join(' OR ')})`
295
+ }
296
+ case 'not':
297
+ return `(NOT ${emit(predicate.predicate, params)})`
298
+ }
299
+ }
300
+
301
+ /** Compiles a portable predicate to a parameterized SQL boolean expression. */
302
+ export function compilePredicateToSql(predicate: Predicate): CompiledSql {
303
+ const params: unknown[] = []
304
+ return { sql: emit(predicate, params), params }
305
+ }
306
+
307
+ const ACTION_TO_SQL = {
308
+ read: 'SELECT',
309
+ insert: 'INSERT',
310
+ update: 'UPDATE',
311
+ delete: 'DELETE',
312
+ } as const
313
+
314
+ /** Compiles one ward ability into a Postgres RLS policy statement. */
315
+ export function compileWardPolicy(
316
+ resource: ResourceKey,
317
+ action: WardAction,
318
+ actor: GateActor,
319
+ role = 'authenticated',
320
+ ): { statement: string; params: readonly unknown[] } {
321
+ const table = wards.get(resourceName(resource))?.table ?? resourceTable(resource)
322
+ const predicate = resolveWard(resource, action, actor)
323
+ const compiled = compilePredicateToSql(predicate)
324
+ const command = ACTION_TO_SQL[action]
325
+ const policyName = `${table}_${action}`
326
+ const clause = action === 'insert' ? 'WITH CHECK' : 'USING'
327
+ const statement = `CREATE POLICY ${quoteIdent(policyName)} ON ${quoteIdent(table)} AS PERMISSIVE FOR ${command} TO ${quoteIdent(role)} ${clause} (${compiled.sql})`
328
+ return { statement, params: compiled.params }
329
+ }
330
+
331
+ export interface WardFixture {
332
+ /** Row offered to the client-side evaluator. */
333
+ row: Readonly<Record<string, unknown>>
334
+ /** Whether the row must be visible. */
335
+ allowed: boolean
336
+ }
337
+
338
+ export interface WardDrift {
339
+ row: Readonly<Record<string, unknown>>
340
+ expected: boolean
341
+ actual: boolean
342
+ }
343
+
344
+ /**
345
+ * Compares client-side evaluation against a fixture set. A mismatch is a drift failure, which is
346
+ * how `ward:check` will later fail CI.
347
+ */
348
+ export function detectWardDrift(
349
+ predicate: Predicate,
350
+ fixtures: readonly WardFixture[],
351
+ ): readonly WardDrift[] {
352
+ return fixtures.flatMap((fixture) => {
353
+ const actual = rowAllowedByWard(predicate, fixture.row)
354
+ if (actual === fixture.allowed) return []
355
+ return [{ row: fixture.row, expected: fixture.allowed, actual }]
356
+ })
357
+ }
358
+
359
+ /** Compares a freshly compiled policy statement to a previously recorded one. */
360
+ export function detectPolicyDrift(currentSql: string, recordedSql: string): boolean {
361
+ return currentSql !== recordedSql
362
+ }