@syncular/server-hono 0.0.4-25 → 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 (57) hide show
  1. package/README.md +6 -1
  2. package/dist/console/gateway.d.ts +3 -1
  3. package/dist/console/gateway.d.ts.map +1 -1
  4. package/dist/console/gateway.js +227 -42
  5. package/dist/console/gateway.js.map +1 -1
  6. package/dist/console/index.d.ts +2 -0
  7. package/dist/console/index.d.ts.map +1 -1
  8. package/dist/console/index.js +2 -0
  9. package/dist/console/index.js.map +1 -1
  10. package/dist/console/routes.d.ts +3 -97
  11. package/dist/console/routes.d.ts.map +1 -1
  12. package/dist/console/routes.js +516 -81
  13. package/dist/console/routes.js.map +1 -1
  14. package/dist/console/schemas.d.ts +29 -0
  15. package/dist/console/schemas.d.ts.map +1 -1
  16. package/dist/console/schemas.js +22 -0
  17. package/dist/console/schemas.js.map +1 -1
  18. package/dist/console/types.d.ts +175 -0
  19. package/dist/console/types.d.ts.map +1 -0
  20. package/dist/console/types.js +2 -0
  21. package/dist/console/types.js.map +1 -0
  22. package/dist/console/ui.d.ts +38 -0
  23. package/dist/console/ui.d.ts.map +1 -0
  24. package/dist/console/ui.js +43 -0
  25. package/dist/console/ui.js.map +1 -0
  26. package/dist/create-server.d.ts +17 -34
  27. package/dist/create-server.d.ts.map +1 -1
  28. package/dist/create-server.js +26 -26
  29. package/dist/create-server.js.map +1 -1
  30. package/dist/proxy/connection-manager.d.ts +3 -3
  31. package/dist/proxy/connection-manager.d.ts.map +1 -1
  32. package/dist/proxy/routes.d.ts +4 -4
  33. package/dist/proxy/routes.d.ts.map +1 -1
  34. package/dist/proxy/routes.js +1 -1
  35. package/dist/routes.d.ts +33 -9
  36. package/dist/routes.d.ts.map +1 -1
  37. package/dist/routes.js +153 -70
  38. package/dist/routes.js.map +1 -1
  39. package/package.json +21 -6
  40. package/src/__tests__/blob-routes.test.ts +424 -0
  41. package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
  42. package/src/__tests__/console-routes.test.ts +161 -7
  43. package/src/__tests__/console-ui.test.ts +114 -0
  44. package/src/__tests__/create-server.test.ts +233 -10
  45. package/src/__tests__/pull-chunk-storage.test.ts +6 -2
  46. package/src/__tests__/realtime-bridge.test.ts +6 -2
  47. package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
  48. package/src/console/gateway.ts +286 -54
  49. package/src/console/index.ts +2 -0
  50. package/src/console/routes.ts +663 -199
  51. package/src/console/schemas.ts +29 -0
  52. package/src/console/types.ts +185 -0
  53. package/src/console/ui.ts +100 -0
  54. package/src/create-server.ts +56 -53
  55. package/src/proxy/connection-manager.ts +3 -3
  56. package/src/proxy/routes.ts +4 -4
  57. 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,28 +262,89 @@ 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
- allowHeaders: ['Content-Type', 'Authorization'],
314
+ allowHeaders: [
315
+ 'Content-Type',
316
+ 'Authorization',
317
+ 'X-Syncular-Transport-Path',
318
+ 'Baggage',
319
+ 'Sentry-Trace',
320
+ 'Traceparent',
321
+ 'Tracestate',
322
+ ],
284
323
  exposeHeaders: ['X-Total-Count'],
285
- credentials: true,
324
+ credentials: !allowWildcardCors,
286
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
+ });
287
348
  // Auth middleware
288
349
  const requireAuth = async (c) => {
289
350
  const auth = await options.authenticate(c);
@@ -369,6 +430,161 @@ export function createConsoleRoutes(options) {
369
430
  resultPayload: parseJsonValue(row.result_payload),
370
431
  createdAt: row.created_at ?? '',
371
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
+ };
372
588
  const recordOperationEvent = async (event) => {
373
589
  await db
374
590
  .insertInto('sync_operation_events')
@@ -515,7 +731,7 @@ export function createConsoleRoutes(options) {
515
731
  const partitionFilter = partitionId
516
732
  ? sql `and partition_id = ${partitionId}`
517
733
  : sql ``;
518
- if (options.dialect.name === 'sqlite') {
734
+ if (options.dialect.family === 'sqlite') {
519
735
  const bucketFormat = intervalToSqliteBucketFormat(interval);
520
736
  const rowsResult = await sql `
521
737
  select
@@ -630,7 +846,7 @@ export function createConsoleRoutes(options) {
630
846
  const startTime = new Date(Date.now() - rangeMs);
631
847
  const startIso = startTime.toISOString();
632
848
  const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
633
- if (!useRawMetrics && options.dialect.name !== 'sqlite') {
849
+ if (!useRawMetrics && options.dialect.family !== 'sqlite') {
634
850
  const partitionFilter = partitionId
635
851
  ? sql `and partition_id = ${partitionId}`
636
852
  : sql ``;
@@ -1623,9 +1839,31 @@ export function createConsoleRoutes(options) {
1623
1839
  const upgradeWebSocket = options.websocket.upgradeWebSocket;
1624
1840
  const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
1625
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
+ };
1626
1866
  routes.get('/events/live', upgradeWebSocket(async (c) => {
1627
- // Auth check via query param (WebSocket doesn't support headers easily)
1628
- const token = c.req.query('token');
1629
1867
  const authHeader = c.req.header('Authorization');
1630
1868
  const partitionId = c.req.query('partitionId')?.trim() || undefined;
1631
1869
  const replaySince = c.req.query('since');
@@ -1639,34 +1877,159 @@ export function createConsoleRoutes(options) {
1639
1877
  const mockContext = {
1640
1878
  req: {
1641
1879
  header: (name) => name === 'Authorization' ? authHeader : undefined,
1642
- query: (name) => (name === 'token' ? token : undefined),
1880
+ query: () => undefined,
1643
1881
  },
1644
1882
  };
1645
- 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
+ };
1646
1896
  return {
1647
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
+ }
1648
2006
  if (!auth) {
1649
- ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
1650
- ws.close(4001, 'Unauthenticated');
2007
+ closeUnauthenticated(ws);
2008
+ cleanup(ws);
1651
2009
  return;
1652
2010
  }
1653
- const listener = (event) => {
2011
+ currentState.isAuthenticated = true;
2012
+ if (currentState.authTimeout) {
2013
+ clearTimeout(currentState.authTimeout);
2014
+ currentState.authTimeout = null;
2015
+ }
2016
+ const listener = (liveEvent) => {
1654
2017
  if (partitionId) {
1655
- const eventPartitionId = event.data.partitionId;
2018
+ const eventPartitionId = liveEvent.data.partitionId;
1656
2019
  if (typeof eventPartitionId !== 'string' ||
1657
2020
  eventPartitionId !== partitionId) {
1658
2021
  return;
1659
2022
  }
1660
2023
  }
1661
2024
  try {
1662
- ws.send(JSON.stringify(event));
2025
+ ws.send(JSON.stringify(liveEvent));
1663
2026
  }
1664
2027
  catch {
1665
2028
  // Connection closed
1666
2029
  }
1667
2030
  };
1668
2031
  emitter.addListener(listener);
1669
- // Send connected message
2032
+ currentState.listener = listener;
1670
2033
  ws.send(JSON.stringify({
1671
2034
  type: 'connected',
1672
2035
  timestamp: new Date().toISOString(),
@@ -1685,7 +2048,6 @@ export function createConsoleRoutes(options) {
1685
2048
  break;
1686
2049
  }
1687
2050
  }
1688
- // Start heartbeat
1689
2051
  const heartbeatInterval = setInterval(() => {
1690
2052
  try {
1691
2053
  ws.send(JSON.stringify({
@@ -1697,23 +2059,13 @@ export function createConsoleRoutes(options) {
1697
2059
  clearInterval(heartbeatInterval);
1698
2060
  }
1699
2061
  }, heartbeatIntervalMs);
1700
- wsState.set(ws, { listener, heartbeatInterval });
2062
+ currentState.heartbeatInterval = heartbeatInterval;
1701
2063
  },
1702
2064
  onClose(_event, ws) {
1703
- const state = wsState.get(ws);
1704
- if (!state)
1705
- return;
1706
- emitter.removeListener(state.listener);
1707
- clearInterval(state.heartbeatInterval);
1708
- wsState.delete(ws);
2065
+ cleanup(ws);
1709
2066
  },
1710
2067
  onError(_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);
2068
+ cleanup(ws);
1717
2069
  },
1718
2070
  };
1719
2071
  }));
@@ -1878,10 +2230,12 @@ export function createConsoleRoutes(options) {
1878
2230
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
1879
2231
  const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
1880
2232
  const deletedCount = Number(res?.numDeletedRows ?? 0);
2233
+ const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
1881
2234
  logSyncEvent({
1882
2235
  event: 'console.clear_events',
1883
2236
  consoleUserId: auth.consoleUserId,
1884
2237
  deletedCount,
2238
+ payloadDeletedCount,
1885
2239
  });
1886
2240
  const result = { deletedCount };
1887
2241
  return c.json(result, 200);
@@ -1912,45 +2266,15 @@ export function createConsoleRoutes(options) {
1912
2266
  const auth = await requireAuth(c);
1913
2267
  if (!auth)
1914
2268
  return c.json({ error: 'UNAUTHENTICATED' }, 401);
1915
- // Prune events older than 7 days or keep max 10000 events
1916
- const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
1917
- // Delete by date first
1918
- const resByDate = await db
1919
- .deleteFrom('sync_request_events')
1920
- .where('created_at', '<', cutoffDate.toISOString())
1921
- .executeTakeFirst();
1922
- let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
1923
- // Then delete oldest if we still have more than 10000 events
1924
- const countRow = await db
1925
- .selectFrom('sync_request_events')
1926
- .select(({ fn }) => fn.countAll().as('total'))
1927
- .executeTakeFirst();
1928
- const total = coerceNumber(countRow?.total) ?? 0;
1929
- const maxEvents = 10000;
1930
- if (total > maxEvents) {
1931
- // Find event_id cutoff to keep only newest maxEvents
1932
- const cutoffRow = await db
1933
- .selectFrom('sync_request_events')
1934
- .select(['event_id'])
1935
- .orderBy('event_id', 'desc')
1936
- .offset(maxEvents)
1937
- .limit(1)
1938
- .executeTakeFirst();
1939
- if (cutoffRow) {
1940
- const cutoffEventId = coerceNumber(cutoffRow.event_id);
1941
- if (cutoffEventId !== null) {
1942
- const resByCount = await db
1943
- .deleteFrom('sync_request_events')
1944
- .where('event_id', '<=', cutoffEventId)
1945
- .executeTakeFirst();
1946
- deletedCount += Number(resByCount?.numDeletedRows ?? 0);
1947
- }
1948
- }
1949
- }
2269
+ const pruneResult = await runEventsPrune();
2270
+ const deletedCount = pruneResult.totalDeleted;
1950
2271
  logSyncEvent({
1951
2272
  event: 'console.prune_events',
1952
2273
  consoleUserId: auth.consoleUserId,
1953
2274
  deletedCount,
2275
+ requestEventsDeleted: pruneResult.requestEventsDeleted,
2276
+ operationEventsDeleted: pruneResult.operationEventsDeleted,
2277
+ payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
1954
2278
  });
1955
2279
  const result = { deletedCount };
1956
2280
  return c.json(result, 200);
@@ -2541,6 +2865,125 @@ export function createConsoleRoutes(options) {
2541
2865
  };
2542
2866
  return c.json(response, 200);
2543
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
+ });
2544
2987
  return routes;
2545
2988
  }
2546
2989
  // ===========================================================================
@@ -2569,25 +3012,17 @@ async function hashApiKey(secretKey) {
2569
3012
  * The token can be set via SYNC_CONSOLE_TOKEN env var or passed directly.
2570
3013
  */
2571
3014
  export function createTokenAuthenticator(token) {
2572
- const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
3015
+ const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
2573
3016
  return async (c) => {
2574
- if (!expectedToken) {
2575
- // No token configured, allow all requests (not recommended for production)
2576
- return { consoleUserId: 'anonymous' };
2577
- }
2578
- // Check Authorization header
2579
- const authHeader = c.req.header('Authorization');
3017
+ if (!expectedToken)
3018
+ return null;
3019
+ const authHeader = c.req.header('Authorization')?.trim();
2580
3020
  if (authHeader?.startsWith('Bearer ')) {
2581
- const bearerToken = authHeader.slice(7);
3021
+ const bearerToken = authHeader.slice(7).trim();
2582
3022
  if (bearerToken === expectedToken) {
2583
3023
  return { consoleUserId: 'token' };
2584
3024
  }
2585
3025
  }
2586
- // Check query parameter
2587
- const queryToken = c.req.query('token');
2588
- if (queryToken === expectedToken) {
2589
- return { consoleUserId: 'token' };
2590
- }
2591
3026
  return null;
2592
3027
  };
2593
3028
  }