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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.4.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?: ScopeResolver
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
- scope?: ScopeResolver
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
- scope: input.scope,
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
- tableAccess: ResolvedTableAccess,
72
- ctx: {
73
- user: AccessUser | null
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 scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
280
+ const readScope = scopeFor(tableAccess.readScope, { user, session, request: c.req.raw })
284
281
  if (
285
- scope &&
286
- !rowMatchesScope(existing[0] as Record<string, unknown>, scope)
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(values)
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)
@@ -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.scope) return true
88
- return rowMatchesScope(record, entry.scope(ctx))
87
+ if (!entry.readScope) return true
88
+ return rowMatchesScope(record, entry.readScope(ctx))
89
89
  }
90
90
 
91
91
  export function buildRealtimeRouter(
@@ -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.scope && !rowMatchesScope(record, entry.scope(ctx))) return false
120
+ if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx))) return false
121
121
  return true
122
122
  }
123
123
 
@@ -25,7 +25,10 @@ export type BucketConfigInput = {
25
25
  }
26
26
  upload?: { maxSize?: string | number; accept?: string[] }
27
27
  transforms?: boolean
28
- scope?: ScopeResolver
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
- scope?: ScopeResolver
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
- scope: input.scope,
255
+ readScope: input.scope?.read,
256
+ writeScope: input.scope?.write,
252
257
  quota,
253
258
  }
254
259
  }
@@ -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.scope?.(ctx)
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.scope?.(ctx)
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.scope?.(ctx)
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.scope?.(ctx)
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
  }