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
@@ -0,0 +1,488 @@
1
+ import {
2
+ eq,
3
+ getTableColumns,
4
+ getTableName,
5
+ isTable,
6
+ type Table,
7
+ } from 'drizzle-orm'
8
+
9
+ import type { AnyDb } from './dialect'
10
+ import type { RealtimeFacade } from './realtime/facade'
11
+
12
+ import {
13
+ checkAccess,
14
+ rowMatchesScope,
15
+ sanitizeWriteBody,
16
+ stampScope,
17
+ tableEntryForName,
18
+ type AccessUser,
19
+ type ResolvedAccess,
20
+ type ResolvedTableAccess,
21
+ type ScopeMap,
22
+ type ScopeResolver,
23
+ } from './access'
24
+ import {
25
+ BunderstackError,
26
+ ErrorCode,
27
+ ListQueryError,
28
+ type BunderstackErrorCode,
29
+ type ErrorCodeValue,
30
+ } from './errors'
31
+ import {
32
+ lookupIdempotency,
33
+ resolveIdempotencyConfig,
34
+ storeIdempotency,
35
+ type IdempotencyConfig,
36
+ } from './idempotency'
37
+ import { executeList, parseListParams, type ListResult } from './list-query'
38
+ import { buildScopeWhere } from './scope'
39
+
40
+ export interface CrudExecutionContext {
41
+ request: Request
42
+ user: AccessUser | null
43
+ session: { activeOrganizationId: string | null }
44
+ }
45
+
46
+ function errorCodeForStatus(status: number): BunderstackErrorCode {
47
+ if (status === 401) return 'UNAUTHORIZED'
48
+ if (status === 403) return 'FORBIDDEN'
49
+ if (status === 404) return 'NOT_FOUND'
50
+ if (status === 409) return 'CONFLICT'
51
+ if (status === 413) return 'PAYLOAD_TOO_LARGE'
52
+ if (status === 429) return 'RATE_LIMITED'
53
+ return 'VALIDATION_ERROR'
54
+ }
55
+
56
+ export class CrudOperationError extends BunderstackError {
57
+ constructor(
58
+ status: number,
59
+ readonly legacyCode: ErrorCodeValue,
60
+ message: string,
61
+ details?: unknown,
62
+ ) {
63
+ const code = errorCodeForStatus(status)
64
+ super(
65
+ code,
66
+ message,
67
+ legacyCode === code
68
+ ? details
69
+ : {
70
+ code: legacyCode,
71
+ ...(details === undefined ? {} : { details }),
72
+ },
73
+ )
74
+ this.name = 'CrudOperationError'
75
+ }
76
+ }
77
+
78
+ export type CrudOperationsDeps<
79
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
80
+ > = {
81
+ schema: TSchema
82
+ db: AnyDb
83
+ access: ResolvedAccess
84
+ idempotency?: boolean | IdempotencyConfig
85
+ realtime?: RealtimeFacade<TSchema>
86
+ }
87
+
88
+ export type CreateResult =
89
+ | { type: 'created'; status: 201; record: Record<string, unknown> }
90
+ | {
91
+ type: 'replay'
92
+ status: number
93
+ body: string
94
+ record: Record<string, unknown>
95
+ }
96
+
97
+ function isRecord(value: unknown): value is Record<string, unknown> {
98
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
99
+ }
100
+
101
+ function isUniqueConstraintError(error: unknown): boolean {
102
+ const seen = new Set<unknown>()
103
+ let current = error
104
+
105
+ while (isRecord(current) && !seen.has(current)) {
106
+ seen.add(current)
107
+ const code = current['code']
108
+ if (
109
+ code === '23505' ||
110
+ code === 'SQLITE_CONSTRAINT_UNIQUE' ||
111
+ code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
112
+ ) {
113
+ return true
114
+ }
115
+
116
+ const message = current['message']
117
+ if (
118
+ typeof message === 'string' &&
119
+ (/duplicate key value violates unique constraint/i.test(message) ||
120
+ /unique constraint failed/i.test(message))
121
+ ) {
122
+ return true
123
+ }
124
+
125
+ current = current['cause']
126
+ }
127
+
128
+ return false
129
+ }
130
+
131
+ function coerceId(rawId: string | number): string | number {
132
+ if (typeof rawId === 'number') return rawId
133
+ return isNaN(Number(rawId)) ? rawId : Number(rawId)
134
+ }
135
+
136
+ export function createCrudOperations<
137
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
138
+ >(deps: CrudOperationsDeps<TSchema>) {
139
+ const { schema, db, access, realtime } = deps
140
+ const idempotency = resolveIdempotencyConfig(deps.idempotency)
141
+
142
+ const scopeFor = (
143
+ resolver: ScopeResolver | undefined,
144
+ ctx: {
145
+ user: AccessUser | null
146
+ session: { activeOrganizationId: string | null }
147
+ request: Request
148
+ row?: Record<string, unknown>
149
+ body?: Record<string, unknown>
150
+ },
151
+ ): ScopeMap | undefined => (resolver ? resolver(ctx) : undefined)
152
+
153
+ function resolveTable(tableName: string) {
154
+ const table = Object.values(schema).find(
155
+ (t) => isTable(t) && getTableName(t) === tableName,
156
+ ) as Table | undefined
157
+ if (!table) {
158
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
159
+ }
160
+ const tableAccess = tableEntryForName(access, tableName)
161
+ if (!tableAccess || !tableAccess.enabled) {
162
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
163
+ }
164
+ const idCol = getTableColumns(table)['id']
165
+ if (!idCol) {
166
+ throw new CrudOperationError(
167
+ 400,
168
+ ErrorCode.VALIDATION_ERROR,
169
+ `Table ${tableName} has no id column`,
170
+ )
171
+ }
172
+ return { table, tableAccess, idCol }
173
+ }
174
+
175
+ return {
176
+ async list(
177
+ tableName: string,
178
+ paramsInput: URL | Record<string, unknown> | undefined,
179
+ ctx: CrudExecutionContext,
180
+ ): Promise<ListResult<Record<string, unknown>>> {
181
+ const { table, tableAccess, idCol } = resolveTable(tableName)
182
+
183
+ const denied = await checkAccess(
184
+ tableAccess.list,
185
+ ctx,
186
+ tableAccess.ownerColumn,
187
+ )
188
+ if (!denied.allowed) {
189
+ throw new CrudOperationError(
190
+ denied.status === 401 ? 401 : 403,
191
+ ErrorCode.FORBIDDEN,
192
+ 'Forbidden',
193
+ )
194
+ }
195
+
196
+ let urlObj: URL
197
+ if (paramsInput instanceof URL) {
198
+ urlObj = paramsInput
199
+ } else {
200
+ urlObj = new URL(ctx.request.url || 'http://localhost')
201
+ if (paramsInput) {
202
+ for (const [k, v] of Object.entries(paramsInput)) {
203
+ if (v !== undefined && v !== null) {
204
+ urlObj.searchParams.set(k, String(v))
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ try {
211
+ const params = parseListParams(urlObj, tableAccess)
212
+ const scope = scopeFor(tableAccess.readScope, ctx)
213
+ const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
214
+ return await executeList(
215
+ db,
216
+ table,
217
+ tableAccess,
218
+ params,
219
+ idCol,
220
+ scopeWhere,
221
+ )
222
+ } catch (err) {
223
+ if (err instanceof ListQueryError) {
224
+ throw new CrudOperationError(400, err.code, err.message, err.details)
225
+ }
226
+ throw err
227
+ }
228
+ },
229
+
230
+ async get(
231
+ tableName: string,
232
+ rawId: string | number,
233
+ ctx: CrudExecutionContext,
234
+ ): Promise<Record<string, unknown>> {
235
+ const { table, tableAccess, idCol } = resolveTable(tableName)
236
+ const id = coerceId(rawId)
237
+
238
+ const rows = await db
239
+ .select()
240
+ .from(table)
241
+ .where(eq(idCol, id))
242
+ if (!rows[0]) {
243
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
244
+ }
245
+ const row = rows[0] as Record<string, unknown>
246
+
247
+ const denied = await checkAccess(
248
+ tableAccess.get,
249
+ { ...ctx, row },
250
+ tableAccess.ownerColumn,
251
+ )
252
+ if (!denied.allowed) {
253
+ throw new CrudOperationError(
254
+ denied.status === 401 ? 401 : 403,
255
+ ErrorCode.FORBIDDEN,
256
+ 'Forbidden',
257
+ )
258
+ }
259
+
260
+ const scope = scopeFor(tableAccess.readScope, ctx)
261
+ if (scope && !rowMatchesScope(row, scope)) {
262
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
263
+ }
264
+
265
+ return row
266
+ },
267
+
268
+ async create(
269
+ tableName: string,
270
+ body: unknown,
271
+ rawBody: string | undefined,
272
+ idempotencyKey: string | undefined,
273
+ ctx: CrudExecutionContext,
274
+ ): Promise<CreateResult> {
275
+ const { table, tableAccess } = resolveTable(tableName)
276
+
277
+ const denied = await checkAccess(
278
+ tableAccess.create,
279
+ ctx,
280
+ tableAccess.ownerColumn,
281
+ )
282
+ if (!denied.allowed) {
283
+ throw new CrudOperationError(
284
+ denied.status === 401 ? 401 : 403,
285
+ ErrorCode.FORBIDDEN,
286
+ 'Forbidden',
287
+ )
288
+ }
289
+
290
+ if (!isRecord(body)) {
291
+ throw new CrudOperationError(
292
+ 400,
293
+ ErrorCode.VALIDATION_ERROR,
294
+ 'Invalid JSON body',
295
+ )
296
+ }
297
+
298
+ const trimmedKey = idempotencyKey?.trim()
299
+ const effectiveRawBody = rawBody ?? JSON.stringify(body)
300
+
301
+ if (idempotency && trimmedKey) {
302
+ const lookup = await lookupIdempotency(
303
+ db,
304
+ tableName,
305
+ trimmedKey,
306
+ effectiveRawBody,
307
+ idempotency,
308
+ )
309
+ if (lookup.type === 'conflict') {
310
+ throw new CrudOperationError(
311
+ 409,
312
+ ErrorCode.IDEMPOTENCY_CONFLICT,
313
+ 'Idempotency key reused with different body',
314
+ )
315
+ }
316
+ if (lookup.type === 'replay') {
317
+ return {
318
+ type: 'replay',
319
+ status: lookup.status,
320
+ body: lookup.response,
321
+ record: JSON.parse(lookup.response) as Record<string, unknown>,
322
+ }
323
+ }
324
+ }
325
+
326
+ const values = sanitizeWriteBody(
327
+ body,
328
+ tableAccess,
329
+ 'create',
330
+ ctx.user?.id ?? null,
331
+ )
332
+
333
+ const scope = scopeFor(tableAccess.writeScope, { ...ctx, body })
334
+ const stamped = scope ? stampScope(values, scope) : values
335
+
336
+ let rows: Record<string, unknown>[]
337
+ try {
338
+ rows = await db.insert(table).values(stamped).returning()
339
+ } catch (error) {
340
+ if (isUniqueConstraintError(error)) {
341
+ throw new CrudOperationError(
342
+ 409,
343
+ ErrorCode.CONFLICT,
344
+ 'Record already exists',
345
+ )
346
+ }
347
+ throw error
348
+ }
349
+ const created = rows[0] as Record<string, unknown>
350
+ void realtime?.publish(table as never, 'create', created as never)
351
+
352
+ if (idempotency && trimmedKey) {
353
+ await storeIdempotency(
354
+ db,
355
+ tableName,
356
+ trimmedKey,
357
+ effectiveRawBody,
358
+ 201,
359
+ created,
360
+ idempotency,
361
+ )
362
+ }
363
+
364
+ return {
365
+ type: 'created',
366
+ status: 201,
367
+ record: created,
368
+ }
369
+ },
370
+
371
+ async update(
372
+ tableName: string,
373
+ rawId: string | number,
374
+ body: unknown,
375
+ ctx: CrudExecutionContext,
376
+ ): Promise<Record<string, unknown>> {
377
+ const { table, tableAccess, idCol } = resolveTable(tableName)
378
+ const id = coerceId(rawId)
379
+
380
+ const existing = await db
381
+ .select()
382
+ .from(table)
383
+ .where(eq(idCol, id))
384
+ if (!existing[0]) {
385
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
386
+ }
387
+ const existingRow = existing[0] as Record<string, unknown>
388
+
389
+ const readScope = scopeFor(tableAccess.readScope, ctx)
390
+ if (readScope && !rowMatchesScope(existingRow, readScope)) {
391
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
392
+ }
393
+
394
+ const denied = await checkAccess(
395
+ tableAccess.update,
396
+ { ...ctx, row: existingRow },
397
+ tableAccess.ownerColumn,
398
+ )
399
+ if (!denied.allowed) {
400
+ throw new CrudOperationError(
401
+ denied.status === 401 ? 401 : 403,
402
+ ErrorCode.FORBIDDEN,
403
+ 'Forbidden',
404
+ )
405
+ }
406
+
407
+ if (!isRecord(body)) {
408
+ throw new CrudOperationError(
409
+ 400,
410
+ ErrorCode.VALIDATION_ERROR,
411
+ 'Invalid JSON body',
412
+ )
413
+ }
414
+
415
+ const values = sanitizeWriteBody(
416
+ body,
417
+ tableAccess,
418
+ 'update',
419
+ ctx.user?.id ?? null,
420
+ )
421
+
422
+ if (Object.keys(values).length === 0) {
423
+ throw new CrudOperationError(
424
+ 400,
425
+ ErrorCode.VALIDATION_ERROR,
426
+ 'No writable fields to update',
427
+ )
428
+ }
429
+
430
+ const writeScope = scopeFor(tableAccess.writeScope, { ...ctx, body })
431
+ const stamped = writeScope ? stampScope(values, writeScope) : values
432
+
433
+ const rows = await db
434
+ .update(table)
435
+ .set(stamped)
436
+ .where(eq(idCol, id))
437
+ .returning()
438
+
439
+ if (!rows[0]) {
440
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
441
+ }
442
+ const updated = rows[0] as Record<string, unknown>
443
+ void realtime?.publish(table as never, 'update', updated as never)
444
+ return updated
445
+ },
446
+
447
+ async delete(
448
+ tableName: string,
449
+ rawId: string | number,
450
+ ctx: CrudExecutionContext,
451
+ ): Promise<void> {
452
+ const { table, tableAccess, idCol } = resolveTable(tableName)
453
+ const id = coerceId(rawId)
454
+
455
+ const existing = await db
456
+ .select()
457
+ .from(table)
458
+ .where(eq(idCol, id))
459
+ if (!existing[0]) {
460
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
461
+ }
462
+ const existingRow = existing[0] as Record<string, unknown>
463
+
464
+ const readScope = scopeFor(tableAccess.readScope, ctx)
465
+ if (readScope && !rowMatchesScope(existingRow, readScope)) {
466
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
467
+ }
468
+
469
+ const denied = await checkAccess(
470
+ tableAccess.delete,
471
+ { ...ctx, row: existingRow },
472
+ tableAccess.ownerColumn,
473
+ )
474
+ if (!denied.allowed) {
475
+ throw new CrudOperationError(
476
+ denied.status === 401 ? 401 : 403,
477
+ ErrorCode.FORBIDDEN,
478
+ 'Forbidden',
479
+ )
480
+ }
481
+
482
+ await db.delete(table).where(eq(idCol, id))
483
+ void realtime?.publish(table as never, 'delete', existingRow as never)
484
+ },
485
+ }
486
+ }
487
+
488
+ export type CrudOperations = ReturnType<typeof createCrudOperations>
package/src/dialect.ts CHANGED
@@ -10,7 +10,7 @@ export type Dialect = 'sqlite' | 'pg'
10
10
  * Minimal structural view of a drizzle db shared by both dialects. Internal
11
11
  * modules run dynamic tables (Record<string, unknown> schemas) where drizzle's
12
12
  * generics add no safety, so they accept this instead of a per-dialect union.
13
- * The public surface (`app.db`, tRPC ctx) keeps full per-dialect typing via
13
+ * The public surface (`app.db`, API context) keeps full per-dialect typing via
14
14
  * `DbFor` in db.ts.
15
15
  */
16
16
  export type AnyDb = {
package/src/env.ts CHANGED
@@ -1,15 +1,24 @@
1
- // src/env.ts — env validation. Browser-safe: imports zod only.
2
- import { z, type ZodType } from 'zod'
1
+ // src/env.ts — env validation. Browser-safe: type-only Standard Schema import.
2
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
3
+
4
+ import {
5
+ StandardSchemaValidationError,
6
+ validateStandardSchema,
7
+ } from './standard-schema'
3
8
 
4
9
  export const CLIENT_PREFIX = 'PUBLIC_' as const
5
10
 
6
11
  export type EnvConfigInput = {
7
- server?: Record<string, ZodType>
8
- client?: Record<string, ZodType>
12
+ server?: Record<string, StandardSchemaV1>
13
+ client?: Record<string, StandardSchemaV1>
9
14
  /** Explicit value source for client vars (e.g. Vite's import.meta.env). */
10
15
  runtimeEnv?: Record<string, unknown>
11
16
  }
12
17
 
18
+ export type BunderstackRole = 'all' | 'web' | 'worker'
19
+
20
+ const ROLES: readonly BunderstackRole[] = ['all', 'web', 'worker']
21
+
13
22
  /** Vars bunderstack itself consumes, always validated. */
14
23
  export type BaseEnv = {
15
24
  NODE_ENV?: string
@@ -19,12 +28,12 @@ export type BaseEnv = {
19
28
  REDIS_URL?: string
20
29
  RESEND_API_KEY?: string
21
30
  SMTP_URL?: string
22
- BUNDERSTACK_CRON_SECRET?: string
31
+ BUNDERSTACK_ROLE: BunderstackRole
23
32
  }
24
33
 
25
34
  type InferVars<T> =
26
- T extends Record<string, ZodType>
27
- ? { [K in keyof T]: z.output<T[K]> }
35
+ T extends Record<string, StandardSchemaV1>
36
+ ? { [K in keyof T]: StandardSchemaV1.InferOutput<T[K]> }
28
37
  : unknown
29
38
 
30
39
  // Non-distributive so `ValidatedEnv<undefined>` is BaseEnv, not `never`.
@@ -53,14 +62,12 @@ export type ValidateEnvOptions = {
53
62
  source?: Record<string, string | undefined>
54
63
  /** Dialect-aware DATABASE_URL fallback; createBunderstack passes it. */
55
64
  defaultDatabaseUrl?: string
56
- /** Require the platform schedule secret for an application with cron work. */
57
- cronConfigured?: boolean
58
65
  }
59
66
 
60
67
  const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
61
68
 
62
69
  function validateSection(
63
- section: Record<string, ZodType> | undefined,
70
+ section: Record<string, StandardSchemaV1> | undefined,
64
71
  kind: 'server' | 'client',
65
72
  source: Record<string, unknown>,
66
73
  issues: string[],
@@ -80,12 +87,13 @@ function validateSection(
80
87
  )
81
88
  continue
82
89
  }
83
- const result = schema.safeParse(source[key])
84
- if (result.success) {
85
- out[key] = result.data
86
- } else {
87
- for (const issue of result.error.issues) {
88
- issues.push(`${key}: ${issue.message}`)
90
+ try {
91
+ out[key] = validateStandardSchema(schema, source[key], 'env')
92
+ } catch (error) {
93
+ if (!(error instanceof StandardSchemaValidationError)) throw error
94
+ for (const issue of error.issues) {
95
+ const path = issue.path.map(String).join('.')
96
+ issues.push(`${key}${path ? `.${path}` : ''}: ${issue.message}`)
89
97
  }
90
98
  }
91
99
  }
@@ -109,18 +117,17 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
109
117
  REDIS_URL: source.REDIS_URL,
110
118
  RESEND_API_KEY: source.RESEND_API_KEY,
111
119
  SMTP_URL: source.SMTP_URL,
112
- BUNDERSTACK_CRON_SECRET: source.BUNDERSTACK_CRON_SECRET,
120
+ BUNDERSTACK_ROLE: (source.BUNDERSTACK_ROLE ?? 'all') as BunderstackRole,
113
121
  }
114
122
  if (isProduction && !source.AUTH_SECRET) {
115
123
  issues.push('AUTH_SECRET: required in production')
116
124
  }
117
125
  if (
118
- isProduction &&
119
- options.cronConfigured &&
120
- !source.BUNDERSTACK_CRON_SECRET
126
+ source.BUNDERSTACK_ROLE !== undefined &&
127
+ !ROLES.includes(source.BUNDERSTACK_ROLE as BunderstackRole)
121
128
  ) {
122
129
  issues.push(
123
- 'BUNDERSTACK_CRON_SECRET: required when cron is configured in production',
130
+ `BUNDERSTACK_ROLE: must be one of ${ROLES.join(', ')} (got "${String(source.BUNDERSTACK_ROLE)}")`,
124
131
  )
125
132
  }
126
133
  if (options.emailProvider === 'resend' && !source.RESEND_API_KEY) {