@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.
- package/dist/console/gateway.d.ts +3 -1
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +218 -41
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/index.d.ts +1 -0
- package/dist/console/index.d.ts.map +1 -1
- package/dist/console/index.js +1 -0
- package/dist/console/index.js.map +1 -1
- package/dist/console/routes.d.ts +3 -97
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +507 -80
- package/dist/console/routes.js.map +1 -1
- package/dist/console/schemas.d.ts +29 -0
- package/dist/console/schemas.d.ts.map +1 -1
- package/dist/console/schemas.js +22 -0
- package/dist/console/schemas.js.map +1 -1
- package/dist/console/types.d.ts +175 -0
- package/dist/console/types.d.ts.map +1 -0
- package/dist/console/types.js +2 -0
- package/dist/console/types.js.map +1 -0
- package/dist/create-server.d.ts +17 -34
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +26 -26
- package/dist/create-server.js.map +1 -1
- package/dist/proxy/connection-manager.d.ts +3 -3
- package/dist/proxy/connection-manager.d.ts.map +1 -1
- package/dist/proxy/routes.d.ts +4 -4
- package/dist/proxy/routes.d.ts.map +1 -1
- package/dist/proxy/routes.js +1 -1
- package/dist/routes.d.ts +33 -9
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +153 -70
- package/dist/routes.js.map +1 -1
- package/package.json +21 -7
- package/src/__tests__/blob-routes.test.ts +424 -0
- package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
- package/src/__tests__/console-routes.test.ts +161 -7
- package/src/__tests__/console-ui.test.ts +114 -0
- package/src/__tests__/create-server.test.ts +233 -10
- package/src/__tests__/pull-chunk-storage.test.ts +6 -2
- package/src/__tests__/realtime-bridge.test.ts +6 -2
- package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
- package/src/console/gateway.ts +277 -53
- package/src/console/index.ts +1 -0
- package/src/console/routes.ts +654 -198
- package/src/console/schemas.ts +29 -0
- package/src/console/types.ts +185 -0
- package/src/create-server.ts +56 -53
- package/src/proxy/connection-manager.ts +3 -3
- package/src/proxy/routes.ts +4 -4
- package/src/routes.ts +225 -96
package/dist/console/routes.js
CHANGED
|
@@ -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
|
-
|
|
271
|
-
|
|
272
|
-
options.
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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: (
|
|
1880
|
+
query: () => undefined,
|
|
1651
1881
|
},
|
|
1652
1882
|
};
|
|
1653
|
-
const
|
|
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
|
|
1658
|
-
ws
|
|
2007
|
+
closeUnauthenticated(ws);
|
|
2008
|
+
cleanup(ws);
|
|
1659
2009
|
return;
|
|
1660
2010
|
}
|
|
1661
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2062
|
+
currentState.heartbeatInterval = heartbeatInterval;
|
|
1709
2063
|
},
|
|
1710
2064
|
onClose(_event, ws) {
|
|
1711
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1924
|
-
const
|
|
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
|
-
|
|
2584
|
-
|
|
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
|
}
|