duron 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.
Files changed (82) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +140 -0
  3. package/dist/action-job.d.ts +24 -0
  4. package/dist/action-job.d.ts.map +1 -0
  5. package/dist/action-job.js +108 -0
  6. package/dist/action-manager.d.ts +21 -0
  7. package/dist/action-manager.d.ts.map +1 -0
  8. package/dist/action-manager.js +78 -0
  9. package/dist/action.d.ts +129 -0
  10. package/dist/action.d.ts.map +1 -0
  11. package/dist/action.js +87 -0
  12. package/dist/adapters/adapter.d.ts +92 -0
  13. package/dist/adapters/adapter.d.ts.map +1 -0
  14. package/dist/adapters/adapter.js +424 -0
  15. package/dist/adapters/postgres/drizzle.config.d.ts +3 -0
  16. package/dist/adapters/postgres/drizzle.config.d.ts.map +1 -0
  17. package/dist/adapters/postgres/drizzle.config.js +10 -0
  18. package/dist/adapters/postgres/pglite.d.ts +13 -0
  19. package/dist/adapters/postgres/pglite.d.ts.map +1 -0
  20. package/dist/adapters/postgres/pglite.js +36 -0
  21. package/dist/adapters/postgres/postgres.d.ts +51 -0
  22. package/dist/adapters/postgres/postgres.d.ts.map +1 -0
  23. package/dist/adapters/postgres/postgres.js +867 -0
  24. package/dist/adapters/postgres/schema.d.ts +581 -0
  25. package/dist/adapters/postgres/schema.d.ts.map +1 -0
  26. package/dist/adapters/postgres/schema.default.d.ts +577 -0
  27. package/dist/adapters/postgres/schema.default.d.ts.map +1 -0
  28. package/dist/adapters/postgres/schema.default.js +3 -0
  29. package/dist/adapters/postgres/schema.js +87 -0
  30. package/dist/adapters/schemas.d.ts +516 -0
  31. package/dist/adapters/schemas.d.ts.map +1 -0
  32. package/dist/adapters/schemas.js +184 -0
  33. package/dist/client.d.ts +85 -0
  34. package/dist/client.d.ts.map +1 -0
  35. package/dist/client.js +416 -0
  36. package/dist/constants.d.ts +14 -0
  37. package/dist/constants.d.ts.map +1 -0
  38. package/dist/constants.js +22 -0
  39. package/dist/errors.d.ts +43 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +75 -0
  42. package/dist/index.d.ts +8 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +6 -0
  45. package/dist/server.d.ts +1193 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +516 -0
  48. package/dist/step-manager.d.ts +46 -0
  49. package/dist/step-manager.d.ts.map +1 -0
  50. package/dist/step-manager.js +216 -0
  51. package/dist/utils/checksum.d.ts +2 -0
  52. package/dist/utils/checksum.d.ts.map +1 -0
  53. package/dist/utils/checksum.js +6 -0
  54. package/dist/utils/p-retry.d.ts +19 -0
  55. package/dist/utils/p-retry.d.ts.map +1 -0
  56. package/dist/utils/p-retry.js +130 -0
  57. package/dist/utils/wait-for-abort.d.ts +5 -0
  58. package/dist/utils/wait-for-abort.d.ts.map +1 -0
  59. package/dist/utils/wait-for-abort.js +32 -0
  60. package/migrations/postgres/0000_lethal_speed_demon.sql +64 -0
  61. package/migrations/postgres/meta/0000_snapshot.json +606 -0
  62. package/migrations/postgres/meta/_journal.json +13 -0
  63. package/package.json +88 -0
  64. package/src/action-job.ts +201 -0
  65. package/src/action-manager.ts +166 -0
  66. package/src/action.ts +247 -0
  67. package/src/adapters/adapter.ts +969 -0
  68. package/src/adapters/postgres/drizzle.config.ts +11 -0
  69. package/src/adapters/postgres/pglite.ts +86 -0
  70. package/src/adapters/postgres/postgres.ts +1346 -0
  71. package/src/adapters/postgres/schema.default.ts +5 -0
  72. package/src/adapters/postgres/schema.ts +119 -0
  73. package/src/adapters/schemas.ts +320 -0
  74. package/src/client.ts +859 -0
  75. package/src/constants.ts +37 -0
  76. package/src/errors.ts +205 -0
  77. package/src/index.ts +14 -0
  78. package/src/server.ts +718 -0
  79. package/src/step-manager.ts +471 -0
  80. package/src/utils/checksum.ts +7 -0
  81. package/src/utils/p-retry.ts +213 -0
  82. package/src/utils/wait-for-abort.ts +40 -0
package/src/server.ts ADDED
@@ -0,0 +1,718 @@
1
+ import { Elysia } from 'elysia'
2
+ import { jwtVerify, SignJWT } from 'jose'
3
+ import { z } from 'zod'
4
+
5
+ import type { GetJobStepsOptions, GetJobsOptions } from './adapters/adapter.js'
6
+ import {
7
+ GetActionsResultSchema,
8
+ GetJobStepsResultSchema,
9
+ GetJobsResultSchema,
10
+ JobSchema,
11
+ JobSortFieldSchema,
12
+ JobStatusResultSchema,
13
+ JobStatusSchema,
14
+ JobStepSchema,
15
+ JobStepStatusResultSchema,
16
+ SortOrderSchema,
17
+ } from './adapters/schemas.js'
18
+ import type { Client } from './client.js'
19
+
20
+ // ============================================================================
21
+ // Custom Errors
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Error thrown when a requested resource is not found.
26
+ */
27
+ export class NotFoundError extends Error {
28
+ /**
29
+ * Create a new NotFoundError.
30
+ *
31
+ * @param message - Error message describing what was not found
32
+ */
33
+ constructor(message: string) {
34
+ super(message)
35
+ this.name = 'NotFoundError'
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Error thrown when authentication fails.
41
+ */
42
+ export class UnauthorizedError extends Error {
43
+ /**
44
+ * Create a new UnauthorizedError.
45
+ *
46
+ * @param message - Error message describing the authentication failure
47
+ */
48
+ constructor(message: string) {
49
+ super(message)
50
+ this.name = 'UnauthorizedError'
51
+ }
52
+ }
53
+
54
+ // ============================================================================
55
+ // Zod Validation Schemas
56
+ // ============================================================================
57
+
58
+ // Note: JobStatusSchema, JobSortFieldSchema, SortOrderSchema, JobSchema,
59
+ // JobStepSchema, GetJobStepsResultSchema, GetJobsResultSchema, and
60
+ // GetActionsResultSchema are imported from ./adapters/schemas.js to avoid duplication
61
+
62
+ export const GetJobStepsQuerySchema = z
63
+ .object({
64
+ page: z.coerce.number().int().min(1).optional(),
65
+ pageSize: z.coerce.number().int().min(1).max(1000).optional(),
66
+ search: z.string().optional(),
67
+ fUpdatedAfter: z.coerce.date().optional(),
68
+ })
69
+ .transform((data) => ({
70
+ page: data.page,
71
+ pageSize: data.pageSize,
72
+ search: data.search,
73
+ updatedAfter: data.fUpdatedAfter,
74
+ }))
75
+
76
+ // Reuse GetJobStepsResultSchema from schemas.ts
77
+ export const GetJobStepsResponseSchema = GetJobStepsResultSchema
78
+
79
+ export const GetJobsQuerySchema = z
80
+ .object({
81
+ // Pagination
82
+ page: z.coerce.number().int().min(1).optional(),
83
+ pageSize: z.coerce.number().int().min(1).max(1000).optional(),
84
+
85
+ // Filters - arrays can be passed as comma-separated or multiple params
86
+ fStatus: z.union([JobStatusSchema, z.array(JobStatusSchema)]).optional(),
87
+ fActionName: z.union([z.string(), z.array(z.string())]).optional(),
88
+ fGroupKey: z.union([z.string(), z.array(z.string())]).optional(),
89
+ fOwnerId: z.union([z.string(), z.array(z.string())]).optional(),
90
+ // Date filters: can be a single ISO string or JSON array [start, end] - both coerced to Date objects
91
+ fCreatedAt: z.union([z.coerce.date(), z.array(z.coerce.date())]).optional(),
92
+ fStartedAt: z.union([z.coerce.date(), z.array(z.coerce.date())]).optional(),
93
+ fFinishedAt: z.union([z.coerce.date(), z.array(z.coerce.date())]).optional(),
94
+ fUpdatedAfter: z.coerce.date().optional(),
95
+ fSearch: z.string().optional(),
96
+
97
+ // Sort - format: "field:asc,field:desc"
98
+ sort: z.string().optional(),
99
+
100
+ // JSONB filters as JSON strings
101
+ fInputFilter: z.record(z.string(), z.any()).optional(),
102
+ fOutputFilter: z.record(z.string(), z.any()).optional(),
103
+ })
104
+ .transform((data) => {
105
+ const filters: any = {}
106
+
107
+ if (data.fStatus) filters.status = data.fStatus
108
+ if (data.fActionName) filters.actionName = data.fActionName
109
+ if (data.fGroupKey) filters.groupKey = data.fGroupKey
110
+ if (data.fOwnerId) filters.ownerId = data.fOwnerId
111
+ if (data.fCreatedAt) filters.createdAt = data.fCreatedAt
112
+ if (data.fStartedAt) filters.startedAt = data.fStartedAt
113
+ if (data.fFinishedAt) filters.finishedAt = data.fFinishedAt
114
+ if (data.fUpdatedAfter) filters.updatedAfter = data.fUpdatedAfter
115
+ if (data.fSearch) filters.search = data.fSearch
116
+ if (data.fInputFilter) filters.inputFilter = data.fInputFilter
117
+ if (data.fOutputFilter) filters.outputFilter = data.fOutputFilter
118
+
119
+ // Parse sort string: "field:asc,field:desc" -> [{ field: 'field', order: 'asc' }, { field: 'field', order: 'desc' }]
120
+ let sort: Array<{ field: z.infer<typeof JobSortFieldSchema>; order: z.infer<typeof SortOrderSchema> }> | undefined
121
+ if (data.sort) {
122
+ const sortParts = data.sort
123
+ .split(',')
124
+ .map((part) => part.trim())
125
+ .filter((part) => part.length > 0)
126
+ const parsedSorts = sortParts
127
+ .map((part) => {
128
+ const [field, order] = part.split(':').map((s) => s.trim())
129
+ if (!field || !order) {
130
+ return null
131
+ }
132
+ // Validate field and order
133
+ const fieldResult = JobSortFieldSchema.safeParse(field)
134
+ const orderResult = SortOrderSchema.safeParse(order.toLowerCase())
135
+ if (!fieldResult.success || !orderResult.success) {
136
+ return null
137
+ }
138
+ return {
139
+ field: fieldResult.data,
140
+ order: orderResult.data,
141
+ }
142
+ })
143
+ .filter(
144
+ (s): s is { field: z.infer<typeof JobSortFieldSchema>; order: z.infer<typeof SortOrderSchema> } => s !== null,
145
+ )
146
+
147
+ // If no valid sorts were parsed, set to undefined
148
+ if (parsedSorts.length === 0) {
149
+ sort = undefined
150
+ } else {
151
+ sort = parsedSorts
152
+ }
153
+ }
154
+
155
+ return {
156
+ page: data.page,
157
+ pageSize: data.pageSize,
158
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
159
+ sort,
160
+ }
161
+ })
162
+
163
+ // Reuse GetJobsResultSchema from schemas.ts
164
+ export const GetJobsResponseSchema = GetJobsResultSchema
165
+
166
+ // Reuse GetActionsResultSchema from schemas.ts
167
+ export const GetActionsResponseSchema = GetActionsResultSchema
168
+
169
+ export const GetActionsMetadataResponseSchema = z.array(
170
+ z.object({
171
+ name: z.string(),
172
+ mockInput: z.any(),
173
+ }),
174
+ )
175
+
176
+ // Export query input types for use in clients
177
+ export type GetJobsQueryInput = z.input<typeof GetJobsQuerySchema>
178
+ export type GetJobStepsQueryInput = z.input<typeof GetJobStepsQuerySchema>
179
+
180
+ export const ErrorResponseSchema = z.object({
181
+ error: z.string(),
182
+ message: z.string().optional(),
183
+ })
184
+
185
+ const JobIdParamsSchema = z.object({
186
+ id: z.uuid(),
187
+ })
188
+
189
+ const StepIdParamsSchema = z.object({
190
+ id: z.uuid(),
191
+ })
192
+
193
+ export const CancelJobResponseSchema = z.object({
194
+ success: z.boolean(),
195
+ message: z.string(),
196
+ })
197
+
198
+ export const RetryJobResponseSchema = z.object({
199
+ success: z.boolean(),
200
+ message: z.string(),
201
+ newJobId: z.string(),
202
+ })
203
+
204
+ // ============================================================================
205
+ // Server Factory
206
+ // ============================================================================
207
+
208
+ export interface CreateServerOptions<P extends string> {
209
+ /**
210
+ * The Duron instance to use for the API endpoints
211
+ */
212
+ client: Client<any, any>
213
+
214
+ /**
215
+ * Optional prefix for all routes (default: '/api')
216
+ */
217
+ prefix?: P
218
+
219
+ login?: {
220
+ onLogin: (body: { email: string; password: string }) => Promise<boolean>
221
+ jwtSecret: string | Uint8Array
222
+ /**
223
+ * Optional expiration time for the access JWT token (default: '1h')
224
+ */
225
+ expirationTime?: string
226
+ /**
227
+ * Optional expiration time for the refresh token (default: '7d')
228
+ */
229
+ refreshTokenExpirationTime?: string
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Creates an Elysia server instance with duron API endpoints.
235
+ * All endpoints use Zod for input and response validation.
236
+ *
237
+ * @param options - Configuration options
238
+ * @returns Elysia server instance
239
+ */
240
+ export function createServer<P extends string>({ client, prefix, login }: CreateServerOptions<P>) {
241
+ // Convert string secret to Uint8Array if needed
242
+ const secretKey = typeof login?.jwtSecret === 'string' ? new TextEncoder().encode(login?.jwtSecret) : login?.jwtSecret
243
+
244
+ const routePrefix = (prefix ?? '/api') as P
245
+
246
+ return new Elysia({
247
+ prefix: routePrefix,
248
+ })
249
+ .onError(({ code, error, set }) => {
250
+ if (code === 'VALIDATION') {
251
+ set.status = 400
252
+ return error
253
+ }
254
+
255
+ if (error instanceof NotFoundError) {
256
+ set.status = 404
257
+ return {
258
+ error: 'Not found',
259
+ message: error.message,
260
+ }
261
+ }
262
+
263
+ if (error instanceof UnauthorizedError) {
264
+ set.status = 401
265
+ return {
266
+ error: 'Unauthorized',
267
+ message: error.message,
268
+ }
269
+ }
270
+
271
+ // Handle other errors
272
+ set.status = code === 'NOT_FOUND' ? 404 : 500
273
+ return {
274
+ error: 'Internal server error',
275
+ message: error instanceof Error ? error.message : 'Unknown error',
276
+ }
277
+ })
278
+ .macro('getUser', {
279
+ headers: z.object({
280
+ authorization: z.string().optional(),
281
+ }),
282
+ resolve: async ({ headers }) => {
283
+ if (login) {
284
+ const authHeader = headers.authorization
285
+ if (!authHeader) {
286
+ return {
287
+ user: null,
288
+ }
289
+ }
290
+
291
+ // Extract token from "Bearer <token>" format
292
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader
293
+ if (!token) {
294
+ return {
295
+ user: null,
296
+ }
297
+ }
298
+
299
+ const { payload } = await jwtVerify(token, secretKey!).catch(() => {
300
+ return {
301
+ payload: null,
302
+ }
303
+ })
304
+
305
+ return {
306
+ user: payload,
307
+ }
308
+ }
309
+
310
+ return {
311
+ user: null,
312
+ }
313
+ },
314
+ })
315
+ .macro('auth', {
316
+ getUser: true,
317
+ beforeHandle: async ({ user }) => {
318
+ if (login && !user) {
319
+ throw new UnauthorizedError('Unauthorized')
320
+ }
321
+ },
322
+ })
323
+ .get(
324
+ '/jobs/:id',
325
+ async ({ params }) => {
326
+ const job = await client.getJobById(params.id)
327
+ if (!job) {
328
+ throw new NotFoundError(`Job with ID ${params.id} was not found`)
329
+ }
330
+ return job
331
+ },
332
+ {
333
+ params: JobIdParamsSchema,
334
+ response: {
335
+ 200: JobSchema,
336
+ 404: ErrorResponseSchema,
337
+ 500: ErrorResponseSchema,
338
+ 401: ErrorResponseSchema,
339
+ },
340
+ auth: true,
341
+ },
342
+ )
343
+ .get(
344
+ '/jobs/:id/steps',
345
+ async ({ params, query }) => {
346
+ const options: GetJobStepsOptions = {
347
+ jobId: params.id,
348
+ page: query.page,
349
+ pageSize: query.pageSize,
350
+ search: query.search,
351
+ updatedAfter: query.updatedAfter,
352
+ }
353
+ return client.getJobSteps(options)
354
+ },
355
+ {
356
+ params: JobIdParamsSchema,
357
+ query: GetJobStepsQuerySchema,
358
+ response: {
359
+ 200: GetJobStepsResponseSchema,
360
+ 400: ErrorResponseSchema,
361
+ 500: ErrorResponseSchema,
362
+ 401: ErrorResponseSchema,
363
+ },
364
+ auth: true,
365
+ },
366
+ )
367
+ .get(
368
+ '/jobs',
369
+ async ({ query }) => {
370
+ const options: GetJobsOptions = {
371
+ page: query.page,
372
+ pageSize: query.pageSize,
373
+ filters: query.filters,
374
+ sort: query.sort,
375
+ }
376
+ return client.getJobs(options)
377
+ },
378
+ {
379
+ query: GetJobsQuerySchema,
380
+ response: {
381
+ 200: GetJobsResponseSchema,
382
+ 400: ErrorResponseSchema,
383
+ 500: ErrorResponseSchema,
384
+ 401: ErrorResponseSchema,
385
+ },
386
+ auth: true,
387
+ },
388
+ )
389
+ .get(
390
+ '/steps/:id',
391
+ async ({ params }) => {
392
+ const step = await client.getJobStepById(params.id)
393
+ if (!step) {
394
+ throw new NotFoundError(`Step with ID ${params.id} was not found`)
395
+ }
396
+ return step
397
+ },
398
+ {
399
+ params: StepIdParamsSchema,
400
+ response: {
401
+ 200: JobStepSchema,
402
+ 404: ErrorResponseSchema,
403
+ 500: ErrorResponseSchema,
404
+ 401: ErrorResponseSchema,
405
+ },
406
+ auth: true,
407
+ },
408
+ )
409
+ .get(
410
+ '/jobs/:id/status',
411
+ async ({ params }) => {
412
+ const status = await client.getJobStatus(params.id)
413
+ if (!status) {
414
+ throw new NotFoundError(`Job with ID ${params.id} was not found`)
415
+ }
416
+ return status
417
+ },
418
+ {
419
+ params: JobIdParamsSchema,
420
+ response: {
421
+ 200: JobStatusResultSchema,
422
+ 404: ErrorResponseSchema,
423
+ 500: ErrorResponseSchema,
424
+ 401: ErrorResponseSchema,
425
+ },
426
+ auth: true,
427
+ },
428
+ )
429
+ .get(
430
+ '/steps/:id/status',
431
+ async ({ params }) => {
432
+ const status = await client.getJobStepStatus(params.id)
433
+ if (!status) {
434
+ throw new NotFoundError(`Step with ID ${params.id} was not found`)
435
+ }
436
+ return status
437
+ },
438
+ {
439
+ params: StepIdParamsSchema,
440
+ response: {
441
+ 200: JobStepStatusResultSchema,
442
+ 404: ErrorResponseSchema,
443
+ 500: ErrorResponseSchema,
444
+ 401: ErrorResponseSchema,
445
+ },
446
+ auth: true,
447
+ },
448
+ )
449
+ .post(
450
+ '/jobs/:id/cancel',
451
+ async ({ params }) => {
452
+ await client.cancelJob(params.id)
453
+ return {
454
+ success: true,
455
+ message: `Job ${params.id} has been cancelled`,
456
+ }
457
+ },
458
+ {
459
+ params: JobIdParamsSchema,
460
+ response: {
461
+ 200: CancelJobResponseSchema,
462
+ 400: ErrorResponseSchema,
463
+ 500: ErrorResponseSchema,
464
+ 401: ErrorResponseSchema,
465
+ },
466
+ auth: true,
467
+ },
468
+ )
469
+ .post(
470
+ '/jobs/:id/retry',
471
+ async ({ params }) => {
472
+ const newJobId = await client.retryJob(params.id)
473
+ if (!newJobId) {
474
+ throw new Error(`Could not retry job ${params.id}. The job may not be in a retryable state.`)
475
+ }
476
+ return {
477
+ success: true,
478
+ message: `Job ${params.id} has been retried`,
479
+ newJobId,
480
+ }
481
+ },
482
+ {
483
+ params: JobIdParamsSchema,
484
+ response: {
485
+ 200: RetryJobResponseSchema,
486
+ 400: ErrorResponseSchema,
487
+ 500: ErrorResponseSchema,
488
+ 401: ErrorResponseSchema,
489
+ },
490
+ auth: true,
491
+ },
492
+ )
493
+ .delete(
494
+ '/jobs/:id',
495
+ async ({ params }) => {
496
+ const deleted = await client.deleteJob(params.id)
497
+ if (!deleted) {
498
+ throw new NotFoundError(
499
+ `Job with ID ${params.id} was not found or cannot be deleted (active jobs cannot be deleted)`,
500
+ )
501
+ }
502
+ return {
503
+ success: true,
504
+ message: `Job ${params.id} has been deleted`,
505
+ }
506
+ },
507
+ {
508
+ params: JobIdParamsSchema,
509
+ response: {
510
+ 200: z.object({
511
+ success: z.boolean(),
512
+ message: z.string(),
513
+ }),
514
+ 404: ErrorResponseSchema,
515
+ 500: ErrorResponseSchema,
516
+ 401: ErrorResponseSchema,
517
+ },
518
+ auth: true,
519
+ },
520
+ )
521
+ .delete(
522
+ '/jobs',
523
+ async ({ query }) => {
524
+ const options: GetJobsOptions = {
525
+ page: query.page,
526
+ pageSize: query.pageSize,
527
+ filters: query.filters,
528
+ sort: query.sort,
529
+ }
530
+ const deletedCount = await client.deleteJobs(options)
531
+ return {
532
+ success: true,
533
+ message: `Deleted ${deletedCount} job(s)`,
534
+ deletedCount,
535
+ }
536
+ },
537
+ {
538
+ query: GetJobsQuerySchema,
539
+ response: {
540
+ 200: z.object({
541
+ success: z.boolean(),
542
+ message: z.string(),
543
+ deletedCount: z.number(),
544
+ }),
545
+ 400: ErrorResponseSchema,
546
+ 500: ErrorResponseSchema,
547
+ 401: ErrorResponseSchema,
548
+ },
549
+ auth: true,
550
+ },
551
+ )
552
+ .get(
553
+ '/actions',
554
+ async () => {
555
+ return client.getActions()
556
+ },
557
+ {
558
+ response: {
559
+ 200: GetActionsResponseSchema,
560
+ 400: ErrorResponseSchema,
561
+ 500: ErrorResponseSchema,
562
+ 401: ErrorResponseSchema,
563
+ },
564
+ auth: true,
565
+ },
566
+ )
567
+ .get(
568
+ '/actions/metadata',
569
+ async () => {
570
+ return client.getActionsMetadata()
571
+ },
572
+ {
573
+ response: {
574
+ 200: GetActionsMetadataResponseSchema,
575
+ 400: ErrorResponseSchema,
576
+ 500: ErrorResponseSchema,
577
+ 401: ErrorResponseSchema,
578
+ },
579
+ auth: true,
580
+ },
581
+ )
582
+ .post(
583
+ '/actions/:actionName/run',
584
+ async ({ params, body }) => {
585
+ const jobId = await client.runAction(params.actionName as any, body)
586
+ return {
587
+ success: true,
588
+ jobId,
589
+ }
590
+ },
591
+ {
592
+ params: z.object({
593
+ actionName: z.string(),
594
+ }),
595
+ body: z.any(),
596
+ response: {
597
+ 200: z.object({
598
+ success: z.boolean(),
599
+ jobId: z.string(),
600
+ }),
601
+ 400: ErrorResponseSchema,
602
+ 500: ErrorResponseSchema,
603
+ 401: ErrorResponseSchema,
604
+ },
605
+ auth: true,
606
+ },
607
+ )
608
+ .post(
609
+ '/login',
610
+ async ({ body }) => {
611
+ if (!login || !secretKey) {
612
+ throw new Error('Login is not configured')
613
+ }
614
+
615
+ const { email, password } = body
616
+
617
+ const success = await login.onLogin({ email, password })
618
+ if (!success) {
619
+ throw new UnauthorizedError('Invalid credentials')
620
+ }
621
+
622
+ // Generate access token (short-lived)
623
+ const accessToken = await new SignJWT({ email, type: 'access' })
624
+ .setProtectedHeader({ alg: 'HS256' })
625
+ .setIssuedAt()
626
+ .setExpirationTime(login.expirationTime ?? '1h')
627
+ .sign(secretKey)
628
+
629
+ // Generate refresh token (long-lived)
630
+ const refreshToken = await new SignJWT({ email, type: 'refresh' })
631
+ .setProtectedHeader({ alg: 'HS256' })
632
+ .setIssuedAt()
633
+ .setExpirationTime(login.refreshTokenExpirationTime ?? '7d')
634
+ .sign(secretKey)
635
+
636
+ return {
637
+ accessToken,
638
+ refreshToken,
639
+ }
640
+ },
641
+ {
642
+ body: z.object({
643
+ email: z.email(),
644
+ password: z.string(),
645
+ }),
646
+ response: {
647
+ 200: z.object({
648
+ accessToken: z.string(),
649
+ refreshToken: z.string(),
650
+ }),
651
+ 401: ErrorResponseSchema,
652
+ 400: ErrorResponseSchema,
653
+ 500: ErrorResponseSchema,
654
+ },
655
+ },
656
+ )
657
+ .post(
658
+ '/refresh',
659
+ async ({ body }) => {
660
+ if (!login || !secretKey) {
661
+ throw new Error('Login is not configured')
662
+ }
663
+
664
+ const { refreshToken: providedRefreshToken } = body
665
+
666
+ if (!providedRefreshToken) {
667
+ throw new UnauthorizedError('Refresh token is required')
668
+ }
669
+
670
+ try {
671
+ // Verify refresh token
672
+ const { payload } = await jwtVerify(providedRefreshToken, secretKey)
673
+
674
+ // Type assertion for JWT payload
675
+ interface RefreshTokenPayload {
676
+ email?: string
677
+ type?: string
678
+ }
679
+ const typedPayload = payload as RefreshTokenPayload
680
+
681
+ // Ensure it's a refresh token
682
+ if (typedPayload.type !== 'refresh') {
683
+ throw new UnauthorizedError('Invalid token type')
684
+ }
685
+
686
+ if (!typedPayload.email) {
687
+ throw new UnauthorizedError('Invalid token payload')
688
+ }
689
+
690
+ // Generate new access token
691
+ const accessToken = await new SignJWT({ email: typedPayload.email, type: 'access' })
692
+ .setProtectedHeader({ alg: 'HS256' })
693
+ .setIssuedAt()
694
+ .setExpirationTime(login.expirationTime ?? '1h')
695
+ .sign(secretKey)
696
+
697
+ return {
698
+ accessToken,
699
+ }
700
+ } catch {
701
+ throw new UnauthorizedError('Invalid or expired refresh token')
702
+ }
703
+ },
704
+ {
705
+ body: z.object({
706
+ refreshToken: z.string(),
707
+ }),
708
+ response: {
709
+ 200: z.object({
710
+ accessToken: z.string(),
711
+ }),
712
+ 401: ErrorResponseSchema,
713
+ 400: ErrorResponseSchema,
714
+ 500: ErrorResponseSchema,
715
+ },
716
+ },
717
+ )
718
+ }