@syncular/server-hono 0.0.4-26 → 0.0.6-100

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 (51) hide show
  1. package/dist/console/gateway.d.ts +3 -1
  2. package/dist/console/gateway.d.ts.map +1 -1
  3. package/dist/console/gateway.js +218 -41
  4. package/dist/console/gateway.js.map +1 -1
  5. package/dist/console/index.d.ts +1 -0
  6. package/dist/console/index.d.ts.map +1 -1
  7. package/dist/console/index.js +1 -0
  8. package/dist/console/index.js.map +1 -1
  9. package/dist/console/routes.d.ts +3 -97
  10. package/dist/console/routes.d.ts.map +1 -1
  11. package/dist/console/routes.js +507 -80
  12. package/dist/console/routes.js.map +1 -1
  13. package/dist/console/schemas.d.ts +29 -0
  14. package/dist/console/schemas.d.ts.map +1 -1
  15. package/dist/console/schemas.js +22 -0
  16. package/dist/console/schemas.js.map +1 -1
  17. package/dist/console/types.d.ts +175 -0
  18. package/dist/console/types.d.ts.map +1 -0
  19. package/dist/console/types.js +2 -0
  20. package/dist/console/types.js.map +1 -0
  21. package/dist/create-server.d.ts +17 -34
  22. package/dist/create-server.d.ts.map +1 -1
  23. package/dist/create-server.js +26 -26
  24. package/dist/create-server.js.map +1 -1
  25. package/dist/proxy/connection-manager.d.ts +3 -3
  26. package/dist/proxy/connection-manager.d.ts.map +1 -1
  27. package/dist/proxy/routes.d.ts +4 -4
  28. package/dist/proxy/routes.d.ts.map +1 -1
  29. package/dist/proxy/routes.js +1 -1
  30. package/dist/routes.d.ts +33 -9
  31. package/dist/routes.d.ts.map +1 -1
  32. package/dist/routes.js +153 -70
  33. package/dist/routes.js.map +1 -1
  34. package/package.json +21 -7
  35. package/src/__tests__/blob-routes.test.ts +424 -0
  36. package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
  37. package/src/__tests__/console-routes.test.ts +161 -7
  38. package/src/__tests__/console-ui.test.ts +114 -0
  39. package/src/__tests__/create-server.test.ts +233 -10
  40. package/src/__tests__/pull-chunk-storage.test.ts +6 -2
  41. package/src/__tests__/realtime-bridge.test.ts +6 -2
  42. package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
  43. package/src/console/gateway.ts +277 -53
  44. package/src/console/index.ts +1 -0
  45. package/src/console/routes.ts +654 -198
  46. package/src/console/schemas.ts +29 -0
  47. package/src/console/types.ts +185 -0
  48. package/src/create-server.ts +56 -53
  49. package/src/proxy/connection-manager.ts +3 -3
  50. package/src/proxy/routes.ts +4 -4
  51. package/src/routes.ts +225 -96
@@ -21,7 +21,7 @@ import { cors } from 'hono/cors';
21
21
  import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
22
22
  import { sql } from 'kysely';
23
23
  import { z } from 'zod';
24
- import { ApiKeyTypeSchema, ConsoleApiKeyBulkRevokeRequestSchema, ConsoleApiKeyBulkRevokeResponseSchema, ConsoleApiKeyCreateRequestSchema, ConsoleApiKeyCreateResponseSchema, ConsoleApiKeyRevokeResponseSchema, ConsoleApiKeySchema, ConsoleClearEventsResultSchema, ConsoleClientSchema, ConsoleCommitDetailSchema, ConsoleCommitListItemSchema, ConsoleCompactResultSchema, ConsoleEvictResultSchema, ConsoleHandlerSchema, ConsoleOperationEventSchema, ConsoleOperationsQuerySchema, ConsolePaginatedResponseSchema, ConsolePaginationQuerySchema, ConsolePartitionedPaginationQuerySchema, ConsolePartitionQuerySchema, ConsolePruneEventsResultSchema, ConsolePrunePreviewSchema, ConsolePruneResultSchema, ConsoleRequestEventSchema, ConsoleRequestPayloadSchema, ConsoleTimelineItemSchema, ConsoleTimelineQuerySchema, LatencyQuerySchema, LatencyStatsResponseSchema, SyncStatsSchema, TimeseriesQuerySchema, TimeseriesStatsResponseSchema, } from './schemas.js';
24
+ import { ApiKeyTypeSchema, ConsoleApiKeyBulkRevokeRequestSchema, ConsoleApiKeyBulkRevokeResponseSchema, ConsoleApiKeyCreateRequestSchema, ConsoleApiKeyCreateResponseSchema, ConsoleApiKeyRevokeResponseSchema, ConsoleApiKeySchema, ConsoleBlobDeleteResponseSchema, ConsoleBlobListQuerySchema, ConsoleBlobListResponseSchema, ConsoleClearEventsResultSchema, ConsoleClientSchema, ConsoleCommitDetailSchema, ConsoleCommitListItemSchema, ConsoleCompactResultSchema, ConsoleEvictResultSchema, ConsoleHandlerSchema, ConsoleOperationEventSchema, ConsoleOperationsQuerySchema, ConsolePaginatedResponseSchema, ConsolePaginationQuerySchema, ConsolePartitionedPaginationQuerySchema, ConsolePartitionQuerySchema, ConsolePruneEventsResultSchema, ConsolePrunePreviewSchema, ConsolePruneResultSchema, ConsoleRequestEventSchema, ConsoleRequestPayloadSchema, ConsoleTimelineItemSchema, ConsoleTimelineQuerySchema, LatencyQuerySchema, LatencyStatsResponseSchema, SyncStatsSchema, TimeseriesQuerySchema, TimeseriesStatsResponseSchema, } from './schemas.js';
25
25
  /**
26
26
  * Create a simple console event emitter for broadcasting live events.
27
27
  */
@@ -262,23 +262,54 @@ const apiKeysQuerySchema = ConsolePaginationQuerySchema.extend({
262
262
  const handlersResponseSchema = z.object({
263
263
  items: z.array(ConsoleHandlerSchema),
264
264
  });
265
+ const DEFAULT_REQUEST_EVENTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
266
+ const DEFAULT_REQUEST_EVENTS_MAX_ROWS = 10_000;
267
+ const DEFAULT_OPERATION_EVENTS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
268
+ const DEFAULT_OPERATION_EVENTS_MAX_ROWS = 5_000;
269
+ const DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS = 5 * 60 * 1000;
270
+ function readNonNegativeInteger(value, fallback) {
271
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
272
+ return fallback;
273
+ }
274
+ if (value < 0) {
275
+ return fallback;
276
+ }
277
+ return Math.floor(value);
278
+ }
265
279
  export function createConsoleRoutes(options) {
266
280
  const routes = new Hono();
281
+ routes.onError((error, context) => {
282
+ const message = error instanceof Error ? error.message : 'Unknown console error';
283
+ console.error('[console] route error', error);
284
+ return context.json({
285
+ error: 'CONSOLE_ROUTE_ERROR',
286
+ message,
287
+ }, 500);
288
+ });
267
289
  const db = options.db;
268
290
  const metricsAggregationMode = options.metrics?.aggregationMode ?? 'auto';
269
291
  const rawFallbackMaxEvents = Math.max(1, options.metrics?.rawFallbackMaxEvents ?? 5000);
270
- // Ensure console schema exists (creates sync_request_events table if needed)
271
- // Run asynchronously - will be ready before first request typically
272
- options.dialect.ensureConsoleSchema?.(options.db).catch((err) => {
292
+ const requestEventsMaxAgeMs = readNonNegativeInteger(options.maintenance?.requestEventsMaxAgeMs, DEFAULT_REQUEST_EVENTS_MAX_AGE_MS);
293
+ const requestEventsMaxRows = readNonNegativeInteger(options.maintenance?.requestEventsMaxRows, DEFAULT_REQUEST_EVENTS_MAX_ROWS);
294
+ const operationEventsMaxAgeMs = readNonNegativeInteger(options.maintenance?.operationEventsMaxAgeMs, DEFAULT_OPERATION_EVENTS_MAX_AGE_MS);
295
+ const operationEventsMaxRows = readNonNegativeInteger(options.maintenance?.operationEventsMaxRows, DEFAULT_OPERATION_EVENTS_MAX_ROWS);
296
+ const autoEventsPruneIntervalMs = readNonNegativeInteger(options.maintenance?.autoPruneIntervalMs, DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS);
297
+ let lastEventsPruneRunAt = 0;
298
+ // Ensure console schema exists before handlers query console tables.
299
+ const consoleSchemaReadyPromise = (options.consoleSchemaReady ??
300
+ options.dialect.ensureConsoleSchema?.(options.db) ??
301
+ Promise.resolve()).catch((err) => {
273
302
  console.error('[console] Failed to ensure console schema:', err);
303
+ throw err;
274
304
  });
275
305
  // CORS configuration
276
306
  const corsOrigins = options.corsOrigins ?? [
277
307
  'http://localhost:5173',
278
308
  'https://console.sync.dev',
279
309
  ];
310
+ const allowWildcardCors = corsOrigins === '*';
280
311
  routes.use('*', cors({
281
- origin: corsOrigins === '*' ? '*' : corsOrigins,
312
+ origin: allowWildcardCors ? '*' : corsOrigins,
282
313
  allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
283
314
  allowHeaders: [
284
315
  'Content-Type',
@@ -290,8 +321,30 @@ export function createConsoleRoutes(options) {
290
321
  'Tracestate',
291
322
  ],
292
323
  exposeHeaders: ['X-Total-Count'],
293
- credentials: true,
324
+ credentials: !allowWildcardCors,
294
325
  }));
326
+ const ensureConsoleSchemaReady = async (c) => {
327
+ try {
328
+ await consoleSchemaReadyPromise;
329
+ return null;
330
+ }
331
+ catch {
332
+ return c.json({ error: 'CONSOLE_SCHEMA_UNAVAILABLE' }, 503);
333
+ }
334
+ };
335
+ routes.use('*', async (c, next) => {
336
+ const readyError = await ensureConsoleSchemaReady(c);
337
+ if (readyError) {
338
+ return readyError;
339
+ }
340
+ await next();
341
+ });
342
+ routes.use('*', async (c, next) => {
343
+ if (c.req.method !== 'OPTIONS') {
344
+ triggerAutomaticEventsPrune();
345
+ }
346
+ await next();
347
+ });
295
348
  // Auth middleware
296
349
  const requireAuth = async (c) => {
297
350
  const auth = await options.authenticate(c);
@@ -377,6 +430,161 @@ export function createConsoleRoutes(options) {
377
430
  resultPayload: parseJsonValue(row.result_payload),
378
431
  createdAt: row.created_at ?? '',
379
432
  });
433
+ const deleteUnreferencedPayloadSnapshots = async () => {
434
+ const result = await db
435
+ .deleteFrom('sync_request_payloads')
436
+ .where('payload_ref', 'not in', db
437
+ .selectFrom('sync_request_events')
438
+ .select('payload_ref')
439
+ .where('payload_ref', 'is not', null))
440
+ .executeTakeFirst();
441
+ return Number(result?.numDeletedRows ?? 0);
442
+ };
443
+ const pruneRequestEventsByAge = async () => {
444
+ if (requestEventsMaxAgeMs <= 0) {
445
+ return 0;
446
+ }
447
+ const cutoffDate = new Date(Date.now() - requestEventsMaxAgeMs);
448
+ const result = await db
449
+ .deleteFrom('sync_request_events')
450
+ .where('created_at', '<', cutoffDate.toISOString())
451
+ .executeTakeFirst();
452
+ return Number(result?.numDeletedRows ?? 0);
453
+ };
454
+ const pruneRequestEventsByCount = async () => {
455
+ if (requestEventsMaxRows <= 0) {
456
+ return 0;
457
+ }
458
+ const countRow = await db
459
+ .selectFrom('sync_request_events')
460
+ .select(({ fn }) => fn.countAll().as('total'))
461
+ .executeTakeFirst();
462
+ const total = coerceNumber(countRow?.total) ?? 0;
463
+ if (total <= requestEventsMaxRows) {
464
+ return 0;
465
+ }
466
+ const cutoffRow = await db
467
+ .selectFrom('sync_request_events')
468
+ .select(['event_id'])
469
+ .orderBy('event_id', 'desc')
470
+ .offset(requestEventsMaxRows)
471
+ .limit(1)
472
+ .executeTakeFirst();
473
+ const cutoffEventId = coerceNumber(cutoffRow?.event_id);
474
+ if (cutoffEventId === null) {
475
+ return 0;
476
+ }
477
+ const result = await db
478
+ .deleteFrom('sync_request_events')
479
+ .where('event_id', '<=', cutoffEventId)
480
+ .executeTakeFirst();
481
+ return Number(result?.numDeletedRows ?? 0);
482
+ };
483
+ const pruneOperationEventsByAge = async () => {
484
+ if (operationEventsMaxAgeMs <= 0) {
485
+ return 0;
486
+ }
487
+ const cutoffDate = new Date(Date.now() - operationEventsMaxAgeMs);
488
+ const result = await db
489
+ .deleteFrom('sync_operation_events')
490
+ .where('created_at', '<', cutoffDate.toISOString())
491
+ .executeTakeFirst();
492
+ return Number(result?.numDeletedRows ?? 0);
493
+ };
494
+ const pruneOperationEventsByCount = async () => {
495
+ if (operationEventsMaxRows <= 0) {
496
+ return 0;
497
+ }
498
+ const countRow = await db
499
+ .selectFrom('sync_operation_events')
500
+ .select(({ fn }) => fn.countAll().as('total'))
501
+ .executeTakeFirst();
502
+ const total = coerceNumber(countRow?.total) ?? 0;
503
+ if (total <= operationEventsMaxRows) {
504
+ return 0;
505
+ }
506
+ const cutoffRow = await db
507
+ .selectFrom('sync_operation_events')
508
+ .select(['operation_id'])
509
+ .orderBy('operation_id', 'desc')
510
+ .offset(operationEventsMaxRows)
511
+ .limit(1)
512
+ .executeTakeFirst();
513
+ const cutoffOperationId = coerceNumber(cutoffRow?.operation_id);
514
+ if (cutoffOperationId === null) {
515
+ return 0;
516
+ }
517
+ const result = await db
518
+ .deleteFrom('sync_operation_events')
519
+ .where('operation_id', '<=', cutoffOperationId)
520
+ .executeTakeFirst();
521
+ return Number(result?.numDeletedRows ?? 0);
522
+ };
523
+ const pruneConsoleEvents = async () => {
524
+ const requestEventsDeletedByAge = await pruneRequestEventsByAge();
525
+ const requestEventsDeletedByCount = await pruneRequestEventsByCount();
526
+ const requestEventsDeleted = requestEventsDeletedByAge + requestEventsDeletedByCount;
527
+ const operationEventsDeletedByAge = await pruneOperationEventsByAge();
528
+ const operationEventsDeletedByCount = await pruneOperationEventsByCount();
529
+ const operationEventsDeleted = operationEventsDeletedByAge + operationEventsDeletedByCount;
530
+ const payloadSnapshotsDeleted = await deleteUnreferencedPayloadSnapshots();
531
+ const totalDeleted = requestEventsDeleted + operationEventsDeleted;
532
+ return {
533
+ requestEventsDeleted,
534
+ operationEventsDeleted,
535
+ payloadSnapshotsDeleted,
536
+ totalDeleted,
537
+ };
538
+ };
539
+ let eventsPrunePromise = null;
540
+ const runEventsPrune = async () => {
541
+ if (eventsPrunePromise) {
542
+ return eventsPrunePromise;
543
+ }
544
+ let pending;
545
+ pending = pruneConsoleEvents()
546
+ .then((result) => {
547
+ lastEventsPruneRunAt = Date.now();
548
+ return result;
549
+ })
550
+ .finally(() => {
551
+ if (eventsPrunePromise === pending) {
552
+ eventsPrunePromise = null;
553
+ }
554
+ });
555
+ eventsPrunePromise = pending;
556
+ return pending;
557
+ };
558
+ const triggerAutomaticEventsPrune = () => {
559
+ if (autoEventsPruneIntervalMs <= 0) {
560
+ return;
561
+ }
562
+ if (eventsPrunePromise) {
563
+ return;
564
+ }
565
+ if (Date.now() - lastEventsPruneRunAt < autoEventsPruneIntervalMs) {
566
+ return;
567
+ }
568
+ void runEventsPrune()
569
+ .then((result) => {
570
+ if (result.totalDeleted <= 0 && result.payloadSnapshotsDeleted <= 0) {
571
+ return;
572
+ }
573
+ logSyncEvent({
574
+ event: 'console.prune_events_auto',
575
+ deletedCount: result.totalDeleted,
576
+ requestEventsDeleted: result.requestEventsDeleted,
577
+ operationEventsDeleted: result.operationEventsDeleted,
578
+ payloadDeletedCount: result.payloadSnapshotsDeleted,
579
+ });
580
+ })
581
+ .catch((error) => {
582
+ logSyncEvent({
583
+ event: 'console.prune_events_auto_failed',
584
+ error: error instanceof Error ? error.message : String(error),
585
+ });
586
+ });
587
+ };
380
588
  const recordOperationEvent = async (event) => {
381
589
  await db
382
590
  .insertInto('sync_operation_events')
@@ -523,7 +731,7 @@ export function createConsoleRoutes(options) {
523
731
  const partitionFilter = partitionId
524
732
  ? sql `and partition_id = ${partitionId}`
525
733
  : sql ``;
526
- if (options.dialect.name === 'sqlite') {
734
+ if (options.dialect.family === 'sqlite') {
527
735
  const bucketFormat = intervalToSqliteBucketFormat(interval);
528
736
  const rowsResult = await sql `
529
737
  select
@@ -638,7 +846,7 @@ export function createConsoleRoutes(options) {
638
846
  const startTime = new Date(Date.now() - rangeMs);
639
847
  const startIso = startTime.toISOString();
640
848
  const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
641
- if (!useRawMetrics && options.dialect.name !== 'sqlite') {
849
+ if (!useRawMetrics && options.dialect.family !== 'sqlite') {
642
850
  const partitionFilter = partitionId
643
851
  ? sql `and partition_id = ${partitionId}`
644
852
  : sql ``;
@@ -1631,9 +1839,31 @@ export function createConsoleRoutes(options) {
1631
1839
  const upgradeWebSocket = options.websocket.upgradeWebSocket;
1632
1840
  const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
1633
1841
  const wsState = new WeakMap();
1842
+ const closeUnauthenticated = (ws) => {
1843
+ try {
1844
+ ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
1845
+ }
1846
+ catch {
1847
+ // ignore send errors
1848
+ }
1849
+ ws.close(4001, 'Unauthenticated');
1850
+ };
1851
+ const cleanup = (ws) => {
1852
+ const state = wsState.get(ws);
1853
+ if (!state)
1854
+ return;
1855
+ if (state.listener) {
1856
+ emitter.removeListener(state.listener);
1857
+ }
1858
+ if (state.heartbeatInterval) {
1859
+ clearInterval(state.heartbeatInterval);
1860
+ }
1861
+ if (state.authTimeout) {
1862
+ clearTimeout(state.authTimeout);
1863
+ }
1864
+ wsState.delete(ws);
1865
+ };
1634
1866
  routes.get('/events/live', upgradeWebSocket(async (c) => {
1635
- // Auth check via query param (WebSocket doesn't support headers easily)
1636
- const token = c.req.query('token');
1637
1867
  const authHeader = c.req.header('Authorization');
1638
1868
  const partitionId = c.req.query('partitionId')?.trim() || undefined;
1639
1869
  const replaySince = c.req.query('since');
@@ -1647,34 +1877,159 @@ export function createConsoleRoutes(options) {
1647
1877
  const mockContext = {
1648
1878
  req: {
1649
1879
  header: (name) => name === 'Authorization' ? authHeader : undefined,
1650
- query: (name) => (name === 'token' ? token : undefined),
1880
+ query: () => undefined,
1651
1881
  },
1652
1882
  };
1653
- const auth = await options.authenticate(mockContext);
1883
+ const initialAuth = await options.authenticate(mockContext);
1884
+ const authenticateWithBearer = async (token) => {
1885
+ const trimmedToken = token.trim();
1886
+ if (!trimmedToken)
1887
+ return null;
1888
+ const authContext = {
1889
+ req: {
1890
+ header: (name) => name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
1891
+ query: () => undefined,
1892
+ },
1893
+ };
1894
+ return options.authenticate(authContext);
1895
+ };
1654
1896
  return {
1655
1897
  onOpen(_event, ws) {
1898
+ const state = {
1899
+ listener: null,
1900
+ heartbeatInterval: null,
1901
+ authTimeout: null,
1902
+ isAuthenticated: false,
1903
+ };
1904
+ wsState.set(ws, state);
1905
+ const startAuthenticatedSession = () => {
1906
+ if (state.isAuthenticated)
1907
+ return;
1908
+ state.isAuthenticated = true;
1909
+ if (state.authTimeout) {
1910
+ clearTimeout(state.authTimeout);
1911
+ state.authTimeout = null;
1912
+ }
1913
+ const listener = (event) => {
1914
+ if (partitionId) {
1915
+ const eventPartitionId = event.data.partitionId;
1916
+ if (typeof eventPartitionId !== 'string' ||
1917
+ eventPartitionId !== partitionId) {
1918
+ return;
1919
+ }
1920
+ }
1921
+ try {
1922
+ ws.send(JSON.stringify(event));
1923
+ }
1924
+ catch {
1925
+ // Connection closed
1926
+ }
1927
+ };
1928
+ emitter.addListener(listener);
1929
+ state.listener = listener;
1930
+ ws.send(JSON.stringify({
1931
+ type: 'connected',
1932
+ timestamp: new Date().toISOString(),
1933
+ }));
1934
+ const replayEvents = emitter.replay({
1935
+ since: replaySince,
1936
+ limit: replayLimit,
1937
+ partitionId,
1938
+ });
1939
+ for (const replayEvent of replayEvents) {
1940
+ try {
1941
+ ws.send(JSON.stringify(replayEvent));
1942
+ }
1943
+ catch {
1944
+ // Connection closed
1945
+ break;
1946
+ }
1947
+ }
1948
+ const heartbeatInterval = setInterval(() => {
1949
+ try {
1950
+ ws.send(JSON.stringify({
1951
+ type: 'heartbeat',
1952
+ timestamp: new Date().toISOString(),
1953
+ }));
1954
+ }
1955
+ catch {
1956
+ clearInterval(heartbeatInterval);
1957
+ }
1958
+ }, heartbeatIntervalMs);
1959
+ state.heartbeatInterval = heartbeatInterval;
1960
+ };
1961
+ if (initialAuth) {
1962
+ startAuthenticatedSession();
1963
+ return;
1964
+ }
1965
+ state.authTimeout = setTimeout(() => {
1966
+ const current = wsState.get(ws);
1967
+ if (!current || current.isAuthenticated) {
1968
+ return;
1969
+ }
1970
+ closeUnauthenticated(ws);
1971
+ cleanup(ws);
1972
+ }, 5_000);
1973
+ },
1974
+ async onMessage(event, ws) {
1975
+ const state = wsState.get(ws);
1976
+ if (!state || state.isAuthenticated) {
1977
+ return;
1978
+ }
1979
+ if (typeof event.data !== 'string') {
1980
+ closeUnauthenticated(ws);
1981
+ cleanup(ws);
1982
+ return;
1983
+ }
1984
+ let token = '';
1985
+ try {
1986
+ const parsed = JSON.parse(event.data);
1987
+ if (parsed.type === 'auth' &&
1988
+ typeof parsed.token === 'string' &&
1989
+ parsed.token.trim().length > 0) {
1990
+ token = parsed.token;
1991
+ }
1992
+ }
1993
+ catch {
1994
+ // Ignore parse errors and close as unauthenticated below.
1995
+ }
1996
+ if (!token) {
1997
+ closeUnauthenticated(ws);
1998
+ cleanup(ws);
1999
+ return;
2000
+ }
2001
+ const auth = await authenticateWithBearer(token);
2002
+ const currentState = wsState.get(ws);
2003
+ if (!currentState || currentState.isAuthenticated) {
2004
+ return;
2005
+ }
1656
2006
  if (!auth) {
1657
- ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
1658
- ws.close(4001, 'Unauthenticated');
2007
+ closeUnauthenticated(ws);
2008
+ cleanup(ws);
1659
2009
  return;
1660
2010
  }
1661
- const listener = (event) => {
2011
+ currentState.isAuthenticated = true;
2012
+ if (currentState.authTimeout) {
2013
+ clearTimeout(currentState.authTimeout);
2014
+ currentState.authTimeout = null;
2015
+ }
2016
+ const listener = (liveEvent) => {
1662
2017
  if (partitionId) {
1663
- const eventPartitionId = event.data.partitionId;
2018
+ const eventPartitionId = liveEvent.data.partitionId;
1664
2019
  if (typeof eventPartitionId !== 'string' ||
1665
2020
  eventPartitionId !== partitionId) {
1666
2021
  return;
1667
2022
  }
1668
2023
  }
1669
2024
  try {
1670
- ws.send(JSON.stringify(event));
2025
+ ws.send(JSON.stringify(liveEvent));
1671
2026
  }
1672
2027
  catch {
1673
2028
  // Connection closed
1674
2029
  }
1675
2030
  };
1676
2031
  emitter.addListener(listener);
1677
- // Send connected message
2032
+ currentState.listener = listener;
1678
2033
  ws.send(JSON.stringify({
1679
2034
  type: 'connected',
1680
2035
  timestamp: new Date().toISOString(),
@@ -1693,7 +2048,6 @@ export function createConsoleRoutes(options) {
1693
2048
  break;
1694
2049
  }
1695
2050
  }
1696
- // Start heartbeat
1697
2051
  const heartbeatInterval = setInterval(() => {
1698
2052
  try {
1699
2053
  ws.send(JSON.stringify({
@@ -1705,23 +2059,13 @@ export function createConsoleRoutes(options) {
1705
2059
  clearInterval(heartbeatInterval);
1706
2060
  }
1707
2061
  }, heartbeatIntervalMs);
1708
- wsState.set(ws, { listener, heartbeatInterval });
2062
+ currentState.heartbeatInterval = heartbeatInterval;
1709
2063
  },
1710
2064
  onClose(_event, ws) {
1711
- const state = wsState.get(ws);
1712
- if (!state)
1713
- return;
1714
- emitter.removeListener(state.listener);
1715
- clearInterval(state.heartbeatInterval);
1716
- wsState.delete(ws);
2065
+ cleanup(ws);
1717
2066
  },
1718
2067
  onError(_event, ws) {
1719
- const state = wsState.get(ws);
1720
- if (!state)
1721
- return;
1722
- emitter.removeListener(state.listener);
1723
- clearInterval(state.heartbeatInterval);
1724
- wsState.delete(ws);
2068
+ cleanup(ws);
1725
2069
  },
1726
2070
  };
1727
2071
  }));
@@ -1886,10 +2230,12 @@ export function createConsoleRoutes(options) {
1886
2230
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
1887
2231
  const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
1888
2232
  const deletedCount = Number(res?.numDeletedRows ?? 0);
2233
+ const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
1889
2234
  logSyncEvent({
1890
2235
  event: 'console.clear_events',
1891
2236
  consoleUserId: auth.consoleUserId,
1892
2237
  deletedCount,
2238
+ payloadDeletedCount,
1893
2239
  });
1894
2240
  const result = { deletedCount };
1895
2241
  return c.json(result, 200);
@@ -1920,45 +2266,15 @@ export function createConsoleRoutes(options) {
1920
2266
  const auth = await requireAuth(c);
1921
2267
  if (!auth)
1922
2268
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
1923
- // Prune events older than 7 days or keep max 10000 events
1924
- const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
1925
- // Delete by date first
1926
- const resByDate = await db
1927
- .deleteFrom('sync_request_events')
1928
- .where('created_at', '<', cutoffDate.toISOString())
1929
- .executeTakeFirst();
1930
- let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
1931
- // Then delete oldest if we still have more than 10000 events
1932
- const countRow = await db
1933
- .selectFrom('sync_request_events')
1934
- .select(({ fn }) => fn.countAll().as('total'))
1935
- .executeTakeFirst();
1936
- const total = coerceNumber(countRow?.total) ?? 0;
1937
- const maxEvents = 10000;
1938
- if (total > maxEvents) {
1939
- // Find event_id cutoff to keep only newest maxEvents
1940
- const cutoffRow = await db
1941
- .selectFrom('sync_request_events')
1942
- .select(['event_id'])
1943
- .orderBy('event_id', 'desc')
1944
- .offset(maxEvents)
1945
- .limit(1)
1946
- .executeTakeFirst();
1947
- if (cutoffRow) {
1948
- const cutoffEventId = coerceNumber(cutoffRow.event_id);
1949
- if (cutoffEventId !== null) {
1950
- const resByCount = await db
1951
- .deleteFrom('sync_request_events')
1952
- .where('event_id', '<=', cutoffEventId)
1953
- .executeTakeFirst();
1954
- deletedCount += Number(resByCount?.numDeletedRows ?? 0);
1955
- }
1956
- }
1957
- }
2269
+ const pruneResult = await runEventsPrune();
2270
+ const deletedCount = pruneResult.totalDeleted;
1958
2271
  logSyncEvent({
1959
2272
  event: 'console.prune_events',
1960
2273
  consoleUserId: auth.consoleUserId,
1961
2274
  deletedCount,
2275
+ requestEventsDeleted: pruneResult.requestEventsDeleted,
2276
+ operationEventsDeleted: pruneResult.operationEventsDeleted,
2277
+ payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
1962
2278
  });
1963
2279
  const result = { deletedCount };
1964
2280
  return c.json(result, 200);
@@ -2549,6 +2865,125 @@ export function createConsoleRoutes(options) {
2549
2865
  };
2550
2866
  return c.json(response, 200);
2551
2867
  });
2868
+ // -----------------------------------------------------------------------
2869
+ // Storage endpoints
2870
+ // -----------------------------------------------------------------------
2871
+ const bucket = options.blobBucket;
2872
+ routes.get('/storage', describeRoute({
2873
+ tags: ['console'],
2874
+ summary: 'List storage items',
2875
+ responses: {
2876
+ 200: {
2877
+ description: 'Paginated list of storage items',
2878
+ content: {
2879
+ 'application/json': {
2880
+ schema: resolver(ConsoleBlobListResponseSchema),
2881
+ },
2882
+ },
2883
+ },
2884
+ 401: {
2885
+ description: 'Unauthenticated',
2886
+ content: {
2887
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
2888
+ },
2889
+ },
2890
+ },
2891
+ }), zValidator('query', ConsoleBlobListQuerySchema), async (c) => {
2892
+ const auth = await requireAuth(c);
2893
+ if (!auth)
2894
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
2895
+ if (!bucket) {
2896
+ return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
2897
+ }
2898
+ const { prefix, cursor, limit } = c.req.valid('query');
2899
+ const listed = await bucket.list({
2900
+ prefix: prefix || undefined,
2901
+ cursor: cursor || undefined,
2902
+ limit,
2903
+ });
2904
+ return c.json({
2905
+ items: listed.objects.map((obj) => ({
2906
+ key: obj.key,
2907
+ size: obj.size,
2908
+ uploaded: obj.uploaded.toISOString(),
2909
+ httpMetadata: obj.httpMetadata?.contentType
2910
+ ? { contentType: obj.httpMetadata.contentType }
2911
+ : undefined,
2912
+ })),
2913
+ truncated: listed.truncated,
2914
+ cursor: listed.cursor ?? null,
2915
+ }, 200);
2916
+ });
2917
+ routes.get('/storage/:key{.+}/download', describeRoute({
2918
+ tags: ['console'],
2919
+ summary: 'Download a storage item',
2920
+ responses: {
2921
+ 200: { description: 'Storage item contents' },
2922
+ 401: {
2923
+ description: 'Unauthenticated',
2924
+ content: {
2925
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
2926
+ },
2927
+ },
2928
+ 404: {
2929
+ description: 'Blob not found',
2930
+ content: {
2931
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
2932
+ },
2933
+ },
2934
+ },
2935
+ }), async (c) => {
2936
+ const auth = await requireAuth(c);
2937
+ if (!auth)
2938
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
2939
+ if (!bucket) {
2940
+ return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
2941
+ }
2942
+ const key = decodeURIComponent(c.req.param('key'));
2943
+ const object = await bucket.get(key);
2944
+ if (!object) {
2945
+ return c.json({ error: 'BLOB_NOT_FOUND' }, 404);
2946
+ }
2947
+ const headers = new Headers();
2948
+ headers.set('Content-Length', String(object.size));
2949
+ headers.set('Content-Type', object.httpMetadata?.contentType ?? 'application/octet-stream');
2950
+ const filename = key.split('/').pop() || key;
2951
+ headers.set('Content-Disposition', `attachment; filename="${filename.replace(/"/g, '\\"')}"`);
2952
+ return new Response(object.body, {
2953
+ status: 200,
2954
+ headers,
2955
+ });
2956
+ });
2957
+ routes.delete('/storage/:key{.+}', describeRoute({
2958
+ tags: ['console'],
2959
+ summary: 'Delete a storage item',
2960
+ responses: {
2961
+ 200: {
2962
+ description: 'Storage item deleted',
2963
+ content: {
2964
+ 'application/json': {
2965
+ schema: resolver(ConsoleBlobDeleteResponseSchema),
2966
+ },
2967
+ },
2968
+ },
2969
+ 401: {
2970
+ description: 'Unauthenticated',
2971
+ content: {
2972
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
2973
+ },
2974
+ },
2975
+ },
2976
+ }), async (c) => {
2977
+ const auth = await requireAuth(c);
2978
+ if (!auth)
2979
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
2980
+ if (!bucket) {
2981
+ return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
2982
+ }
2983
+ const key = decodeURIComponent(c.req.param('key'));
2984
+ await bucket.delete(key);
2985
+ return c.json({ deleted: true }, 200);
2986
+ });
2552
2987
  return routes;
2553
2988
  }
2554
2989
  // ===========================================================================
@@ -2577,25 +3012,17 @@ async function hashApiKey(secretKey) {
2577
3012
  * The token can be set via SYNC_CONSOLE_TOKEN env var or passed directly.
2578
3013
  */
2579
3014
  export function createTokenAuthenticator(token) {
2580
- const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
3015
+ const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
2581
3016
  return async (c) => {
2582
- if (!expectedToken) {
2583
- // No token configured, allow all requests (not recommended for production)
2584
- return { consoleUserId: 'anonymous' };
2585
- }
2586
- // Check Authorization header
2587
- const authHeader = c.req.header('Authorization');
3017
+ if (!expectedToken)
3018
+ return null;
3019
+ const authHeader = c.req.header('Authorization')?.trim();
2588
3020
  if (authHeader?.startsWith('Bearer ')) {
2589
- const bearerToken = authHeader.slice(7);
3021
+ const bearerToken = authHeader.slice(7).trim();
2590
3022
  if (bearerToken === expectedToken) {
2591
3023
  return { consoleUserId: 'token' };
2592
3024
  }
2593
3025
  }
2594
- // Check query parameter
2595
- const queryToken = c.req.query('token');
2596
- if (queryToken === expectedToken) {
2597
- return { consoleUserId: 'token' };
2598
- }
2599
3026
  return null;
2600
3027
  };
2601
3028
  }