bunderstack 0.4.0 → 0.5.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.
- package/package.json +1 -1
- package/src/access.ts +9 -4
- package/src/crud.ts +16 -16
- package/src/realtime/index.ts +2 -2
- package/src/realtime/redis.ts +1 -1
- package/src/storage/buckets.ts +8 -3
- package/src/storage/router.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backend",
|
package/src/access.ts
CHANGED
|
@@ -75,7 +75,10 @@ export type TableAccessInput = {
|
|
|
75
75
|
sortableColumns?: string[]
|
|
76
76
|
/** Default list ordering when `?sort` is omitted. Defaults to `{ column: 'id', order: 'desc' }`. */
|
|
77
77
|
defaultSort?: DefaultSort
|
|
78
|
-
scope?:
|
|
78
|
+
scope?: {
|
|
79
|
+
read?: ScopeResolver
|
|
80
|
+
write?: ScopeResolver
|
|
81
|
+
}
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
export type ResolvedTableAccess = {
|
|
@@ -94,7 +97,8 @@ export type ResolvedTableAccess = {
|
|
|
94
97
|
filterableColumns: string[]
|
|
95
98
|
sortableColumns: string[]
|
|
96
99
|
defaultSort: DefaultSort
|
|
97
|
-
|
|
100
|
+
readScope?: ScopeResolver
|
|
101
|
+
writeScope?: ScopeResolver
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
export type ResolvedAccess = Map<string, ResolvedTableAccess>
|
|
@@ -200,7 +204,8 @@ function resolveDefaults(
|
|
|
200
204
|
...(ownerColumn ? [ownerColumn] : []),
|
|
201
205
|
],
|
|
202
206
|
searchableColumns: input.searchableColumns,
|
|
203
|
-
|
|
207
|
+
readScope: input.scope?.read,
|
|
208
|
+
writeScope: input.scope?.write,
|
|
204
209
|
...listAccess,
|
|
205
210
|
}
|
|
206
211
|
}
|
|
@@ -306,7 +311,7 @@ export function validateAndResolveAccess<
|
|
|
306
311
|
columns.includes('userId')
|
|
307
312
|
|
|
308
313
|
if (!hasExplicitRules && !hasConventionOwner) continue
|
|
309
|
-
if (!ownerColumn && input?.crud !== true && !input?.scope) continue
|
|
314
|
+
if (!ownerColumn && input?.crud !== true && !input?.scope?.read && !input?.scope?.write) continue
|
|
310
315
|
|
|
311
316
|
if (input?.ownerColumn && !columns.includes(input.ownerColumn)) {
|
|
312
317
|
throw new Error(
|
package/src/crud.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
type ResolvedAccess,
|
|
17
17
|
type ResolvedTableAccess,
|
|
18
18
|
type ScopeMap,
|
|
19
|
+
type ScopeResolver,
|
|
20
|
+
type AccessContext,
|
|
19
21
|
} from './access'
|
|
20
22
|
import { ErrorCode, apiError, ListQueryError } from './errors'
|
|
21
23
|
import {
|
|
@@ -68,14 +70,9 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
68
70
|
const idempotency = resolveIdempotencyConfig(options.idempotency)
|
|
69
71
|
|
|
70
72
|
const scopeFor = (
|
|
71
|
-
|
|
72
|
-
ctx:
|
|
73
|
-
|
|
74
|
-
session: { activeOrganizationId: string | null } | null
|
|
75
|
-
request: Request
|
|
76
|
-
},
|
|
77
|
-
): ScopeMap | undefined =>
|
|
78
|
-
tableAccess.scope ? tableAccess.scope({ ...ctx }) : undefined
|
|
73
|
+
resolver: ScopeResolver | undefined,
|
|
74
|
+
ctx: AccessContext,
|
|
75
|
+
): ScopeMap | undefined => (resolver ? resolver(ctx) : undefined)
|
|
79
76
|
|
|
80
77
|
for (const table of Object.values(schema)) {
|
|
81
78
|
if (!isTable(table)) continue
|
|
@@ -109,7 +106,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
109
106
|
|
|
110
107
|
try {
|
|
111
108
|
const params = parseListParams(new URL(c.req.url), tableAccess)
|
|
112
|
-
const scope = scopeFor(tableAccess, {
|
|
109
|
+
const scope = scopeFor(tableAccess.readScope, {
|
|
113
110
|
user,
|
|
114
111
|
session,
|
|
115
112
|
request: c.req.raw,
|
|
@@ -164,7 +161,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
164
161
|
)
|
|
165
162
|
}
|
|
166
163
|
|
|
167
|
-
const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
|
|
164
|
+
const scope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
|
|
168
165
|
if (
|
|
169
166
|
scope &&
|
|
170
167
|
!rowMatchesScope(rows[0] as Record<string, unknown>, scope)
|
|
@@ -241,7 +238,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
241
238
|
user?.id ?? null,
|
|
242
239
|
)
|
|
243
240
|
|
|
244
|
-
const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
|
|
241
|
+
const scope = scopeFor(tableAccess.writeScope, { user, session, request: c.req.raw, body: body as Record<string, unknown> })
|
|
245
242
|
const stamped = scope ? stampScope(values, scope) : values
|
|
246
243
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
247
244
|
const rows = await (db as any).insert(table).values(stamped).returning()
|
|
@@ -280,10 +277,10 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
280
277
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
281
278
|
}
|
|
282
279
|
|
|
283
|
-
const
|
|
280
|
+
const readScope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
|
|
284
281
|
if (
|
|
285
|
-
|
|
286
|
-
!rowMatchesScope(existing[0] as Record<string, unknown>,
|
|
282
|
+
readScope &&
|
|
283
|
+
!rowMatchesScope(existing[0] as Record<string, unknown>, readScope)
|
|
287
284
|
) {
|
|
288
285
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
289
286
|
}
|
|
@@ -320,10 +317,13 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
320
317
|
user?.id ?? null,
|
|
321
318
|
)
|
|
322
319
|
|
|
320
|
+
const writeScope = scopeFor(tableAccess.writeScope, { user, session, request: c.req.raw, body: body as Record<string, unknown> })
|
|
321
|
+
const stamped = writeScope ? stampScope(values, writeScope) : values
|
|
322
|
+
|
|
323
323
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
324
324
|
const rows = await (db as any)
|
|
325
325
|
.update(table)
|
|
326
|
-
.set(
|
|
326
|
+
.set(stamped)
|
|
327
327
|
.where(eq(idCol as any, id))
|
|
328
328
|
.returning()
|
|
329
329
|
if (!rows[0]) {
|
|
@@ -350,7 +350,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
350
350
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
|
|
353
|
+
const scope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
|
|
354
354
|
if (
|
|
355
355
|
scope &&
|
|
356
356
|
!rowMatchesScope(existing[0] as Record<string, unknown>, scope)
|
package/src/realtime/index.ts
CHANGED
|
@@ -84,8 +84,8 @@ function scopeOk(
|
|
|
84
84
|
ctx: Parameters<typeof checkAccessSync>[1],
|
|
85
85
|
record: Record<string, unknown>,
|
|
86
86
|
): boolean {
|
|
87
|
-
if (!entry.
|
|
88
|
-
return rowMatchesScope(record, entry.
|
|
87
|
+
if (!entry.readScope) return true
|
|
88
|
+
return rowMatchesScope(record, entry.readScope(ctx))
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export function buildRealtimeRouter(
|
package/src/realtime/redis.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
117
117
|
if (typeof entry.get === 'function') return false
|
|
118
118
|
if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
|
|
119
119
|
return false
|
|
120
|
-
if (entry.
|
|
120
|
+
if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx))) return false
|
|
121
121
|
return true
|
|
122
122
|
}
|
|
123
123
|
|
package/src/storage/buckets.ts
CHANGED
|
@@ -25,7 +25,10 @@ export type BucketConfigInput = {
|
|
|
25
25
|
}
|
|
26
26
|
upload?: { maxSize?: string | number; accept?: string[] }
|
|
27
27
|
transforms?: boolean
|
|
28
|
-
scope?:
|
|
28
|
+
scope?: {
|
|
29
|
+
read?: ScopeResolver
|
|
30
|
+
write?: ScopeResolver
|
|
31
|
+
}
|
|
29
32
|
quota?: { perUser?: string | number; perScope?: string | number }
|
|
30
33
|
} & Partial<BucketBackendInput>
|
|
31
34
|
|
|
@@ -59,7 +62,8 @@ export type ResolvedBucket = {
|
|
|
59
62
|
access: { create: OperationRule; get: OperationRule; delete: OperationRule }
|
|
60
63
|
upload?: { maxSizeBytes?: number; accept?: string[] }
|
|
61
64
|
transforms: boolean
|
|
62
|
-
|
|
65
|
+
readScope?: ScopeResolver
|
|
66
|
+
writeScope?: ScopeResolver
|
|
63
67
|
quota?: { perUserBytes?: number; perScopeBytes?: number }
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -248,7 +252,8 @@ function resolveSingleBucket(
|
|
|
248
252
|
access,
|
|
249
253
|
upload,
|
|
250
254
|
transforms: input.transforms ?? false,
|
|
251
|
-
|
|
255
|
+
readScope: input.scope?.read,
|
|
256
|
+
writeScope: input.scope?.write,
|
|
252
257
|
quota,
|
|
253
258
|
}
|
|
254
259
|
}
|
package/src/storage/router.ts
CHANGED
|
@@ -165,7 +165,7 @@ export function buildBucketStorageRouter(
|
|
|
165
165
|
const contentType =
|
|
166
166
|
typeof body.contentType === 'string' ? body.contentType : undefined
|
|
167
167
|
|
|
168
|
-
const requesterScope = bucket.
|
|
168
|
+
const requesterScope = bucket.writeScope?.(ctx)
|
|
169
169
|
const scopeJson = scopeToJson(requesterScope)
|
|
170
170
|
|
|
171
171
|
// Quota pre-check: reserve the configured max upload size.
|
|
@@ -252,7 +252,7 @@ export function buildBucketStorageRouter(
|
|
|
252
252
|
return apiError(c, ErrorCode.VALIDATION_ERROR, 'File too large', 422)
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
const requesterScope = bucket.
|
|
255
|
+
const requesterScope = bucket.readScope?.(ctx)
|
|
256
256
|
const scopeJson = scopeToJson(requesterScope)
|
|
257
257
|
|
|
258
258
|
if (bucket.quota) {
|
|
@@ -395,7 +395,7 @@ export function buildBucketStorageRouter(
|
|
|
395
395
|
const denied = await gate(bucket.access.get, ctx, c)
|
|
396
396
|
if (denied) return denied
|
|
397
397
|
|
|
398
|
-
const requesterScope = bucket.
|
|
398
|
+
const requesterScope = bucket.readScope?.(ctx)
|
|
399
399
|
if (!fileMatchesScope(row, requesterScope)) {
|
|
400
400
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
401
401
|
}
|
|
@@ -491,7 +491,7 @@ export function buildBucketStorageRouter(
|
|
|
491
491
|
const denied = await gate(bucket.access.delete, ctx, c)
|
|
492
492
|
if (denied) return denied
|
|
493
493
|
|
|
494
|
-
const requesterScope = bucket.
|
|
494
|
+
const requesterScope = bucket.readScope?.(ctx)
|
|
495
495
|
if (!fileMatchesScope(row, requesterScope)) {
|
|
496
496
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
497
497
|
}
|