bunderstack 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.
package/src/auth.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
+
3
+ // src/auth.ts
4
+ import { betterAuth } from 'better-auth'
5
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
6
+
7
+ import type { BetterAuthConfig } from './config'
8
+ import type { AuthSessionResolver } from './access'
9
+ import type { EmailFacade } from './email'
10
+
11
+ export function createAuth(
12
+ db: LibSQLDatabase<Record<string, unknown>>,
13
+ cfg: BetterAuthConfig,
14
+ ) {
15
+ return betterAuth({
16
+ ...cfg,
17
+ database: drizzleAdapter(db, { provider: 'sqlite' }),
18
+ })
19
+ }
20
+
21
+ /**
22
+ * Adapt the raw better-auth instance to our internal {@link AuthSessionResolver}
23
+ * contract. better-auth's `getSession` has a union return (a bare session, or a
24
+ * `{ headers, response }` wrapper when `returnHeaders` is set); we only ever
25
+ * call the bare form, so we narrow on `'user' in result` and map to our shape.
26
+ * Keeping this adapter here means internal modules never depend on better-auth's
27
+ * evolving types.
28
+ */
29
+ export function toAuthSessionResolver(
30
+ auth: ReturnType<typeof createAuth>,
31
+ ): AuthSessionResolver {
32
+ return {
33
+ api: {
34
+ async getSession({ headers }) {
35
+ const result = await auth.api.getSession({ headers })
36
+ if (result && 'user' in result && result.user) {
37
+ const session = 'session' in result ? result.session : null
38
+ const activeOrganizationId =
39
+ session &&
40
+ 'activeOrganizationId' in session &&
41
+ typeof session.activeOrganizationId === 'string'
42
+ ? session.activeOrganizationId
43
+ : null
44
+ return {
45
+ user: {
46
+ id: result.user.id,
47
+ email: result.user.email,
48
+ name: result.user.name,
49
+ },
50
+ session: session
51
+ ? { activeOrganizationId }
52
+ : null,
53
+ }
54
+ }
55
+ return null
56
+ },
57
+ },
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Fill better-auth's email hooks from the bunderstack email facade. Only fills
63
+ * gaps: user-supplied handlers always win, and nothing is injected when email
64
+ * isn't configured. emailAndPassword is only touched when the user enabled it
65
+ * (injecting it unasked would enable the feature).
66
+ */
67
+ export function withEmailAuthDefaults(
68
+ cfg: BetterAuthConfig,
69
+ email: EmailFacade,
70
+ emailConfigured: boolean,
71
+ ): BetterAuthConfig {
72
+ if (!emailConfigured) return cfg
73
+ const out: BetterAuthConfig = { ...cfg }
74
+
75
+ if (cfg.emailAndPassword?.enabled && !cfg.emailAndPassword.sendResetPassword) {
76
+ out.emailAndPassword = {
77
+ ...cfg.emailAndPassword,
78
+ sendResetPassword: async ({ user, url }) => {
79
+ await email.send({
80
+ to: user.email,
81
+ subject: 'Reset your password',
82
+ text: `Click the link to reset your password:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`,
83
+ })
84
+ },
85
+ }
86
+ }
87
+
88
+ if (!cfg.emailVerification?.sendVerificationEmail) {
89
+ out.emailVerification = {
90
+ ...cfg.emailVerification,
91
+ sendVerificationEmail: async ({ user, url }) => {
92
+ await email.send({
93
+ to: user.email,
94
+ subject: 'Verify your email',
95
+ text: `Click the link to verify your email address:\n\n${url}`,
96
+ })
97
+ },
98
+ }
99
+ }
100
+
101
+ return out
102
+ }
package/src/config.ts ADDED
@@ -0,0 +1,152 @@
1
+ // src/config.ts
2
+ import { betterAuth } from 'better-auth'
3
+ import { z } from 'zod'
4
+
5
+ import type { TableAccessInput } from './access'
6
+ import type { IdempotencyConfig } from './idempotency'
7
+ import type { RateLimitConfig } from './rate-limit'
8
+
9
+ import { validateEnv, type BaseEnv, type EnvConfigInput } from './env'
10
+ import type { EmailConfigInput } from './email'
11
+
12
+ import {
13
+ resolveBuckets,
14
+ type ResolvedStorageBuckets,
15
+ type StorageConfigInput,
16
+ } from './storage/buckets'
17
+
18
+ export type BetterAuthConfig = Omit<
19
+ NonNullable<Parameters<typeof betterAuth>[0]>,
20
+ 'database'
21
+ >
22
+
23
+ export const BunderstackOptionsSchema = z.object({
24
+ schema: z.record(z.string(), z.unknown()),
25
+ access: z.record(z.string(), z.any()).optional(),
26
+ database: z
27
+ .object({ url: z.string().optional(), authToken: z.string().optional() })
28
+ .optional(),
29
+ auth: z.record(z.string(), z.unknown()).optional(),
30
+ // Loose: bucket access/scope hold functions that can't survive strict zod
31
+ // (mirrors how `access` is loose). Resolution happens in resolveBuckets.
32
+ storage: z.unknown().optional(),
33
+ // Loose: holds zod schemas. Validation happens in validateEnv.
34
+ env: z.unknown().optional(),
35
+ // Loose: provider may be a function/adapter. Resolution happens in createEmail.
36
+ email: z.unknown().optional(),
37
+ // Loose: a tRPC router or builder callback. Resolved in createBunderstack.
38
+ trpc: z.unknown().optional(),
39
+ rateLimit: z
40
+ .union([
41
+ z.boolean(),
42
+ z.object({
43
+ windowMs: z.number().optional(),
44
+ max: z.number().optional(),
45
+ }),
46
+ ])
47
+ .optional(),
48
+ idempotency: z
49
+ .union([z.boolean(), z.object({ ttlMs: z.number().optional() })])
50
+ .optional(),
51
+ realtime: z
52
+ .union([
53
+ z.boolean(),
54
+ z.object({
55
+ keepaliveMs: z.number().optional(),
56
+ bufferSize: z.number().optional(),
57
+ redis: z
58
+ .union([
59
+ z.string(),
60
+ z.object({ url: z.string(), token: z.string().optional() }),
61
+ ])
62
+ .optional(),
63
+ }),
64
+ ])
65
+ .optional(),
66
+ })
67
+
68
+ export type BunderstackConfig<
69
+ TSchema extends Record<string, unknown>,
70
+ TAccess extends Record<string, TableAccessInput> | undefined =
71
+ | Record<string, TableAccessInput>
72
+ | undefined,
73
+ TStorage extends StorageConfigInput | undefined =
74
+ | StorageConfigInput
75
+ | undefined,
76
+ TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
77
+ > = Omit<
78
+ z.input<typeof BunderstackOptionsSchema>,
79
+ 'schema' | 'access' | 'auth' | 'storage' | 'env' | 'email' | 'trpc'
80
+ > & {
81
+ schema: TSchema
82
+ access?: TAccess
83
+ auth?: BetterAuthConfig
84
+ storage?: TStorage
85
+ env?: TEnv
86
+ email?: EmailConfigInput
87
+ // `trpc` is intentionally NOT declared here: createBunderstack intersects
88
+ // its own inference-friendly `trpc` declaration (router | builder callback)
89
+ // so the callback's `t` parameter gets contextual typing.
90
+ rateLimit?: boolean | RateLimitConfig
91
+ idempotency?: boolean | IdempotencyConfig
92
+ realtime?:
93
+ | boolean
94
+ | {
95
+ keepaliveMs?: number
96
+ bufferSize?: number
97
+ redis?: string | { url: string; token?: string }
98
+ }
99
+ }
100
+
101
+ export type ResolvedConfig = {
102
+ database: { url: string; authToken?: string }
103
+ auth: BetterAuthConfig
104
+ storage: ResolvedStorageBuckets
105
+ realtime?:
106
+ | boolean
107
+ | {
108
+ keepaliveMs?: number
109
+ bufferSize?: number
110
+ redis?: string | { url: string; token?: string }
111
+ }
112
+ }
113
+
114
+ export function resolveConfig<TSchema extends Record<string, unknown>>(
115
+ options: BunderstackConfig<TSchema>,
116
+ env?: BaseEnv,
117
+ ): ResolvedConfig {
118
+ const parsed = BunderstackOptionsSchema.parse(options)
119
+ // Self-validate when the caller didn't pass a pre-validated env, so
120
+ // resolveConfig stays usable standalone.
121
+ const resolvedEnv =
122
+ env ?? validateEnv(options.env as EnvConfigInput | undefined)
123
+
124
+ return {
125
+ database: {
126
+ url: parsed.database?.url ?? resolvedEnv.DATABASE_URL,
127
+ authToken: parsed.database?.authToken ?? resolvedEnv.DATABASE_AUTH_TOKEN,
128
+ },
129
+ auth: (() => {
130
+ const authInput = options.auth ?? {}
131
+ return {
132
+ ...authInput,
133
+ secret: authInput.secret ?? resolvedEnv.AUTH_SECRET,
134
+ }
135
+ })(),
136
+ storage: resolveBuckets(options.storage),
137
+ realtime: parsed.realtime,
138
+ }
139
+ }
140
+
141
+ export function resolveRealtimeRedisUrl(
142
+ realtime: ResolvedConfig['realtime'],
143
+ env?: BaseEnv,
144
+ ): string | undefined {
145
+ const fromConfig =
146
+ typeof realtime === 'object' && realtime.redis
147
+ ? typeof realtime.redis === 'string'
148
+ ? realtime.redis
149
+ : realtime.redis.url
150
+ : undefined
151
+ return fromConfig ?? env?.REDIS_URL ?? process.env.REDIS_URL ?? undefined
152
+ }
package/src/crud.ts ADDED
@@ -0,0 +1,389 @@
1
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
+
3
+ import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
4
+ import { Hono } from 'hono'
5
+
6
+ import type { RealtimeBroker } from './realtime/index'
7
+
8
+ import {
9
+ checkAccess,
10
+ resolveSession,
11
+ rowMatchesScope,
12
+ stampScope,
13
+ sanitizeWriteBody,
14
+ type AccessUser,
15
+ type AuthSessionResolver,
16
+ type CrudOperation,
17
+ type ResolvedAccess,
18
+ type ResolvedTableAccess,
19
+ type ScopeMap,
20
+ } from './access'
21
+ import { ErrorCode, apiError, ListQueryError } from './errors'
22
+ import {
23
+ lookupIdempotency,
24
+ resolveIdempotencyConfig,
25
+ storeIdempotency,
26
+ type IdempotencyConfig,
27
+ } from './idempotency'
28
+ import { executeList, parseListParams } from './list-query'
29
+ import { buildScopeWhere } from './scope'
30
+
31
+ export type CrudRouterOptions = {
32
+ auth?: AuthSessionResolver
33
+ access: ResolvedAccess
34
+ idempotency?: boolean | IdempotencyConfig
35
+ broker?: RealtimeBroker
36
+ }
37
+
38
+ function tableEntryForName(
39
+ access: ResolvedAccess,
40
+ tableName: string,
41
+ ): ResolvedTableAccess | undefined {
42
+ for (const entry of access.values()) {
43
+ if (entry.tableName === tableName) return entry
44
+ }
45
+ return undefined
46
+ }
47
+
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
50
+ }
51
+
52
+ async function enforce(
53
+ operation: CrudOperation,
54
+ access: ResolvedTableAccess,
55
+ ctx: Parameters<typeof checkAccess>[1],
56
+ ) {
57
+ const rule = access[operation]
58
+ const result = await checkAccess(rule, ctx, access.ownerColumn)
59
+ return result
60
+ }
61
+
62
+ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
63
+ schema: TSchema,
64
+ db: LibSQLDatabase<TSchema>,
65
+ options: CrudRouterOptions,
66
+ ): Hono {
67
+ const router = new Hono()
68
+ const { auth, access, broker } = options
69
+ const idempotency = resolveIdempotencyConfig(options.idempotency)
70
+
71
+ const scopeFor = (
72
+ tableAccess: ResolvedTableAccess,
73
+ ctx: {
74
+ user: AccessUser | null
75
+ session: { activeOrganizationId: string | null } | null
76
+ request: Request
77
+ },
78
+ ): ScopeMap | undefined =>
79
+ tableAccess.scope ? tableAccess.scope({ ...ctx }) : undefined
80
+
81
+ for (const table of Object.values(schema)) {
82
+ if (!isTable(table)) continue
83
+
84
+ const name = getTableName(table)
85
+ const tableAccess = tableEntryForName(access, name)
86
+ if (!tableAccess?.enabled) continue
87
+
88
+ const idCol = getTableColumns(table)['id']
89
+ if (!idCol) continue
90
+
91
+ router.get(`/${name}`, async (c) => {
92
+ const { user, activeOrganizationId } = await resolveSession(
93
+ auth,
94
+ c.req.raw.headers,
95
+ )
96
+ const session = { activeOrganizationId }
97
+ const denied = await enforce('list', tableAccess, {
98
+ user,
99
+ session,
100
+ request: c.req.raw,
101
+ })
102
+ if (!denied.allowed) {
103
+ return apiError(
104
+ c,
105
+ ErrorCode.FORBIDDEN,
106
+ 'Forbidden',
107
+ denied.status === 401 ? 401 : 403,
108
+ )
109
+ }
110
+
111
+ try {
112
+ const params = parseListParams(new URL(c.req.url), tableAccess)
113
+ const scope = scopeFor(tableAccess, {
114
+ user,
115
+ session,
116
+ request: c.req.raw,
117
+ })
118
+ const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
119
+ const result = await executeList(
120
+ db,
121
+ table,
122
+ tableAccess,
123
+ params,
124
+ idCol,
125
+ scopeWhere,
126
+ )
127
+ return c.json(result)
128
+ } catch (err) {
129
+ if (err instanceof ListQueryError) {
130
+ return apiError(c, err.code, err.message, 400, err.details)
131
+ }
132
+ throw err
133
+ }
134
+ })
135
+
136
+ router.get(`/${name}/:id`, async (c) => {
137
+ const { user, activeOrganizationId } = await resolveSession(
138
+ auth,
139
+ c.req.raw.headers,
140
+ )
141
+ const session = { activeOrganizationId }
142
+ const rawId = c.req.param('id')
143
+ const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
144
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
+ const rows = await (db as any)
146
+ .select()
147
+ .from(table)
148
+ .where(eq(idCol as any, id))
149
+ if (!rows[0]) {
150
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
151
+ }
152
+
153
+ const denied = await enforce('get', tableAccess, {
154
+ user,
155
+ session,
156
+ request: c.req.raw,
157
+ row: rows[0] as Record<string, unknown>,
158
+ })
159
+ if (!denied.allowed) {
160
+ return apiError(
161
+ c,
162
+ ErrorCode.FORBIDDEN,
163
+ 'Forbidden',
164
+ denied.status === 401 ? 401 : 403,
165
+ )
166
+ }
167
+
168
+ const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
169
+ if (
170
+ scope &&
171
+ !rowMatchesScope(rows[0] as Record<string, unknown>, scope)
172
+ ) {
173
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
174
+ }
175
+
176
+ return c.json(rows[0])
177
+ })
178
+
179
+ router.post(`/${name}`, async (c) => {
180
+ const { user, activeOrganizationId } = await resolveSession(
181
+ auth,
182
+ c.req.raw.headers,
183
+ )
184
+ const session = { activeOrganizationId }
185
+ const denied = await enforce('create', tableAccess, {
186
+ user,
187
+ session,
188
+ request: c.req.raw,
189
+ })
190
+ if (!denied.allowed) {
191
+ return apiError(
192
+ c,
193
+ ErrorCode.FORBIDDEN,
194
+ 'Forbidden',
195
+ denied.status === 401 ? 401 : 403,
196
+ )
197
+ }
198
+
199
+ const rawBody = await c.req.text()
200
+ let body: unknown
201
+ try {
202
+ body = rawBody ? JSON.parse(rawBody) : null
203
+ } catch {
204
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
205
+ }
206
+ if (!isRecord(body)) {
207
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
208
+ }
209
+
210
+ const idempotencyKey = c.req.header('Idempotency-Key')?.trim()
211
+ if (idempotency && idempotencyKey) {
212
+ const lookup = await lookupIdempotency(
213
+ db,
214
+ name,
215
+ idempotencyKey,
216
+ rawBody,
217
+ idempotency,
218
+ )
219
+ if (lookup.type === 'conflict') {
220
+ return apiError(
221
+ c,
222
+ ErrorCode.IDEMPOTENCY_CONFLICT,
223
+ 'Idempotency key reused with different body',
224
+ 409,
225
+ )
226
+ }
227
+ if (lookup.type === 'replay') {
228
+ return new Response(lookup.response, {
229
+ status: lookup.status,
230
+ headers: {
231
+ 'Content-Type': 'application/json',
232
+ 'Idempotency-Replayed': 'true',
233
+ },
234
+ })
235
+ }
236
+ }
237
+
238
+ const values = sanitizeWriteBody(
239
+ body,
240
+ tableAccess,
241
+ 'create',
242
+ user?.id ?? null,
243
+ )
244
+
245
+ const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
246
+ const stamped = scope ? stampScope(values, scope) : values
247
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
248
+ const rows = await (db as any).insert(table).values(stamped).returning()
249
+ const created = rows[0]
250
+ void broker?.publish(name, 'create', created as Record<string, unknown>)
251
+
252
+ if (idempotency && idempotencyKey) {
253
+ await storeIdempotency(
254
+ db,
255
+ name,
256
+ idempotencyKey,
257
+ rawBody,
258
+ 201,
259
+ created,
260
+ idempotency,
261
+ )
262
+ }
263
+
264
+ return c.json(created, 201)
265
+ })
266
+
267
+ router.patch(`/${name}/:id`, async (c) => {
268
+ const { user, activeOrganizationId } = await resolveSession(
269
+ auth,
270
+ c.req.raw.headers,
271
+ )
272
+ const session = { activeOrganizationId }
273
+ const rawId = c.req.param('id')
274
+ const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
275
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
+ const existing = await (db as any)
277
+ .select()
278
+ .from(table)
279
+ .where(eq(idCol as any, id))
280
+ if (!existing[0]) {
281
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
282
+ }
283
+
284
+ const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
285
+ if (
286
+ scope &&
287
+ !rowMatchesScope(existing[0] as Record<string, unknown>, scope)
288
+ ) {
289
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
290
+ }
291
+
292
+ const denied = await enforce('update', tableAccess, {
293
+ user,
294
+ session,
295
+ request: c.req.raw,
296
+ row: existing[0] as Record<string, unknown>,
297
+ })
298
+ if (!denied.allowed) {
299
+ return apiError(
300
+ c,
301
+ ErrorCode.FORBIDDEN,
302
+ 'Forbidden',
303
+ denied.status === 401 ? 401 : 403,
304
+ )
305
+ }
306
+
307
+ let body: unknown
308
+ try {
309
+ body = await c.req.json()
310
+ } catch {
311
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
312
+ }
313
+ if (!isRecord(body)) {
314
+ return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
315
+ }
316
+
317
+ const values = sanitizeWriteBody(
318
+ body,
319
+ tableAccess,
320
+ 'update',
321
+ user?.id ?? null,
322
+ )
323
+
324
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
325
+ const rows = await (db as any)
326
+ .update(table)
327
+ .set(values)
328
+ .where(eq(idCol as any, id))
329
+ .returning()
330
+ if (!rows[0]) {
331
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
332
+ }
333
+ void broker?.publish(name, 'update', rows[0] as Record<string, unknown>)
334
+ return c.json(rows[0])
335
+ })
336
+
337
+ router.delete(`/${name}/:id`, async (c) => {
338
+ const { user, activeOrganizationId } = await resolveSession(
339
+ auth,
340
+ c.req.raw.headers,
341
+ )
342
+ const session = { activeOrganizationId }
343
+ const rawId = c.req.param('id')
344
+ const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
345
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
346
+ const existing = await (db as any)
347
+ .select()
348
+ .from(table)
349
+ .where(eq(idCol as any, id))
350
+ if (!existing[0]) {
351
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
352
+ }
353
+
354
+ const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
355
+ if (
356
+ scope &&
357
+ !rowMatchesScope(existing[0] as Record<string, unknown>, scope)
358
+ ) {
359
+ return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
360
+ }
361
+
362
+ const denied = await enforce('delete', tableAccess, {
363
+ user,
364
+ session,
365
+ request: c.req.raw,
366
+ row: existing[0] as Record<string, unknown>,
367
+ })
368
+ if (!denied.allowed) {
369
+ return apiError(
370
+ c,
371
+ ErrorCode.FORBIDDEN,
372
+ 'Forbidden',
373
+ denied.status === 401 ? 401 : 403,
374
+ )
375
+ }
376
+
377
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
378
+ await (db as any).delete(table).where(eq(idCol as any, id))
379
+ void broker?.publish(
380
+ name,
381
+ 'delete',
382
+ existing[0] as Record<string, unknown>,
383
+ )
384
+ return new Response(null, { status: 204 })
385
+ })
386
+ }
387
+
388
+ return router
389
+ }
package/src/db.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { createClient } from '@libsql/client'
2
+ import { drizzle } from 'drizzle-orm/libsql'
3
+
4
+ export function createDb<TSchema extends Record<string, unknown>>(
5
+ schema: TSchema,
6
+ cfg: { url: string; authToken?: string },
7
+ ) {
8
+ const client = createClient({ url: cfg.url, authToken: cfg.authToken })
9
+ return drizzle(client, { schema })
10
+ }