@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/src/console/routes.ts
CHANGED
|
@@ -16,11 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { logSyncEvent } from '@syncular/core';
|
|
19
|
-
import type {
|
|
20
|
-
ServerSyncDialect,
|
|
21
|
-
ServerTableHandler,
|
|
22
|
-
SyncCoreDb,
|
|
23
|
-
} from '@syncular/server';
|
|
19
|
+
import type { SqlFamily, SyncCoreDb, SyncServerAuth } from '@syncular/server';
|
|
24
20
|
import {
|
|
25
21
|
compactChanges,
|
|
26
22
|
computePruneWatermarkCommitSeq,
|
|
@@ -31,11 +27,9 @@ import {
|
|
|
31
27
|
import type { Context } from 'hono';
|
|
32
28
|
import { Hono } from 'hono';
|
|
33
29
|
import { cors } from 'hono/cors';
|
|
34
|
-
import type { UpgradeWebSocket } from 'hono/ws';
|
|
35
30
|
import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
|
|
36
31
|
import { type Generated, type Kysely, type Selectable, sql } from 'kysely';
|
|
37
32
|
import { z } from 'zod';
|
|
38
|
-
import type { WebSocketConnectionManager } from '../ws';
|
|
39
33
|
import {
|
|
40
34
|
type ApiKeyType,
|
|
41
35
|
ApiKeyTypeSchema,
|
|
@@ -48,6 +42,9 @@ import {
|
|
|
48
42
|
ConsoleApiKeyCreateResponseSchema,
|
|
49
43
|
ConsoleApiKeyRevokeResponseSchema,
|
|
50
44
|
ConsoleApiKeySchema,
|
|
45
|
+
ConsoleBlobDeleteResponseSchema,
|
|
46
|
+
ConsoleBlobListQuerySchema,
|
|
47
|
+
ConsoleBlobListResponseSchema,
|
|
51
48
|
type ConsoleChange,
|
|
52
49
|
type ConsoleClearEventsResult,
|
|
53
50
|
ConsoleClearEventsResultSchema,
|
|
@@ -97,36 +94,12 @@ import {
|
|
|
97
94
|
type TimeseriesStatsResponse,
|
|
98
95
|
TimeseriesStatsResponseSchema,
|
|
99
96
|
} from './schemas';
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Listener for console live events (SSE streaming).
|
|
108
|
-
*/
|
|
109
|
-
export type ConsoleEventListener = (event: LiveEvent) => void;
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Console event emitter for broadcasting live events.
|
|
113
|
-
*/
|
|
114
|
-
export interface ConsoleEventEmitter {
|
|
115
|
-
/** Add a listener for live events */
|
|
116
|
-
addListener(listener: ConsoleEventListener): void;
|
|
117
|
-
/** Remove a listener */
|
|
118
|
-
removeListener(listener: ConsoleEventListener): void;
|
|
119
|
-
/** Emit an event to all listeners */
|
|
120
|
-
emit(event: LiveEvent): void;
|
|
121
|
-
/**
|
|
122
|
-
* Replay recent events, optionally constrained by timestamp, partition, and max count.
|
|
123
|
-
*/
|
|
124
|
-
replay(options?: {
|
|
125
|
-
since?: string;
|
|
126
|
-
limit?: number;
|
|
127
|
-
partitionId?: string;
|
|
128
|
-
}): LiveEvent[];
|
|
129
|
-
}
|
|
97
|
+
import type {
|
|
98
|
+
ConsoleAuthResult,
|
|
99
|
+
ConsoleEventEmitter,
|
|
100
|
+
ConsoleEventListener,
|
|
101
|
+
CreateConsoleRoutesOptions,
|
|
102
|
+
} from './types';
|
|
130
103
|
|
|
131
104
|
/**
|
|
132
105
|
* Create a simple console event emitter for broadcasting live events.
|
|
@@ -193,73 +166,6 @@ export function createConsoleEventEmitter(options?: {
|
|
|
193
166
|
};
|
|
194
167
|
}
|
|
195
168
|
|
|
196
|
-
export interface CreateConsoleRoutesOptions<
|
|
197
|
-
DB extends SyncCoreDb = SyncCoreDb,
|
|
198
|
-
> {
|
|
199
|
-
db: Kysely<DB>;
|
|
200
|
-
dialect: ServerSyncDialect;
|
|
201
|
-
handlers: ServerTableHandler<DB>[];
|
|
202
|
-
/**
|
|
203
|
-
* Authentication function for console requests.
|
|
204
|
-
* Return null to reject the request.
|
|
205
|
-
*/
|
|
206
|
-
authenticate: (c: Context) => Promise<ConsoleAuthResult | null>;
|
|
207
|
-
/**
|
|
208
|
-
* CORS origins to allow. Defaults to ['http://localhost:5173', 'https://console.sync.dev'].
|
|
209
|
-
* Set to '*' to allow all origins (not recommended for production).
|
|
210
|
-
*/
|
|
211
|
-
corsOrigins?: string[] | '*';
|
|
212
|
-
/**
|
|
213
|
-
* Compaction options (required for /compact endpoint).
|
|
214
|
-
*/
|
|
215
|
-
compact?: {
|
|
216
|
-
fullHistoryHours?: number;
|
|
217
|
-
};
|
|
218
|
-
/**
|
|
219
|
-
* Pruning options.
|
|
220
|
-
*/
|
|
221
|
-
prune?: {
|
|
222
|
-
activeWindowMs?: number;
|
|
223
|
-
fallbackMaxAgeMs?: number;
|
|
224
|
-
keepNewestCommits?: number;
|
|
225
|
-
};
|
|
226
|
-
/**
|
|
227
|
-
* Event emitter for live console events.
|
|
228
|
-
* If provided along with websocket config, enables the /events/live WebSocket endpoint.
|
|
229
|
-
*/
|
|
230
|
-
eventEmitter?: ConsoleEventEmitter;
|
|
231
|
-
/**
|
|
232
|
-
* Shared sync WebSocket connection manager.
|
|
233
|
-
* When provided, `/clients` includes realtime connection state per client.
|
|
234
|
-
*/
|
|
235
|
-
wsConnectionManager?: WebSocketConnectionManager;
|
|
236
|
-
/**
|
|
237
|
-
* WebSocket configuration for live events streaming.
|
|
238
|
-
*/
|
|
239
|
-
websocket?: {
|
|
240
|
-
enabled?: boolean;
|
|
241
|
-
/**
|
|
242
|
-
* Runtime-provided WebSocket upgrader (e.g. from `hono/bun`'s `createBunWebSocket()`).
|
|
243
|
-
*/
|
|
244
|
-
upgradeWebSocket?: UpgradeWebSocket;
|
|
245
|
-
/**
|
|
246
|
-
* Heartbeat interval in milliseconds. Default: 30000
|
|
247
|
-
*/
|
|
248
|
-
heartbeatIntervalMs?: number;
|
|
249
|
-
};
|
|
250
|
-
/**
|
|
251
|
-
* Metrics query strategy for timeseries/latency endpoints.
|
|
252
|
-
* - raw: in-memory processing from raw event rows
|
|
253
|
-
* - aggregated: DB-level aggregation where supported (raw fallback for unsupported paths)
|
|
254
|
-
* - auto: use raw for small windows, aggregated for larger windows
|
|
255
|
-
*/
|
|
256
|
-
metrics?: {
|
|
257
|
-
aggregationMode?: 'auto' | 'raw' | 'aggregated';
|
|
258
|
-
/** Max events for using raw mode when aggregationMode is 'auto'. */
|
|
259
|
-
rawFallbackMaxEvents?: number;
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
169
|
function coerceNumber(value: unknown): number | null {
|
|
264
170
|
if (value === null || value === undefined) return null;
|
|
265
171
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
@@ -485,11 +391,45 @@ const handlersResponseSchema = z.object({
|
|
|
485
391
|
items: z.array(ConsoleHandlerSchema),
|
|
486
392
|
});
|
|
487
393
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
394
|
+
const DEFAULT_REQUEST_EVENTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
395
|
+
const DEFAULT_REQUEST_EVENTS_MAX_ROWS = 10_000;
|
|
396
|
+
const DEFAULT_OPERATION_EVENTS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
397
|
+
const DEFAULT_OPERATION_EVENTS_MAX_ROWS = 5_000;
|
|
398
|
+
const DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS = 5 * 60 * 1000;
|
|
399
|
+
|
|
400
|
+
function readNonNegativeInteger(
|
|
401
|
+
value: number | undefined,
|
|
402
|
+
fallback: number
|
|
403
|
+
): number {
|
|
404
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
405
|
+
return fallback;
|
|
406
|
+
}
|
|
407
|
+
if (value < 0) {
|
|
408
|
+
return fallback;
|
|
409
|
+
}
|
|
410
|
+
return Math.floor(value);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function createConsoleRoutes<
|
|
414
|
+
DB extends SyncCoreDb,
|
|
415
|
+
Auth extends SyncServerAuth,
|
|
416
|
+
F extends SqlFamily = SqlFamily,
|
|
417
|
+
>(options: CreateConsoleRoutesOptions<DB, Auth, F>): Hono {
|
|
491
418
|
const routes = new Hono();
|
|
492
419
|
|
|
420
|
+
routes.onError((error, context) => {
|
|
421
|
+
const message =
|
|
422
|
+
error instanceof Error ? error.message : 'Unknown console error';
|
|
423
|
+
console.error('[console] route error', error);
|
|
424
|
+
return context.json(
|
|
425
|
+
{
|
|
426
|
+
error: 'CONSOLE_ROUTE_ERROR',
|
|
427
|
+
message,
|
|
428
|
+
},
|
|
429
|
+
500
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
493
433
|
interface SyncRequestEventsTable {
|
|
494
434
|
event_id: number;
|
|
495
435
|
partition_id: string;
|
|
@@ -568,11 +508,36 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
568
508
|
1,
|
|
569
509
|
options.metrics?.rawFallbackMaxEvents ?? 5000
|
|
570
510
|
);
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
511
|
+
const requestEventsMaxAgeMs = readNonNegativeInteger(
|
|
512
|
+
options.maintenance?.requestEventsMaxAgeMs,
|
|
513
|
+
DEFAULT_REQUEST_EVENTS_MAX_AGE_MS
|
|
514
|
+
);
|
|
515
|
+
const requestEventsMaxRows = readNonNegativeInteger(
|
|
516
|
+
options.maintenance?.requestEventsMaxRows,
|
|
517
|
+
DEFAULT_REQUEST_EVENTS_MAX_ROWS
|
|
518
|
+
);
|
|
519
|
+
const operationEventsMaxAgeMs = readNonNegativeInteger(
|
|
520
|
+
options.maintenance?.operationEventsMaxAgeMs,
|
|
521
|
+
DEFAULT_OPERATION_EVENTS_MAX_AGE_MS
|
|
522
|
+
);
|
|
523
|
+
const operationEventsMaxRows = readNonNegativeInteger(
|
|
524
|
+
options.maintenance?.operationEventsMaxRows,
|
|
525
|
+
DEFAULT_OPERATION_EVENTS_MAX_ROWS
|
|
526
|
+
);
|
|
527
|
+
const autoEventsPruneIntervalMs = readNonNegativeInteger(
|
|
528
|
+
options.maintenance?.autoPruneIntervalMs,
|
|
529
|
+
DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS
|
|
530
|
+
);
|
|
531
|
+
let lastEventsPruneRunAt = 0;
|
|
532
|
+
|
|
533
|
+
// Ensure console schema exists before handlers query console tables.
|
|
534
|
+
const consoleSchemaReadyPromise = (
|
|
535
|
+
options.consoleSchemaReady ??
|
|
536
|
+
options.dialect.ensureConsoleSchema?.(options.db) ??
|
|
537
|
+
Promise.resolve()
|
|
538
|
+
).catch((err) => {
|
|
575
539
|
console.error('[console] Failed to ensure console schema:', err);
|
|
540
|
+
throw err;
|
|
576
541
|
});
|
|
577
542
|
|
|
578
543
|
// CORS configuration
|
|
@@ -580,18 +545,53 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
580
545
|
'http://localhost:5173',
|
|
581
546
|
'https://console.sync.dev',
|
|
582
547
|
];
|
|
548
|
+
const allowWildcardCors = corsOrigins === '*';
|
|
583
549
|
|
|
584
550
|
routes.use(
|
|
585
551
|
'*',
|
|
586
552
|
cors({
|
|
587
|
-
origin:
|
|
553
|
+
origin: allowWildcardCors ? '*' : corsOrigins,
|
|
588
554
|
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
589
|
-
allowHeaders: [
|
|
555
|
+
allowHeaders: [
|
|
556
|
+
'Content-Type',
|
|
557
|
+
'Authorization',
|
|
558
|
+
'X-Syncular-Transport-Path',
|
|
559
|
+
'Baggage',
|
|
560
|
+
'Sentry-Trace',
|
|
561
|
+
'Traceparent',
|
|
562
|
+
'Tracestate',
|
|
563
|
+
],
|
|
590
564
|
exposeHeaders: ['X-Total-Count'],
|
|
591
|
-
credentials:
|
|
565
|
+
credentials: !allowWildcardCors,
|
|
592
566
|
})
|
|
593
567
|
);
|
|
594
568
|
|
|
569
|
+
const ensureConsoleSchemaReady = async (
|
|
570
|
+
c: Context
|
|
571
|
+
): Promise<Response | null> => {
|
|
572
|
+
try {
|
|
573
|
+
await consoleSchemaReadyPromise;
|
|
574
|
+
return null;
|
|
575
|
+
} catch {
|
|
576
|
+
return c.json({ error: 'CONSOLE_SCHEMA_UNAVAILABLE' }, 503);
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
routes.use('*', async (c, next) => {
|
|
581
|
+
const readyError = await ensureConsoleSchemaReady(c);
|
|
582
|
+
if (readyError) {
|
|
583
|
+
return readyError;
|
|
584
|
+
}
|
|
585
|
+
await next();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
routes.use('*', async (c, next) => {
|
|
589
|
+
if (c.req.method !== 'OPTIONS') {
|
|
590
|
+
triggerAutomaticEventsPrune();
|
|
591
|
+
}
|
|
592
|
+
await next();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
595
|
// Auth middleware
|
|
596
596
|
const requireAuth = async (c: Context): Promise<ConsoleAuthResult | null> => {
|
|
597
597
|
const auth = await options.authenticate(c);
|
|
@@ -687,6 +687,202 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
687
687
|
createdAt: row.created_at ?? '',
|
|
688
688
|
});
|
|
689
689
|
|
|
690
|
+
type PruneEventsRunResult = {
|
|
691
|
+
requestEventsDeleted: number;
|
|
692
|
+
operationEventsDeleted: number;
|
|
693
|
+
payloadSnapshotsDeleted: number;
|
|
694
|
+
totalDeleted: number;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const deleteUnreferencedPayloadSnapshots = async (): Promise<number> => {
|
|
698
|
+
const result = await db
|
|
699
|
+
.deleteFrom('sync_request_payloads')
|
|
700
|
+
.where(
|
|
701
|
+
'payload_ref',
|
|
702
|
+
'not in',
|
|
703
|
+
db
|
|
704
|
+
.selectFrom('sync_request_events')
|
|
705
|
+
.select('payload_ref')
|
|
706
|
+
.where('payload_ref', 'is not', null)
|
|
707
|
+
)
|
|
708
|
+
.executeTakeFirst();
|
|
709
|
+
return Number(result?.numDeletedRows ?? 0);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const pruneRequestEventsByAge = async (): Promise<number> => {
|
|
713
|
+
if (requestEventsMaxAgeMs <= 0) {
|
|
714
|
+
return 0;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const cutoffDate = new Date(Date.now() - requestEventsMaxAgeMs);
|
|
718
|
+
const result = await db
|
|
719
|
+
.deleteFrom('sync_request_events')
|
|
720
|
+
.where('created_at', '<', cutoffDate.toISOString())
|
|
721
|
+
.executeTakeFirst();
|
|
722
|
+
|
|
723
|
+
return Number(result?.numDeletedRows ?? 0);
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const pruneRequestEventsByCount = async (): Promise<number> => {
|
|
727
|
+
if (requestEventsMaxRows <= 0) {
|
|
728
|
+
return 0;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const countRow = await db
|
|
732
|
+
.selectFrom('sync_request_events')
|
|
733
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
734
|
+
.executeTakeFirst();
|
|
735
|
+
|
|
736
|
+
const total = coerceNumber(countRow?.total) ?? 0;
|
|
737
|
+
if (total <= requestEventsMaxRows) {
|
|
738
|
+
return 0;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const cutoffRow = await db
|
|
742
|
+
.selectFrom('sync_request_events')
|
|
743
|
+
.select(['event_id'])
|
|
744
|
+
.orderBy('event_id', 'desc')
|
|
745
|
+
.offset(requestEventsMaxRows)
|
|
746
|
+
.limit(1)
|
|
747
|
+
.executeTakeFirst();
|
|
748
|
+
|
|
749
|
+
const cutoffEventId = coerceNumber(cutoffRow?.event_id);
|
|
750
|
+
if (cutoffEventId === null) {
|
|
751
|
+
return 0;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const result = await db
|
|
755
|
+
.deleteFrom('sync_request_events')
|
|
756
|
+
.where('event_id', '<=', cutoffEventId)
|
|
757
|
+
.executeTakeFirst();
|
|
758
|
+
return Number(result?.numDeletedRows ?? 0);
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const pruneOperationEventsByAge = async (): Promise<number> => {
|
|
762
|
+
if (operationEventsMaxAgeMs <= 0) {
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const cutoffDate = new Date(Date.now() - operationEventsMaxAgeMs);
|
|
767
|
+
const result = await db
|
|
768
|
+
.deleteFrom('sync_operation_events')
|
|
769
|
+
.where('created_at', '<', cutoffDate.toISOString())
|
|
770
|
+
.executeTakeFirst();
|
|
771
|
+
return Number(result?.numDeletedRows ?? 0);
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const pruneOperationEventsByCount = async (): Promise<number> => {
|
|
775
|
+
if (operationEventsMaxRows <= 0) {
|
|
776
|
+
return 0;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const countRow = await db
|
|
780
|
+
.selectFrom('sync_operation_events')
|
|
781
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
782
|
+
.executeTakeFirst();
|
|
783
|
+
const total = coerceNumber(countRow?.total) ?? 0;
|
|
784
|
+
if (total <= operationEventsMaxRows) {
|
|
785
|
+
return 0;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const cutoffRow = await db
|
|
789
|
+
.selectFrom('sync_operation_events')
|
|
790
|
+
.select(['operation_id'])
|
|
791
|
+
.orderBy('operation_id', 'desc')
|
|
792
|
+
.offset(operationEventsMaxRows)
|
|
793
|
+
.limit(1)
|
|
794
|
+
.executeTakeFirst();
|
|
795
|
+
|
|
796
|
+
const cutoffOperationId = coerceNumber(cutoffRow?.operation_id);
|
|
797
|
+
if (cutoffOperationId === null) {
|
|
798
|
+
return 0;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const result = await db
|
|
802
|
+
.deleteFrom('sync_operation_events')
|
|
803
|
+
.where('operation_id', '<=', cutoffOperationId)
|
|
804
|
+
.executeTakeFirst();
|
|
805
|
+
return Number(result?.numDeletedRows ?? 0);
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const pruneConsoleEvents = async (): Promise<PruneEventsRunResult> => {
|
|
809
|
+
const requestEventsDeletedByAge = await pruneRequestEventsByAge();
|
|
810
|
+
const requestEventsDeletedByCount = await pruneRequestEventsByCount();
|
|
811
|
+
const requestEventsDeleted =
|
|
812
|
+
requestEventsDeletedByAge + requestEventsDeletedByCount;
|
|
813
|
+
|
|
814
|
+
const operationEventsDeletedByAge = await pruneOperationEventsByAge();
|
|
815
|
+
const operationEventsDeletedByCount = await pruneOperationEventsByCount();
|
|
816
|
+
const operationEventsDeleted =
|
|
817
|
+
operationEventsDeletedByAge + operationEventsDeletedByCount;
|
|
818
|
+
|
|
819
|
+
const payloadSnapshotsDeleted = await deleteUnreferencedPayloadSnapshots();
|
|
820
|
+
const totalDeleted = requestEventsDeleted + operationEventsDeleted;
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
requestEventsDeleted,
|
|
824
|
+
operationEventsDeleted,
|
|
825
|
+
payloadSnapshotsDeleted,
|
|
826
|
+
totalDeleted,
|
|
827
|
+
};
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
let eventsPrunePromise: Promise<PruneEventsRunResult> | null = null;
|
|
831
|
+
|
|
832
|
+
const runEventsPrune = async (): Promise<PruneEventsRunResult> => {
|
|
833
|
+
if (eventsPrunePromise) {
|
|
834
|
+
return eventsPrunePromise;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
let pending: Promise<PruneEventsRunResult>;
|
|
838
|
+
pending = pruneConsoleEvents()
|
|
839
|
+
.then((result) => {
|
|
840
|
+
lastEventsPruneRunAt = Date.now();
|
|
841
|
+
return result;
|
|
842
|
+
})
|
|
843
|
+
.finally(() => {
|
|
844
|
+
if (eventsPrunePromise === pending) {
|
|
845
|
+
eventsPrunePromise = null;
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
eventsPrunePromise = pending;
|
|
850
|
+
return pending;
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
const triggerAutomaticEventsPrune = (): void => {
|
|
854
|
+
if (autoEventsPruneIntervalMs <= 0) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (eventsPrunePromise) {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (Date.now() - lastEventsPruneRunAt < autoEventsPruneIntervalMs) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
void runEventsPrune()
|
|
865
|
+
.then((result) => {
|
|
866
|
+
if (result.totalDeleted <= 0 && result.payloadSnapshotsDeleted <= 0) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
logSyncEvent({
|
|
871
|
+
event: 'console.prune_events_auto',
|
|
872
|
+
deletedCount: result.totalDeleted,
|
|
873
|
+
requestEventsDeleted: result.requestEventsDeleted,
|
|
874
|
+
operationEventsDeleted: result.operationEventsDeleted,
|
|
875
|
+
payloadDeletedCount: result.payloadSnapshotsDeleted,
|
|
876
|
+
});
|
|
877
|
+
})
|
|
878
|
+
.catch((error) => {
|
|
879
|
+
logSyncEvent({
|
|
880
|
+
event: 'console.prune_events_auto_failed',
|
|
881
|
+
error: error instanceof Error ? error.message : String(error),
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
};
|
|
885
|
+
|
|
690
886
|
const recordOperationEvent = async (event: {
|
|
691
887
|
operationType: ConsoleOperationType;
|
|
692
888
|
consoleUserId?: string;
|
|
@@ -875,7 +1071,7 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
875
1071
|
? sql`and partition_id = ${partitionId}`
|
|
876
1072
|
: sql``;
|
|
877
1073
|
|
|
878
|
-
if (options.dialect.
|
|
1074
|
+
if (options.dialect.family === 'sqlite') {
|
|
879
1075
|
const bucketFormat = intervalToSqliteBucketFormat(interval);
|
|
880
1076
|
const rowsResult = await sql<{
|
|
881
1077
|
bucket: unknown;
|
|
@@ -1019,7 +1215,7 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
1019
1215
|
const startIso = startTime.toISOString();
|
|
1020
1216
|
const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
|
|
1021
1217
|
|
|
1022
|
-
if (!useRawMetrics && options.dialect.
|
|
1218
|
+
if (!useRawMetrics && options.dialect.family !== 'sqlite') {
|
|
1023
1219
|
const partitionFilter = partitionId
|
|
1024
1220
|
? sql`and partition_id = ${partitionId}`
|
|
1025
1221
|
: sql``;
|
|
@@ -2288,16 +2484,40 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2288
2484
|
const wsState = new WeakMap<
|
|
2289
2485
|
WebSocketLike,
|
|
2290
2486
|
{
|
|
2291
|
-
listener: ConsoleEventListener;
|
|
2292
|
-
heartbeatInterval: ReturnType<typeof setInterval
|
|
2487
|
+
listener: ConsoleEventListener | null;
|
|
2488
|
+
heartbeatInterval: ReturnType<typeof setInterval> | null;
|
|
2489
|
+
authTimeout: ReturnType<typeof setTimeout> | null;
|
|
2490
|
+
isAuthenticated: boolean;
|
|
2293
2491
|
}
|
|
2294
2492
|
>();
|
|
2295
2493
|
|
|
2494
|
+
const closeUnauthenticated = (ws: WebSocketLike) => {
|
|
2495
|
+
try {
|
|
2496
|
+
ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
|
|
2497
|
+
} catch {
|
|
2498
|
+
// ignore send errors
|
|
2499
|
+
}
|
|
2500
|
+
ws.close(4001, 'Unauthenticated');
|
|
2501
|
+
};
|
|
2502
|
+
|
|
2503
|
+
const cleanup = (ws: WebSocketLike) => {
|
|
2504
|
+
const state = wsState.get(ws);
|
|
2505
|
+
if (!state) return;
|
|
2506
|
+
if (state.listener) {
|
|
2507
|
+
emitter.removeListener(state.listener);
|
|
2508
|
+
}
|
|
2509
|
+
if (state.heartbeatInterval) {
|
|
2510
|
+
clearInterval(state.heartbeatInterval);
|
|
2511
|
+
}
|
|
2512
|
+
if (state.authTimeout) {
|
|
2513
|
+
clearTimeout(state.authTimeout);
|
|
2514
|
+
}
|
|
2515
|
+
wsState.delete(ws);
|
|
2516
|
+
};
|
|
2517
|
+
|
|
2296
2518
|
routes.get(
|
|
2297
2519
|
'/events/live',
|
|
2298
2520
|
upgradeWebSocket(async (c) => {
|
|
2299
|
-
// Auth check via query param (WebSocket doesn't support headers easily)
|
|
2300
|
-
const token = c.req.query('token');
|
|
2301
2521
|
const authHeader = c.req.header('Authorization');
|
|
2302
2522
|
const partitionId = c.req.query('partitionId')?.trim() || undefined;
|
|
2303
2523
|
const replaySince = c.req.query('since');
|
|
@@ -2312,25 +2532,173 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2312
2532
|
req: {
|
|
2313
2533
|
header: (name: string) =>
|
|
2314
2534
|
name === 'Authorization' ? authHeader : undefined,
|
|
2315
|
-
query: (
|
|
2535
|
+
query: () => undefined,
|
|
2316
2536
|
},
|
|
2317
|
-
} as Context;
|
|
2537
|
+
} as unknown as Context;
|
|
2318
2538
|
|
|
2319
|
-
const
|
|
2539
|
+
const initialAuth = await options.authenticate(mockContext);
|
|
2540
|
+
|
|
2541
|
+
const authenticateWithBearer = async (token: string) => {
|
|
2542
|
+
const trimmedToken = token.trim();
|
|
2543
|
+
if (!trimmedToken) return null;
|
|
2544
|
+
const authContext = {
|
|
2545
|
+
req: {
|
|
2546
|
+
header: (name: string) =>
|
|
2547
|
+
name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
|
|
2548
|
+
query: () => undefined,
|
|
2549
|
+
},
|
|
2550
|
+
} as unknown as Context;
|
|
2551
|
+
return options.authenticate(authContext);
|
|
2552
|
+
};
|
|
2320
2553
|
|
|
2321
2554
|
return {
|
|
2322
2555
|
onOpen(_event, ws) {
|
|
2323
|
-
|
|
2556
|
+
const state = {
|
|
2557
|
+
listener: null,
|
|
2558
|
+
heartbeatInterval: null,
|
|
2559
|
+
authTimeout: null,
|
|
2560
|
+
isAuthenticated: false,
|
|
2561
|
+
} as {
|
|
2562
|
+
listener: ConsoleEventListener | null;
|
|
2563
|
+
heartbeatInterval: ReturnType<typeof setInterval> | null;
|
|
2564
|
+
authTimeout: ReturnType<typeof setTimeout> | null;
|
|
2565
|
+
isAuthenticated: boolean;
|
|
2566
|
+
};
|
|
2567
|
+
wsState.set(ws, state);
|
|
2568
|
+
|
|
2569
|
+
const startAuthenticatedSession = () => {
|
|
2570
|
+
if (state.isAuthenticated) return;
|
|
2571
|
+
state.isAuthenticated = true;
|
|
2572
|
+
if (state.authTimeout) {
|
|
2573
|
+
clearTimeout(state.authTimeout);
|
|
2574
|
+
state.authTimeout = null;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
const listener: ConsoleEventListener = (event) => {
|
|
2578
|
+
if (partitionId) {
|
|
2579
|
+
const eventPartitionId = event.data.partitionId;
|
|
2580
|
+
if (
|
|
2581
|
+
typeof eventPartitionId !== 'string' ||
|
|
2582
|
+
eventPartitionId !== partitionId
|
|
2583
|
+
) {
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
try {
|
|
2588
|
+
ws.send(JSON.stringify(event));
|
|
2589
|
+
} catch {
|
|
2590
|
+
// Connection closed
|
|
2591
|
+
}
|
|
2592
|
+
};
|
|
2593
|
+
|
|
2594
|
+
emitter.addListener(listener);
|
|
2595
|
+
state.listener = listener;
|
|
2596
|
+
|
|
2324
2597
|
ws.send(
|
|
2325
|
-
JSON.stringify({
|
|
2598
|
+
JSON.stringify({
|
|
2599
|
+
type: 'connected',
|
|
2600
|
+
timestamp: new Date().toISOString(),
|
|
2601
|
+
})
|
|
2326
2602
|
);
|
|
2327
|
-
|
|
2603
|
+
|
|
2604
|
+
const replayEvents = emitter.replay({
|
|
2605
|
+
since: replaySince,
|
|
2606
|
+
limit: replayLimit,
|
|
2607
|
+
partitionId,
|
|
2608
|
+
});
|
|
2609
|
+
for (const replayEvent of replayEvents) {
|
|
2610
|
+
try {
|
|
2611
|
+
ws.send(JSON.stringify(replayEvent));
|
|
2612
|
+
} catch {
|
|
2613
|
+
// Connection closed
|
|
2614
|
+
break;
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
const heartbeatInterval = setInterval(() => {
|
|
2619
|
+
try {
|
|
2620
|
+
ws.send(
|
|
2621
|
+
JSON.stringify({
|
|
2622
|
+
type: 'heartbeat',
|
|
2623
|
+
timestamp: new Date().toISOString(),
|
|
2624
|
+
})
|
|
2625
|
+
);
|
|
2626
|
+
} catch {
|
|
2627
|
+
clearInterval(heartbeatInterval);
|
|
2628
|
+
}
|
|
2629
|
+
}, heartbeatIntervalMs);
|
|
2630
|
+
state.heartbeatInterval = heartbeatInterval;
|
|
2631
|
+
};
|
|
2632
|
+
|
|
2633
|
+
if (initialAuth) {
|
|
2634
|
+
startAuthenticatedSession();
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
state.authTimeout = setTimeout(() => {
|
|
2639
|
+
const current = wsState.get(ws);
|
|
2640
|
+
if (!current || current.isAuthenticated) {
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
closeUnauthenticated(ws);
|
|
2644
|
+
cleanup(ws);
|
|
2645
|
+
}, 5_000);
|
|
2646
|
+
},
|
|
2647
|
+
async onMessage(event, ws) {
|
|
2648
|
+
const state = wsState.get(ws);
|
|
2649
|
+
if (!state || state.isAuthenticated) {
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
if (typeof event.data !== 'string') {
|
|
2654
|
+
closeUnauthenticated(ws);
|
|
2655
|
+
cleanup(ws);
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
let token = '';
|
|
2660
|
+
try {
|
|
2661
|
+
const parsed = JSON.parse(event.data) as {
|
|
2662
|
+
type?: unknown;
|
|
2663
|
+
token?: unknown;
|
|
2664
|
+
};
|
|
2665
|
+
if (
|
|
2666
|
+
parsed.type === 'auth' &&
|
|
2667
|
+
typeof parsed.token === 'string' &&
|
|
2668
|
+
parsed.token.trim().length > 0
|
|
2669
|
+
) {
|
|
2670
|
+
token = parsed.token;
|
|
2671
|
+
}
|
|
2672
|
+
} catch {
|
|
2673
|
+
// Ignore parse errors and close as unauthenticated below.
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
if (!token) {
|
|
2677
|
+
closeUnauthenticated(ws);
|
|
2678
|
+
cleanup(ws);
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const auth = await authenticateWithBearer(token);
|
|
2683
|
+
const currentState = wsState.get(ws);
|
|
2684
|
+
if (!currentState || currentState.isAuthenticated) {
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
if (!auth) {
|
|
2688
|
+
closeUnauthenticated(ws);
|
|
2689
|
+
cleanup(ws);
|
|
2328
2690
|
return;
|
|
2329
2691
|
}
|
|
2330
2692
|
|
|
2331
|
-
|
|
2693
|
+
currentState.isAuthenticated = true;
|
|
2694
|
+
if (currentState.authTimeout) {
|
|
2695
|
+
clearTimeout(currentState.authTimeout);
|
|
2696
|
+
currentState.authTimeout = null;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
const listener: ConsoleEventListener = (liveEvent) => {
|
|
2332
2700
|
if (partitionId) {
|
|
2333
|
-
const eventPartitionId =
|
|
2701
|
+
const eventPartitionId = liveEvent.data.partitionId;
|
|
2334
2702
|
if (
|
|
2335
2703
|
typeof eventPartitionId !== 'string' ||
|
|
2336
2704
|
eventPartitionId !== partitionId
|
|
@@ -2339,15 +2707,15 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2339
2707
|
}
|
|
2340
2708
|
}
|
|
2341
2709
|
try {
|
|
2342
|
-
ws.send(JSON.stringify(
|
|
2710
|
+
ws.send(JSON.stringify(liveEvent));
|
|
2343
2711
|
} catch {
|
|
2344
2712
|
// Connection closed
|
|
2345
2713
|
}
|
|
2346
2714
|
};
|
|
2347
2715
|
|
|
2348
2716
|
emitter.addListener(listener);
|
|
2717
|
+
currentState.listener = listener;
|
|
2349
2718
|
|
|
2350
|
-
// Send connected message
|
|
2351
2719
|
ws.send(
|
|
2352
2720
|
JSON.stringify({
|
|
2353
2721
|
type: 'connected',
|
|
@@ -2369,7 +2737,6 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2369
2737
|
}
|
|
2370
2738
|
}
|
|
2371
2739
|
|
|
2372
|
-
// Start heartbeat
|
|
2373
2740
|
const heartbeatInterval = setInterval(() => {
|
|
2374
2741
|
try {
|
|
2375
2742
|
ws.send(
|
|
@@ -2382,22 +2749,13 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2382
2749
|
clearInterval(heartbeatInterval);
|
|
2383
2750
|
}
|
|
2384
2751
|
}, heartbeatIntervalMs);
|
|
2385
|
-
|
|
2386
|
-
wsState.set(ws, { listener, heartbeatInterval });
|
|
2752
|
+
currentState.heartbeatInterval = heartbeatInterval;
|
|
2387
2753
|
},
|
|
2388
2754
|
onClose(_event, ws) {
|
|
2389
|
-
|
|
2390
|
-
if (!state) return;
|
|
2391
|
-
emitter.removeListener(state.listener);
|
|
2392
|
-
clearInterval(state.heartbeatInterval);
|
|
2393
|
-
wsState.delete(ws);
|
|
2755
|
+
cleanup(ws);
|
|
2394
2756
|
},
|
|
2395
2757
|
onError(_event, ws) {
|
|
2396
|
-
|
|
2397
|
-
if (!state) return;
|
|
2398
|
-
emitter.removeListener(state.listener);
|
|
2399
|
-
clearInterval(state.heartbeatInterval);
|
|
2400
|
-
wsState.delete(ws);
|
|
2758
|
+
cleanup(ws);
|
|
2401
2759
|
},
|
|
2402
2760
|
};
|
|
2403
2761
|
})
|
|
@@ -2605,11 +2963,13 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2605
2963
|
const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
|
|
2606
2964
|
|
|
2607
2965
|
const deletedCount = Number(res?.numDeletedRows ?? 0);
|
|
2966
|
+
const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
|
|
2608
2967
|
|
|
2609
2968
|
logSyncEvent({
|
|
2610
2969
|
event: 'console.clear_events',
|
|
2611
2970
|
consoleUserId: auth.consoleUserId,
|
|
2612
2971
|
deletedCount,
|
|
2972
|
+
payloadDeletedCount,
|
|
2613
2973
|
});
|
|
2614
2974
|
|
|
2615
2975
|
const result: ConsoleClearEventsResult = { deletedCount };
|
|
@@ -2647,53 +3007,16 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2647
3007
|
const auth = await requireAuth(c);
|
|
2648
3008
|
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2649
3009
|
|
|
2650
|
-
|
|
2651
|
-
const
|
|
2652
|
-
|
|
2653
|
-
// Delete by date first
|
|
2654
|
-
const resByDate = await db
|
|
2655
|
-
.deleteFrom('sync_request_events')
|
|
2656
|
-
.where('created_at', '<', cutoffDate.toISOString())
|
|
2657
|
-
.executeTakeFirst();
|
|
2658
|
-
|
|
2659
|
-
let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
|
|
2660
|
-
|
|
2661
|
-
// Then delete oldest if we still have more than 10000 events
|
|
2662
|
-
const countRow = await db
|
|
2663
|
-
.selectFrom('sync_request_events')
|
|
2664
|
-
.select(({ fn }) => fn.countAll().as('total'))
|
|
2665
|
-
.executeTakeFirst();
|
|
2666
|
-
|
|
2667
|
-
const total = coerceNumber(countRow?.total) ?? 0;
|
|
2668
|
-
const maxEvents = 10000;
|
|
2669
|
-
|
|
2670
|
-
if (total > maxEvents) {
|
|
2671
|
-
// Find event_id cutoff to keep only newest maxEvents
|
|
2672
|
-
const cutoffRow = await db
|
|
2673
|
-
.selectFrom('sync_request_events')
|
|
2674
|
-
.select(['event_id'])
|
|
2675
|
-
.orderBy('event_id', 'desc')
|
|
2676
|
-
.offset(maxEvents)
|
|
2677
|
-
.limit(1)
|
|
2678
|
-
.executeTakeFirst();
|
|
2679
|
-
|
|
2680
|
-
if (cutoffRow) {
|
|
2681
|
-
const cutoffEventId = coerceNumber(cutoffRow.event_id);
|
|
2682
|
-
if (cutoffEventId !== null) {
|
|
2683
|
-
const resByCount = await db
|
|
2684
|
-
.deleteFrom('sync_request_events')
|
|
2685
|
-
.where('event_id', '<=', cutoffEventId)
|
|
2686
|
-
.executeTakeFirst();
|
|
2687
|
-
|
|
2688
|
-
deletedCount += Number(resByCount?.numDeletedRows ?? 0);
|
|
2689
|
-
}
|
|
2690
|
-
}
|
|
2691
|
-
}
|
|
3010
|
+
const pruneResult = await runEventsPrune();
|
|
3011
|
+
const deletedCount = pruneResult.totalDeleted;
|
|
2692
3012
|
|
|
2693
3013
|
logSyncEvent({
|
|
2694
3014
|
event: 'console.prune_events',
|
|
2695
3015
|
consoleUserId: auth.consoleUserId,
|
|
2696
3016
|
deletedCount,
|
|
3017
|
+
requestEventsDeleted: pruneResult.requestEventsDeleted,
|
|
3018
|
+
operationEventsDeleted: pruneResult.operationEventsDeleted,
|
|
3019
|
+
payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
|
|
2697
3020
|
});
|
|
2698
3021
|
|
|
2699
3022
|
const result: ConsolePruneEventsResult = { deletedCount };
|
|
@@ -3407,6 +3730,157 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
3407
3730
|
}
|
|
3408
3731
|
);
|
|
3409
3732
|
|
|
3733
|
+
// -----------------------------------------------------------------------
|
|
3734
|
+
// Storage endpoints
|
|
3735
|
+
// -----------------------------------------------------------------------
|
|
3736
|
+
const bucket = options.blobBucket;
|
|
3737
|
+
|
|
3738
|
+
routes.get(
|
|
3739
|
+
'/storage',
|
|
3740
|
+
describeRoute({
|
|
3741
|
+
tags: ['console'],
|
|
3742
|
+
summary: 'List storage items',
|
|
3743
|
+
responses: {
|
|
3744
|
+
200: {
|
|
3745
|
+
description: 'Paginated list of storage items',
|
|
3746
|
+
content: {
|
|
3747
|
+
'application/json': {
|
|
3748
|
+
schema: resolver(ConsoleBlobListResponseSchema),
|
|
3749
|
+
},
|
|
3750
|
+
},
|
|
3751
|
+
},
|
|
3752
|
+
401: {
|
|
3753
|
+
description: 'Unauthenticated',
|
|
3754
|
+
content: {
|
|
3755
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
3756
|
+
},
|
|
3757
|
+
},
|
|
3758
|
+
},
|
|
3759
|
+
}),
|
|
3760
|
+
zValidator('query', ConsoleBlobListQuerySchema),
|
|
3761
|
+
async (c) => {
|
|
3762
|
+
const auth = await requireAuth(c);
|
|
3763
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
3764
|
+
|
|
3765
|
+
if (!bucket) {
|
|
3766
|
+
return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
const { prefix, cursor, limit } = c.req.valid('query');
|
|
3770
|
+
const listed = await bucket.list({
|
|
3771
|
+
prefix: prefix || undefined,
|
|
3772
|
+
cursor: cursor || undefined,
|
|
3773
|
+
limit,
|
|
3774
|
+
});
|
|
3775
|
+
|
|
3776
|
+
return c.json(
|
|
3777
|
+
{
|
|
3778
|
+
items: listed.objects.map((obj) => ({
|
|
3779
|
+
key: obj.key,
|
|
3780
|
+
size: obj.size,
|
|
3781
|
+
uploaded: obj.uploaded.toISOString(),
|
|
3782
|
+
httpMetadata: obj.httpMetadata?.contentType
|
|
3783
|
+
? { contentType: obj.httpMetadata.contentType }
|
|
3784
|
+
: undefined,
|
|
3785
|
+
})),
|
|
3786
|
+
truncated: listed.truncated,
|
|
3787
|
+
cursor: listed.cursor ?? null,
|
|
3788
|
+
},
|
|
3789
|
+
200
|
|
3790
|
+
);
|
|
3791
|
+
}
|
|
3792
|
+
);
|
|
3793
|
+
|
|
3794
|
+
routes.get(
|
|
3795
|
+
'/storage/:key{.+}/download',
|
|
3796
|
+
describeRoute({
|
|
3797
|
+
tags: ['console'],
|
|
3798
|
+
summary: 'Download a storage item',
|
|
3799
|
+
responses: {
|
|
3800
|
+
200: { description: 'Storage item contents' },
|
|
3801
|
+
401: {
|
|
3802
|
+
description: 'Unauthenticated',
|
|
3803
|
+
content: {
|
|
3804
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
3805
|
+
},
|
|
3806
|
+
},
|
|
3807
|
+
404: {
|
|
3808
|
+
description: 'Blob not found',
|
|
3809
|
+
content: {
|
|
3810
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
3811
|
+
},
|
|
3812
|
+
},
|
|
3813
|
+
},
|
|
3814
|
+
}),
|
|
3815
|
+
async (c) => {
|
|
3816
|
+
const auth = await requireAuth(c);
|
|
3817
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
3818
|
+
|
|
3819
|
+
if (!bucket) {
|
|
3820
|
+
return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
const key = decodeURIComponent(c.req.param('key'));
|
|
3824
|
+
const object = await bucket.get(key);
|
|
3825
|
+
if (!object) {
|
|
3826
|
+
return c.json({ error: 'BLOB_NOT_FOUND' }, 404);
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
const headers = new Headers();
|
|
3830
|
+
headers.set('Content-Length', String(object.size));
|
|
3831
|
+
headers.set(
|
|
3832
|
+
'Content-Type',
|
|
3833
|
+
object.httpMetadata?.contentType ?? 'application/octet-stream'
|
|
3834
|
+
);
|
|
3835
|
+
const filename = key.split('/').pop() || key;
|
|
3836
|
+
headers.set(
|
|
3837
|
+
'Content-Disposition',
|
|
3838
|
+
`attachment; filename="${filename.replace(/"/g, '\\"')}"`
|
|
3839
|
+
);
|
|
3840
|
+
|
|
3841
|
+
return new Response(object.body as ReadableStream, {
|
|
3842
|
+
status: 200,
|
|
3843
|
+
headers,
|
|
3844
|
+
});
|
|
3845
|
+
}
|
|
3846
|
+
);
|
|
3847
|
+
|
|
3848
|
+
routes.delete(
|
|
3849
|
+
'/storage/:key{.+}',
|
|
3850
|
+
describeRoute({
|
|
3851
|
+
tags: ['console'],
|
|
3852
|
+
summary: 'Delete a storage item',
|
|
3853
|
+
responses: {
|
|
3854
|
+
200: {
|
|
3855
|
+
description: 'Storage item deleted',
|
|
3856
|
+
content: {
|
|
3857
|
+
'application/json': {
|
|
3858
|
+
schema: resolver(ConsoleBlobDeleteResponseSchema),
|
|
3859
|
+
},
|
|
3860
|
+
},
|
|
3861
|
+
},
|
|
3862
|
+
401: {
|
|
3863
|
+
description: 'Unauthenticated',
|
|
3864
|
+
content: {
|
|
3865
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
3866
|
+
},
|
|
3867
|
+
},
|
|
3868
|
+
},
|
|
3869
|
+
}),
|
|
3870
|
+
async (c) => {
|
|
3871
|
+
const auth = await requireAuth(c);
|
|
3872
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
3873
|
+
|
|
3874
|
+
if (!bucket) {
|
|
3875
|
+
return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
const key = decodeURIComponent(c.req.param('key'));
|
|
3879
|
+
await bucket.delete(key);
|
|
3880
|
+
return c.json({ deleted: true }, 200);
|
|
3881
|
+
}
|
|
3882
|
+
);
|
|
3883
|
+
|
|
3410
3884
|
return routes;
|
|
3411
3885
|
}
|
|
3412
3886
|
|
|
@@ -3444,29 +3918,19 @@ async function hashApiKey(secretKey: string): Promise<string> {
|
|
|
3444
3918
|
export function createTokenAuthenticator(
|
|
3445
3919
|
token?: string
|
|
3446
3920
|
): (c: Context) => Promise<ConsoleAuthResult | null> {
|
|
3447
|
-
const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
|
|
3921
|
+
const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
|
|
3448
3922
|
|
|
3449
3923
|
return async (c: Context) => {
|
|
3450
|
-
if (!expectedToken)
|
|
3451
|
-
// No token configured, allow all requests (not recommended for production)
|
|
3452
|
-
return { consoleUserId: 'anonymous' };
|
|
3453
|
-
}
|
|
3924
|
+
if (!expectedToken) return null;
|
|
3454
3925
|
|
|
3455
|
-
|
|
3456
|
-
const authHeader = c.req.header('Authorization');
|
|
3926
|
+
const authHeader = c.req.header('Authorization')?.trim();
|
|
3457
3927
|
if (authHeader?.startsWith('Bearer ')) {
|
|
3458
|
-
const bearerToken = authHeader.slice(7);
|
|
3928
|
+
const bearerToken = authHeader.slice(7).trim();
|
|
3459
3929
|
if (bearerToken === expectedToken) {
|
|
3460
3930
|
return { consoleUserId: 'token' };
|
|
3461
3931
|
}
|
|
3462
3932
|
}
|
|
3463
3933
|
|
|
3464
|
-
// Check query parameter
|
|
3465
|
-
const queryToken = c.req.query('token');
|
|
3466
|
-
if (queryToken === expectedToken) {
|
|
3467
|
-
return { consoleUserId: 'token' };
|
|
3468
|
-
}
|
|
3469
|
-
|
|
3470
3934
|
return null;
|
|
3471
3935
|
};
|
|
3472
3936
|
}
|