@syncular/server-hono 0.0.1-60

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 (76) hide show
  1. package/dist/api-key-auth.d.ts +49 -0
  2. package/dist/api-key-auth.d.ts.map +1 -0
  3. package/dist/api-key-auth.js +110 -0
  4. package/dist/api-key-auth.js.map +1 -0
  5. package/dist/blobs.d.ts +69 -0
  6. package/dist/blobs.d.ts.map +1 -0
  7. package/dist/blobs.js +383 -0
  8. package/dist/blobs.js.map +1 -0
  9. package/dist/console/index.d.ts +8 -0
  10. package/dist/console/index.d.ts.map +1 -0
  11. package/dist/console/index.js +7 -0
  12. package/dist/console/index.js.map +1 -0
  13. package/dist/console/routes.d.ts +106 -0
  14. package/dist/console/routes.d.ts.map +1 -0
  15. package/dist/console/routes.js +1612 -0
  16. package/dist/console/routes.js.map +1 -0
  17. package/dist/console/schemas.d.ts +308 -0
  18. package/dist/console/schemas.d.ts.map +1 -0
  19. package/dist/console/schemas.js +201 -0
  20. package/dist/console/schemas.js.map +1 -0
  21. package/dist/create-server.d.ts +78 -0
  22. package/dist/create-server.d.ts.map +1 -0
  23. package/dist/create-server.js +99 -0
  24. package/dist/create-server.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +25 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/openapi.d.ts +45 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +59 -0
  32. package/dist/openapi.js.map +1 -0
  33. package/dist/proxy/connection-manager.d.ts +78 -0
  34. package/dist/proxy/connection-manager.d.ts.map +1 -0
  35. package/dist/proxy/connection-manager.js +251 -0
  36. package/dist/proxy/connection-manager.js.map +1 -0
  37. package/dist/proxy/index.d.ts +8 -0
  38. package/dist/proxy/index.d.ts.map +1 -0
  39. package/dist/proxy/index.js +8 -0
  40. package/dist/proxy/index.js.map +1 -0
  41. package/dist/proxy/routes.d.ts +74 -0
  42. package/dist/proxy/routes.d.ts.map +1 -0
  43. package/dist/proxy/routes.js +147 -0
  44. package/dist/proxy/routes.js.map +1 -0
  45. package/dist/rate-limit.d.ts +101 -0
  46. package/dist/rate-limit.d.ts.map +1 -0
  47. package/dist/rate-limit.js +186 -0
  48. package/dist/rate-limit.js.map +1 -0
  49. package/dist/routes.d.ts +126 -0
  50. package/dist/routes.d.ts.map +1 -0
  51. package/dist/routes.js +788 -0
  52. package/dist/routes.js.map +1 -0
  53. package/dist/ws.d.ts +230 -0
  54. package/dist/ws.d.ts.map +1 -0
  55. package/dist/ws.js +601 -0
  56. package/dist/ws.js.map +1 -0
  57. package/package.json +73 -0
  58. package/src/__tests__/create-server.test.ts +187 -0
  59. package/src/__tests__/pull-chunk-storage.test.ts +189 -0
  60. package/src/__tests__/rate-limit.test.ts +78 -0
  61. package/src/__tests__/realtime-bridge.test.ts +131 -0
  62. package/src/__tests__/ws-connection-manager.test.ts +176 -0
  63. package/src/api-key-auth.ts +179 -0
  64. package/src/blobs.ts +534 -0
  65. package/src/console/index.ts +17 -0
  66. package/src/console/routes.ts +2155 -0
  67. package/src/console/schemas.ts +299 -0
  68. package/src/create-server.ts +180 -0
  69. package/src/index.ts +42 -0
  70. package/src/openapi.ts +74 -0
  71. package/src/proxy/connection-manager.ts +340 -0
  72. package/src/proxy/index.ts +8 -0
  73. package/src/proxy/routes.ts +223 -0
  74. package/src/rate-limit.ts +321 -0
  75. package/src/routes.ts +1186 -0
  76. package/src/ws.ts +789 -0
@@ -0,0 +1,1612 @@
1
+ /**
2
+ * @syncular/server-hono - Console API routes
3
+ *
4
+ * Provides monitoring and operations endpoints for the @syncular dashboard.
5
+ *
6
+ * Endpoints:
7
+ * - GET /stats - Sync statistics
8
+ * - GET /commits - Paginated commit list
9
+ * - GET /commits/:seq - Single commit with changes
10
+ * - GET /clients - Client cursor list
11
+ * - GET /handlers - Registered handlers
12
+ * - POST /prune - Trigger pruning
13
+ * - POST /prune/preview - Preview pruning (dry run)
14
+ * - POST /compact - Trigger compaction
15
+ * - DELETE /clients/:id - Evict client
16
+ */
17
+ import { logSyncEvent } from '@syncular/core';
18
+ import { compactChanges, computePruneWatermarkCommitSeq, pruneSync, readSyncStats, } from '@syncular/server';
19
+ import { Hono } from 'hono';
20
+ import { cors } from 'hono/cors';
21
+ import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
22
+ import { z } from 'zod';
23
+ import { ApiKeyTypeSchema, ConsoleApiKeyCreateRequestSchema, ConsoleApiKeyCreateResponseSchema, ConsoleApiKeyRevokeResponseSchema, ConsoleApiKeySchema, ConsoleClearEventsResultSchema, ConsoleClientSchema, ConsoleCommitDetailSchema, ConsoleCommitListItemSchema, ConsoleCompactResultSchema, ConsoleEvictResultSchema, ConsoleHandlerSchema, ConsolePaginatedResponseSchema, ConsolePaginationQuerySchema, ConsolePruneEventsResultSchema, ConsolePrunePreviewSchema, ConsolePruneResultSchema, ConsoleRequestEventSchema, LatencyQuerySchema, LatencyStatsResponseSchema, SyncStatsSchema, TimeseriesQuerySchema, TimeseriesStatsResponseSchema, } from './schemas';
24
+ /**
25
+ * Create a simple console event emitter for broadcasting live events.
26
+ */
27
+ export function createConsoleEventEmitter() {
28
+ const listeners = new Set();
29
+ return {
30
+ addListener(listener) {
31
+ listeners.add(listener);
32
+ },
33
+ removeListener(listener) {
34
+ listeners.delete(listener);
35
+ },
36
+ emit(event) {
37
+ for (const listener of listeners) {
38
+ try {
39
+ listener(event);
40
+ }
41
+ catch {
42
+ // Ignore errors in listeners
43
+ }
44
+ }
45
+ },
46
+ };
47
+ }
48
+ function coerceNumber(value) {
49
+ if (value === null || value === undefined)
50
+ return null;
51
+ if (typeof value === 'number')
52
+ return Number.isFinite(value) ? value : null;
53
+ if (typeof value === 'bigint')
54
+ return Number.isFinite(Number(value)) ? Number(value) : null;
55
+ if (typeof value === 'string') {
56
+ const n = Number(value);
57
+ return Number.isFinite(n) ? n : null;
58
+ }
59
+ return null;
60
+ }
61
+ function parseDate(value) {
62
+ if (!value)
63
+ return null;
64
+ const parsed = Date.parse(value);
65
+ return Number.isFinite(parsed) ? parsed : null;
66
+ }
67
+ function getClientActivityState(args) {
68
+ if (args.connectionCount > 0) {
69
+ return 'active';
70
+ }
71
+ const updatedAtMs = parseDate(args.updatedAt);
72
+ if (updatedAtMs === null) {
73
+ return 'stale';
74
+ }
75
+ const ageMs = Date.now() - updatedAtMs;
76
+ if (ageMs <= 60_000) {
77
+ return 'active';
78
+ }
79
+ if (ageMs <= 5 * 60_000) {
80
+ return 'idle';
81
+ }
82
+ return 'stale';
83
+ }
84
+ // ============================================================================
85
+ // Route Schemas
86
+ // ============================================================================
87
+ const ErrorResponseSchema = z.object({
88
+ error: z.string(),
89
+ message: z.string().optional(),
90
+ });
91
+ const commitSeqParamSchema = z.object({ seq: z.coerce.number().int() });
92
+ const clientIdParamSchema = z.object({ id: z.string().min(1) });
93
+ const eventIdParamSchema = z.object({ id: z.coerce.number().int() });
94
+ const apiKeyIdParamSchema = z.object({ id: z.string().min(1) });
95
+ const eventsQuerySchema = ConsolePaginationQuerySchema.extend({
96
+ eventType: z.enum(['push', 'pull']).optional(),
97
+ actorId: z.string().optional(),
98
+ clientId: z.string().optional(),
99
+ outcome: z.string().optional(),
100
+ });
101
+ const apiKeysQuerySchema = ConsolePaginationQuerySchema.extend({
102
+ type: ApiKeyTypeSchema.optional(),
103
+ });
104
+ const handlersResponseSchema = z.object({
105
+ items: z.array(ConsoleHandlerSchema),
106
+ });
107
+ export function createConsoleRoutes(options) {
108
+ const routes = new Hono();
109
+ const db = options.db;
110
+ // Ensure console schema exists (creates sync_request_events table if needed)
111
+ // Run asynchronously - will be ready before first request typically
112
+ options.dialect.ensureConsoleSchema?.(options.db).catch((err) => {
113
+ console.error('[console] Failed to ensure console schema:', err);
114
+ });
115
+ // CORS configuration
116
+ const corsOrigins = options.corsOrigins ?? [
117
+ 'http://localhost:5173',
118
+ 'https://console.sync.dev',
119
+ ];
120
+ routes.use('*', cors({
121
+ origin: corsOrigins === '*' ? '*' : corsOrigins,
122
+ allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
123
+ allowHeaders: ['Content-Type', 'Authorization'],
124
+ exposeHeaders: ['X-Total-Count'],
125
+ credentials: true,
126
+ }));
127
+ // Auth middleware
128
+ const requireAuth = async (c) => {
129
+ const auth = await options.authenticate(c);
130
+ if (!auth) {
131
+ return null;
132
+ }
133
+ return auth;
134
+ };
135
+ // -------------------------------------------------------------------------
136
+ // GET /stats
137
+ // -------------------------------------------------------------------------
138
+ routes.get('/stats', describeRoute({
139
+ tags: ['console'],
140
+ summary: 'Get sync statistics',
141
+ responses: {
142
+ 200: {
143
+ description: 'Sync statistics',
144
+ content: {
145
+ 'application/json': { schema: resolver(SyncStatsSchema) },
146
+ },
147
+ },
148
+ 401: {
149
+ description: 'Unauthenticated',
150
+ content: {
151
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
152
+ },
153
+ },
154
+ },
155
+ }), async (c) => {
156
+ const auth = await requireAuth(c);
157
+ if (!auth)
158
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
159
+ const stats = await readSyncStats(options.db);
160
+ logSyncEvent({
161
+ event: 'console.stats',
162
+ consoleUserId: auth.consoleUserId,
163
+ });
164
+ return c.json(stats, 200);
165
+ });
166
+ // -------------------------------------------------------------------------
167
+ // GET /stats/timeseries
168
+ // -------------------------------------------------------------------------
169
+ routes.get('/stats/timeseries', describeRoute({
170
+ tags: ['console'],
171
+ summary: 'Get time-series statistics',
172
+ responses: {
173
+ 200: {
174
+ description: 'Time-series statistics',
175
+ content: {
176
+ 'application/json': {
177
+ schema: resolver(TimeseriesStatsResponseSchema),
178
+ },
179
+ },
180
+ },
181
+ 401: {
182
+ description: 'Unauthenticated',
183
+ content: {
184
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
185
+ },
186
+ },
187
+ },
188
+ }), zValidator('query', TimeseriesQuerySchema), async (c) => {
189
+ const auth = await requireAuth(c);
190
+ if (!auth)
191
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
192
+ const { interval, range } = c.req.valid('query');
193
+ // Calculate the time range
194
+ const rangeMs = {
195
+ '1h': 60 * 60 * 1000,
196
+ '6h': 6 * 60 * 60 * 1000,
197
+ '24h': 24 * 60 * 60 * 1000,
198
+ '7d': 7 * 24 * 60 * 60 * 1000,
199
+ '30d': 30 * 24 * 60 * 60 * 1000,
200
+ }[range];
201
+ const startTime = new Date(Date.now() - rangeMs);
202
+ // Get interval in milliseconds for bucket size
203
+ const intervalMs = {
204
+ minute: 60 * 1000,
205
+ hour: 60 * 60 * 1000,
206
+ day: 24 * 60 * 60 * 1000,
207
+ }[interval];
208
+ // Query events within the time range
209
+ const events = await db
210
+ .selectFrom('sync_request_events')
211
+ .select(['event_type', 'duration_ms', 'outcome', 'created_at'])
212
+ .where('created_at', '>=', startTime.toISOString())
213
+ .orderBy('created_at', 'asc')
214
+ .execute();
215
+ // Build buckets
216
+ const bucketMap = new Map();
217
+ // Initialize buckets for the entire range
218
+ const bucketCount = Math.ceil(rangeMs / intervalMs);
219
+ for (let i = 0; i < bucketCount; i++) {
220
+ const bucketTime = new Date(startTime.getTime() + i * intervalMs).toISOString();
221
+ bucketMap.set(bucketTime, {
222
+ pushCount: 0,
223
+ pullCount: 0,
224
+ errorCount: 0,
225
+ totalLatency: 0,
226
+ eventCount: 0,
227
+ });
228
+ }
229
+ // Populate buckets with event data
230
+ for (const event of events) {
231
+ const eventTime = new Date(event.created_at).getTime();
232
+ const bucketIndex = Math.floor((eventTime - startTime.getTime()) / intervalMs);
233
+ const bucketTime = new Date(startTime.getTime() + bucketIndex * intervalMs).toISOString();
234
+ let bucket = bucketMap.get(bucketTime);
235
+ if (!bucket) {
236
+ bucket = {
237
+ pushCount: 0,
238
+ pullCount: 0,
239
+ errorCount: 0,
240
+ totalLatency: 0,
241
+ eventCount: 0,
242
+ };
243
+ bucketMap.set(bucketTime, bucket);
244
+ }
245
+ if (event.event_type === 'push') {
246
+ bucket.pushCount++;
247
+ }
248
+ else if (event.event_type === 'pull') {
249
+ bucket.pullCount++;
250
+ }
251
+ if (event.outcome === 'error') {
252
+ bucket.errorCount++;
253
+ }
254
+ const durationMs = coerceNumber(event.duration_ms);
255
+ if (durationMs !== null) {
256
+ bucket.totalLatency += durationMs;
257
+ bucket.eventCount++;
258
+ }
259
+ }
260
+ // Convert to array and calculate averages
261
+ const buckets = Array.from(bucketMap.entries())
262
+ .sort(([a], [b]) => a.localeCompare(b))
263
+ .map(([timestamp, data]) => ({
264
+ timestamp,
265
+ pushCount: data.pushCount,
266
+ pullCount: data.pullCount,
267
+ errorCount: data.errorCount,
268
+ avgLatencyMs: data.eventCount > 0 ? data.totalLatency / data.eventCount : 0,
269
+ }));
270
+ const response = {
271
+ buckets,
272
+ interval,
273
+ range,
274
+ };
275
+ return c.json(response, 200);
276
+ });
277
+ // -------------------------------------------------------------------------
278
+ // GET /stats/latency
279
+ // -------------------------------------------------------------------------
280
+ routes.get('/stats/latency', describeRoute({
281
+ tags: ['console'],
282
+ summary: 'Get latency percentiles',
283
+ responses: {
284
+ 200: {
285
+ description: 'Latency percentiles',
286
+ content: {
287
+ 'application/json': {
288
+ schema: resolver(LatencyStatsResponseSchema),
289
+ },
290
+ },
291
+ },
292
+ 401: {
293
+ description: 'Unauthenticated',
294
+ content: {
295
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
296
+ },
297
+ },
298
+ },
299
+ }), zValidator('query', LatencyQuerySchema), async (c) => {
300
+ const auth = await requireAuth(c);
301
+ if (!auth)
302
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
303
+ const { range } = c.req.valid('query');
304
+ // Calculate the time range
305
+ const rangeMs = {
306
+ '1h': 60 * 60 * 1000,
307
+ '6h': 6 * 60 * 60 * 1000,
308
+ '24h': 24 * 60 * 60 * 1000,
309
+ '7d': 7 * 24 * 60 * 60 * 1000,
310
+ '30d': 30 * 24 * 60 * 60 * 1000,
311
+ }[range];
312
+ const startTime = new Date(Date.now() - rangeMs);
313
+ // Get all latencies for push and pull events
314
+ const events = await db
315
+ .selectFrom('sync_request_events')
316
+ .select(['event_type', 'duration_ms'])
317
+ .where('created_at', '>=', startTime.toISOString())
318
+ .execute();
319
+ const pushLatencies = [];
320
+ const pullLatencies = [];
321
+ for (const event of events) {
322
+ const durationMs = coerceNumber(event.duration_ms);
323
+ if (durationMs !== null) {
324
+ if (event.event_type === 'push') {
325
+ pushLatencies.push(durationMs);
326
+ }
327
+ else if (event.event_type === 'pull') {
328
+ pullLatencies.push(durationMs);
329
+ }
330
+ }
331
+ }
332
+ // Calculate percentiles
333
+ const calculatePercentiles = (latencies) => {
334
+ if (latencies.length === 0) {
335
+ return { p50: 0, p90: 0, p99: 0 };
336
+ }
337
+ const sorted = [...latencies].sort((a, b) => a - b);
338
+ const getPercentile = (p) => {
339
+ const index = Math.ceil((p / 100) * sorted.length) - 1;
340
+ return sorted[Math.max(0, index)] ?? 0;
341
+ };
342
+ return {
343
+ p50: getPercentile(50),
344
+ p90: getPercentile(90),
345
+ p99: getPercentile(99),
346
+ };
347
+ };
348
+ const response = {
349
+ push: calculatePercentiles(pushLatencies),
350
+ pull: calculatePercentiles(pullLatencies),
351
+ range,
352
+ };
353
+ return c.json(response, 200);
354
+ });
355
+ // -------------------------------------------------------------------------
356
+ // GET /commits
357
+ // -------------------------------------------------------------------------
358
+ routes.get('/commits', describeRoute({
359
+ tags: ['console'],
360
+ summary: 'List commits',
361
+ responses: {
362
+ 200: {
363
+ description: 'Paginated commit list',
364
+ content: {
365
+ 'application/json': {
366
+ schema: resolver(ConsolePaginatedResponseSchema(ConsoleCommitListItemSchema)),
367
+ },
368
+ },
369
+ },
370
+ 401: {
371
+ description: 'Unauthenticated',
372
+ content: {
373
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
374
+ },
375
+ },
376
+ },
377
+ }), zValidator('query', ConsolePaginationQuerySchema), async (c) => {
378
+ const auth = await requireAuth(c);
379
+ if (!auth)
380
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
381
+ const { limit, offset } = c.req.valid('query');
382
+ const [rows, countRow] = await Promise.all([
383
+ db
384
+ .selectFrom('sync_commits')
385
+ .select([
386
+ 'commit_seq',
387
+ 'actor_id',
388
+ 'client_id',
389
+ 'client_commit_id',
390
+ 'created_at',
391
+ 'change_count',
392
+ 'affected_tables',
393
+ ])
394
+ .orderBy('commit_seq', 'desc')
395
+ .limit(limit)
396
+ .offset(offset)
397
+ .execute(),
398
+ db
399
+ .selectFrom('sync_commits')
400
+ .select(({ fn }) => fn.countAll().as('total'))
401
+ .executeTakeFirst(),
402
+ ]);
403
+ const items = rows.map((row) => ({
404
+ commitSeq: coerceNumber(row.commit_seq) ?? 0,
405
+ actorId: row.actor_id ?? '',
406
+ clientId: row.client_id ?? '',
407
+ clientCommitId: row.client_commit_id ?? '',
408
+ createdAt: row.created_at ?? '',
409
+ changeCount: coerceNumber(row.change_count) ?? 0,
410
+ affectedTables: options.dialect.dbToArray(row.affected_tables),
411
+ }));
412
+ const total = coerceNumber(countRow?.total) ?? 0;
413
+ const response = {
414
+ items,
415
+ total,
416
+ offset,
417
+ limit,
418
+ };
419
+ c.header('X-Total-Count', String(total));
420
+ return c.json(response, 200);
421
+ });
422
+ // -------------------------------------------------------------------------
423
+ // GET /commits/:seq
424
+ // -------------------------------------------------------------------------
425
+ routes.get('/commits/:seq', describeRoute({
426
+ tags: ['console'],
427
+ summary: 'Get commit details',
428
+ responses: {
429
+ 200: {
430
+ description: 'Commit with changes',
431
+ content: {
432
+ 'application/json': { schema: resolver(ConsoleCommitDetailSchema) },
433
+ },
434
+ },
435
+ 400: {
436
+ description: 'Invalid request',
437
+ content: {
438
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
439
+ },
440
+ },
441
+ 401: {
442
+ description: 'Unauthenticated',
443
+ content: {
444
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
445
+ },
446
+ },
447
+ 404: {
448
+ description: 'Not found',
449
+ content: {
450
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
451
+ },
452
+ },
453
+ },
454
+ }), zValidator('param', commitSeqParamSchema), async (c) => {
455
+ const auth = await requireAuth(c);
456
+ if (!auth)
457
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
458
+ const { seq } = c.req.valid('param');
459
+ const commitRow = await db
460
+ .selectFrom('sync_commits')
461
+ .select([
462
+ 'commit_seq',
463
+ 'actor_id',
464
+ 'client_id',
465
+ 'client_commit_id',
466
+ 'created_at',
467
+ 'change_count',
468
+ 'affected_tables',
469
+ ])
470
+ .where('commit_seq', '=', seq)
471
+ .executeTakeFirst();
472
+ if (!commitRow) {
473
+ return c.json({ error: 'NOT_FOUND' }, 404);
474
+ }
475
+ const changeRows = await db
476
+ .selectFrom('sync_changes')
477
+ .select([
478
+ 'change_id',
479
+ 'table',
480
+ 'row_id',
481
+ 'op',
482
+ 'row_json',
483
+ 'row_version',
484
+ 'scopes',
485
+ ])
486
+ .where('commit_seq', '=', seq)
487
+ .orderBy('change_id', 'asc')
488
+ .execute();
489
+ const changes = changeRows.map((row) => ({
490
+ changeId: coerceNumber(row.change_id) ?? 0,
491
+ table: row.table ?? '',
492
+ rowId: row.row_id ?? '',
493
+ op: row.op === 'delete' ? 'delete' : 'upsert',
494
+ rowJson: row.row_json,
495
+ rowVersion: coerceNumber(row.row_version),
496
+ scopes: typeof row.scopes === 'string'
497
+ ? JSON.parse(row.scopes || '{}')
498
+ : (row.scopes ?? {}),
499
+ }));
500
+ const commit = {
501
+ commitSeq: coerceNumber(commitRow.commit_seq) ?? 0,
502
+ actorId: commitRow.actor_id ?? '',
503
+ clientId: commitRow.client_id ?? '',
504
+ clientCommitId: commitRow.client_commit_id ?? '',
505
+ createdAt: commitRow.created_at ?? '',
506
+ changeCount: coerceNumber(commitRow.change_count) ?? 0,
507
+ affectedTables: Array.isArray(commitRow.affected_tables)
508
+ ? commitRow.affected_tables
509
+ : typeof commitRow.affected_tables === 'string'
510
+ ? JSON.parse(commitRow.affected_tables || '[]')
511
+ : [],
512
+ changes,
513
+ };
514
+ return c.json(commit, 200);
515
+ });
516
+ // -------------------------------------------------------------------------
517
+ // GET /clients
518
+ // -------------------------------------------------------------------------
519
+ routes.get('/clients', describeRoute({
520
+ tags: ['console'],
521
+ summary: 'List clients',
522
+ responses: {
523
+ 200: {
524
+ description: 'Paginated client list',
525
+ content: {
526
+ 'application/json': {
527
+ schema: resolver(ConsolePaginatedResponseSchema(ConsoleClientSchema)),
528
+ },
529
+ },
530
+ },
531
+ 401: {
532
+ description: 'Unauthenticated',
533
+ content: {
534
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
535
+ },
536
+ },
537
+ },
538
+ }), zValidator('query', ConsolePaginationQuerySchema), async (c) => {
539
+ const auth = await requireAuth(c);
540
+ if (!auth)
541
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
542
+ const { limit, offset } = c.req.valid('query');
543
+ const [rows, countRow, maxCommitSeqRow] = await Promise.all([
544
+ db
545
+ .selectFrom('sync_client_cursors')
546
+ .select([
547
+ 'client_id',
548
+ 'actor_id',
549
+ 'cursor',
550
+ 'effective_scopes',
551
+ 'updated_at',
552
+ ])
553
+ .orderBy('updated_at', 'desc')
554
+ .limit(limit)
555
+ .offset(offset)
556
+ .execute(),
557
+ db
558
+ .selectFrom('sync_client_cursors')
559
+ .select(({ fn }) => fn.countAll().as('total'))
560
+ .executeTakeFirst(),
561
+ db
562
+ .selectFrom('sync_commits')
563
+ .select(({ fn }) => fn.max('commit_seq').as('max_commit_seq'))
564
+ .executeTakeFirst(),
565
+ ]);
566
+ const maxCommitSeq = coerceNumber(maxCommitSeqRow?.max_commit_seq) ?? 0;
567
+ const pagedClientIds = rows
568
+ .map((row) => row.client_id)
569
+ .filter((clientId) => typeof clientId === 'string');
570
+ const latestEventsByClientId = new Map();
571
+ if (pagedClientIds.length > 0) {
572
+ const recentEventRows = await db
573
+ .selectFrom('sync_request_events')
574
+ .select([
575
+ 'client_id',
576
+ 'event_type',
577
+ 'outcome',
578
+ 'created_at',
579
+ 'transport_path',
580
+ ])
581
+ .where('client_id', 'in', pagedClientIds)
582
+ .orderBy('created_at', 'desc')
583
+ .execute();
584
+ for (const row of recentEventRows) {
585
+ const clientId = row.client_id;
586
+ if (!clientId || latestEventsByClientId.has(clientId)) {
587
+ continue;
588
+ }
589
+ const eventType = row.event_type === 'push' ? 'push' : 'pull';
590
+ latestEventsByClientId.set(clientId, {
591
+ createdAt: row.created_at ?? '',
592
+ eventType,
593
+ outcome: row.outcome ?? '',
594
+ transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
595
+ });
596
+ }
597
+ }
598
+ const items = rows.map((row) => {
599
+ const clientId = row.client_id ?? '';
600
+ const cursor = coerceNumber(row.cursor) ?? 0;
601
+ const latestEvent = latestEventsByClientId.get(clientId);
602
+ const connectionCount = options.wsConnectionManager?.getConnectionCount(clientId) ?? 0;
603
+ const connectionPath = options.wsConnectionManager?.getClientTransportPath(clientId) ??
604
+ latestEvent?.transportPath ??
605
+ 'direct';
606
+ return {
607
+ clientId,
608
+ actorId: row.actor_id ?? '',
609
+ cursor,
610
+ lagCommitCount: Math.max(0, maxCommitSeq - cursor),
611
+ connectionPath,
612
+ connectionMode: connectionCount > 0 ? 'realtime' : 'polling',
613
+ realtimeConnectionCount: connectionCount,
614
+ isRealtimeConnected: connectionCount > 0,
615
+ activityState: getClientActivityState({
616
+ connectionCount,
617
+ updatedAt: row.updated_at,
618
+ }),
619
+ lastRequestAt: latestEvent?.createdAt ?? null,
620
+ lastRequestType: latestEvent?.eventType ?? null,
621
+ lastRequestOutcome: latestEvent?.outcome ?? null,
622
+ effectiveScopes: options.dialect.dbToScopes(row.effective_scopes),
623
+ updatedAt: row.updated_at ?? '',
624
+ };
625
+ });
626
+ const total = coerceNumber(countRow?.total) ?? 0;
627
+ const response = {
628
+ items,
629
+ total,
630
+ offset,
631
+ limit,
632
+ };
633
+ c.header('X-Total-Count', String(total));
634
+ return c.json(response, 200);
635
+ });
636
+ // -------------------------------------------------------------------------
637
+ // GET /handlers
638
+ // -------------------------------------------------------------------------
639
+ routes.get('/handlers', describeRoute({
640
+ tags: ['console'],
641
+ summary: 'List registered handlers',
642
+ responses: {
643
+ 200: {
644
+ description: 'Handler list',
645
+ content: {
646
+ 'application/json': { schema: resolver(handlersResponseSchema) },
647
+ },
648
+ },
649
+ 401: {
650
+ description: 'Unauthenticated',
651
+ content: {
652
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
653
+ },
654
+ },
655
+ },
656
+ }), async (c) => {
657
+ const auth = await requireAuth(c);
658
+ if (!auth)
659
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
660
+ const items = options.handlers.map((handler) => ({
661
+ table: handler.table,
662
+ dependsOn: handler.dependsOn,
663
+ snapshotChunkTtlMs: handler.snapshotChunkTtlMs,
664
+ }));
665
+ return c.json({ items }, 200);
666
+ });
667
+ // -------------------------------------------------------------------------
668
+ // POST /prune/preview
669
+ // -------------------------------------------------------------------------
670
+ routes.post('/prune/preview', describeRoute({
671
+ tags: ['console'],
672
+ summary: 'Preview pruning',
673
+ responses: {
674
+ 200: {
675
+ description: 'Prune preview',
676
+ content: {
677
+ 'application/json': { schema: resolver(ConsolePrunePreviewSchema) },
678
+ },
679
+ },
680
+ 401: {
681
+ description: 'Unauthenticated',
682
+ content: {
683
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
684
+ },
685
+ },
686
+ },
687
+ }), async (c) => {
688
+ const auth = await requireAuth(c);
689
+ if (!auth)
690
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
691
+ const watermarkCommitSeq = await computePruneWatermarkCommitSeq(options.db, options.prune);
692
+ // Count commits that would be deleted
693
+ const countRow = await db
694
+ .selectFrom('sync_commits')
695
+ .select(({ fn }) => fn.countAll().as('count'))
696
+ .where('commit_seq', '<=', watermarkCommitSeq)
697
+ .executeTakeFirst();
698
+ const commitsToDelete = coerceNumber(countRow?.count) ?? 0;
699
+ const preview = {
700
+ watermarkCommitSeq,
701
+ commitsToDelete,
702
+ };
703
+ return c.json(preview, 200);
704
+ });
705
+ // -------------------------------------------------------------------------
706
+ // POST /prune
707
+ // -------------------------------------------------------------------------
708
+ routes.post('/prune', describeRoute({
709
+ tags: ['console'],
710
+ summary: 'Trigger pruning',
711
+ responses: {
712
+ 200: {
713
+ description: 'Prune result',
714
+ content: {
715
+ 'application/json': { schema: resolver(ConsolePruneResultSchema) },
716
+ },
717
+ },
718
+ 401: {
719
+ description: 'Unauthenticated',
720
+ content: {
721
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
722
+ },
723
+ },
724
+ },
725
+ }), async (c) => {
726
+ const auth = await requireAuth(c);
727
+ if (!auth)
728
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
729
+ const watermarkCommitSeq = await computePruneWatermarkCommitSeq(options.db, options.prune);
730
+ const deletedCommits = await pruneSync(options.db, {
731
+ watermarkCommitSeq,
732
+ keepNewestCommits: options.prune?.keepNewestCommits,
733
+ });
734
+ logSyncEvent({
735
+ event: 'console.prune',
736
+ consoleUserId: auth.consoleUserId,
737
+ deletedCommits,
738
+ watermarkCommitSeq,
739
+ });
740
+ const result = { deletedCommits };
741
+ return c.json(result, 200);
742
+ });
743
+ // -------------------------------------------------------------------------
744
+ // POST /compact
745
+ // -------------------------------------------------------------------------
746
+ routes.post('/compact', describeRoute({
747
+ tags: ['console'],
748
+ summary: 'Trigger compaction',
749
+ responses: {
750
+ 200: {
751
+ description: 'Compact result',
752
+ content: {
753
+ 'application/json': {
754
+ schema: resolver(ConsoleCompactResultSchema),
755
+ },
756
+ },
757
+ },
758
+ 401: {
759
+ description: 'Unauthenticated',
760
+ content: {
761
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
762
+ },
763
+ },
764
+ },
765
+ }), async (c) => {
766
+ const auth = await requireAuth(c);
767
+ if (!auth)
768
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
769
+ const fullHistoryHours = options.compact?.fullHistoryHours ?? 24 * 7;
770
+ const deletedChanges = await compactChanges(options.db, {
771
+ dialect: options.dialect,
772
+ options: { fullHistoryHours },
773
+ });
774
+ logSyncEvent({
775
+ event: 'console.compact',
776
+ consoleUserId: auth.consoleUserId,
777
+ deletedChanges,
778
+ fullHistoryHours,
779
+ });
780
+ const result = { deletedChanges };
781
+ return c.json(result, 200);
782
+ });
783
+ // -------------------------------------------------------------------------
784
+ // DELETE /clients/:id
785
+ // -------------------------------------------------------------------------
786
+ routes.delete('/clients/:id', describeRoute({
787
+ tags: ['console'],
788
+ summary: 'Evict client',
789
+ responses: {
790
+ 200: {
791
+ description: 'Evict result',
792
+ content: {
793
+ 'application/json': { schema: resolver(ConsoleEvictResultSchema) },
794
+ },
795
+ },
796
+ 400: {
797
+ description: 'Invalid request',
798
+ content: {
799
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
800
+ },
801
+ },
802
+ 401: {
803
+ description: 'Unauthenticated',
804
+ content: {
805
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
806
+ },
807
+ },
808
+ },
809
+ }), zValidator('param', clientIdParamSchema), async (c) => {
810
+ const auth = await requireAuth(c);
811
+ if (!auth)
812
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
813
+ const { id: clientId } = c.req.valid('param');
814
+ const res = await db
815
+ .deleteFrom('sync_client_cursors')
816
+ .where('client_id', '=', clientId)
817
+ .executeTakeFirst();
818
+ const evicted = Number(res?.numDeletedRows ?? 0) > 0;
819
+ logSyncEvent({
820
+ event: 'console.evict_client',
821
+ consoleUserId: auth.consoleUserId,
822
+ clientId,
823
+ evicted,
824
+ });
825
+ const result = { evicted };
826
+ return c.json(result, 200);
827
+ });
828
+ // -------------------------------------------------------------------------
829
+ // GET /events - Paginated request events list
830
+ // -------------------------------------------------------------------------
831
+ routes.get('/events', describeRoute({
832
+ tags: ['console'],
833
+ summary: 'List request events',
834
+ responses: {
835
+ 200: {
836
+ description: 'Paginated event list',
837
+ content: {
838
+ 'application/json': {
839
+ schema: resolver(ConsolePaginatedResponseSchema(ConsoleRequestEventSchema)),
840
+ },
841
+ },
842
+ },
843
+ 401: {
844
+ description: 'Unauthenticated',
845
+ content: {
846
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
847
+ },
848
+ },
849
+ },
850
+ }), zValidator('query', eventsQuerySchema), async (c) => {
851
+ const auth = await requireAuth(c);
852
+ if (!auth)
853
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
854
+ const { limit, offset, eventType, actorId, clientId, outcome } = c.req.valid('query');
855
+ let query = db
856
+ .selectFrom('sync_request_events')
857
+ .select([
858
+ 'event_id',
859
+ 'event_type',
860
+ 'transport_path',
861
+ 'actor_id',
862
+ 'client_id',
863
+ 'status_code',
864
+ 'outcome',
865
+ 'duration_ms',
866
+ 'commit_seq',
867
+ 'operation_count',
868
+ 'row_count',
869
+ 'tables',
870
+ 'error_message',
871
+ 'created_at',
872
+ ]);
873
+ let countQuery = db
874
+ .selectFrom('sync_request_events')
875
+ .select(({ fn }) => fn.countAll().as('total'));
876
+ if (eventType) {
877
+ query = query.where('event_type', '=', eventType);
878
+ countQuery = countQuery.where('event_type', '=', eventType);
879
+ }
880
+ if (actorId) {
881
+ query = query.where('actor_id', '=', actorId);
882
+ countQuery = countQuery.where('actor_id', '=', actorId);
883
+ }
884
+ if (clientId) {
885
+ query = query.where('client_id', '=', clientId);
886
+ countQuery = countQuery.where('client_id', '=', clientId);
887
+ }
888
+ if (outcome) {
889
+ query = query.where('outcome', '=', outcome);
890
+ countQuery = countQuery.where('outcome', '=', outcome);
891
+ }
892
+ const [rows, countRow] = await Promise.all([
893
+ query
894
+ .orderBy('created_at', 'desc')
895
+ .limit(limit)
896
+ .offset(offset)
897
+ .execute(),
898
+ countQuery.executeTakeFirst(),
899
+ ]);
900
+ const items = rows.map((row) => ({
901
+ eventId: coerceNumber(row.event_id) ?? 0,
902
+ eventType: row.event_type,
903
+ transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
904
+ actorId: row.actor_id ?? '',
905
+ clientId: row.client_id ?? '',
906
+ statusCode: coerceNumber(row.status_code) ?? 0,
907
+ outcome: row.outcome ?? '',
908
+ durationMs: coerceNumber(row.duration_ms) ?? 0,
909
+ commitSeq: coerceNumber(row.commit_seq),
910
+ operationCount: coerceNumber(row.operation_count),
911
+ rowCount: coerceNumber(row.row_count),
912
+ tables: options.dialect.dbToArray(row.tables),
913
+ errorMessage: row.error_message ?? null,
914
+ createdAt: row.created_at ?? '',
915
+ }));
916
+ const total = coerceNumber(countRow?.total) ?? 0;
917
+ const response = {
918
+ items,
919
+ total,
920
+ offset,
921
+ limit,
922
+ };
923
+ c.header('X-Total-Count', String(total));
924
+ return c.json(response, 200);
925
+ });
926
+ // -------------------------------------------------------------------------
927
+ // GET /events/live - WebSocket for live activity feed
928
+ // NOTE: Must be defined BEFORE /events/:id to avoid route conflict
929
+ // -------------------------------------------------------------------------
930
+ if (options.eventEmitter &&
931
+ options.websocket?.enabled &&
932
+ options.websocket?.upgradeWebSocket) {
933
+ const emitter = options.eventEmitter;
934
+ const upgradeWebSocket = options.websocket.upgradeWebSocket;
935
+ const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
936
+ const wsState = new WeakMap();
937
+ routes.get('/events/live', upgradeWebSocket(async (c) => {
938
+ // Auth check via query param (WebSocket doesn't support headers easily)
939
+ const token = c.req.query('token');
940
+ const authHeader = c.req.header('Authorization');
941
+ const mockContext = {
942
+ req: {
943
+ header: (name) => name === 'Authorization' ? authHeader : undefined,
944
+ query: (name) => (name === 'token' ? token : undefined),
945
+ },
946
+ };
947
+ const auth = await options.authenticate(mockContext);
948
+ return {
949
+ onOpen(_event, ws) {
950
+ if (!auth) {
951
+ ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
952
+ ws.close(4001, 'Unauthenticated');
953
+ return;
954
+ }
955
+ const listener = (event) => {
956
+ try {
957
+ ws.send(JSON.stringify(event));
958
+ }
959
+ catch {
960
+ // Connection closed
961
+ }
962
+ };
963
+ emitter.addListener(listener);
964
+ // Send connected message
965
+ ws.send(JSON.stringify({
966
+ type: 'connected',
967
+ timestamp: new Date().toISOString(),
968
+ }));
969
+ // Start heartbeat
970
+ const heartbeatInterval = setInterval(() => {
971
+ try {
972
+ ws.send(JSON.stringify({
973
+ type: 'heartbeat',
974
+ timestamp: new Date().toISOString(),
975
+ }));
976
+ }
977
+ catch {
978
+ clearInterval(heartbeatInterval);
979
+ }
980
+ }, heartbeatIntervalMs);
981
+ wsState.set(ws, { listener, heartbeatInterval });
982
+ },
983
+ onClose(_event, ws) {
984
+ const state = wsState.get(ws);
985
+ if (!state)
986
+ return;
987
+ emitter.removeListener(state.listener);
988
+ clearInterval(state.heartbeatInterval);
989
+ wsState.delete(ws);
990
+ },
991
+ onError(_event, ws) {
992
+ const state = wsState.get(ws);
993
+ if (!state)
994
+ return;
995
+ emitter.removeListener(state.listener);
996
+ clearInterval(state.heartbeatInterval);
997
+ wsState.delete(ws);
998
+ },
999
+ };
1000
+ }));
1001
+ }
1002
+ // -------------------------------------------------------------------------
1003
+ // GET /events/:id - Single event detail
1004
+ // -------------------------------------------------------------------------
1005
+ routes.get('/events/:id', describeRoute({
1006
+ tags: ['console'],
1007
+ summary: 'Get event details',
1008
+ responses: {
1009
+ 200: {
1010
+ description: 'Event details',
1011
+ content: {
1012
+ 'application/json': {
1013
+ schema: resolver(ConsoleRequestEventSchema),
1014
+ },
1015
+ },
1016
+ },
1017
+ 400: {
1018
+ description: 'Invalid request',
1019
+ content: {
1020
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1021
+ },
1022
+ },
1023
+ 401: {
1024
+ description: 'Unauthenticated',
1025
+ content: {
1026
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1027
+ },
1028
+ },
1029
+ 404: {
1030
+ description: 'Not found',
1031
+ content: {
1032
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1033
+ },
1034
+ },
1035
+ },
1036
+ }), zValidator('param', eventIdParamSchema), async (c) => {
1037
+ const auth = await requireAuth(c);
1038
+ if (!auth)
1039
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1040
+ const { id: eventId } = c.req.valid('param');
1041
+ const row = await db
1042
+ .selectFrom('sync_request_events')
1043
+ .select([
1044
+ 'event_id',
1045
+ 'event_type',
1046
+ 'transport_path',
1047
+ 'actor_id',
1048
+ 'client_id',
1049
+ 'status_code',
1050
+ 'outcome',
1051
+ 'duration_ms',
1052
+ 'commit_seq',
1053
+ 'operation_count',
1054
+ 'row_count',
1055
+ 'tables',
1056
+ 'error_message',
1057
+ 'created_at',
1058
+ ])
1059
+ .where('event_id', '=', eventId)
1060
+ .executeTakeFirst();
1061
+ if (!row) {
1062
+ return c.json({ error: 'NOT_FOUND' }, 404);
1063
+ }
1064
+ const event = {
1065
+ eventId: coerceNumber(row.event_id) ?? 0,
1066
+ eventType: row.event_type,
1067
+ transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
1068
+ actorId: row.actor_id ?? '',
1069
+ clientId: row.client_id ?? '',
1070
+ statusCode: coerceNumber(row.status_code) ?? 0,
1071
+ outcome: row.outcome ?? '',
1072
+ durationMs: coerceNumber(row.duration_ms) ?? 0,
1073
+ commitSeq: coerceNumber(row.commit_seq),
1074
+ operationCount: coerceNumber(row.operation_count),
1075
+ rowCount: coerceNumber(row.row_count),
1076
+ tables: options.dialect.dbToArray(row.tables),
1077
+ errorMessage: row.error_message ?? null,
1078
+ createdAt: row.created_at ?? '',
1079
+ };
1080
+ return c.json(event, 200);
1081
+ });
1082
+ // -------------------------------------------------------------------------
1083
+ // DELETE /events - Clear all events
1084
+ // -------------------------------------------------------------------------
1085
+ routes.delete('/events', describeRoute({
1086
+ tags: ['console'],
1087
+ summary: 'Clear all events',
1088
+ responses: {
1089
+ 200: {
1090
+ description: 'Clear result',
1091
+ content: {
1092
+ 'application/json': {
1093
+ schema: resolver(ConsoleClearEventsResultSchema),
1094
+ },
1095
+ },
1096
+ },
1097
+ 401: {
1098
+ description: 'Unauthenticated',
1099
+ content: {
1100
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1101
+ },
1102
+ },
1103
+ },
1104
+ }), async (c) => {
1105
+ const auth = await requireAuth(c);
1106
+ if (!auth)
1107
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1108
+ const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
1109
+ const deletedCount = Number(res?.numDeletedRows ?? 0);
1110
+ logSyncEvent({
1111
+ event: 'console.clear_events',
1112
+ consoleUserId: auth.consoleUserId,
1113
+ deletedCount,
1114
+ });
1115
+ const result = { deletedCount };
1116
+ return c.json(result, 200);
1117
+ });
1118
+ // -------------------------------------------------------------------------
1119
+ // POST /events/prune - Prune old events
1120
+ // -------------------------------------------------------------------------
1121
+ routes.post('/events/prune', describeRoute({
1122
+ tags: ['console'],
1123
+ summary: 'Prune old events',
1124
+ responses: {
1125
+ 200: {
1126
+ description: 'Prune result',
1127
+ content: {
1128
+ 'application/json': {
1129
+ schema: resolver(ConsolePruneEventsResultSchema),
1130
+ },
1131
+ },
1132
+ },
1133
+ 401: {
1134
+ description: 'Unauthenticated',
1135
+ content: {
1136
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1137
+ },
1138
+ },
1139
+ },
1140
+ }), async (c) => {
1141
+ const auth = await requireAuth(c);
1142
+ if (!auth)
1143
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1144
+ // Prune events older than 7 days or keep max 10000 events
1145
+ const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
1146
+ // Delete by date first
1147
+ const resByDate = await db
1148
+ .deleteFrom('sync_request_events')
1149
+ .where('created_at', '<', cutoffDate.toISOString())
1150
+ .executeTakeFirst();
1151
+ let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
1152
+ // Then delete oldest if we still have more than 10000 events
1153
+ const countRow = await db
1154
+ .selectFrom('sync_request_events')
1155
+ .select(({ fn }) => fn.countAll().as('total'))
1156
+ .executeTakeFirst();
1157
+ const total = coerceNumber(countRow?.total) ?? 0;
1158
+ const maxEvents = 10000;
1159
+ if (total > maxEvents) {
1160
+ // Find event_id cutoff to keep only newest maxEvents
1161
+ const cutoffRow = await db
1162
+ .selectFrom('sync_request_events')
1163
+ .select(['event_id'])
1164
+ .orderBy('event_id', 'desc')
1165
+ .offset(maxEvents)
1166
+ .limit(1)
1167
+ .executeTakeFirst();
1168
+ if (cutoffRow) {
1169
+ const cutoffEventId = coerceNumber(cutoffRow.event_id);
1170
+ if (cutoffEventId !== null) {
1171
+ const resByCount = await db
1172
+ .deleteFrom('sync_request_events')
1173
+ .where('event_id', '<=', cutoffEventId)
1174
+ .executeTakeFirst();
1175
+ deletedCount += Number(resByCount?.numDeletedRows ?? 0);
1176
+ }
1177
+ }
1178
+ }
1179
+ logSyncEvent({
1180
+ event: 'console.prune_events',
1181
+ consoleUserId: auth.consoleUserId,
1182
+ deletedCount,
1183
+ });
1184
+ const result = { deletedCount };
1185
+ return c.json(result, 200);
1186
+ });
1187
+ // -------------------------------------------------------------------------
1188
+ // GET /api-keys - List all API keys
1189
+ // -------------------------------------------------------------------------
1190
+ routes.get('/api-keys', describeRoute({
1191
+ tags: ['console'],
1192
+ summary: 'List API keys',
1193
+ responses: {
1194
+ 200: {
1195
+ description: 'Paginated API key list',
1196
+ content: {
1197
+ 'application/json': {
1198
+ schema: resolver(ConsolePaginatedResponseSchema(ConsoleApiKeySchema)),
1199
+ },
1200
+ },
1201
+ },
1202
+ 401: {
1203
+ description: 'Unauthenticated',
1204
+ content: {
1205
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1206
+ },
1207
+ },
1208
+ },
1209
+ }), zValidator('query', apiKeysQuerySchema), async (c) => {
1210
+ const auth = await requireAuth(c);
1211
+ if (!auth)
1212
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1213
+ const { limit, offset, type: keyType } = c.req.valid('query');
1214
+ let query = db
1215
+ .selectFrom('sync_api_keys')
1216
+ .select([
1217
+ 'key_id',
1218
+ 'key_prefix',
1219
+ 'name',
1220
+ 'key_type',
1221
+ 'scope_keys',
1222
+ 'actor_id',
1223
+ 'created_at',
1224
+ 'expires_at',
1225
+ 'last_used_at',
1226
+ 'revoked_at',
1227
+ ]);
1228
+ let countQuery = db
1229
+ .selectFrom('sync_api_keys')
1230
+ .select(({ fn }) => fn.countAll().as('total'));
1231
+ if (keyType) {
1232
+ query = query.where('key_type', '=', keyType);
1233
+ countQuery = countQuery.where('key_type', '=', keyType);
1234
+ }
1235
+ const [rows, countRow] = await Promise.all([
1236
+ query
1237
+ .orderBy('created_at', 'desc')
1238
+ .limit(limit)
1239
+ .offset(offset)
1240
+ .execute(),
1241
+ countQuery.executeTakeFirst(),
1242
+ ]);
1243
+ const items = rows.map((row) => ({
1244
+ keyId: row.key_id ?? '',
1245
+ keyPrefix: row.key_prefix ?? '',
1246
+ name: row.name ?? '',
1247
+ keyType: row.key_type,
1248
+ scopeKeys: options.dialect.dbToArray(row.scope_keys),
1249
+ actorId: row.actor_id ?? null,
1250
+ createdAt: row.created_at ?? '',
1251
+ expiresAt: row.expires_at ?? null,
1252
+ lastUsedAt: row.last_used_at ?? null,
1253
+ revokedAt: row.revoked_at ?? null,
1254
+ }));
1255
+ const totalCount = coerceNumber(countRow?.total) ?? 0;
1256
+ const response = {
1257
+ items,
1258
+ total: totalCount,
1259
+ offset,
1260
+ limit,
1261
+ };
1262
+ c.header('X-Total-Count', String(totalCount));
1263
+ return c.json(response, 200);
1264
+ });
1265
+ // -------------------------------------------------------------------------
1266
+ // POST /api-keys - Create new API key
1267
+ // -------------------------------------------------------------------------
1268
+ routes.post('/api-keys', describeRoute({
1269
+ tags: ['console'],
1270
+ summary: 'Create API key',
1271
+ responses: {
1272
+ 201: {
1273
+ description: 'Created API key',
1274
+ content: {
1275
+ 'application/json': {
1276
+ schema: resolver(ConsoleApiKeyCreateResponseSchema),
1277
+ },
1278
+ },
1279
+ },
1280
+ 400: {
1281
+ description: 'Invalid request',
1282
+ content: {
1283
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1284
+ },
1285
+ },
1286
+ 401: {
1287
+ description: 'Unauthenticated',
1288
+ content: {
1289
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1290
+ },
1291
+ },
1292
+ },
1293
+ }), zValidator('json', ConsoleApiKeyCreateRequestSchema), async (c) => {
1294
+ const auth = await requireAuth(c);
1295
+ if (!auth)
1296
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1297
+ const body = c.req.valid('json');
1298
+ // Generate key components
1299
+ const keyId = generateKeyId();
1300
+ const secretKey = generateSecretKey(body.keyType);
1301
+ const keyHash = await hashApiKey(secretKey);
1302
+ const keyPrefix = secretKey.slice(0, 12);
1303
+ // Calculate expiry
1304
+ let expiresAt = null;
1305
+ if (body.expiresInDays && body.expiresInDays > 0) {
1306
+ expiresAt = new Date(Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000).toISOString();
1307
+ }
1308
+ const scopeKeys = body.scopeKeys ?? [];
1309
+ const now = new Date().toISOString();
1310
+ // Insert into database
1311
+ await db
1312
+ .insertInto('sync_api_keys')
1313
+ .values({
1314
+ key_id: keyId,
1315
+ key_hash: keyHash,
1316
+ key_prefix: keyPrefix,
1317
+ name: body.name,
1318
+ key_type: body.keyType,
1319
+ scope_keys: options.dialect.arrayToDb(scopeKeys),
1320
+ actor_id: body.actorId ?? null,
1321
+ created_at: now,
1322
+ expires_at: expiresAt,
1323
+ last_used_at: null,
1324
+ revoked_at: null,
1325
+ })
1326
+ .execute();
1327
+ logSyncEvent({
1328
+ event: 'console.create_api_key',
1329
+ consoleUserId: auth.consoleUserId,
1330
+ keyId,
1331
+ keyType: body.keyType,
1332
+ });
1333
+ const key = {
1334
+ keyId,
1335
+ keyPrefix,
1336
+ name: body.name,
1337
+ keyType: body.keyType,
1338
+ scopeKeys,
1339
+ actorId: body.actorId ?? null,
1340
+ createdAt: now,
1341
+ expiresAt,
1342
+ lastUsedAt: null,
1343
+ revokedAt: null,
1344
+ };
1345
+ const response = {
1346
+ key,
1347
+ secretKey,
1348
+ };
1349
+ return c.json(response, 201);
1350
+ });
1351
+ // -------------------------------------------------------------------------
1352
+ // GET /api-keys/:id - Get single API key
1353
+ // -------------------------------------------------------------------------
1354
+ routes.get('/api-keys/:id', describeRoute({
1355
+ tags: ['console'],
1356
+ summary: 'Get API key',
1357
+ responses: {
1358
+ 200: {
1359
+ description: 'API key details',
1360
+ content: {
1361
+ 'application/json': { schema: resolver(ConsoleApiKeySchema) },
1362
+ },
1363
+ },
1364
+ 401: {
1365
+ description: 'Unauthenticated',
1366
+ content: {
1367
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1368
+ },
1369
+ },
1370
+ 404: {
1371
+ description: 'Not found',
1372
+ content: {
1373
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1374
+ },
1375
+ },
1376
+ },
1377
+ }), zValidator('param', apiKeyIdParamSchema), async (c) => {
1378
+ const auth = await requireAuth(c);
1379
+ if (!auth)
1380
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1381
+ const { id: keyId } = c.req.valid('param');
1382
+ const row = await db
1383
+ .selectFrom('sync_api_keys')
1384
+ .select([
1385
+ 'key_id',
1386
+ 'key_prefix',
1387
+ 'name',
1388
+ 'key_type',
1389
+ 'scope_keys',
1390
+ 'actor_id',
1391
+ 'created_at',
1392
+ 'expires_at',
1393
+ 'last_used_at',
1394
+ 'revoked_at',
1395
+ ])
1396
+ .where('key_id', '=', keyId)
1397
+ .executeTakeFirst();
1398
+ if (!row) {
1399
+ return c.json({ error: 'NOT_FOUND' }, 404);
1400
+ }
1401
+ const key = {
1402
+ keyId: row.key_id ?? '',
1403
+ keyPrefix: row.key_prefix ?? '',
1404
+ name: row.name ?? '',
1405
+ keyType: row.key_type,
1406
+ scopeKeys: options.dialect.dbToArray(row.scope_keys),
1407
+ actorId: row.actor_id ?? null,
1408
+ createdAt: row.created_at ?? '',
1409
+ expiresAt: row.expires_at ?? null,
1410
+ lastUsedAt: row.last_used_at ?? null,
1411
+ revokedAt: row.revoked_at ?? null,
1412
+ };
1413
+ return c.json(key, 200);
1414
+ });
1415
+ // -------------------------------------------------------------------------
1416
+ // DELETE /api-keys/:id - Revoke API key (soft delete)
1417
+ // -------------------------------------------------------------------------
1418
+ routes.delete('/api-keys/:id', describeRoute({
1419
+ tags: ['console'],
1420
+ summary: 'Revoke API key',
1421
+ responses: {
1422
+ 200: {
1423
+ description: 'Revoke result',
1424
+ content: {
1425
+ 'application/json': {
1426
+ schema: resolver(ConsoleApiKeyRevokeResponseSchema),
1427
+ },
1428
+ },
1429
+ },
1430
+ 401: {
1431
+ description: 'Unauthenticated',
1432
+ content: {
1433
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1434
+ },
1435
+ },
1436
+ },
1437
+ }), zValidator('param', apiKeyIdParamSchema), async (c) => {
1438
+ const auth = await requireAuth(c);
1439
+ if (!auth)
1440
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1441
+ const { id: keyId } = c.req.valid('param');
1442
+ const now = new Date().toISOString();
1443
+ const res = await db
1444
+ .updateTable('sync_api_keys')
1445
+ .set({ revoked_at: now })
1446
+ .where('key_id', '=', keyId)
1447
+ .where('revoked_at', 'is', null)
1448
+ .executeTakeFirst();
1449
+ const revoked = Number(res?.numUpdatedRows ?? 0) > 0;
1450
+ logSyncEvent({
1451
+ event: 'console.revoke_api_key',
1452
+ consoleUserId: auth.consoleUserId,
1453
+ keyId,
1454
+ revoked,
1455
+ });
1456
+ return c.json({ revoked }, 200);
1457
+ });
1458
+ // -------------------------------------------------------------------------
1459
+ // POST /api-keys/:id/rotate - Rotate API key
1460
+ // -------------------------------------------------------------------------
1461
+ routes.post('/api-keys/:id/rotate', describeRoute({
1462
+ tags: ['console'],
1463
+ summary: 'Rotate API key',
1464
+ responses: {
1465
+ 200: {
1466
+ description: 'Rotated API key',
1467
+ content: {
1468
+ 'application/json': {
1469
+ schema: resolver(ConsoleApiKeyCreateResponseSchema),
1470
+ },
1471
+ },
1472
+ },
1473
+ 401: {
1474
+ description: 'Unauthenticated',
1475
+ content: {
1476
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1477
+ },
1478
+ },
1479
+ 404: {
1480
+ description: 'Not found',
1481
+ content: {
1482
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
1483
+ },
1484
+ },
1485
+ },
1486
+ }), zValidator('param', apiKeyIdParamSchema), async (c) => {
1487
+ const auth = await requireAuth(c);
1488
+ if (!auth)
1489
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
1490
+ const { id: keyId } = c.req.valid('param');
1491
+ const now = new Date().toISOString();
1492
+ // Get existing key
1493
+ const existingRow = await db
1494
+ .selectFrom('sync_api_keys')
1495
+ .select([
1496
+ 'key_id',
1497
+ 'name',
1498
+ 'key_type',
1499
+ 'scope_keys',
1500
+ 'actor_id',
1501
+ 'expires_at',
1502
+ ])
1503
+ .where('key_id', '=', keyId)
1504
+ .where('revoked_at', 'is', null)
1505
+ .executeTakeFirst();
1506
+ if (!existingRow) {
1507
+ return c.json({ error: 'NOT_FOUND' }, 404);
1508
+ }
1509
+ // Revoke old key
1510
+ await db
1511
+ .updateTable('sync_api_keys')
1512
+ .set({ revoked_at: now })
1513
+ .where('key_id', '=', keyId)
1514
+ .execute();
1515
+ // Create new key with same properties
1516
+ const newKeyId = generateKeyId();
1517
+ const keyType = existingRow.key_type;
1518
+ const secretKey = generateSecretKey(keyType);
1519
+ const keyHash = await hashApiKey(secretKey);
1520
+ const keyPrefix = secretKey.slice(0, 12);
1521
+ const scopeKeys = options.dialect.dbToArray(existingRow.scope_keys);
1522
+ await db
1523
+ .insertInto('sync_api_keys')
1524
+ .values({
1525
+ key_id: newKeyId,
1526
+ key_hash: keyHash,
1527
+ key_prefix: keyPrefix,
1528
+ name: existingRow.name,
1529
+ key_type: keyType,
1530
+ scope_keys: options.dialect.arrayToDb(scopeKeys),
1531
+ actor_id: existingRow.actor_id ?? null,
1532
+ created_at: now,
1533
+ expires_at: existingRow.expires_at,
1534
+ last_used_at: null,
1535
+ revoked_at: null,
1536
+ })
1537
+ .execute();
1538
+ logSyncEvent({
1539
+ event: 'console.rotate_api_key',
1540
+ consoleUserId: auth.consoleUserId,
1541
+ oldKeyId: keyId,
1542
+ newKeyId,
1543
+ });
1544
+ const key = {
1545
+ keyId: newKeyId,
1546
+ keyPrefix,
1547
+ name: existingRow.name,
1548
+ keyType,
1549
+ scopeKeys,
1550
+ actorId: existingRow.actor_id ?? null,
1551
+ createdAt: now,
1552
+ expiresAt: existingRow.expires_at ?? null,
1553
+ lastUsedAt: null,
1554
+ revokedAt: null,
1555
+ };
1556
+ const response = {
1557
+ key,
1558
+ secretKey,
1559
+ };
1560
+ return c.json(response, 200);
1561
+ });
1562
+ return routes;
1563
+ }
1564
+ // ===========================================================================
1565
+ // API Key Utilities
1566
+ // ===========================================================================
1567
+ function generateKeyId() {
1568
+ const bytes = new Uint8Array(16);
1569
+ crypto.getRandomValues(bytes);
1570
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1571
+ }
1572
+ function generateSecretKey(keyType) {
1573
+ const bytes = new Uint8Array(24);
1574
+ crypto.getRandomValues(bytes);
1575
+ const random = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1576
+ return `sk_${keyType}_${random}`;
1577
+ }
1578
+ async function hashApiKey(secretKey) {
1579
+ const encoder = new TextEncoder();
1580
+ const data = encoder.encode(secretKey);
1581
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
1582
+ const hashArray = new Uint8Array(hashBuffer);
1583
+ return Array.from(hashArray, (b) => b.toString(16).padStart(2, '0')).join('');
1584
+ }
1585
+ /**
1586
+ * Creates a simple token-based authenticator for local development.
1587
+ * The token can be set via SYNC_CONSOLE_TOKEN env var or passed directly.
1588
+ */
1589
+ export function createTokenAuthenticator(token) {
1590
+ const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
1591
+ return async (c) => {
1592
+ if (!expectedToken) {
1593
+ // No token configured, allow all requests (not recommended for production)
1594
+ return { consoleUserId: 'anonymous' };
1595
+ }
1596
+ // Check Authorization header
1597
+ const authHeader = c.req.header('Authorization');
1598
+ if (authHeader?.startsWith('Bearer ')) {
1599
+ const bearerToken = authHeader.slice(7);
1600
+ if (bearerToken === expectedToken) {
1601
+ return { consoleUserId: 'token' };
1602
+ }
1603
+ }
1604
+ // Check query parameter
1605
+ const queryToken = c.req.query('token');
1606
+ if (queryToken === expectedToken) {
1607
+ return { consoleUserId: 'token' };
1608
+ }
1609
+ return null;
1610
+ };
1611
+ }
1612
+ //# sourceMappingURL=routes.js.map