@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.
- package/README.md +6 -1
- package/dist/console/gateway.d.ts +3 -1
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +227 -42
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/index.d.ts +2 -0
- package/dist/console/index.d.ts.map +1 -1
- package/dist/console/index.js +2 -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 +516 -81
- 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/console/ui.d.ts +38 -0
- package/dist/console/ui.d.ts.map +1 -0
- package/dist/console/ui.js +43 -0
- package/dist/console/ui.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 -6
- 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 +286 -54
- package/src/console/index.ts +2 -0
- package/src/console/routes.ts +663 -199
- package/src/console/schemas.ts +29 -0
- package/src/console/types.ts +185 -0
- package/src/console/ui.ts +100 -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,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
|
-
|
|
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
|
-
allowHeaders: [
|
|
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:
|
|
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.
|
|
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.
|
|
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: (
|
|
1880
|
+
query: () => undefined,
|
|
1643
1881
|
},
|
|
1644
1882
|
};
|
|
1645
|
-
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
|
+
};
|
|
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
|
|
1650
|
-
ws
|
|
2007
|
+
closeUnauthenticated(ws);
|
|
2008
|
+
cleanup(ws);
|
|
1651
2009
|
return;
|
|
1652
2010
|
}
|
|
1653
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2062
|
+
currentState.heartbeatInterval = heartbeatInterval;
|
|
1701
2063
|
},
|
|
1702
2064
|
onClose(_event, ws) {
|
|
1703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1916
|
-
const
|
|
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
|
-
|
|
2576
|
-
|
|
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
|
}
|