@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/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,11 +545,12 @@ 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
555
|
allowHeaders: [
|
|
590
556
|
'Content-Type',
|
|
@@ -596,10 +562,36 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
596
562
|
'Tracestate',
|
|
597
563
|
],
|
|
598
564
|
exposeHeaders: ['X-Total-Count'],
|
|
599
|
-
credentials:
|
|
565
|
+
credentials: !allowWildcardCors,
|
|
600
566
|
})
|
|
601
567
|
);
|
|
602
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
|
+
|
|
603
595
|
// Auth middleware
|
|
604
596
|
const requireAuth = async (c: Context): Promise<ConsoleAuthResult | null> => {
|
|
605
597
|
const auth = await options.authenticate(c);
|
|
@@ -695,6 +687,202 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
695
687
|
createdAt: row.created_at ?? '',
|
|
696
688
|
});
|
|
697
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
|
+
|
|
698
886
|
const recordOperationEvent = async (event: {
|
|
699
887
|
operationType: ConsoleOperationType;
|
|
700
888
|
consoleUserId?: string;
|
|
@@ -883,7 +1071,7 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
883
1071
|
? sql`and partition_id = ${partitionId}`
|
|
884
1072
|
: sql``;
|
|
885
1073
|
|
|
886
|
-
if (options.dialect.
|
|
1074
|
+
if (options.dialect.family === 'sqlite') {
|
|
887
1075
|
const bucketFormat = intervalToSqliteBucketFormat(interval);
|
|
888
1076
|
const rowsResult = await sql<{
|
|
889
1077
|
bucket: unknown;
|
|
@@ -1027,7 +1215,7 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
1027
1215
|
const startIso = startTime.toISOString();
|
|
1028
1216
|
const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
|
|
1029
1217
|
|
|
1030
|
-
if (!useRawMetrics && options.dialect.
|
|
1218
|
+
if (!useRawMetrics && options.dialect.family !== 'sqlite') {
|
|
1031
1219
|
const partitionFilter = partitionId
|
|
1032
1220
|
? sql`and partition_id = ${partitionId}`
|
|
1033
1221
|
: sql``;
|
|
@@ -2296,16 +2484,40 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2296
2484
|
const wsState = new WeakMap<
|
|
2297
2485
|
WebSocketLike,
|
|
2298
2486
|
{
|
|
2299
|
-
listener: ConsoleEventListener;
|
|
2300
|
-
heartbeatInterval: ReturnType<typeof setInterval
|
|
2487
|
+
listener: ConsoleEventListener | null;
|
|
2488
|
+
heartbeatInterval: ReturnType<typeof setInterval> | null;
|
|
2489
|
+
authTimeout: ReturnType<typeof setTimeout> | null;
|
|
2490
|
+
isAuthenticated: boolean;
|
|
2301
2491
|
}
|
|
2302
2492
|
>();
|
|
2303
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
|
+
|
|
2304
2518
|
routes.get(
|
|
2305
2519
|
'/events/live',
|
|
2306
2520
|
upgradeWebSocket(async (c) => {
|
|
2307
|
-
// Auth check via query param (WebSocket doesn't support headers easily)
|
|
2308
|
-
const token = c.req.query('token');
|
|
2309
2521
|
const authHeader = c.req.header('Authorization');
|
|
2310
2522
|
const partitionId = c.req.query('partitionId')?.trim() || undefined;
|
|
2311
2523
|
const replaySince = c.req.query('since');
|
|
@@ -2320,25 +2532,173 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2320
2532
|
req: {
|
|
2321
2533
|
header: (name: string) =>
|
|
2322
2534
|
name === 'Authorization' ? authHeader : undefined,
|
|
2323
|
-
query: (
|
|
2535
|
+
query: () => undefined,
|
|
2324
2536
|
},
|
|
2325
|
-
} as Context;
|
|
2537
|
+
} as unknown as Context;
|
|
2326
2538
|
|
|
2327
|
-
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
|
+
};
|
|
2328
2553
|
|
|
2329
2554
|
return {
|
|
2330
2555
|
onOpen(_event, ws) {
|
|
2331
|
-
|
|
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
|
+
|
|
2332
2597
|
ws.send(
|
|
2333
|
-
JSON.stringify({
|
|
2598
|
+
JSON.stringify({
|
|
2599
|
+
type: 'connected',
|
|
2600
|
+
timestamp: new Date().toISOString(),
|
|
2601
|
+
})
|
|
2334
2602
|
);
|
|
2335
|
-
|
|
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();
|
|
2336
2635
|
return;
|
|
2337
2636
|
}
|
|
2338
2637
|
|
|
2339
|
-
|
|
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);
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
currentState.isAuthenticated = true;
|
|
2694
|
+
if (currentState.authTimeout) {
|
|
2695
|
+
clearTimeout(currentState.authTimeout);
|
|
2696
|
+
currentState.authTimeout = null;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
const listener: ConsoleEventListener = (liveEvent) => {
|
|
2340
2700
|
if (partitionId) {
|
|
2341
|
-
const eventPartitionId =
|
|
2701
|
+
const eventPartitionId = liveEvent.data.partitionId;
|
|
2342
2702
|
if (
|
|
2343
2703
|
typeof eventPartitionId !== 'string' ||
|
|
2344
2704
|
eventPartitionId !== partitionId
|
|
@@ -2347,15 +2707,15 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2347
2707
|
}
|
|
2348
2708
|
}
|
|
2349
2709
|
try {
|
|
2350
|
-
ws.send(JSON.stringify(
|
|
2710
|
+
ws.send(JSON.stringify(liveEvent));
|
|
2351
2711
|
} catch {
|
|
2352
2712
|
// Connection closed
|
|
2353
2713
|
}
|
|
2354
2714
|
};
|
|
2355
2715
|
|
|
2356
2716
|
emitter.addListener(listener);
|
|
2717
|
+
currentState.listener = listener;
|
|
2357
2718
|
|
|
2358
|
-
// Send connected message
|
|
2359
2719
|
ws.send(
|
|
2360
2720
|
JSON.stringify({
|
|
2361
2721
|
type: 'connected',
|
|
@@ -2377,7 +2737,6 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2377
2737
|
}
|
|
2378
2738
|
}
|
|
2379
2739
|
|
|
2380
|
-
// Start heartbeat
|
|
2381
2740
|
const heartbeatInterval = setInterval(() => {
|
|
2382
2741
|
try {
|
|
2383
2742
|
ws.send(
|
|
@@ -2390,22 +2749,13 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2390
2749
|
clearInterval(heartbeatInterval);
|
|
2391
2750
|
}
|
|
2392
2751
|
}, heartbeatIntervalMs);
|
|
2393
|
-
|
|
2394
|
-
wsState.set(ws, { listener, heartbeatInterval });
|
|
2752
|
+
currentState.heartbeatInterval = heartbeatInterval;
|
|
2395
2753
|
},
|
|
2396
2754
|
onClose(_event, ws) {
|
|
2397
|
-
|
|
2398
|
-
if (!state) return;
|
|
2399
|
-
emitter.removeListener(state.listener);
|
|
2400
|
-
clearInterval(state.heartbeatInterval);
|
|
2401
|
-
wsState.delete(ws);
|
|
2755
|
+
cleanup(ws);
|
|
2402
2756
|
},
|
|
2403
2757
|
onError(_event, ws) {
|
|
2404
|
-
|
|
2405
|
-
if (!state) return;
|
|
2406
|
-
emitter.removeListener(state.listener);
|
|
2407
|
-
clearInterval(state.heartbeatInterval);
|
|
2408
|
-
wsState.delete(ws);
|
|
2758
|
+
cleanup(ws);
|
|
2409
2759
|
},
|
|
2410
2760
|
};
|
|
2411
2761
|
})
|
|
@@ -2613,11 +2963,13 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2613
2963
|
const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
|
|
2614
2964
|
|
|
2615
2965
|
const deletedCount = Number(res?.numDeletedRows ?? 0);
|
|
2966
|
+
const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
|
|
2616
2967
|
|
|
2617
2968
|
logSyncEvent({
|
|
2618
2969
|
event: 'console.clear_events',
|
|
2619
2970
|
consoleUserId: auth.consoleUserId,
|
|
2620
2971
|
deletedCount,
|
|
2972
|
+
payloadDeletedCount,
|
|
2621
2973
|
});
|
|
2622
2974
|
|
|
2623
2975
|
const result: ConsoleClearEventsResult = { deletedCount };
|
|
@@ -2655,53 +3007,16 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
2655
3007
|
const auth = await requireAuth(c);
|
|
2656
3008
|
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2657
3009
|
|
|
2658
|
-
|
|
2659
|
-
const
|
|
2660
|
-
|
|
2661
|
-
// Delete by date first
|
|
2662
|
-
const resByDate = await db
|
|
2663
|
-
.deleteFrom('sync_request_events')
|
|
2664
|
-
.where('created_at', '<', cutoffDate.toISOString())
|
|
2665
|
-
.executeTakeFirst();
|
|
2666
|
-
|
|
2667
|
-
let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
|
|
2668
|
-
|
|
2669
|
-
// Then delete oldest if we still have more than 10000 events
|
|
2670
|
-
const countRow = await db
|
|
2671
|
-
.selectFrom('sync_request_events')
|
|
2672
|
-
.select(({ fn }) => fn.countAll().as('total'))
|
|
2673
|
-
.executeTakeFirst();
|
|
2674
|
-
|
|
2675
|
-
const total = coerceNumber(countRow?.total) ?? 0;
|
|
2676
|
-
const maxEvents = 10000;
|
|
2677
|
-
|
|
2678
|
-
if (total > maxEvents) {
|
|
2679
|
-
// Find event_id cutoff to keep only newest maxEvents
|
|
2680
|
-
const cutoffRow = await db
|
|
2681
|
-
.selectFrom('sync_request_events')
|
|
2682
|
-
.select(['event_id'])
|
|
2683
|
-
.orderBy('event_id', 'desc')
|
|
2684
|
-
.offset(maxEvents)
|
|
2685
|
-
.limit(1)
|
|
2686
|
-
.executeTakeFirst();
|
|
2687
|
-
|
|
2688
|
-
if (cutoffRow) {
|
|
2689
|
-
const cutoffEventId = coerceNumber(cutoffRow.event_id);
|
|
2690
|
-
if (cutoffEventId !== null) {
|
|
2691
|
-
const resByCount = await db
|
|
2692
|
-
.deleteFrom('sync_request_events')
|
|
2693
|
-
.where('event_id', '<=', cutoffEventId)
|
|
2694
|
-
.executeTakeFirst();
|
|
2695
|
-
|
|
2696
|
-
deletedCount += Number(resByCount?.numDeletedRows ?? 0);
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
}
|
|
3010
|
+
const pruneResult = await runEventsPrune();
|
|
3011
|
+
const deletedCount = pruneResult.totalDeleted;
|
|
2700
3012
|
|
|
2701
3013
|
logSyncEvent({
|
|
2702
3014
|
event: 'console.prune_events',
|
|
2703
3015
|
consoleUserId: auth.consoleUserId,
|
|
2704
3016
|
deletedCount,
|
|
3017
|
+
requestEventsDeleted: pruneResult.requestEventsDeleted,
|
|
3018
|
+
operationEventsDeleted: pruneResult.operationEventsDeleted,
|
|
3019
|
+
payloadDeletedCount: pruneResult.payloadSnapshotsDeleted,
|
|
2705
3020
|
});
|
|
2706
3021
|
|
|
2707
3022
|
const result: ConsolePruneEventsResult = { deletedCount };
|
|
@@ -3415,6 +3730,157 @@ export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
|
3415
3730
|
}
|
|
3416
3731
|
);
|
|
3417
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
|
+
|
|
3418
3884
|
return routes;
|
|
3419
3885
|
}
|
|
3420
3886
|
|
|
@@ -3452,29 +3918,19 @@ async function hashApiKey(secretKey: string): Promise<string> {
|
|
|
3452
3918
|
export function createTokenAuthenticator(
|
|
3453
3919
|
token?: string
|
|
3454
3920
|
): (c: Context) => Promise<ConsoleAuthResult | null> {
|
|
3455
|
-
const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
|
|
3921
|
+
const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
|
|
3456
3922
|
|
|
3457
3923
|
return async (c: Context) => {
|
|
3458
|
-
if (!expectedToken)
|
|
3459
|
-
// No token configured, allow all requests (not recommended for production)
|
|
3460
|
-
return { consoleUserId: 'anonymous' };
|
|
3461
|
-
}
|
|
3924
|
+
if (!expectedToken) return null;
|
|
3462
3925
|
|
|
3463
|
-
|
|
3464
|
-
const authHeader = c.req.header('Authorization');
|
|
3926
|
+
const authHeader = c.req.header('Authorization')?.trim();
|
|
3465
3927
|
if (authHeader?.startsWith('Bearer ')) {
|
|
3466
|
-
const bearerToken = authHeader.slice(7);
|
|
3928
|
+
const bearerToken = authHeader.slice(7).trim();
|
|
3467
3929
|
if (bearerToken === expectedToken) {
|
|
3468
3930
|
return { consoleUserId: 'token' };
|
|
3469
3931
|
}
|
|
3470
3932
|
}
|
|
3471
3933
|
|
|
3472
|
-
// Check query parameter
|
|
3473
|
-
const queryToken = c.req.query('token');
|
|
3474
|
-
if (queryToken === expectedToken) {
|
|
3475
|
-
return { consoleUserId: 'token' };
|
|
3476
|
-
}
|
|
3477
|
-
|
|
3478
3934
|
return null;
|
|
3479
3935
|
};
|
|
3480
3936
|
}
|