bunderstack 0.15.2 → 0.17.0-beta.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.
Files changed (47) hide show
  1. package/README.md +25 -138
  2. package/package.json +22 -14
  3. package/src/access.ts +24 -1
  4. package/src/api/api-types.types.ts +106 -0
  5. package/src/api/builder.ts +52 -0
  6. package/src/api/context.ts +83 -0
  7. package/src/api/crud-router.ts +321 -0
  8. package/src/api/openapi.ts +184 -0
  9. package/src/api/realtime-router.ts +75 -0
  10. package/src/api/registry.ts +338 -0
  11. package/src/api/router.ts +34 -0
  12. package/src/api/storage-router.ts +224 -0
  13. package/src/api/types.ts +84 -0
  14. package/src/auth.ts +5 -0
  15. package/src/blueprint.ts +88 -105
  16. package/src/config.ts +73 -77
  17. package/src/cron.ts +2 -1
  18. package/src/crud-operations.ts +488 -0
  19. package/src/dialect.ts +1 -1
  20. package/src/env.ts +28 -21
  21. package/src/errors.ts +90 -23
  22. package/src/handler.ts +16 -44
  23. package/src/index.ts +283 -294
  24. package/src/internal-tables-pg.ts +1 -17
  25. package/src/internal-tables.ts +0 -31
  26. package/src/jobs/define.ts +75 -21
  27. package/src/jobs/index.ts +3 -9
  28. package/src/jobs/queue.ts +14 -6
  29. package/src/jobs/slots.ts +52 -0
  30. package/src/jobs/worker.ts +142 -42
  31. package/src/manifest.ts +84 -93
  32. package/src/realtime/facade.ts +16 -13
  33. package/src/realtime/filter.ts +77 -0
  34. package/src/realtime/heartbeat.ts +80 -0
  35. package/src/realtime/publisher.ts +46 -0
  36. package/src/standard-schema.ts +59 -0
  37. package/src/storage/index.ts +8 -0
  38. package/src/storage/operations.ts +398 -0
  39. package/src/crud.ts +0 -408
  40. package/src/jobs/cron-auth.ts +0 -28
  41. package/src/jobs/cron-router.ts +0 -135
  42. package/src/jobs/cron-runner.ts +0 -224
  43. package/src/jobs/local-cron.ts +0 -78
  44. package/src/realtime/index.ts +0 -250
  45. package/src/realtime/redis.ts +0 -228
  46. package/src/storage/router.ts +0 -531
  47. package/src/trpc.ts +0 -57
package/src/crud.ts DELETED
@@ -1,408 +0,0 @@
1
- import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
2
- import { Hono } from 'hono'
3
-
4
- import type { AnyDb } from './dialect'
5
- import type { RealtimeFacade } from './realtime/facade'
6
-
7
- import {
8
- checkAccess,
9
- resolveSession,
10
- rowMatchesScope,
11
- stampScope,
12
- sanitizeWriteBody,
13
- type AccessUser,
14
- type AuthSessionResolver,
15
- type CrudOperation,
16
- type ResolvedAccess,
17
- type ResolvedTableAccess,
18
- type ScopeMap,
19
- type ScopeResolver,
20
- type AccessContext,
21
- } from './access'
22
- import { ErrorCode, apiError, ListQueryError } from './errors'
23
- import {
24
- lookupIdempotency,
25
- resolveIdempotencyConfig,
26
- storeIdempotency,
27
- type IdempotencyConfig,
28
- } from './idempotency'
29
- import { executeList, parseListParams } from './list-query'
30
- import { buildScopeWhere } from './scope'
31
-
32
- export type CrudRouterOptions<
33
- TSchema extends Record<string, unknown> = Record<string, unknown>,
34
- > = {
35
- auth?: AuthSessionResolver
36
- access: ResolvedAccess
37
- idempotency?: boolean | IdempotencyConfig
38
- realtime?: RealtimeFacade<TSchema>
39
- }
40
-
41
- function tableEntryForName(
42
- access: ResolvedAccess,
43
- tableName: string,
44
- ): ResolvedTableAccess | undefined {
45
- for (const entry of access.values()) {
46
- if (entry.tableName === tableName) return entry
47
- }
48
- return undefined
49
- }
50
-
51
- function isRecord(value: unknown): value is Record<string, unknown> {
52
- return value !== null && typeof value === 'object' && !Array.isArray(value)
53
- }
54
-
55
- async function enforce(
56
- operation: CrudOperation,
57
- access: ResolvedTableAccess,
58
- ctx: Parameters<typeof checkAccess>[1],
59
- ) {
60
- const rule = access[operation]
61
- const result = await checkAccess(rule, ctx, access.ownerColumn)
62
- return result
63
- }
64
-
65
- export function buildCrudRouter<TSchema extends Record<string, unknown>>(
66
- schema: TSchema,
67
- db: AnyDb,
68
- options: CrudRouterOptions<TSchema>,
69
- ): Hono {
70
- const router = new Hono()
71
- const { auth, access, realtime } = options
72
- const idempotency = resolveIdempotencyConfig(options.idempotency)
73
-
74
- const scopeFor = (
75
- resolver: ScopeResolver | undefined,
76
- ctx: AccessContext,
77
- ): ScopeMap | undefined => (resolver ? resolver(ctx) : undefined)
78
-
79
- for (const table of Object.values(schema)) {
80
- if (!isTable(table)) continue
81
-
82
- const name = getTableName(table)
83
- const tableAccess = tableEntryForName(access, name)
84
- if (!tableAccess?.enabled) continue
85
-
86
- const idCol = getTableColumns(table)['id']
87
- if (!idCol) continue
88
-
89
- router.get(`/${name}`, async (c) => {
90
- const { user, activeOrganizationId } = await resolveSession(
91
- auth,
92
- c.req.raw.headers,
93
- )
94
- const session = { activeOrganizationId }
95
- const denied = await enforce('list', tableAccess, {
96
- user,
97
- session,
98
- request: c.req.raw,
99
- })
100
- if (!denied.allowed) {
101
- return apiError(
102
- c,
103
- ErrorCode.FORBIDDEN,
104
- 'Forbidden',
105
- denied.status === 401 ? 401 : 403,
106
- )
107
- }
108
-
109
- try {
110
- const params = parseListParams(new URL(c.req.url), tableAccess)
111
- const scope = scopeFor(tableAccess.readScope, {
112
- user,
113
- session,
114
- request: c.req.raw,
115
- })
116
- const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
117
- const result = await executeList(
118
- db,
119
- table,
120
- tableAccess,
121
- params,
122
- idCol,
123
- scopeWhere,
124
- )
125
- return c.json(result)
126
- } catch (err) {
127
- if (err instanceof ListQueryError) {
128
- return apiError(c, err.code, err.message, 400, err.details)
129
- }
130
- throw err
131
- }
132
- })
133
-
134
- router.get(`/${name}/:id`, async (c) => {
135
- const { user, activeOrganizationId } = await resolveSession(
136
- auth,
137
- c.req.raw.headers,
138
- )
139
- const session = { activeOrganizationId }
140
- const rawId = c.req.param('id')
141
- const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
142
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
143
- const rows = await (db as any)
144
- .select()
145
- .from(table)
146
- .where(eq(idCol as any, id))
147
- if (!rows[0]) {
148
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
149
- }
150
-
151
- const denied = await enforce('get', tableAccess, {
152
- user,
153
- session,
154
- request: c.req.raw,
155
- row: rows[0] as Record<string, unknown>,
156
- })
157
- if (!denied.allowed) {
158
- return apiError(
159
- c,
160
- ErrorCode.FORBIDDEN,
161
- 'Forbidden',
162
- denied.status === 401 ? 401 : 403,
163
- )
164
- }
165
-
166
- const scope = scopeFor(tableAccess.readScope, {
167
- user,
168
- session,
169
- request: c.req.raw,
170
- })
171
- if (
172
- scope &&
173
- !rowMatchesScope(rows[0] as Record<string, unknown>, scope)
174
- ) {
175
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
176
- }
177
-
178
- return c.json(rows[0])
179
- })
180
-
181
- router.post(`/${name}`, async (c) => {
182
- const { user, activeOrganizationId } = await resolveSession(
183
- auth,
184
- c.req.raw.headers,
185
- )
186
- const session = { activeOrganizationId }
187
- const denied = await enforce('create', tableAccess, {
188
- user,
189
- session,
190
- request: c.req.raw,
191
- })
192
- if (!denied.allowed) {
193
- return apiError(
194
- c,
195
- ErrorCode.FORBIDDEN,
196
- 'Forbidden',
197
- denied.status === 401 ? 401 : 403,
198
- )
199
- }
200
-
201
- const rawBody = await c.req.text()
202
- let body: unknown
203
- try {
204
- body = rawBody ? JSON.parse(rawBody) : null
205
- } catch {
206
- return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
207
- }
208
- if (!isRecord(body)) {
209
- return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
210
- }
211
-
212
- const idempotencyKey = c.req.header('Idempotency-Key')?.trim()
213
- if (idempotency && idempotencyKey) {
214
- const lookup = await lookupIdempotency(
215
- db,
216
- name,
217
- idempotencyKey,
218
- rawBody,
219
- idempotency,
220
- )
221
- if (lookup.type === 'conflict') {
222
- return apiError(
223
- c,
224
- ErrorCode.IDEMPOTENCY_CONFLICT,
225
- 'Idempotency key reused with different body',
226
- 409,
227
- )
228
- }
229
- if (lookup.type === 'replay') {
230
- return new Response(lookup.response, {
231
- status: lookup.status,
232
- headers: {
233
- 'Content-Type': 'application/json',
234
- 'Idempotency-Replayed': 'true',
235
- },
236
- })
237
- }
238
- }
239
-
240
- const values = sanitizeWriteBody(
241
- body,
242
- tableAccess,
243
- 'create',
244
- user?.id ?? null,
245
- )
246
-
247
- const scope = scopeFor(tableAccess.writeScope, {
248
- user,
249
- session,
250
- request: c.req.raw,
251
- body: body as Record<string, unknown>,
252
- })
253
- const stamped = scope ? stampScope(values, scope) : values
254
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
255
- const rows = await (db as any).insert(table).values(stamped).returning()
256
- const created = rows[0]
257
- void realtime?.publish(table as never, 'create', created as never)
258
-
259
- if (idempotency && idempotencyKey) {
260
- await storeIdempotency(
261
- db,
262
- name,
263
- idempotencyKey,
264
- rawBody,
265
- 201,
266
- created,
267
- idempotency,
268
- )
269
- }
270
-
271
- return c.json(created, 201)
272
- })
273
-
274
- router.patch(`/${name}/:id`, async (c) => {
275
- const { user, activeOrganizationId } = await resolveSession(
276
- auth,
277
- c.req.raw.headers,
278
- )
279
- const session = { activeOrganizationId }
280
- const rawId = c.req.param('id')
281
- const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
282
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
283
- const existing = await (db as any)
284
- .select()
285
- .from(table)
286
- .where(eq(idCol as any, id))
287
- if (!existing[0]) {
288
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
289
- }
290
-
291
- const readScope = scopeFor(tableAccess.readScope, {
292
- user,
293
- session,
294
- request: c.req.raw,
295
- })
296
- if (
297
- readScope &&
298
- !rowMatchesScope(existing[0] as Record<string, unknown>, readScope)
299
- ) {
300
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
301
- }
302
-
303
- const denied = await enforce('update', tableAccess, {
304
- user,
305
- session,
306
- request: c.req.raw,
307
- row: existing[0] as Record<string, unknown>,
308
- })
309
- if (!denied.allowed) {
310
- return apiError(
311
- c,
312
- ErrorCode.FORBIDDEN,
313
- 'Forbidden',
314
- denied.status === 401 ? 401 : 403,
315
- )
316
- }
317
-
318
- let body: unknown
319
- try {
320
- body = await c.req.json()
321
- } catch {
322
- return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
323
- }
324
- if (!isRecord(body)) {
325
- return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
326
- }
327
-
328
- const values = sanitizeWriteBody(
329
- body,
330
- tableAccess,
331
- 'update',
332
- user?.id ?? null,
333
- )
334
-
335
- const writeScope = scopeFor(tableAccess.writeScope, {
336
- user,
337
- session,
338
- request: c.req.raw,
339
- body: body as Record<string, unknown>,
340
- })
341
- const stamped = writeScope ? stampScope(values, writeScope) : values
342
-
343
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
344
- const rows = await (db as any)
345
- .update(table)
346
- .set(stamped)
347
- .where(eq(idCol as any, id))
348
- .returning()
349
- if (!rows[0]) {
350
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
351
- }
352
- void realtime?.publish(table as never, 'update', rows[0] as never)
353
- return c.json(rows[0])
354
- })
355
-
356
- router.delete(`/${name}/:id`, async (c) => {
357
- const { user, activeOrganizationId } = await resolveSession(
358
- auth,
359
- c.req.raw.headers,
360
- )
361
- const session = { activeOrganizationId }
362
- const rawId = c.req.param('id')
363
- const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
364
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
365
- const existing = await (db as any)
366
- .select()
367
- .from(table)
368
- .where(eq(idCol as any, id))
369
- if (!existing[0]) {
370
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
371
- }
372
-
373
- const scope = scopeFor(tableAccess.readScope, {
374
- user,
375
- session,
376
- request: c.req.raw,
377
- })
378
- if (
379
- scope &&
380
- !rowMatchesScope(existing[0] as Record<string, unknown>, scope)
381
- ) {
382
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
383
- }
384
-
385
- const denied = await enforce('delete', tableAccess, {
386
- user,
387
- session,
388
- request: c.req.raw,
389
- row: existing[0] as Record<string, unknown>,
390
- })
391
- if (!denied.allowed) {
392
- return apiError(
393
- c,
394
- ErrorCode.FORBIDDEN,
395
- 'Forbidden',
396
- denied.status === 401 ? 401 : 403,
397
- )
398
- }
399
-
400
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
401
- await (db as any).delete(table).where(eq(idCol as any, id))
402
- void realtime?.publish(table as never, 'delete', existing[0] as never)
403
- return new Response(null, { status: 204 })
404
- })
405
- }
406
-
407
- return router
408
- }
@@ -1,28 +0,0 @@
1
- import { createHmac, timingSafeEqual } from 'node:crypto'
2
-
3
- function canonical(taskId: string, slot: number): string {
4
- return `${taskId}\n${slot}`
5
- }
6
-
7
- export function signScheduleRequest(
8
- secret: string,
9
- taskId: string,
10
- slot: number,
11
- ): string {
12
- const digest = createHmac('sha256', secret)
13
- .update(canonical(taskId, slot))
14
- .digest('hex')
15
- return `sha256=${digest}`
16
- }
17
-
18
- export function verifyScheduleRequest(
19
- secret: string,
20
- taskId: string,
21
- slot: number,
22
- signature: string,
23
- ): boolean {
24
- if (!/^sha256=[0-9a-f]{64}$/.test(signature)) return false
25
- const expected = Buffer.from(signScheduleRequest(secret, taskId, slot))
26
- const received = Buffer.from(signature)
27
- return timingSafeEqual(expected, received)
28
- }
@@ -1,135 +0,0 @@
1
- import { and, eq, lt } from 'drizzle-orm'
2
- import { Hono } from 'hono'
3
-
4
- import type { AnyDb } from '../dialect'
5
- import type { BackgroundDefs } from './define'
6
-
7
- import { cronRunsTableFor } from '../internal-tables'
8
- import { verifyScheduleRequest } from './cron-auth'
9
- import { runCronSlot, runScheduledSlot } from './cron-runner'
10
-
11
- const MAX_SLOT_AGE_MS = 60 * 60_000
12
- const MAX_FUTURE_SLOT_MS = 60_000
13
- const STORAGE_SWEEP_SCHEDULE = '0 4 * * *'
14
- const SCHEDULED_RUN_RETENTION_MS = 30 * 24 * 60 * 60_000
15
-
16
- export function buildCronRouter(args: {
17
- db: AnyDb
18
- defs: BackgroundDefs
19
- ctx: Record<string, unknown>
20
- secret: string
21
- storage: { sweep: () => Promise<unknown> }
22
- now?: () => number
23
- }): Hono {
24
- const app = new Hono()
25
- const now = args.now ?? Date.now
26
-
27
- app.post('/cron/:name', async (c) => {
28
- const name = c.req.param('name')
29
- const slotText = c.req.header('X-Bunderstack-Cron-Slot')
30
- const signature = c.req.header('X-Bunderstack-Cron-Signature')
31
- const slot = Number(slotText)
32
- if (!slotText || !Number.isSafeInteger(slot) || !signature) {
33
- return c.json({ error: 'invalid schedule signature' }, 401)
34
- }
35
- if (!verifyScheduleRequest(args.secret, `cron:${name}`, slot, signature)) {
36
- return c.json({ error: 'invalid schedule signature' }, 401)
37
- }
38
- const definition = args.defs[name]
39
- if (!definition || definition.kind !== 'cron') {
40
- return c.json({ error: 'unknown cron' }, 404)
41
- }
42
- const current = now()
43
- if (
44
- slot % 60_000 !== 0 ||
45
- slot < current - MAX_SLOT_AGE_MS ||
46
- slot > current + MAX_FUTURE_SLOT_MS
47
- ) {
48
- return c.json({ error: 'invalid cron slot' }, 400)
49
- }
50
- try {
51
- const result = await runCronSlot({
52
- db: args.db,
53
- defs: args.defs,
54
- ctx: args.ctx,
55
- name,
56
- slot,
57
- now: current,
58
- })
59
- return c.json(result, result.status === 'running' ? 202 : 200)
60
- } catch (error) {
61
- if (
62
- error instanceof Error &&
63
- error.message === '[bunderstack] cron slot does not match its schedule'
64
- ) {
65
- return c.json({ error: 'invalid cron slot' }, 400)
66
- }
67
- return c.json({ error: 'cron handler failed' }, 500)
68
- }
69
- })
70
-
71
- app.post('/maintenance/:name', async (c) => {
72
- const name = c.req.param('name')
73
- const slotText = c.req.header('X-Bunderstack-Cron-Slot')
74
- const signature = c.req.header('X-Bunderstack-Cron-Signature')
75
- const slot = Number(slotText)
76
- if (!slotText || !Number.isSafeInteger(slot) || !signature) {
77
- return c.json({ error: 'invalid schedule signature' }, 401)
78
- }
79
- if (
80
- !verifyScheduleRequest(
81
- args.secret,
82
- `maintenance:${name}`,
83
- slot,
84
- signature,
85
- )
86
- ) {
87
- return c.json({ error: 'invalid schedule signature' }, 401)
88
- }
89
- if (name !== 'storage-sweep') {
90
- return c.json({ error: 'unknown maintenance task' }, 404)
91
- }
92
- const current = now()
93
- if (
94
- slot % 60_000 !== 0 ||
95
- slot < current - MAX_SLOT_AGE_MS ||
96
- slot > current + MAX_FUTURE_SLOT_MS
97
- ) {
98
- return c.json({ error: 'invalid cron slot' }, 400)
99
- }
100
- try {
101
- const result = await runScheduledSlot({
102
- db: args.db,
103
- taskId: 'maintenance:storage-sweep',
104
- schedule: STORAGE_SWEEP_SCHEDULE,
105
- slot,
106
- now: current,
107
- run: async () => {
108
- await args.storage.sweep()
109
- },
110
- })
111
- if (result.status === 'succeeded') {
112
- const t = cronRunsTableFor(args.db)
113
- await args.db
114
- .delete(t)
115
- .where(
116
- and(
117
- eq(t.status, 'succeeded'),
118
- lt(t.finishedAt, current - SCHEDULED_RUN_RETENTION_MS),
119
- ),
120
- )
121
- }
122
- return c.json(result, result.status === 'running' ? 202 : 200)
123
- } catch (error) {
124
- if (
125
- error instanceof Error &&
126
- error.message === '[bunderstack] cron slot does not match its schedule'
127
- ) {
128
- return c.json({ error: 'invalid cron slot' }, 400)
129
- }
130
- return c.json({ error: 'maintenance handler failed' }, 500)
131
- }
132
- })
133
-
134
- return app
135
- }