bunderstack 0.9.1 → 0.11.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.9.1",
3
+ "version": "0.11.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
@@ -311,7 +311,13 @@ export function validateAndResolveAccess<
311
311
  columns.includes('userId')
312
312
 
313
313
  if (!hasExplicitRules && !hasConventionOwner) continue
314
- if (!ownerColumn && input?.crud !== true && !input?.scope?.read && !input?.scope?.write) continue
314
+ if (
315
+ !ownerColumn &&
316
+ input?.crud !== true &&
317
+ !input?.scope?.read &&
318
+ !input?.scope?.write
319
+ )
320
+ continue
315
321
 
316
322
  if (input?.ownerColumn && !columns.includes(input.ownerColumn)) {
317
323
  throw new Error(
@@ -483,7 +489,7 @@ export function sanitizeWriteBody(
483
489
  export type AuthSessionResolver = {
484
490
  api: {
485
491
  getSession: (opts: { headers: Headers }) => Promise<{
486
- user: { id: string; email: string; name?: string } | null
492
+ user: { id: string; email: string; name?: string; role?: string } | null
487
493
  session?: { activeOrganizationId?: string | null } | null
488
494
  } | null>
489
495
  }
@@ -500,6 +506,7 @@ export async function resolveAccessUser(
500
506
  id: session.user.id,
501
507
  email: session.user.email,
502
508
  name: session.user.name,
509
+ role: session.user.role,
503
510
  }
504
511
  }
505
512
 
@@ -515,6 +522,7 @@ export async function resolveSession(
515
522
  id: session.user.id,
516
523
  email: session.user.email,
517
524
  name: session.user.name,
525
+ role: session.user.role,
518
526
  },
519
527
  activeOrganizationId: session.session?.activeOrganizationId ?? null,
520
528
  }
package/src/auth.ts CHANGED
@@ -2,16 +2,22 @@
2
2
  import { betterAuth } from 'better-auth'
3
3
  import { drizzleAdapter } from 'better-auth/adapters/drizzle'
4
4
 
5
- import type { BetterAuthConfig } from './config'
6
5
  import type { AuthSessionResolver } from './access'
6
+ import type { BetterAuthConfig } from './config'
7
7
  import type { AnyDb, Dialect } from './dialect'
8
8
  import type { EmailFacade } from './email'
9
9
 
10
- export function createAuth(db: AnyDb, cfg: BetterAuthConfig, dialect: Dialect) {
10
+ export function createAuth(
11
+ db: AnyDb,
12
+ cfg: BetterAuthConfig,
13
+ dialect: Dialect,
14
+ userSchema?: Record<string, unknown>,
15
+ ) {
11
16
  return betterAuth({
12
17
  ...cfg,
13
18
  database: drizzleAdapter(db as Parameters<typeof drizzleAdapter>[0], {
14
19
  provider: dialect === 'pg' ? 'pg' : 'sqlite',
20
+ ...(userSchema ? { schema: userSchema } : {}),
15
21
  }),
16
22
  })
17
23
  }
@@ -39,15 +45,18 @@ export function toAuthSessionResolver(
39
45
  typeof session.activeOrganizationId === 'string'
40
46
  ? session.activeOrganizationId
41
47
  : null
48
+ const role =
49
+ 'role' in result.user && typeof result.user.role === 'string'
50
+ ? result.user.role
51
+ : undefined
42
52
  return {
43
53
  user: {
44
54
  id: result.user.id,
45
55
  email: result.user.email,
46
56
  name: result.user.name,
57
+ ...(role ? { role } : {}),
47
58
  },
48
- session: session
49
- ? { activeOrganizationId }
50
- : null,
59
+ session: session ? { activeOrganizationId } : null,
51
60
  }
52
61
  }
53
62
  return null
@@ -70,7 +79,10 @@ export function withEmailAuthDefaults(
70
79
  if (!emailConfigured) return cfg
71
80
  const out: BetterAuthConfig = { ...cfg }
72
81
 
73
- if (cfg.emailAndPassword?.enabled && !cfg.emailAndPassword.sendResetPassword) {
82
+ if (
83
+ cfg.emailAndPassword?.enabled &&
84
+ !cfg.emailAndPassword.sendResetPassword
85
+ ) {
74
86
  out.emailAndPassword = {
75
87
  ...cfg.emailAndPassword,
76
88
  sendResetPassword: async ({ user, url }) => {
package/src/config.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { betterAuth } from 'better-auth'
3
3
  import { z } from 'zod'
4
4
 
5
- import type { TableAccessInput } from './access'
5
+ import type { AuthSessionResolver, TableAccessInput } from './access'
6
6
  import type { DatabaseAdapter } from './database/adapter'
7
7
  import type { EmailConfigInput } from './email'
8
8
  import type { IdempotencyConfig } from './idempotency'
@@ -86,6 +86,7 @@ export type BunderstackConfig<
86
86
  | 'schema'
87
87
  | 'access'
88
88
  | 'auth'
89
+ | 'authResolver'
89
90
  | 'storage'
90
91
  | 'env'
91
92
  | 'email'
@@ -102,6 +103,11 @@ export type BunderstackConfig<
102
103
  migrations?: string
103
104
  }
104
105
  auth?: BetterAuthConfig
106
+ /**
107
+ * Reuse an application-owned session reader for CRUD, realtime, storage,
108
+ * and tRPC while keeping Bunderstack's auth handler available.
109
+ */
110
+ authResolver?: AuthSessionResolver
105
111
  storage?: TStorage
106
112
  env?: TEnv
107
113
  email?: EmailConfigInput
package/src/index.ts CHANGED
@@ -107,6 +107,14 @@ export interface StorageFacade {
107
107
  * the count reaped.
108
108
  */
109
109
  sweep(olderThanMs?: number): Promise<number>
110
+ /**
111
+ * Get a public or presigned download URL for a file key.
112
+ * Returns a presigned S3 URL when S3 is configured, or local proxy route `/api/files/...` in development.
113
+ */
114
+ getUrl(
115
+ key: string,
116
+ opts?: { bucket?: string; expiresIn?: number },
117
+ ): Promise<string>
110
118
  }
111
119
 
112
120
  export type AppStartWorkerOptions = Omit<StartWorkerOptions, 'tick'>
@@ -362,10 +370,11 @@ export async function createBunderstack<
362
370
  db,
363
371
  withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
364
372
  dialect,
373
+ options.schema as Record<string, unknown>,
365
374
  )
366
375
  // Internal routers consume the narrow AuthSessionResolver contract, not the
367
376
  // raw better-auth instance. app.auth still exposes `auth` unchanged.
368
- const authResolver = toAuthSessionResolver(auth)
377
+ const authResolver = options.authResolver ?? toAuthSessionResolver(auth)
369
378
  const resolvedAccess = validateAndResolveAccess(
370
379
  options.schema,
371
380
  options.access,
@@ -465,6 +474,19 @@ export async function createBunderstack<
465
474
  sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
466
475
  return sweepOrphans(registry, db, olderThanMs)
467
476
  },
477
+ async getUrl(key, opts = {}) {
478
+ const bucketName =
479
+ opts.bucket ??
480
+ key.split('/')[0] ??
481
+ registry.defaultBucketName
482
+ const adapter = registry.get(bucketName)?.adapter
483
+ if (adapter?.presignGet) {
484
+ return adapter.presignGet(key, {
485
+ expiresIn: opts.expiresIn ?? 3600,
486
+ })
487
+ }
488
+ return `/api/files/${key}`
489
+ },
468
490
  }
469
491
  const jobRunner = jobsDefs
470
492
  ? createJobRunner({
@@ -708,6 +730,7 @@ export {
708
730
  verifyScheduleRequest,
709
731
  } from './jobs/index'
710
732
  export type {
733
+ BunderstackJobContext,
711
734
  BunderstackJobsBuilder,
712
735
  BackgroundDefinition,
713
736
  BackgroundDefs,
@@ -760,6 +783,7 @@ export type {
760
783
  } from './storage/buckets'
761
784
  // StorageFacade is declared+exported inline above.
762
785
  export type { TransformSpec } from './storage/thumbnails'
786
+ export { mockAuthSession } from './testing'
763
787
 
764
788
  export type { RealtimeAction } from './realtime/index'
765
789
  export { createRealtimeFacade } from './realtime/facade'
@@ -49,6 +49,11 @@ export type JobContext<
49
49
  realtime: RealtimeFacade<TSchema>
50
50
  }
51
51
 
52
+ export type BunderstackJobContext<
53
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
54
+ TEnvResult = Record<string, unknown>,
55
+ > = JobContext<TSchema, TEnvResult>
56
+
52
57
  export type QueueJobDefinition<
53
58
  TInput,
54
59
  TSchema extends Record<string, unknown> = Record<string, unknown>,
@@ -82,9 +87,10 @@ export type CronInvocation = { scheduledFor: Date }
82
87
  export type CronDefinition<
83
88
  TSchema extends Record<string, unknown> = Record<string, unknown>,
84
89
  TEnvResult = Record<string, unknown>,
90
+ TSchedule extends string = string,
85
91
  > = {
86
92
  kind: 'cron'
87
- schedule: string
93
+ schedule: TSchedule
88
94
  handler: (
89
95
  invocation: CronInvocation,
90
96
  ctx: JobContext<TSchema, TEnvResult>,
@@ -97,18 +103,22 @@ export type BackgroundDefinition =
97
103
  export type BackgroundDefs = Record<string, BackgroundDefinition>
98
104
 
99
105
  /** @deprecated Use QueueJobDefinition. */
100
- export type JobDefinition<TInput, TSchema extends Record<string, unknown> = Record<string, unknown>, TEnvResult = Record<string, unknown>> = QueueJobDefinition<
106
+ export type JobDefinition<
101
107
  TInput,
102
- TSchema,
103
- TEnvResult
104
- >
108
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
109
+ TEnvResult = Record<string, unknown>,
110
+ > = QueueJobDefinition<TInput, TSchema, TEnvResult>
105
111
 
106
112
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
113
  export type AnyJobDefinition = QueueJobDefinition<any, any, any>
108
114
  export type JobsDefs = BackgroundDefs
109
115
 
110
116
  export type QueueJobKeys<TDefs extends BackgroundDefs> = {
111
- [K in keyof TDefs & string]: TDefs[K] extends QueueJobDefinition<any, any, any>
117
+ [K in keyof TDefs & string]: TDefs[K] extends QueueJobDefinition<
118
+ any,
119
+ any,
120
+ any
121
+ >
112
122
  ? K
113
123
  : never
114
124
  }[keyof TDefs & string]
@@ -123,11 +133,21 @@ export function validateBackgroundDefs(defs: BackgroundDefs): void {
123
133
  parseCron(def.schedule)
124
134
  continue
125
135
  }
126
- if (def.retries !== undefined && (def.retries < 0 || !Number.isInteger(def.retries))) {
127
- throw new Error(`[bunderstack] job "${name}": retries must be a non-negative integer`)
136
+ if (
137
+ def.retries !== undefined &&
138
+ (def.retries < 0 || !Number.isInteger(def.retries))
139
+ ) {
140
+ throw new Error(
141
+ `[bunderstack] job "${name}": retries must be a non-negative integer`,
142
+ )
128
143
  }
129
- if (def.concurrency !== undefined && (def.concurrency < 1 || !Number.isInteger(def.concurrency))) {
130
- throw new Error(`[bunderstack] job "${name}": concurrency must be a positive integer`)
144
+ if (
145
+ def.concurrency !== undefined &&
146
+ (def.concurrency < 1 || !Number.isInteger(def.concurrency))
147
+ ) {
148
+ throw new Error(
149
+ `[bunderstack] job "${name}": concurrency must be a positive integer`,
150
+ )
131
151
  }
132
152
  if (def.timeout !== undefined && def.timeout <= 0) {
133
153
  throw new Error(`[bunderstack] job "${name}": timeout must be positive`)
@@ -162,9 +182,9 @@ export function createJobsBuilder<
162
182
  ): QueueJobDefinition<TInput, TSchema, TEnvResult> {
163
183
  return { kind: 'job', ...def }
164
184
  },
165
- cron(
166
- def: Omit<CronDefinition<TSchema, TEnvResult>, 'kind'>,
167
- ): CronDefinition<TSchema, TEnvResult> {
185
+ cron<const TSchedule extends string>(
186
+ def: Omit<CronDefinition<TSchema, TEnvResult, TSchedule>, 'kind'>,
187
+ ): CronDefinition<TSchema, TEnvResult, TSchedule> {
168
188
  parseCron(def.schedule)
169
189
  return { kind: 'cron', ...def }
170
190
  },
@@ -187,9 +207,8 @@ export type BunderstackJobsBuilder<
187
207
  // `TDef extends { input: ZodType<infer I> }` fails structurally because
188
208
  // `input?: ZodType<TInput>` desugars to `ZodType<TInput> | undefined`, which
189
209
  // can never satisfy a required-property pattern.
190
- type JobInputOf<TDef> = TDef extends QueueJobDefinition<infer TInput, any, any>
191
- ? TInput
192
- : undefined
210
+ type JobInputOf<TDef> =
211
+ TDef extends QueueJobDefinition<infer TInput, any, any> ? TInput : undefined
193
212
 
194
213
  /**
195
214
  * `app.jobs`: `enqueue` narrowed to declared names + payloads. `Omit`s the
package/src/jobs/index.ts CHANGED
@@ -10,6 +10,7 @@ export type {
10
10
  AnyJobDefinition,
11
11
  BackgroundDefinition,
12
12
  BackgroundDefs,
13
+ BunderstackJobContext,
13
14
  BunderstackJobsBuilder,
14
15
  EnqueueOptions,
15
16
  JobContext,
@@ -1,6 +1,6 @@
1
1
  import { cronMatches, parseCron } from './cron'
2
2
 
3
- type Timer = ReturnType<typeof setTimeout>
3
+ type Timer = ReturnType<typeof setTimeout> | number
4
4
 
5
5
  export type LocalCronDefinition = {
6
6
  name: string
@@ -45,7 +45,9 @@ export function startLocalCronScheduler(
45
45
  timer = setTimer(() => {
46
46
  timer = undefined
47
47
  void tick().catch((error: unknown) => {
48
- options.onError?.(error instanceof Error ? error : new Error(String(error)))
48
+ options.onError?.(
49
+ error instanceof Error ? error : new Error(String(error)),
50
+ )
49
51
  })
50
52
  }, delay)
51
53
  }
package/src/testing.ts ADDED
@@ -0,0 +1,31 @@
1
+ // src/testing.ts — test utilities for Bunderstack applications.
2
+
3
+ export type AuthSessionResolverLike = {
4
+ api: {
5
+ getSession: (opts: { headers: Headers }) => Promise<unknown>
6
+ }
7
+ }
8
+
9
+ export type BunderstackAppLike = {
10
+ auth: unknown
11
+ }
12
+
13
+ /**
14
+ * Mock the auth session resolver on a Bunderstack app instance for unit testing.
15
+ */
16
+ export function mockAuthSession<
17
+ TUser extends { id: string; email: string; name?: string; role?: string },
18
+ >(
19
+ app: BunderstackAppLike,
20
+ resolver: (opts: { headers: Headers }) => Promise<{
21
+ user: TUser
22
+ session?: { activeOrganizationId?: string | null } | null
23
+ } | null>,
24
+ ): void {
25
+ const auth = app.auth as unknown as AuthSessionResolverLike
26
+ if (auth?.api) {
27
+ auth.api.getSession = resolver as unknown as (opts: {
28
+ headers: Headers
29
+ }) => Promise<unknown>
30
+ }
31
+ }