@openmdm/hono 0.2.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/index.ts ADDED
@@ -0,0 +1,746 @@
1
+ /**
2
+ * OpenMDM Hono Adapter
3
+ *
4
+ * HTTP routes adapter for Hono framework.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { Hono } from 'hono';
9
+ * import { createMDM } from '@openmdm/core';
10
+ * import { honoAdapter } from '@openmdm/hono';
11
+ *
12
+ * const mdm = createMDM({ ... });
13
+ * const app = new Hono<MDMEnv>();
14
+ *
15
+ * // Mount MDM routes
16
+ * app.route('/mdm', honoAdapter(mdm));
17
+ * ```
18
+ */
19
+
20
+ import { Hono } from 'hono';
21
+ import { HTTPException } from 'hono/http-exception';
22
+ import type { Context, MiddlewareHandler, Env } from 'hono';
23
+ import type {
24
+ MDMInstance,
25
+ EnrollmentRequest,
26
+ Heartbeat,
27
+ DeviceFilter,
28
+ CommandFilter,
29
+ CreatePolicyInput,
30
+ UpdatePolicyInput,
31
+ CreateApplicationInput,
32
+ UpdateApplicationInput,
33
+ CreateGroupInput,
34
+ UpdateGroupInput,
35
+ SendCommandInput,
36
+ MDMError,
37
+ AuthenticationError,
38
+ AuthorizationError,
39
+ } from '@openmdm/core';
40
+
41
+ /**
42
+ * Context variables set by OpenMDM middlewares
43
+ */
44
+ interface MDMVariables {
45
+ deviceId?: string;
46
+ user?: unknown;
47
+ }
48
+
49
+ /**
50
+ * Hono environment type for OpenMDM routes
51
+ */
52
+ type MDMEnv = {
53
+ Variables: MDMVariables;
54
+ };
55
+
56
+ export interface HonoAdapterOptions {
57
+ /**
58
+ * Base path prefix for all routes (default: '')
59
+ */
60
+ basePath?: string;
61
+
62
+ /**
63
+ * Enable authentication middleware for admin routes
64
+ */
65
+ enableAuth?: boolean;
66
+
67
+ /**
68
+ * Custom error handler
69
+ */
70
+ onError?: (error: Error, c: Context) => Response | Promise<Response>;
71
+
72
+ /**
73
+ * Routes to expose (default: all)
74
+ */
75
+ routes?: {
76
+ enrollment?: boolean;
77
+ devices?: boolean;
78
+ policies?: boolean;
79
+ applications?: boolean;
80
+ groups?: boolean;
81
+ commands?: boolean;
82
+ events?: boolean;
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Create a Hono router with OpenMDM API routes
88
+ */
89
+ export function honoAdapter(
90
+ mdm: MDMInstance,
91
+ options: HonoAdapterOptions = {}
92
+ ): Hono<MDMEnv> {
93
+ const app = new Hono<MDMEnv>();
94
+
95
+ const routes = {
96
+ enrollment: true,
97
+ devices: true,
98
+ policies: true,
99
+ applications: true,
100
+ groups: true,
101
+ commands: true,
102
+ events: true,
103
+ ...options.routes,
104
+ };
105
+
106
+ // Error handling middleware
107
+ app.onError((error, c) => {
108
+ if (options.onError) {
109
+ return options.onError(error, c);
110
+ }
111
+
112
+ console.error('[OpenMDM] Error:', error);
113
+
114
+ if (error instanceof HTTPException) {
115
+ return c.json({ error: error.message }, error.status);
116
+ }
117
+
118
+ const mdmError = error as MDMError;
119
+ if (mdmError.code && mdmError.statusCode) {
120
+ return c.json(
121
+ {
122
+ error: mdmError.message,
123
+ code: mdmError.code,
124
+ details: mdmError.details,
125
+ },
126
+ mdmError.statusCode as any
127
+ );
128
+ }
129
+
130
+ return c.json({ error: 'Internal server error' }, 500);
131
+ });
132
+
133
+ // Device authentication middleware
134
+ const deviceAuth: MiddlewareHandler = async (c, next) => {
135
+ const token = c.req.header('Authorization')?.replace('Bearer ', '');
136
+ const deviceId = c.req.header('X-Device-Id');
137
+
138
+ if (!token && !deviceId) {
139
+ throw new HTTPException(401, { message: 'Device authentication required' });
140
+ }
141
+
142
+ if (token) {
143
+ const result = await mdm.verifyDeviceToken(token);
144
+ if (!result) {
145
+ throw new HTTPException(401, { message: 'Invalid device token' });
146
+ }
147
+ c.set('deviceId', result.deviceId);
148
+ } else if (deviceId) {
149
+ c.set('deviceId', deviceId);
150
+ }
151
+
152
+ await next();
153
+ };
154
+
155
+ // Admin authentication middleware
156
+ const adminAuth: MiddlewareHandler = async (c, next) => {
157
+ if (!mdm.config.auth) {
158
+ await next();
159
+ return;
160
+ }
161
+
162
+ const user = await mdm.config.auth.getUser(c);
163
+ if (!user) {
164
+ throw new HTTPException(401, { message: 'Authentication required' });
165
+ }
166
+
167
+ if (mdm.config.auth.isAdmin) {
168
+ const isAdmin = await mdm.config.auth.isAdmin(user);
169
+ if (!isAdmin) {
170
+ throw new HTTPException(403, { message: 'Admin access required' });
171
+ }
172
+ }
173
+
174
+ c.set('user', user);
175
+ await next();
176
+ };
177
+
178
+ // ============================================
179
+ // Enrollment Routes (Device-facing)
180
+ // ============================================
181
+
182
+ if (routes.enrollment) {
183
+ const enrollment = new Hono<MDMEnv>();
184
+
185
+ // Enroll device
186
+ enrollment.post('/enroll', async (c) => {
187
+ const body = await c.req.json<EnrollmentRequest>();
188
+
189
+ // Validate required fields
190
+ if (!body.model || !body.manufacturer || !body.osVersion) {
191
+ throw new HTTPException(400, {
192
+ message: 'Missing required fields: model, manufacturer, osVersion',
193
+ });
194
+ }
195
+
196
+ if (!body.macAddress && !body.serialNumber && !body.imei && !body.androidId) {
197
+ throw new HTTPException(400, {
198
+ message: 'At least one device identifier required',
199
+ });
200
+ }
201
+
202
+ const result = await mdm.enroll(body);
203
+
204
+ // Add server URL from request if not configured
205
+ if (!result.serverUrl) {
206
+ const url = new URL(c.req.url);
207
+ result.serverUrl = `${url.protocol}//${url.host}`;
208
+ }
209
+
210
+ return c.json(result, 201);
211
+ });
212
+
213
+ // Device heartbeat
214
+ enrollment.post('/heartbeat', deviceAuth, async (c) => {
215
+ const deviceId = c.get('deviceId') as string;
216
+ const body = await c.req.json<Omit<Heartbeat, 'deviceId'>>();
217
+
218
+ await mdm.processHeartbeat(deviceId, {
219
+ ...body,
220
+ deviceId,
221
+ timestamp: new Date(body.timestamp || Date.now()),
222
+ });
223
+
224
+ // Return pending commands for the device
225
+ const pendingCommands = await mdm.commands.getPending(deviceId);
226
+
227
+ return c.json({
228
+ status: 'ok',
229
+ commands: pendingCommands,
230
+ });
231
+ });
232
+
233
+ // Get device config/policy
234
+ enrollment.get('/config', deviceAuth, async (c) => {
235
+ const deviceId = c.get('deviceId') as string;
236
+ const device = await mdm.devices.get(deviceId);
237
+
238
+ if (!device) {
239
+ throw new HTTPException(404, { message: 'Device not found' });
240
+ }
241
+
242
+ let policy = null;
243
+ if (device.policyId) {
244
+ policy = await mdm.policies.get(device.policyId);
245
+ } else {
246
+ policy = await mdm.policies.getDefault();
247
+ }
248
+
249
+ return c.json({
250
+ device: {
251
+ id: device.id,
252
+ enrollmentId: device.enrollmentId,
253
+ status: device.status,
254
+ },
255
+ policy,
256
+ });
257
+ });
258
+
259
+ // Register push token
260
+ enrollment.post('/push-token', deviceAuth, async (c) => {
261
+ const deviceId = c.get('deviceId') as string;
262
+ const body = await c.req.json<{ provider: string; token: string }>();
263
+
264
+ await mdm.db.upsertPushToken({
265
+ deviceId,
266
+ provider: body.provider as any,
267
+ token: body.token,
268
+ });
269
+
270
+ return c.json({ status: 'ok' });
271
+ });
272
+
273
+ // Acknowledge command
274
+ enrollment.post('/commands/:id/ack', deviceAuth, async (c) => {
275
+ const commandId = c.req.param('id');
276
+ const command = await mdm.commands.acknowledge(commandId);
277
+ return c.json(command);
278
+ });
279
+
280
+ // Complete command
281
+ enrollment.post('/commands/:id/complete', deviceAuth, async (c) => {
282
+ const commandId = c.req.param('id');
283
+ const body = await c.req.json<{ success: boolean; message?: string; data?: unknown }>();
284
+ const command = await mdm.commands.complete(commandId, body);
285
+ return c.json(command);
286
+ });
287
+
288
+ // Fail command
289
+ enrollment.post('/commands/:id/fail', deviceAuth, async (c) => {
290
+ const commandId = c.req.param('id');
291
+ const body = await c.req.json<{ error: string }>();
292
+ const command = await mdm.commands.fail(commandId, body.error);
293
+ return c.json(command);
294
+ });
295
+
296
+ app.route('/agent', enrollment);
297
+ }
298
+
299
+ // ============================================
300
+ // Device Routes (Admin-facing)
301
+ // ============================================
302
+
303
+ if (routes.devices) {
304
+ const devices = new Hono<MDMEnv>();
305
+
306
+ if (options.enableAuth) {
307
+ devices.use('/*', adminAuth);
308
+ }
309
+
310
+ // List devices
311
+ devices.get('/', async (c) => {
312
+ const filter: DeviceFilter = {
313
+ status: c.req.query('status') as any,
314
+ policyId: c.req.query('policyId'),
315
+ groupId: c.req.query('groupId'),
316
+ search: c.req.query('search'),
317
+ limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : undefined,
318
+ offset: c.req.query('offset') ? parseInt(c.req.query('offset')!) : undefined,
319
+ };
320
+
321
+ const result = await mdm.devices.list(filter);
322
+ return c.json(result);
323
+ });
324
+
325
+ // Get device
326
+ devices.get('/:id', async (c) => {
327
+ const device = await mdm.devices.get(c.req.param('id'));
328
+ if (!device) {
329
+ throw new HTTPException(404, { message: 'Device not found' });
330
+ }
331
+ return c.json(device);
332
+ });
333
+
334
+ // Update device
335
+ devices.patch('/:id', async (c) => {
336
+ const body = await c.req.json();
337
+ const device = await mdm.devices.update(c.req.param('id'), body);
338
+ return c.json(device);
339
+ });
340
+
341
+ // Delete device
342
+ devices.delete('/:id', async (c) => {
343
+ await mdm.devices.delete(c.req.param('id'));
344
+ return c.json({ status: 'ok' });
345
+ });
346
+
347
+ // Assign policy to device
348
+ devices.post('/:id/policy', async (c) => {
349
+ const { policyId } = await c.req.json<{ policyId: string | null }>();
350
+ const device = await mdm.devices.assignPolicy(c.req.param('id'), policyId);
351
+ return c.json(device);
352
+ });
353
+
354
+ // Get device groups
355
+ devices.get('/:id/groups', async (c) => {
356
+ const groups = await mdm.devices.getGroups(c.req.param('id'));
357
+ return c.json({ groups });
358
+ });
359
+
360
+ // Add device to group
361
+ devices.post('/:id/groups', async (c) => {
362
+ const { groupId } = await c.req.json<{ groupId: string }>();
363
+ await mdm.devices.addToGroup(c.req.param('id'), groupId);
364
+ return c.json({ status: 'ok' });
365
+ });
366
+
367
+ // Remove device from group
368
+ devices.delete('/:id/groups/:groupId', async (c) => {
369
+ await mdm.devices.removeFromGroup(c.req.param('id'), c.req.param('groupId'));
370
+ return c.json({ status: 'ok' });
371
+ });
372
+
373
+ // Send command to device
374
+ devices.post('/:id/commands', async (c) => {
375
+ const body = await c.req.json<Omit<SendCommandInput, 'deviceId'>>();
376
+ const command = await mdm.devices.sendCommand(c.req.param('id'), body);
377
+ return c.json(command, 201);
378
+ });
379
+
380
+ // Convenience: Sync device
381
+ devices.post('/:id/sync', async (c) => {
382
+ const command = await mdm.devices.sync(c.req.param('id'));
383
+ return c.json(command, 201);
384
+ });
385
+
386
+ // Convenience: Reboot device
387
+ devices.post('/:id/reboot', async (c) => {
388
+ const command = await mdm.devices.reboot(c.req.param('id'));
389
+ return c.json(command, 201);
390
+ });
391
+
392
+ // Convenience: Lock device
393
+ devices.post('/:id/lock', async (c) => {
394
+ const body = await c.req.json<{ message?: string }>().catch(() => ({ message: undefined }));
395
+ const command = await mdm.devices.lock(c.req.param('id'), body.message);
396
+ return c.json(command, 201);
397
+ });
398
+
399
+ // Convenience: Wipe device
400
+ devices.post('/:id/wipe', async (c) => {
401
+ const body = await c.req.json<{ preserveData?: boolean }>().catch(() => ({ preserveData: undefined }));
402
+ const command = await mdm.devices.wipe(c.req.param('id'), body.preserveData);
403
+ return c.json(command, 201);
404
+ });
405
+
406
+ app.route('/devices', devices);
407
+ }
408
+
409
+ // ============================================
410
+ // Policy Routes
411
+ // ============================================
412
+
413
+ if (routes.policies) {
414
+ const policies = new Hono<MDMEnv>();
415
+
416
+ if (options.enableAuth) {
417
+ policies.use('/*', adminAuth);
418
+ }
419
+
420
+ // List policies
421
+ policies.get('/', async (c) => {
422
+ const result = await mdm.policies.list();
423
+ return c.json({ policies: result });
424
+ });
425
+
426
+ // Get default policy
427
+ policies.get('/default', async (c) => {
428
+ const policy = await mdm.policies.getDefault();
429
+ if (!policy) {
430
+ throw new HTTPException(404, { message: 'No default policy set' });
431
+ }
432
+ return c.json(policy);
433
+ });
434
+
435
+ // Get policy
436
+ policies.get('/:id', async (c) => {
437
+ const policy = await mdm.policies.get(c.req.param('id'));
438
+ if (!policy) {
439
+ throw new HTTPException(404, { message: 'Policy not found' });
440
+ }
441
+ return c.json(policy);
442
+ });
443
+
444
+ // Create policy
445
+ policies.post('/', async (c) => {
446
+ const body = await c.req.json<CreatePolicyInput>();
447
+ const policy = await mdm.policies.create(body);
448
+ return c.json(policy, 201);
449
+ });
450
+
451
+ // Update policy
452
+ policies.patch('/:id', async (c) => {
453
+ const body = await c.req.json<UpdatePolicyInput>();
454
+ const policy = await mdm.policies.update(c.req.param('id'), body);
455
+ return c.json(policy);
456
+ });
457
+
458
+ // Delete policy
459
+ policies.delete('/:id', async (c) => {
460
+ await mdm.policies.delete(c.req.param('id'));
461
+ return c.json({ status: 'ok' });
462
+ });
463
+
464
+ // Set default policy
465
+ policies.post('/:id/default', async (c) => {
466
+ const policy = await mdm.policies.setDefault(c.req.param('id'));
467
+ return c.json(policy);
468
+ });
469
+
470
+ // Get devices with this policy
471
+ policies.get('/:id/devices', async (c) => {
472
+ const devices = await mdm.policies.getDevices(c.req.param('id'));
473
+ return c.json({ devices });
474
+ });
475
+
476
+ app.route('/policies', policies);
477
+ }
478
+
479
+ // ============================================
480
+ // Application Routes
481
+ // ============================================
482
+
483
+ if (routes.applications) {
484
+ const applications = new Hono<MDMEnv>();
485
+
486
+ if (options.enableAuth) {
487
+ applications.use('/*', adminAuth);
488
+ }
489
+
490
+ // List applications
491
+ applications.get('/', async (c) => {
492
+ const activeOnly = c.req.query('active') === 'true';
493
+ const result = await mdm.apps.list(activeOnly);
494
+ return c.json({ applications: result });
495
+ });
496
+
497
+ // Get application by ID
498
+ applications.get('/:id', async (c) => {
499
+ const app = await mdm.apps.get(c.req.param('id'));
500
+ if (!app) {
501
+ throw new HTTPException(404, { message: 'Application not found' });
502
+ }
503
+ return c.json(app);
504
+ });
505
+
506
+ // Get application by package name
507
+ applications.get('/package/:packageName', async (c) => {
508
+ const version = c.req.query('version');
509
+ const app = await mdm.apps.getByPackage(c.req.param('packageName'), version);
510
+ if (!app) {
511
+ throw new HTTPException(404, { message: 'Application not found' });
512
+ }
513
+ return c.json(app);
514
+ });
515
+
516
+ // Register application
517
+ applications.post('/', async (c) => {
518
+ const body = await c.req.json<CreateApplicationInput>();
519
+ const app = await mdm.apps.register(body);
520
+ return c.json(app, 201);
521
+ });
522
+
523
+ // Update application
524
+ applications.patch('/:id', async (c) => {
525
+ const body = await c.req.json<UpdateApplicationInput>();
526
+ const app = await mdm.apps.update(c.req.param('id'), body);
527
+ return c.json(app);
528
+ });
529
+
530
+ // Delete application
531
+ applications.delete('/:id', async (c) => {
532
+ await mdm.apps.delete(c.req.param('id'));
533
+ return c.json({ status: 'ok' });
534
+ });
535
+
536
+ // Activate application
537
+ applications.post('/:id/activate', async (c) => {
538
+ const app = await mdm.apps.activate(c.req.param('id'));
539
+ return c.json(app);
540
+ });
541
+
542
+ // Deactivate application
543
+ applications.post('/:id/deactivate', async (c) => {
544
+ const app = await mdm.apps.deactivate(c.req.param('id'));
545
+ return c.json(app);
546
+ });
547
+
548
+ // Deploy application
549
+ applications.post('/:packageName/deploy', async (c) => {
550
+ const body = await c.req.json<{
551
+ devices?: string[];
552
+ policies?: string[];
553
+ groups?: string[];
554
+ }>();
555
+ await mdm.apps.deploy(c.req.param('packageName'), body);
556
+ return c.json({ status: 'ok', message: 'Deployment initiated' });
557
+ });
558
+
559
+ // Install app on device
560
+ applications.post('/:packageName/install/:deviceId', async (c) => {
561
+ const version = c.req.query('version');
562
+ const command = await mdm.apps.installOnDevice(
563
+ c.req.param('packageName'),
564
+ c.req.param('deviceId'),
565
+ version
566
+ );
567
+ return c.json(command, 201);
568
+ });
569
+
570
+ // Uninstall app from device
571
+ applications.post('/:packageName/uninstall/:deviceId', async (c) => {
572
+ const command = await mdm.apps.uninstallFromDevice(
573
+ c.req.param('packageName'),
574
+ c.req.param('deviceId')
575
+ );
576
+ return c.json(command, 201);
577
+ });
578
+
579
+ app.route('/applications', applications);
580
+ }
581
+
582
+ // ============================================
583
+ // Group Routes
584
+ // ============================================
585
+
586
+ if (routes.groups) {
587
+ const groups = new Hono<MDMEnv>();
588
+
589
+ if (options.enableAuth) {
590
+ groups.use('/*', adminAuth);
591
+ }
592
+
593
+ // List groups
594
+ groups.get('/', async (c) => {
595
+ const result = await mdm.groups.list();
596
+ return c.json({ groups: result });
597
+ });
598
+
599
+ // Get group
600
+ groups.get('/:id', async (c) => {
601
+ const group = await mdm.groups.get(c.req.param('id'));
602
+ if (!group) {
603
+ throw new HTTPException(404, { message: 'Group not found' });
604
+ }
605
+ return c.json(group);
606
+ });
607
+
608
+ // Create group
609
+ groups.post('/', async (c) => {
610
+ const body = await c.req.json<CreateGroupInput>();
611
+ const group = await mdm.groups.create(body);
612
+ return c.json(group, 201);
613
+ });
614
+
615
+ // Update group
616
+ groups.patch('/:id', async (c) => {
617
+ const body = await c.req.json<UpdateGroupInput>();
618
+ const group = await mdm.groups.update(c.req.param('id'), body);
619
+ return c.json(group);
620
+ });
621
+
622
+ // Delete group
623
+ groups.delete('/:id', async (c) => {
624
+ await mdm.groups.delete(c.req.param('id'));
625
+ return c.json({ status: 'ok' });
626
+ });
627
+
628
+ // Get devices in group
629
+ groups.get('/:id/devices', async (c) => {
630
+ const devices = await mdm.groups.getDevices(c.req.param('id'));
631
+ return c.json({ devices });
632
+ });
633
+
634
+ // Add device to group
635
+ groups.post('/:id/devices', async (c) => {
636
+ const { deviceId } = await c.req.json<{ deviceId: string }>();
637
+ await mdm.groups.addDevice(c.req.param('id'), deviceId);
638
+ return c.json({ status: 'ok' });
639
+ });
640
+
641
+ // Remove device from group
642
+ groups.delete('/:id/devices/:deviceId', async (c) => {
643
+ await mdm.groups.removeDevice(c.req.param('id'), c.req.param('deviceId'));
644
+ return c.json({ status: 'ok' });
645
+ });
646
+
647
+ // Get child groups
648
+ groups.get('/:id/children', async (c) => {
649
+ const children = await mdm.groups.getChildren(c.req.param('id'));
650
+ return c.json({ groups: children });
651
+ });
652
+
653
+ app.route('/groups', groups);
654
+ }
655
+
656
+ // ============================================
657
+ // Command Routes
658
+ // ============================================
659
+
660
+ if (routes.commands) {
661
+ const commands = new Hono<MDMEnv>();
662
+
663
+ if (options.enableAuth) {
664
+ commands.use('/*', adminAuth);
665
+ }
666
+
667
+ // List commands
668
+ commands.get('/', async (c) => {
669
+ const filter: CommandFilter = {
670
+ deviceId: c.req.query('deviceId'),
671
+ status: c.req.query('status') as any,
672
+ type: c.req.query('type') as any,
673
+ limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : undefined,
674
+ offset: c.req.query('offset') ? parseInt(c.req.query('offset')!) : undefined,
675
+ };
676
+
677
+ const result = await mdm.commands.list(filter);
678
+ return c.json({ commands: result });
679
+ });
680
+
681
+ // Get command
682
+ commands.get('/:id', async (c) => {
683
+ const command = await mdm.commands.get(c.req.param('id'));
684
+ if (!command) {
685
+ throw new HTTPException(404, { message: 'Command not found' });
686
+ }
687
+ return c.json(command);
688
+ });
689
+
690
+ // Send command
691
+ commands.post('/', async (c) => {
692
+ const body = await c.req.json<SendCommandInput>();
693
+ const command = await mdm.commands.send(body);
694
+ return c.json(command, 201);
695
+ });
696
+
697
+ // Cancel command
698
+ commands.post('/:id/cancel', async (c) => {
699
+ const command = await mdm.commands.cancel(c.req.param('id'));
700
+ return c.json(command);
701
+ });
702
+
703
+ app.route('/commands', commands);
704
+ }
705
+
706
+ // ============================================
707
+ // Event Routes
708
+ // ============================================
709
+
710
+ if (routes.events) {
711
+ const events = new Hono<MDMEnv>();
712
+
713
+ if (options.enableAuth) {
714
+ events.use('/*', adminAuth);
715
+ }
716
+
717
+ // List events
718
+ events.get('/', async (c) => {
719
+ const filter = {
720
+ deviceId: c.req.query('deviceId'),
721
+ type: c.req.query('type') as any,
722
+ limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : undefined,
723
+ offset: c.req.query('offset') ? parseInt(c.req.query('offset')!) : undefined,
724
+ };
725
+
726
+ const result = await mdm.db.listEvents(filter);
727
+ return c.json({ events: result });
728
+ });
729
+
730
+ app.route('/events', events);
731
+ }
732
+
733
+ // ============================================
734
+ // Health Check
735
+ // ============================================
736
+
737
+ app.get('/health', (c) => {
738
+ return c.json({
739
+ status: 'ok',
740
+ version: '0.1.0',
741
+ timestamp: new Date().toISOString(),
742
+ });
743
+ });
744
+
745
+ return app;
746
+ }