@syncular/server-hono 0.0.1-60
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/api-key-auth.d.ts +49 -0
- package/dist/api-key-auth.d.ts.map +1 -0
- package/dist/api-key-auth.js +110 -0
- package/dist/api-key-auth.js.map +1 -0
- package/dist/blobs.d.ts +69 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +383 -0
- package/dist/blobs.js.map +1 -0
- package/dist/console/index.d.ts +8 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +7 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/routes.d.ts +106 -0
- package/dist/console/routes.d.ts.map +1 -0
- package/dist/console/routes.js +1612 -0
- package/dist/console/routes.js.map +1 -0
- package/dist/console/schemas.d.ts +308 -0
- package/dist/console/schemas.d.ts.map +1 -0
- package/dist/console/schemas.js +201 -0
- package/dist/console/schemas.js.map +1 -0
- package/dist/create-server.d.ts +78 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +99 -0
- package/dist/create-server.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/openapi.d.ts +45 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +59 -0
- package/dist/openapi.js.map +1 -0
- package/dist/proxy/connection-manager.d.ts +78 -0
- package/dist/proxy/connection-manager.d.ts.map +1 -0
- package/dist/proxy/connection-manager.js +251 -0
- package/dist/proxy/connection-manager.js.map +1 -0
- package/dist/proxy/index.d.ts +8 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +8 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/routes.d.ts +74 -0
- package/dist/proxy/routes.d.ts.map +1 -0
- package/dist/proxy/routes.js +147 -0
- package/dist/proxy/routes.js.map +1 -0
- package/dist/rate-limit.d.ts +101 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +186 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/routes.d.ts +126 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +788 -0
- package/dist/routes.js.map +1 -0
- package/dist/ws.d.ts +230 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +601 -0
- package/dist/ws.js.map +1 -0
- package/package.json +73 -0
- package/src/__tests__/create-server.test.ts +187 -0
- package/src/__tests__/pull-chunk-storage.test.ts +189 -0
- package/src/__tests__/rate-limit.test.ts +78 -0
- package/src/__tests__/realtime-bridge.test.ts +131 -0
- package/src/__tests__/ws-connection-manager.test.ts +176 -0
- package/src/api-key-auth.ts +179 -0
- package/src/blobs.ts +534 -0
- package/src/console/index.ts +17 -0
- package/src/console/routes.ts +2155 -0
- package/src/console/schemas.ts +299 -0
- package/src/create-server.ts +180 -0
- package/src/index.ts +42 -0
- package/src/openapi.ts +74 -0
- package/src/proxy/connection-manager.ts +340 -0
- package/src/proxy/index.ts +8 -0
- package/src/proxy/routes.ts +223 -0
- package/src/rate-limit.ts +321 -0
- package/src/routes.ts +1186 -0
- package/src/ws.ts +789 -0
|
@@ -0,0 +1,2155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server-hono - Console API routes
|
|
3
|
+
*
|
|
4
|
+
* Provides monitoring and operations endpoints for the @syncular dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints:
|
|
7
|
+
* - GET /stats - Sync statistics
|
|
8
|
+
* - GET /commits - Paginated commit list
|
|
9
|
+
* - GET /commits/:seq - Single commit with changes
|
|
10
|
+
* - GET /clients - Client cursor list
|
|
11
|
+
* - GET /handlers - Registered handlers
|
|
12
|
+
* - POST /prune - Trigger pruning
|
|
13
|
+
* - POST /prune/preview - Preview pruning (dry run)
|
|
14
|
+
* - POST /compact - Trigger compaction
|
|
15
|
+
* - DELETE /clients/:id - Evict client
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { logSyncEvent } from '@syncular/core';
|
|
19
|
+
import type {
|
|
20
|
+
ServerSyncDialect,
|
|
21
|
+
ServerTableHandler,
|
|
22
|
+
SyncCoreDb,
|
|
23
|
+
} from '@syncular/server';
|
|
24
|
+
import {
|
|
25
|
+
compactChanges,
|
|
26
|
+
computePruneWatermarkCommitSeq,
|
|
27
|
+
pruneSync,
|
|
28
|
+
readSyncStats,
|
|
29
|
+
} from '@syncular/server';
|
|
30
|
+
import type { Context } from 'hono';
|
|
31
|
+
import { Hono } from 'hono';
|
|
32
|
+
import { cors } from 'hono/cors';
|
|
33
|
+
import type { UpgradeWebSocket } from 'hono/ws';
|
|
34
|
+
import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
|
|
35
|
+
import type { Kysely } from 'kysely';
|
|
36
|
+
import { z } from 'zod';
|
|
37
|
+
import type { WebSocketConnectionManager } from '../ws';
|
|
38
|
+
import {
|
|
39
|
+
type ApiKeyType,
|
|
40
|
+
ApiKeyTypeSchema,
|
|
41
|
+
type ConsoleApiKey,
|
|
42
|
+
ConsoleApiKeyCreateRequestSchema,
|
|
43
|
+
type ConsoleApiKeyCreateResponse,
|
|
44
|
+
ConsoleApiKeyCreateResponseSchema,
|
|
45
|
+
ConsoleApiKeyRevokeResponseSchema,
|
|
46
|
+
ConsoleApiKeySchema,
|
|
47
|
+
type ConsoleChange,
|
|
48
|
+
type ConsoleClearEventsResult,
|
|
49
|
+
ConsoleClearEventsResultSchema,
|
|
50
|
+
type ConsoleClient,
|
|
51
|
+
ConsoleClientSchema,
|
|
52
|
+
type ConsoleCommitDetail,
|
|
53
|
+
ConsoleCommitDetailSchema,
|
|
54
|
+
type ConsoleCommitListItem,
|
|
55
|
+
ConsoleCommitListItemSchema,
|
|
56
|
+
type ConsoleCompactResult,
|
|
57
|
+
ConsoleCompactResultSchema,
|
|
58
|
+
type ConsoleEvictResult,
|
|
59
|
+
ConsoleEvictResultSchema,
|
|
60
|
+
type ConsoleHandler,
|
|
61
|
+
ConsoleHandlerSchema,
|
|
62
|
+
type ConsolePaginatedResponse,
|
|
63
|
+
ConsolePaginatedResponseSchema,
|
|
64
|
+
ConsolePaginationQuerySchema,
|
|
65
|
+
type ConsolePruneEventsResult,
|
|
66
|
+
ConsolePruneEventsResultSchema,
|
|
67
|
+
type ConsolePrunePreview,
|
|
68
|
+
ConsolePrunePreviewSchema,
|
|
69
|
+
type ConsolePruneResult,
|
|
70
|
+
ConsolePruneResultSchema,
|
|
71
|
+
type ConsoleRequestEvent,
|
|
72
|
+
ConsoleRequestEventSchema,
|
|
73
|
+
type LatencyPercentiles,
|
|
74
|
+
LatencyQuerySchema,
|
|
75
|
+
type LatencyStatsResponse,
|
|
76
|
+
LatencyStatsResponseSchema,
|
|
77
|
+
type LiveEvent,
|
|
78
|
+
type SyncStats,
|
|
79
|
+
SyncStatsSchema,
|
|
80
|
+
type TimeseriesBucket,
|
|
81
|
+
TimeseriesQuerySchema,
|
|
82
|
+
type TimeseriesStatsResponse,
|
|
83
|
+
TimeseriesStatsResponseSchema,
|
|
84
|
+
} from './schemas';
|
|
85
|
+
|
|
86
|
+
export interface ConsoleAuthResult {
|
|
87
|
+
/** Identifier for the console user (for audit logging). */
|
|
88
|
+
consoleUserId?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Listener for console live events (SSE streaming).
|
|
93
|
+
*/
|
|
94
|
+
export type ConsoleEventListener = (event: LiveEvent) => void;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Console event emitter for broadcasting live events.
|
|
98
|
+
*/
|
|
99
|
+
export interface ConsoleEventEmitter {
|
|
100
|
+
/** Add a listener for live events */
|
|
101
|
+
addListener(listener: ConsoleEventListener): void;
|
|
102
|
+
/** Remove a listener */
|
|
103
|
+
removeListener(listener: ConsoleEventListener): void;
|
|
104
|
+
/** Emit an event to all listeners */
|
|
105
|
+
emit(event: LiveEvent): void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a simple console event emitter for broadcasting live events.
|
|
110
|
+
*/
|
|
111
|
+
export function createConsoleEventEmitter(): ConsoleEventEmitter {
|
|
112
|
+
const listeners = new Set<ConsoleEventListener>();
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
addListener(listener: ConsoleEventListener) {
|
|
116
|
+
listeners.add(listener);
|
|
117
|
+
},
|
|
118
|
+
removeListener(listener: ConsoleEventListener) {
|
|
119
|
+
listeners.delete(listener);
|
|
120
|
+
},
|
|
121
|
+
emit(event: LiveEvent) {
|
|
122
|
+
for (const listener of listeners) {
|
|
123
|
+
try {
|
|
124
|
+
listener(event);
|
|
125
|
+
} catch {
|
|
126
|
+
// Ignore errors in listeners
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface CreateConsoleRoutesOptions<
|
|
134
|
+
DB extends SyncCoreDb = SyncCoreDb,
|
|
135
|
+
> {
|
|
136
|
+
db: Kysely<DB>;
|
|
137
|
+
dialect: ServerSyncDialect;
|
|
138
|
+
handlers: ServerTableHandler<DB>[];
|
|
139
|
+
/**
|
|
140
|
+
* Authentication function for console requests.
|
|
141
|
+
* Return null to reject the request.
|
|
142
|
+
*/
|
|
143
|
+
authenticate: (c: Context) => Promise<ConsoleAuthResult | null>;
|
|
144
|
+
/**
|
|
145
|
+
* CORS origins to allow. Defaults to ['http://localhost:5173', 'https://console.sync.dev'].
|
|
146
|
+
* Set to '*' to allow all origins (not recommended for production).
|
|
147
|
+
*/
|
|
148
|
+
corsOrigins?: string[] | '*';
|
|
149
|
+
/**
|
|
150
|
+
* Compaction options (required for /compact endpoint).
|
|
151
|
+
*/
|
|
152
|
+
compact?: {
|
|
153
|
+
fullHistoryHours?: number;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Pruning options.
|
|
157
|
+
*/
|
|
158
|
+
prune?: {
|
|
159
|
+
activeWindowMs?: number;
|
|
160
|
+
fallbackMaxAgeMs?: number;
|
|
161
|
+
keepNewestCommits?: number;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Event emitter for live console events.
|
|
165
|
+
* If provided along with websocket config, enables the /events/live WebSocket endpoint.
|
|
166
|
+
*/
|
|
167
|
+
eventEmitter?: ConsoleEventEmitter;
|
|
168
|
+
/**
|
|
169
|
+
* Shared sync WebSocket connection manager.
|
|
170
|
+
* When provided, `/clients` includes realtime connection state per client.
|
|
171
|
+
*/
|
|
172
|
+
wsConnectionManager?: WebSocketConnectionManager;
|
|
173
|
+
/**
|
|
174
|
+
* WebSocket configuration for live events streaming.
|
|
175
|
+
*/
|
|
176
|
+
websocket?: {
|
|
177
|
+
enabled?: boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Runtime-provided WebSocket upgrader (e.g. from `hono/bun`'s `createBunWebSocket()`).
|
|
180
|
+
*/
|
|
181
|
+
upgradeWebSocket?: UpgradeWebSocket;
|
|
182
|
+
/**
|
|
183
|
+
* Heartbeat interval in milliseconds. Default: 30000
|
|
184
|
+
*/
|
|
185
|
+
heartbeatIntervalMs?: number;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function coerceNumber(value: unknown): number | null {
|
|
190
|
+
if (value === null || value === undefined) return null;
|
|
191
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
192
|
+
if (typeof value === 'bigint')
|
|
193
|
+
return Number.isFinite(Number(value)) ? Number(value) : null;
|
|
194
|
+
if (typeof value === 'string') {
|
|
195
|
+
const n = Number(value);
|
|
196
|
+
return Number.isFinite(n) ? n : null;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseDate(value: string | null | undefined): number | null {
|
|
202
|
+
if (!value) return null;
|
|
203
|
+
const parsed = Date.parse(value);
|
|
204
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function getClientActivityState(args: {
|
|
208
|
+
connectionCount: number;
|
|
209
|
+
updatedAt: string | null | undefined;
|
|
210
|
+
}): 'active' | 'idle' | 'stale' {
|
|
211
|
+
if (args.connectionCount > 0) {
|
|
212
|
+
return 'active';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const updatedAtMs = parseDate(args.updatedAt);
|
|
216
|
+
if (updatedAtMs === null) {
|
|
217
|
+
return 'stale';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const ageMs = Date.now() - updatedAtMs;
|
|
221
|
+
if (ageMs <= 60_000) {
|
|
222
|
+
return 'active';
|
|
223
|
+
}
|
|
224
|
+
if (ageMs <= 5 * 60_000) {
|
|
225
|
+
return 'idle';
|
|
226
|
+
}
|
|
227
|
+
return 'stale';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Route Schemas
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
const ErrorResponseSchema = z.object({
|
|
235
|
+
error: z.string(),
|
|
236
|
+
message: z.string().optional(),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const commitSeqParamSchema = z.object({ seq: z.coerce.number().int() });
|
|
240
|
+
const clientIdParamSchema = z.object({ id: z.string().min(1) });
|
|
241
|
+
const eventIdParamSchema = z.object({ id: z.coerce.number().int() });
|
|
242
|
+
const apiKeyIdParamSchema = z.object({ id: z.string().min(1) });
|
|
243
|
+
|
|
244
|
+
const eventsQuerySchema = ConsolePaginationQuerySchema.extend({
|
|
245
|
+
eventType: z.enum(['push', 'pull']).optional(),
|
|
246
|
+
actorId: z.string().optional(),
|
|
247
|
+
clientId: z.string().optional(),
|
|
248
|
+
outcome: z.string().optional(),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const apiKeysQuerySchema = ConsolePaginationQuerySchema.extend({
|
|
252
|
+
type: ApiKeyTypeSchema.optional(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const handlersResponseSchema = z.object({
|
|
256
|
+
items: z.array(ConsoleHandlerSchema),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
export function createConsoleRoutes<DB extends SyncCoreDb>(
|
|
260
|
+
options: CreateConsoleRoutesOptions<DB>
|
|
261
|
+
): Hono {
|
|
262
|
+
const routes = new Hono();
|
|
263
|
+
|
|
264
|
+
interface SyncRequestEventsTable {
|
|
265
|
+
event_id: number;
|
|
266
|
+
event_type: string;
|
|
267
|
+
transport_path: string;
|
|
268
|
+
actor_id: string;
|
|
269
|
+
client_id: string;
|
|
270
|
+
status_code: number;
|
|
271
|
+
outcome: string;
|
|
272
|
+
duration_ms: number;
|
|
273
|
+
commit_seq: number | null;
|
|
274
|
+
operation_count: number | null;
|
|
275
|
+
row_count: number | null;
|
|
276
|
+
tables: unknown;
|
|
277
|
+
error_message: string | null;
|
|
278
|
+
created_at: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface SyncApiKeysTable {
|
|
282
|
+
key_id: string;
|
|
283
|
+
key_hash: string;
|
|
284
|
+
key_prefix: string;
|
|
285
|
+
name: string;
|
|
286
|
+
key_type: string;
|
|
287
|
+
scope_keys: unknown | null;
|
|
288
|
+
actor_id: string | null;
|
|
289
|
+
created_at: string;
|
|
290
|
+
expires_at: string | null;
|
|
291
|
+
last_used_at: string | null;
|
|
292
|
+
revoked_at: string | null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
interface ConsoleDb extends SyncCoreDb {
|
|
296
|
+
sync_request_events: SyncRequestEventsTable;
|
|
297
|
+
sync_api_keys: SyncApiKeysTable;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const db = options.db as Pick<
|
|
301
|
+
Kysely<ConsoleDb>,
|
|
302
|
+
'selectFrom' | 'insertInto' | 'updateTable' | 'deleteFrom'
|
|
303
|
+
>;
|
|
304
|
+
|
|
305
|
+
// Ensure console schema exists (creates sync_request_events table if needed)
|
|
306
|
+
// Run asynchronously - will be ready before first request typically
|
|
307
|
+
options.dialect.ensureConsoleSchema?.(options.db).catch((err) => {
|
|
308
|
+
console.error('[console] Failed to ensure console schema:', err);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// CORS configuration
|
|
312
|
+
const corsOrigins = options.corsOrigins ?? [
|
|
313
|
+
'http://localhost:5173',
|
|
314
|
+
'https://console.sync.dev',
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
routes.use(
|
|
318
|
+
'*',
|
|
319
|
+
cors({
|
|
320
|
+
origin: corsOrigins === '*' ? '*' : corsOrigins,
|
|
321
|
+
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
322
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
323
|
+
exposeHeaders: ['X-Total-Count'],
|
|
324
|
+
credentials: true,
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Auth middleware
|
|
329
|
+
const requireAuth = async (c: Context): Promise<ConsoleAuthResult | null> => {
|
|
330
|
+
const auth = await options.authenticate(c);
|
|
331
|
+
if (!auth) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
return auth;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// -------------------------------------------------------------------------
|
|
338
|
+
// GET /stats
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
routes.get(
|
|
342
|
+
'/stats',
|
|
343
|
+
describeRoute({
|
|
344
|
+
tags: ['console'],
|
|
345
|
+
summary: 'Get sync statistics',
|
|
346
|
+
responses: {
|
|
347
|
+
200: {
|
|
348
|
+
description: 'Sync statistics',
|
|
349
|
+
content: {
|
|
350
|
+
'application/json': { schema: resolver(SyncStatsSchema) },
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
401: {
|
|
354
|
+
description: 'Unauthenticated',
|
|
355
|
+
content: {
|
|
356
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
}),
|
|
361
|
+
async (c) => {
|
|
362
|
+
const auth = await requireAuth(c);
|
|
363
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
364
|
+
|
|
365
|
+
const stats: SyncStats = await readSyncStats(options.db);
|
|
366
|
+
|
|
367
|
+
logSyncEvent({
|
|
368
|
+
event: 'console.stats',
|
|
369
|
+
consoleUserId: auth.consoleUserId,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return c.json(stats, 200);
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// -------------------------------------------------------------------------
|
|
377
|
+
// GET /stats/timeseries
|
|
378
|
+
// -------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
routes.get(
|
|
381
|
+
'/stats/timeseries',
|
|
382
|
+
describeRoute({
|
|
383
|
+
tags: ['console'],
|
|
384
|
+
summary: 'Get time-series statistics',
|
|
385
|
+
responses: {
|
|
386
|
+
200: {
|
|
387
|
+
description: 'Time-series statistics',
|
|
388
|
+
content: {
|
|
389
|
+
'application/json': {
|
|
390
|
+
schema: resolver(TimeseriesStatsResponseSchema),
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
401: {
|
|
395
|
+
description: 'Unauthenticated',
|
|
396
|
+
content: {
|
|
397
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
}),
|
|
402
|
+
zValidator('query', TimeseriesQuerySchema),
|
|
403
|
+
async (c) => {
|
|
404
|
+
const auth = await requireAuth(c);
|
|
405
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
406
|
+
|
|
407
|
+
const { interval, range } = c.req.valid('query');
|
|
408
|
+
|
|
409
|
+
// Calculate the time range
|
|
410
|
+
const rangeMs = {
|
|
411
|
+
'1h': 60 * 60 * 1000,
|
|
412
|
+
'6h': 6 * 60 * 60 * 1000,
|
|
413
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
414
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
415
|
+
'30d': 30 * 24 * 60 * 60 * 1000,
|
|
416
|
+
}[range];
|
|
417
|
+
|
|
418
|
+
const startTime = new Date(Date.now() - rangeMs);
|
|
419
|
+
|
|
420
|
+
// Get interval in milliseconds for bucket size
|
|
421
|
+
const intervalMs = {
|
|
422
|
+
minute: 60 * 1000,
|
|
423
|
+
hour: 60 * 60 * 1000,
|
|
424
|
+
day: 24 * 60 * 60 * 1000,
|
|
425
|
+
}[interval];
|
|
426
|
+
|
|
427
|
+
// Query events within the time range
|
|
428
|
+
const events = await db
|
|
429
|
+
.selectFrom('sync_request_events')
|
|
430
|
+
.select(['event_type', 'duration_ms', 'outcome', 'created_at'])
|
|
431
|
+
.where('created_at', '>=', startTime.toISOString())
|
|
432
|
+
.orderBy('created_at', 'asc')
|
|
433
|
+
.execute();
|
|
434
|
+
|
|
435
|
+
// Build buckets
|
|
436
|
+
const bucketMap = new Map<
|
|
437
|
+
string,
|
|
438
|
+
{
|
|
439
|
+
pushCount: number;
|
|
440
|
+
pullCount: number;
|
|
441
|
+
errorCount: number;
|
|
442
|
+
totalLatency: number;
|
|
443
|
+
eventCount: number;
|
|
444
|
+
}
|
|
445
|
+
>();
|
|
446
|
+
|
|
447
|
+
// Initialize buckets for the entire range
|
|
448
|
+
const bucketCount = Math.ceil(rangeMs / intervalMs);
|
|
449
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
450
|
+
const bucketTime = new Date(
|
|
451
|
+
startTime.getTime() + i * intervalMs
|
|
452
|
+
).toISOString();
|
|
453
|
+
bucketMap.set(bucketTime, {
|
|
454
|
+
pushCount: 0,
|
|
455
|
+
pullCount: 0,
|
|
456
|
+
errorCount: 0,
|
|
457
|
+
totalLatency: 0,
|
|
458
|
+
eventCount: 0,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Populate buckets with event data
|
|
463
|
+
for (const event of events) {
|
|
464
|
+
const eventTime = new Date(event.created_at as string).getTime();
|
|
465
|
+
const bucketIndex = Math.floor(
|
|
466
|
+
(eventTime - startTime.getTime()) / intervalMs
|
|
467
|
+
);
|
|
468
|
+
const bucketTime = new Date(
|
|
469
|
+
startTime.getTime() + bucketIndex * intervalMs
|
|
470
|
+
).toISOString();
|
|
471
|
+
|
|
472
|
+
let bucket = bucketMap.get(bucketTime);
|
|
473
|
+
if (!bucket) {
|
|
474
|
+
bucket = {
|
|
475
|
+
pushCount: 0,
|
|
476
|
+
pullCount: 0,
|
|
477
|
+
errorCount: 0,
|
|
478
|
+
totalLatency: 0,
|
|
479
|
+
eventCount: 0,
|
|
480
|
+
};
|
|
481
|
+
bucketMap.set(bucketTime, bucket);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (event.event_type === 'push') {
|
|
485
|
+
bucket.pushCount++;
|
|
486
|
+
} else if (event.event_type === 'pull') {
|
|
487
|
+
bucket.pullCount++;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (event.outcome === 'error') {
|
|
491
|
+
bucket.errorCount++;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const durationMs = coerceNumber(event.duration_ms);
|
|
495
|
+
if (durationMs !== null) {
|
|
496
|
+
bucket.totalLatency += durationMs;
|
|
497
|
+
bucket.eventCount++;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Convert to array and calculate averages
|
|
502
|
+
const buckets: TimeseriesBucket[] = Array.from(bucketMap.entries())
|
|
503
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
504
|
+
.map(([timestamp, data]) => ({
|
|
505
|
+
timestamp,
|
|
506
|
+
pushCount: data.pushCount,
|
|
507
|
+
pullCount: data.pullCount,
|
|
508
|
+
errorCount: data.errorCount,
|
|
509
|
+
avgLatencyMs:
|
|
510
|
+
data.eventCount > 0 ? data.totalLatency / data.eventCount : 0,
|
|
511
|
+
}));
|
|
512
|
+
|
|
513
|
+
const response: TimeseriesStatsResponse = {
|
|
514
|
+
buckets,
|
|
515
|
+
interval,
|
|
516
|
+
range,
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
return c.json(response, 200);
|
|
520
|
+
}
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
// -------------------------------------------------------------------------
|
|
524
|
+
// GET /stats/latency
|
|
525
|
+
// -------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
routes.get(
|
|
528
|
+
'/stats/latency',
|
|
529
|
+
describeRoute({
|
|
530
|
+
tags: ['console'],
|
|
531
|
+
summary: 'Get latency percentiles',
|
|
532
|
+
responses: {
|
|
533
|
+
200: {
|
|
534
|
+
description: 'Latency percentiles',
|
|
535
|
+
content: {
|
|
536
|
+
'application/json': {
|
|
537
|
+
schema: resolver(LatencyStatsResponseSchema),
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
401: {
|
|
542
|
+
description: 'Unauthenticated',
|
|
543
|
+
content: {
|
|
544
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
}),
|
|
549
|
+
zValidator('query', LatencyQuerySchema),
|
|
550
|
+
async (c) => {
|
|
551
|
+
const auth = await requireAuth(c);
|
|
552
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
553
|
+
|
|
554
|
+
const { range } = c.req.valid('query');
|
|
555
|
+
|
|
556
|
+
// Calculate the time range
|
|
557
|
+
const rangeMs = {
|
|
558
|
+
'1h': 60 * 60 * 1000,
|
|
559
|
+
'6h': 6 * 60 * 60 * 1000,
|
|
560
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
561
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
562
|
+
'30d': 30 * 24 * 60 * 60 * 1000,
|
|
563
|
+
}[range];
|
|
564
|
+
|
|
565
|
+
const startTime = new Date(Date.now() - rangeMs);
|
|
566
|
+
|
|
567
|
+
// Get all latencies for push and pull events
|
|
568
|
+
const events = await db
|
|
569
|
+
.selectFrom('sync_request_events')
|
|
570
|
+
.select(['event_type', 'duration_ms'])
|
|
571
|
+
.where('created_at', '>=', startTime.toISOString())
|
|
572
|
+
.execute();
|
|
573
|
+
|
|
574
|
+
const pushLatencies: number[] = [];
|
|
575
|
+
const pullLatencies: number[] = [];
|
|
576
|
+
|
|
577
|
+
for (const event of events) {
|
|
578
|
+
const durationMs = coerceNumber(event.duration_ms);
|
|
579
|
+
if (durationMs !== null) {
|
|
580
|
+
if (event.event_type === 'push') {
|
|
581
|
+
pushLatencies.push(durationMs);
|
|
582
|
+
} else if (event.event_type === 'pull') {
|
|
583
|
+
pullLatencies.push(durationMs);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Calculate percentiles
|
|
589
|
+
const calculatePercentiles = (
|
|
590
|
+
latencies: number[]
|
|
591
|
+
): LatencyPercentiles => {
|
|
592
|
+
if (latencies.length === 0) {
|
|
593
|
+
return { p50: 0, p90: 0, p99: 0 };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
597
|
+
const getPercentile = (p: number): number => {
|
|
598
|
+
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
|
599
|
+
return sorted[Math.max(0, index)] ?? 0;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
p50: getPercentile(50),
|
|
604
|
+
p90: getPercentile(90),
|
|
605
|
+
p99: getPercentile(99),
|
|
606
|
+
};
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const response: LatencyStatsResponse = {
|
|
610
|
+
push: calculatePercentiles(pushLatencies),
|
|
611
|
+
pull: calculatePercentiles(pullLatencies),
|
|
612
|
+
range,
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
return c.json(response, 200);
|
|
616
|
+
}
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// -------------------------------------------------------------------------
|
|
620
|
+
// GET /commits
|
|
621
|
+
// -------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
routes.get(
|
|
624
|
+
'/commits',
|
|
625
|
+
describeRoute({
|
|
626
|
+
tags: ['console'],
|
|
627
|
+
summary: 'List commits',
|
|
628
|
+
responses: {
|
|
629
|
+
200: {
|
|
630
|
+
description: 'Paginated commit list',
|
|
631
|
+
content: {
|
|
632
|
+
'application/json': {
|
|
633
|
+
schema: resolver(
|
|
634
|
+
ConsolePaginatedResponseSchema(ConsoleCommitListItemSchema)
|
|
635
|
+
),
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
401: {
|
|
640
|
+
description: 'Unauthenticated',
|
|
641
|
+
content: {
|
|
642
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
}),
|
|
647
|
+
zValidator('query', ConsolePaginationQuerySchema),
|
|
648
|
+
async (c) => {
|
|
649
|
+
const auth = await requireAuth(c);
|
|
650
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
651
|
+
|
|
652
|
+
const { limit, offset } = c.req.valid('query');
|
|
653
|
+
|
|
654
|
+
const [rows, countRow] = await Promise.all([
|
|
655
|
+
db
|
|
656
|
+
.selectFrom('sync_commits')
|
|
657
|
+
.select([
|
|
658
|
+
'commit_seq',
|
|
659
|
+
'actor_id',
|
|
660
|
+
'client_id',
|
|
661
|
+
'client_commit_id',
|
|
662
|
+
'created_at',
|
|
663
|
+
'change_count',
|
|
664
|
+
'affected_tables',
|
|
665
|
+
])
|
|
666
|
+
.orderBy('commit_seq', 'desc')
|
|
667
|
+
.limit(limit)
|
|
668
|
+
.offset(offset)
|
|
669
|
+
.execute(),
|
|
670
|
+
db
|
|
671
|
+
.selectFrom('sync_commits')
|
|
672
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
673
|
+
.executeTakeFirst(),
|
|
674
|
+
]);
|
|
675
|
+
|
|
676
|
+
const items: ConsoleCommitListItem[] = rows.map((row) => ({
|
|
677
|
+
commitSeq: coerceNumber(row.commit_seq) ?? 0,
|
|
678
|
+
actorId: row.actor_id ?? '',
|
|
679
|
+
clientId: row.client_id ?? '',
|
|
680
|
+
clientCommitId: row.client_commit_id ?? '',
|
|
681
|
+
createdAt: row.created_at ?? '',
|
|
682
|
+
changeCount: coerceNumber(row.change_count) ?? 0,
|
|
683
|
+
affectedTables: options.dialect.dbToArray(row.affected_tables),
|
|
684
|
+
}));
|
|
685
|
+
|
|
686
|
+
const total = coerceNumber(countRow?.total) ?? 0;
|
|
687
|
+
|
|
688
|
+
const response: ConsolePaginatedResponse<ConsoleCommitListItem> = {
|
|
689
|
+
items,
|
|
690
|
+
total,
|
|
691
|
+
offset,
|
|
692
|
+
limit,
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
c.header('X-Total-Count', String(total));
|
|
696
|
+
return c.json(response, 200);
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// -------------------------------------------------------------------------
|
|
701
|
+
// GET /commits/:seq
|
|
702
|
+
// -------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
routes.get(
|
|
705
|
+
'/commits/:seq',
|
|
706
|
+
describeRoute({
|
|
707
|
+
tags: ['console'],
|
|
708
|
+
summary: 'Get commit details',
|
|
709
|
+
responses: {
|
|
710
|
+
200: {
|
|
711
|
+
description: 'Commit with changes',
|
|
712
|
+
content: {
|
|
713
|
+
'application/json': { schema: resolver(ConsoleCommitDetailSchema) },
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
400: {
|
|
717
|
+
description: 'Invalid request',
|
|
718
|
+
content: {
|
|
719
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
401: {
|
|
723
|
+
description: 'Unauthenticated',
|
|
724
|
+
content: {
|
|
725
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
404: {
|
|
729
|
+
description: 'Not found',
|
|
730
|
+
content: {
|
|
731
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
}),
|
|
736
|
+
zValidator('param', commitSeqParamSchema),
|
|
737
|
+
async (c) => {
|
|
738
|
+
const auth = await requireAuth(c);
|
|
739
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
740
|
+
|
|
741
|
+
const { seq } = c.req.valid('param');
|
|
742
|
+
|
|
743
|
+
const commitRow = await db
|
|
744
|
+
.selectFrom('sync_commits')
|
|
745
|
+
.select([
|
|
746
|
+
'commit_seq',
|
|
747
|
+
'actor_id',
|
|
748
|
+
'client_id',
|
|
749
|
+
'client_commit_id',
|
|
750
|
+
'created_at',
|
|
751
|
+
'change_count',
|
|
752
|
+
'affected_tables',
|
|
753
|
+
])
|
|
754
|
+
.where('commit_seq', '=', seq)
|
|
755
|
+
.executeTakeFirst();
|
|
756
|
+
|
|
757
|
+
if (!commitRow) {
|
|
758
|
+
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const changeRows = await db
|
|
762
|
+
.selectFrom('sync_changes')
|
|
763
|
+
.select([
|
|
764
|
+
'change_id',
|
|
765
|
+
'table',
|
|
766
|
+
'row_id',
|
|
767
|
+
'op',
|
|
768
|
+
'row_json',
|
|
769
|
+
'row_version',
|
|
770
|
+
'scopes',
|
|
771
|
+
])
|
|
772
|
+
.where('commit_seq', '=', seq)
|
|
773
|
+
.orderBy('change_id', 'asc')
|
|
774
|
+
.execute();
|
|
775
|
+
|
|
776
|
+
const changes: ConsoleChange[] = changeRows.map((row) => ({
|
|
777
|
+
changeId: coerceNumber(row.change_id) ?? 0,
|
|
778
|
+
table: row.table ?? '',
|
|
779
|
+
rowId: row.row_id ?? '',
|
|
780
|
+
op: row.op === 'delete' ? 'delete' : 'upsert',
|
|
781
|
+
rowJson: row.row_json,
|
|
782
|
+
rowVersion: coerceNumber(row.row_version),
|
|
783
|
+
scopes:
|
|
784
|
+
typeof row.scopes === 'string'
|
|
785
|
+
? JSON.parse(row.scopes || '{}')
|
|
786
|
+
: (row.scopes ?? {}),
|
|
787
|
+
}));
|
|
788
|
+
|
|
789
|
+
const commit: ConsoleCommitDetail = {
|
|
790
|
+
commitSeq: coerceNumber(commitRow.commit_seq) ?? 0,
|
|
791
|
+
actorId: commitRow.actor_id ?? '',
|
|
792
|
+
clientId: commitRow.client_id ?? '',
|
|
793
|
+
clientCommitId: commitRow.client_commit_id ?? '',
|
|
794
|
+
createdAt: commitRow.created_at ?? '',
|
|
795
|
+
changeCount: coerceNumber(commitRow.change_count) ?? 0,
|
|
796
|
+
affectedTables: Array.isArray(commitRow.affected_tables)
|
|
797
|
+
? commitRow.affected_tables
|
|
798
|
+
: typeof commitRow.affected_tables === 'string'
|
|
799
|
+
? JSON.parse(commitRow.affected_tables || '[]')
|
|
800
|
+
: [],
|
|
801
|
+
changes,
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
return c.json(commit, 200);
|
|
805
|
+
}
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
// -------------------------------------------------------------------------
|
|
809
|
+
// GET /clients
|
|
810
|
+
// -------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
routes.get(
|
|
813
|
+
'/clients',
|
|
814
|
+
describeRoute({
|
|
815
|
+
tags: ['console'],
|
|
816
|
+
summary: 'List clients',
|
|
817
|
+
responses: {
|
|
818
|
+
200: {
|
|
819
|
+
description: 'Paginated client list',
|
|
820
|
+
content: {
|
|
821
|
+
'application/json': {
|
|
822
|
+
schema: resolver(
|
|
823
|
+
ConsolePaginatedResponseSchema(ConsoleClientSchema)
|
|
824
|
+
),
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
401: {
|
|
829
|
+
description: 'Unauthenticated',
|
|
830
|
+
content: {
|
|
831
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
832
|
+
},
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
}),
|
|
836
|
+
zValidator('query', ConsolePaginationQuerySchema),
|
|
837
|
+
async (c) => {
|
|
838
|
+
const auth = await requireAuth(c);
|
|
839
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
840
|
+
|
|
841
|
+
const { limit, offset } = c.req.valid('query');
|
|
842
|
+
|
|
843
|
+
const [rows, countRow, maxCommitSeqRow] = await Promise.all([
|
|
844
|
+
db
|
|
845
|
+
.selectFrom('sync_client_cursors')
|
|
846
|
+
.select([
|
|
847
|
+
'client_id',
|
|
848
|
+
'actor_id',
|
|
849
|
+
'cursor',
|
|
850
|
+
'effective_scopes',
|
|
851
|
+
'updated_at',
|
|
852
|
+
])
|
|
853
|
+
.orderBy('updated_at', 'desc')
|
|
854
|
+
.limit(limit)
|
|
855
|
+
.offset(offset)
|
|
856
|
+
.execute(),
|
|
857
|
+
db
|
|
858
|
+
.selectFrom('sync_client_cursors')
|
|
859
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
860
|
+
.executeTakeFirst(),
|
|
861
|
+
db
|
|
862
|
+
.selectFrom('sync_commits')
|
|
863
|
+
.select(({ fn }) => fn.max('commit_seq').as('max_commit_seq'))
|
|
864
|
+
.executeTakeFirst(),
|
|
865
|
+
]);
|
|
866
|
+
|
|
867
|
+
const maxCommitSeq = coerceNumber(maxCommitSeqRow?.max_commit_seq) ?? 0;
|
|
868
|
+
const pagedClientIds = rows
|
|
869
|
+
.map((row) => row.client_id)
|
|
870
|
+
.filter((clientId): clientId is string => typeof clientId === 'string');
|
|
871
|
+
|
|
872
|
+
const latestEventsByClientId = new Map<
|
|
873
|
+
string,
|
|
874
|
+
{
|
|
875
|
+
createdAt: string;
|
|
876
|
+
eventType: 'push' | 'pull';
|
|
877
|
+
outcome: string;
|
|
878
|
+
transportPath: 'direct' | 'relay';
|
|
879
|
+
}
|
|
880
|
+
>();
|
|
881
|
+
|
|
882
|
+
if (pagedClientIds.length > 0) {
|
|
883
|
+
const recentEventRows = await db
|
|
884
|
+
.selectFrom('sync_request_events')
|
|
885
|
+
.select([
|
|
886
|
+
'client_id',
|
|
887
|
+
'event_type',
|
|
888
|
+
'outcome',
|
|
889
|
+
'created_at',
|
|
890
|
+
'transport_path',
|
|
891
|
+
])
|
|
892
|
+
.where('client_id', 'in', pagedClientIds)
|
|
893
|
+
.orderBy('created_at', 'desc')
|
|
894
|
+
.execute();
|
|
895
|
+
|
|
896
|
+
for (const row of recentEventRows) {
|
|
897
|
+
const clientId = row.client_id;
|
|
898
|
+
if (!clientId || latestEventsByClientId.has(clientId)) {
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const eventType = row.event_type === 'push' ? 'push' : 'pull';
|
|
903
|
+
|
|
904
|
+
latestEventsByClientId.set(clientId, {
|
|
905
|
+
createdAt: row.created_at ?? '',
|
|
906
|
+
eventType,
|
|
907
|
+
outcome: row.outcome ?? '',
|
|
908
|
+
transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const items: ConsoleClient[] = rows.map((row) => {
|
|
914
|
+
const clientId = row.client_id ?? '';
|
|
915
|
+
const cursor = coerceNumber(row.cursor) ?? 0;
|
|
916
|
+
const latestEvent = latestEventsByClientId.get(clientId);
|
|
917
|
+
const connectionCount =
|
|
918
|
+
options.wsConnectionManager?.getConnectionCount(clientId) ?? 0;
|
|
919
|
+
const connectionPath =
|
|
920
|
+
options.wsConnectionManager?.getClientTransportPath(clientId) ??
|
|
921
|
+
latestEvent?.transportPath ??
|
|
922
|
+
'direct';
|
|
923
|
+
|
|
924
|
+
return {
|
|
925
|
+
clientId,
|
|
926
|
+
actorId: row.actor_id ?? '',
|
|
927
|
+
cursor,
|
|
928
|
+
lagCommitCount: Math.max(0, maxCommitSeq - cursor),
|
|
929
|
+
connectionPath,
|
|
930
|
+
connectionMode: connectionCount > 0 ? 'realtime' : 'polling',
|
|
931
|
+
realtimeConnectionCount: connectionCount,
|
|
932
|
+
isRealtimeConnected: connectionCount > 0,
|
|
933
|
+
activityState: getClientActivityState({
|
|
934
|
+
connectionCount,
|
|
935
|
+
updatedAt: row.updated_at,
|
|
936
|
+
}),
|
|
937
|
+
lastRequestAt: latestEvent?.createdAt ?? null,
|
|
938
|
+
lastRequestType: latestEvent?.eventType ?? null,
|
|
939
|
+
lastRequestOutcome: latestEvent?.outcome ?? null,
|
|
940
|
+
effectiveScopes: options.dialect.dbToScopes(row.effective_scopes),
|
|
941
|
+
updatedAt: row.updated_at ?? '',
|
|
942
|
+
};
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
const total = coerceNumber(countRow?.total) ?? 0;
|
|
946
|
+
|
|
947
|
+
const response: ConsolePaginatedResponse<ConsoleClient> = {
|
|
948
|
+
items,
|
|
949
|
+
total,
|
|
950
|
+
offset,
|
|
951
|
+
limit,
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
c.header('X-Total-Count', String(total));
|
|
955
|
+
return c.json(response, 200);
|
|
956
|
+
}
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
// -------------------------------------------------------------------------
|
|
960
|
+
// GET /handlers
|
|
961
|
+
// -------------------------------------------------------------------------
|
|
962
|
+
|
|
963
|
+
routes.get(
|
|
964
|
+
'/handlers',
|
|
965
|
+
describeRoute({
|
|
966
|
+
tags: ['console'],
|
|
967
|
+
summary: 'List registered handlers',
|
|
968
|
+
responses: {
|
|
969
|
+
200: {
|
|
970
|
+
description: 'Handler list',
|
|
971
|
+
content: {
|
|
972
|
+
'application/json': { schema: resolver(handlersResponseSchema) },
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
401: {
|
|
976
|
+
description: 'Unauthenticated',
|
|
977
|
+
content: {
|
|
978
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
}),
|
|
983
|
+
async (c) => {
|
|
984
|
+
const auth = await requireAuth(c);
|
|
985
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
986
|
+
|
|
987
|
+
const items: ConsoleHandler[] = options.handlers.map((handler) => ({
|
|
988
|
+
table: handler.table,
|
|
989
|
+
dependsOn: handler.dependsOn,
|
|
990
|
+
snapshotChunkTtlMs: handler.snapshotChunkTtlMs,
|
|
991
|
+
}));
|
|
992
|
+
|
|
993
|
+
return c.json({ items }, 200);
|
|
994
|
+
}
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
// -------------------------------------------------------------------------
|
|
998
|
+
// POST /prune/preview
|
|
999
|
+
// -------------------------------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
routes.post(
|
|
1002
|
+
'/prune/preview',
|
|
1003
|
+
describeRoute({
|
|
1004
|
+
tags: ['console'],
|
|
1005
|
+
summary: 'Preview pruning',
|
|
1006
|
+
responses: {
|
|
1007
|
+
200: {
|
|
1008
|
+
description: 'Prune preview',
|
|
1009
|
+
content: {
|
|
1010
|
+
'application/json': { schema: resolver(ConsolePrunePreviewSchema) },
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
401: {
|
|
1014
|
+
description: 'Unauthenticated',
|
|
1015
|
+
content: {
|
|
1016
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
}),
|
|
1021
|
+
async (c) => {
|
|
1022
|
+
const auth = await requireAuth(c);
|
|
1023
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1024
|
+
|
|
1025
|
+
const watermarkCommitSeq = await computePruneWatermarkCommitSeq(
|
|
1026
|
+
options.db,
|
|
1027
|
+
options.prune
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
// Count commits that would be deleted
|
|
1031
|
+
const countRow = await db
|
|
1032
|
+
.selectFrom('sync_commits')
|
|
1033
|
+
.select(({ fn }) => fn.countAll().as('count'))
|
|
1034
|
+
.where('commit_seq', '<=', watermarkCommitSeq)
|
|
1035
|
+
.executeTakeFirst();
|
|
1036
|
+
|
|
1037
|
+
const commitsToDelete = coerceNumber(countRow?.count) ?? 0;
|
|
1038
|
+
|
|
1039
|
+
const preview: ConsolePrunePreview = {
|
|
1040
|
+
watermarkCommitSeq,
|
|
1041
|
+
commitsToDelete,
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
return c.json(preview, 200);
|
|
1045
|
+
}
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
// -------------------------------------------------------------------------
|
|
1049
|
+
// POST /prune
|
|
1050
|
+
// -------------------------------------------------------------------------
|
|
1051
|
+
|
|
1052
|
+
routes.post(
|
|
1053
|
+
'/prune',
|
|
1054
|
+
describeRoute({
|
|
1055
|
+
tags: ['console'],
|
|
1056
|
+
summary: 'Trigger pruning',
|
|
1057
|
+
responses: {
|
|
1058
|
+
200: {
|
|
1059
|
+
description: 'Prune result',
|
|
1060
|
+
content: {
|
|
1061
|
+
'application/json': { schema: resolver(ConsolePruneResultSchema) },
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
401: {
|
|
1065
|
+
description: 'Unauthenticated',
|
|
1066
|
+
content: {
|
|
1067
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
},
|
|
1071
|
+
}),
|
|
1072
|
+
async (c) => {
|
|
1073
|
+
const auth = await requireAuth(c);
|
|
1074
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1075
|
+
|
|
1076
|
+
const watermarkCommitSeq = await computePruneWatermarkCommitSeq(
|
|
1077
|
+
options.db,
|
|
1078
|
+
options.prune
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
const deletedCommits = await pruneSync(options.db, {
|
|
1082
|
+
watermarkCommitSeq,
|
|
1083
|
+
keepNewestCommits: options.prune?.keepNewestCommits,
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
logSyncEvent({
|
|
1087
|
+
event: 'console.prune',
|
|
1088
|
+
consoleUserId: auth.consoleUserId,
|
|
1089
|
+
deletedCommits,
|
|
1090
|
+
watermarkCommitSeq,
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
const result: ConsolePruneResult = { deletedCommits };
|
|
1094
|
+
return c.json(result, 200);
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
// -------------------------------------------------------------------------
|
|
1099
|
+
// POST /compact
|
|
1100
|
+
// -------------------------------------------------------------------------
|
|
1101
|
+
|
|
1102
|
+
routes.post(
|
|
1103
|
+
'/compact',
|
|
1104
|
+
describeRoute({
|
|
1105
|
+
tags: ['console'],
|
|
1106
|
+
summary: 'Trigger compaction',
|
|
1107
|
+
responses: {
|
|
1108
|
+
200: {
|
|
1109
|
+
description: 'Compact result',
|
|
1110
|
+
content: {
|
|
1111
|
+
'application/json': {
|
|
1112
|
+
schema: resolver(ConsoleCompactResultSchema),
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
},
|
|
1116
|
+
401: {
|
|
1117
|
+
description: 'Unauthenticated',
|
|
1118
|
+
content: {
|
|
1119
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1120
|
+
},
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
}),
|
|
1124
|
+
async (c) => {
|
|
1125
|
+
const auth = await requireAuth(c);
|
|
1126
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1127
|
+
|
|
1128
|
+
const fullHistoryHours = options.compact?.fullHistoryHours ?? 24 * 7;
|
|
1129
|
+
|
|
1130
|
+
const deletedChanges = await compactChanges(options.db, {
|
|
1131
|
+
dialect: options.dialect,
|
|
1132
|
+
options: { fullHistoryHours },
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
logSyncEvent({
|
|
1136
|
+
event: 'console.compact',
|
|
1137
|
+
consoleUserId: auth.consoleUserId,
|
|
1138
|
+
deletedChanges,
|
|
1139
|
+
fullHistoryHours,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
const result: ConsoleCompactResult = { deletedChanges };
|
|
1143
|
+
return c.json(result, 200);
|
|
1144
|
+
}
|
|
1145
|
+
);
|
|
1146
|
+
|
|
1147
|
+
// -------------------------------------------------------------------------
|
|
1148
|
+
// DELETE /clients/:id
|
|
1149
|
+
// -------------------------------------------------------------------------
|
|
1150
|
+
|
|
1151
|
+
routes.delete(
|
|
1152
|
+
'/clients/:id',
|
|
1153
|
+
describeRoute({
|
|
1154
|
+
tags: ['console'],
|
|
1155
|
+
summary: 'Evict client',
|
|
1156
|
+
responses: {
|
|
1157
|
+
200: {
|
|
1158
|
+
description: 'Evict result',
|
|
1159
|
+
content: {
|
|
1160
|
+
'application/json': { schema: resolver(ConsoleEvictResultSchema) },
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
400: {
|
|
1164
|
+
description: 'Invalid request',
|
|
1165
|
+
content: {
|
|
1166
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1167
|
+
},
|
|
1168
|
+
},
|
|
1169
|
+
401: {
|
|
1170
|
+
description: 'Unauthenticated',
|
|
1171
|
+
content: {
|
|
1172
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1173
|
+
},
|
|
1174
|
+
},
|
|
1175
|
+
},
|
|
1176
|
+
}),
|
|
1177
|
+
zValidator('param', clientIdParamSchema),
|
|
1178
|
+
async (c) => {
|
|
1179
|
+
const auth = await requireAuth(c);
|
|
1180
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1181
|
+
|
|
1182
|
+
const { id: clientId } = c.req.valid('param');
|
|
1183
|
+
|
|
1184
|
+
const res = await db
|
|
1185
|
+
.deleteFrom('sync_client_cursors')
|
|
1186
|
+
.where('client_id', '=', clientId)
|
|
1187
|
+
.executeTakeFirst();
|
|
1188
|
+
|
|
1189
|
+
const evicted = Number(res?.numDeletedRows ?? 0) > 0;
|
|
1190
|
+
|
|
1191
|
+
logSyncEvent({
|
|
1192
|
+
event: 'console.evict_client',
|
|
1193
|
+
consoleUserId: auth.consoleUserId,
|
|
1194
|
+
clientId,
|
|
1195
|
+
evicted,
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
const result: ConsoleEvictResult = { evicted };
|
|
1199
|
+
return c.json(result, 200);
|
|
1200
|
+
}
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
// -------------------------------------------------------------------------
|
|
1204
|
+
// GET /events - Paginated request events list
|
|
1205
|
+
// -------------------------------------------------------------------------
|
|
1206
|
+
|
|
1207
|
+
routes.get(
|
|
1208
|
+
'/events',
|
|
1209
|
+
describeRoute({
|
|
1210
|
+
tags: ['console'],
|
|
1211
|
+
summary: 'List request events',
|
|
1212
|
+
responses: {
|
|
1213
|
+
200: {
|
|
1214
|
+
description: 'Paginated event list',
|
|
1215
|
+
content: {
|
|
1216
|
+
'application/json': {
|
|
1217
|
+
schema: resolver(
|
|
1218
|
+
ConsolePaginatedResponseSchema(ConsoleRequestEventSchema)
|
|
1219
|
+
),
|
|
1220
|
+
},
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
401: {
|
|
1224
|
+
description: 'Unauthenticated',
|
|
1225
|
+
content: {
|
|
1226
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1227
|
+
},
|
|
1228
|
+
},
|
|
1229
|
+
},
|
|
1230
|
+
}),
|
|
1231
|
+
zValidator('query', eventsQuerySchema),
|
|
1232
|
+
async (c) => {
|
|
1233
|
+
const auth = await requireAuth(c);
|
|
1234
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1235
|
+
|
|
1236
|
+
const { limit, offset, eventType, actorId, clientId, outcome } =
|
|
1237
|
+
c.req.valid('query');
|
|
1238
|
+
|
|
1239
|
+
let query = db
|
|
1240
|
+
.selectFrom('sync_request_events')
|
|
1241
|
+
.select([
|
|
1242
|
+
'event_id',
|
|
1243
|
+
'event_type',
|
|
1244
|
+
'transport_path',
|
|
1245
|
+
'actor_id',
|
|
1246
|
+
'client_id',
|
|
1247
|
+
'status_code',
|
|
1248
|
+
'outcome',
|
|
1249
|
+
'duration_ms',
|
|
1250
|
+
'commit_seq',
|
|
1251
|
+
'operation_count',
|
|
1252
|
+
'row_count',
|
|
1253
|
+
'tables',
|
|
1254
|
+
'error_message',
|
|
1255
|
+
'created_at',
|
|
1256
|
+
]);
|
|
1257
|
+
|
|
1258
|
+
let countQuery = db
|
|
1259
|
+
.selectFrom('sync_request_events')
|
|
1260
|
+
.select(({ fn }) => fn.countAll().as('total'));
|
|
1261
|
+
|
|
1262
|
+
if (eventType) {
|
|
1263
|
+
query = query.where('event_type', '=', eventType);
|
|
1264
|
+
countQuery = countQuery.where('event_type', '=', eventType);
|
|
1265
|
+
}
|
|
1266
|
+
if (actorId) {
|
|
1267
|
+
query = query.where('actor_id', '=', actorId);
|
|
1268
|
+
countQuery = countQuery.where('actor_id', '=', actorId);
|
|
1269
|
+
}
|
|
1270
|
+
if (clientId) {
|
|
1271
|
+
query = query.where('client_id', '=', clientId);
|
|
1272
|
+
countQuery = countQuery.where('client_id', '=', clientId);
|
|
1273
|
+
}
|
|
1274
|
+
if (outcome) {
|
|
1275
|
+
query = query.where('outcome', '=', outcome);
|
|
1276
|
+
countQuery = countQuery.where('outcome', '=', outcome);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const [rows, countRow] = await Promise.all([
|
|
1280
|
+
query
|
|
1281
|
+
.orderBy('created_at', 'desc')
|
|
1282
|
+
.limit(limit)
|
|
1283
|
+
.offset(offset)
|
|
1284
|
+
.execute(),
|
|
1285
|
+
countQuery.executeTakeFirst(),
|
|
1286
|
+
]);
|
|
1287
|
+
|
|
1288
|
+
const items: ConsoleRequestEvent[] = rows.map((row) => ({
|
|
1289
|
+
eventId: coerceNumber(row.event_id) ?? 0,
|
|
1290
|
+
eventType: row.event_type as 'push' | 'pull',
|
|
1291
|
+
transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
|
|
1292
|
+
actorId: row.actor_id ?? '',
|
|
1293
|
+
clientId: row.client_id ?? '',
|
|
1294
|
+
statusCode: coerceNumber(row.status_code) ?? 0,
|
|
1295
|
+
outcome: row.outcome ?? '',
|
|
1296
|
+
durationMs: coerceNumber(row.duration_ms) ?? 0,
|
|
1297
|
+
commitSeq: coerceNumber(row.commit_seq),
|
|
1298
|
+
operationCount: coerceNumber(row.operation_count),
|
|
1299
|
+
rowCount: coerceNumber(row.row_count),
|
|
1300
|
+
tables: options.dialect.dbToArray(row.tables),
|
|
1301
|
+
errorMessage: row.error_message ?? null,
|
|
1302
|
+
createdAt: row.created_at ?? '',
|
|
1303
|
+
}));
|
|
1304
|
+
|
|
1305
|
+
const total = coerceNumber(countRow?.total) ?? 0;
|
|
1306
|
+
|
|
1307
|
+
const response: ConsolePaginatedResponse<ConsoleRequestEvent> = {
|
|
1308
|
+
items,
|
|
1309
|
+
total,
|
|
1310
|
+
offset,
|
|
1311
|
+
limit,
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
c.header('X-Total-Count', String(total));
|
|
1315
|
+
return c.json(response, 200);
|
|
1316
|
+
}
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
// -------------------------------------------------------------------------
|
|
1320
|
+
// GET /events/live - WebSocket for live activity feed
|
|
1321
|
+
// NOTE: Must be defined BEFORE /events/:id to avoid route conflict
|
|
1322
|
+
// -------------------------------------------------------------------------
|
|
1323
|
+
|
|
1324
|
+
if (
|
|
1325
|
+
options.eventEmitter &&
|
|
1326
|
+
options.websocket?.enabled &&
|
|
1327
|
+
options.websocket?.upgradeWebSocket
|
|
1328
|
+
) {
|
|
1329
|
+
const emitter = options.eventEmitter;
|
|
1330
|
+
const upgradeWebSocket = options.websocket.upgradeWebSocket;
|
|
1331
|
+
const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
|
|
1332
|
+
|
|
1333
|
+
type WebSocketLike = {
|
|
1334
|
+
send: (data: string) => void;
|
|
1335
|
+
close: (code?: number, reason?: string) => void;
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
const wsState = new WeakMap<
|
|
1339
|
+
WebSocketLike,
|
|
1340
|
+
{
|
|
1341
|
+
listener: ConsoleEventListener;
|
|
1342
|
+
heartbeatInterval: ReturnType<typeof setInterval>;
|
|
1343
|
+
}
|
|
1344
|
+
>();
|
|
1345
|
+
|
|
1346
|
+
routes.get(
|
|
1347
|
+
'/events/live',
|
|
1348
|
+
upgradeWebSocket(async (c) => {
|
|
1349
|
+
// Auth check via query param (WebSocket doesn't support headers easily)
|
|
1350
|
+
const token = c.req.query('token');
|
|
1351
|
+
const authHeader = c.req.header('Authorization');
|
|
1352
|
+
const mockContext = {
|
|
1353
|
+
req: {
|
|
1354
|
+
header: (name: string) =>
|
|
1355
|
+
name === 'Authorization' ? authHeader : undefined,
|
|
1356
|
+
query: (name: string) => (name === 'token' ? token : undefined),
|
|
1357
|
+
},
|
|
1358
|
+
} as Context;
|
|
1359
|
+
|
|
1360
|
+
const auth = await options.authenticate(mockContext);
|
|
1361
|
+
|
|
1362
|
+
return {
|
|
1363
|
+
onOpen(_event, ws) {
|
|
1364
|
+
if (!auth) {
|
|
1365
|
+
ws.send(
|
|
1366
|
+
JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' })
|
|
1367
|
+
);
|
|
1368
|
+
ws.close(4001, 'Unauthenticated');
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const listener: ConsoleEventListener = (event) => {
|
|
1373
|
+
try {
|
|
1374
|
+
ws.send(JSON.stringify(event));
|
|
1375
|
+
} catch {
|
|
1376
|
+
// Connection closed
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
emitter.addListener(listener);
|
|
1381
|
+
|
|
1382
|
+
// Send connected message
|
|
1383
|
+
ws.send(
|
|
1384
|
+
JSON.stringify({
|
|
1385
|
+
type: 'connected',
|
|
1386
|
+
timestamp: new Date().toISOString(),
|
|
1387
|
+
})
|
|
1388
|
+
);
|
|
1389
|
+
|
|
1390
|
+
// Start heartbeat
|
|
1391
|
+
const heartbeatInterval = setInterval(() => {
|
|
1392
|
+
try {
|
|
1393
|
+
ws.send(
|
|
1394
|
+
JSON.stringify({
|
|
1395
|
+
type: 'heartbeat',
|
|
1396
|
+
timestamp: new Date().toISOString(),
|
|
1397
|
+
})
|
|
1398
|
+
);
|
|
1399
|
+
} catch {
|
|
1400
|
+
clearInterval(heartbeatInterval);
|
|
1401
|
+
}
|
|
1402
|
+
}, heartbeatIntervalMs);
|
|
1403
|
+
|
|
1404
|
+
wsState.set(ws, { listener, heartbeatInterval });
|
|
1405
|
+
},
|
|
1406
|
+
onClose(_event, ws) {
|
|
1407
|
+
const state = wsState.get(ws);
|
|
1408
|
+
if (!state) return;
|
|
1409
|
+
emitter.removeListener(state.listener);
|
|
1410
|
+
clearInterval(state.heartbeatInterval);
|
|
1411
|
+
wsState.delete(ws);
|
|
1412
|
+
},
|
|
1413
|
+
onError(_event, ws) {
|
|
1414
|
+
const state = wsState.get(ws);
|
|
1415
|
+
if (!state) return;
|
|
1416
|
+
emitter.removeListener(state.listener);
|
|
1417
|
+
clearInterval(state.heartbeatInterval);
|
|
1418
|
+
wsState.delete(ws);
|
|
1419
|
+
},
|
|
1420
|
+
};
|
|
1421
|
+
})
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// -------------------------------------------------------------------------
|
|
1426
|
+
// GET /events/:id - Single event detail
|
|
1427
|
+
// -------------------------------------------------------------------------
|
|
1428
|
+
|
|
1429
|
+
routes.get(
|
|
1430
|
+
'/events/:id',
|
|
1431
|
+
describeRoute({
|
|
1432
|
+
tags: ['console'],
|
|
1433
|
+
summary: 'Get event details',
|
|
1434
|
+
responses: {
|
|
1435
|
+
200: {
|
|
1436
|
+
description: 'Event details',
|
|
1437
|
+
content: {
|
|
1438
|
+
'application/json': {
|
|
1439
|
+
schema: resolver(ConsoleRequestEventSchema),
|
|
1440
|
+
},
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
1443
|
+
400: {
|
|
1444
|
+
description: 'Invalid request',
|
|
1445
|
+
content: {
|
|
1446
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1447
|
+
},
|
|
1448
|
+
},
|
|
1449
|
+
401: {
|
|
1450
|
+
description: 'Unauthenticated',
|
|
1451
|
+
content: {
|
|
1452
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1453
|
+
},
|
|
1454
|
+
},
|
|
1455
|
+
404: {
|
|
1456
|
+
description: 'Not found',
|
|
1457
|
+
content: {
|
|
1458
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1459
|
+
},
|
|
1460
|
+
},
|
|
1461
|
+
},
|
|
1462
|
+
}),
|
|
1463
|
+
zValidator('param', eventIdParamSchema),
|
|
1464
|
+
async (c) => {
|
|
1465
|
+
const auth = await requireAuth(c);
|
|
1466
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1467
|
+
|
|
1468
|
+
const { id: eventId } = c.req.valid('param');
|
|
1469
|
+
|
|
1470
|
+
const row = await db
|
|
1471
|
+
.selectFrom('sync_request_events')
|
|
1472
|
+
.select([
|
|
1473
|
+
'event_id',
|
|
1474
|
+
'event_type',
|
|
1475
|
+
'transport_path',
|
|
1476
|
+
'actor_id',
|
|
1477
|
+
'client_id',
|
|
1478
|
+
'status_code',
|
|
1479
|
+
'outcome',
|
|
1480
|
+
'duration_ms',
|
|
1481
|
+
'commit_seq',
|
|
1482
|
+
'operation_count',
|
|
1483
|
+
'row_count',
|
|
1484
|
+
'tables',
|
|
1485
|
+
'error_message',
|
|
1486
|
+
'created_at',
|
|
1487
|
+
])
|
|
1488
|
+
.where('event_id', '=', eventId)
|
|
1489
|
+
.executeTakeFirst();
|
|
1490
|
+
|
|
1491
|
+
if (!row) {
|
|
1492
|
+
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const event: ConsoleRequestEvent = {
|
|
1496
|
+
eventId: coerceNumber(row.event_id) ?? 0,
|
|
1497
|
+
eventType: row.event_type as 'push' | 'pull',
|
|
1498
|
+
transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
|
|
1499
|
+
actorId: row.actor_id ?? '',
|
|
1500
|
+
clientId: row.client_id ?? '',
|
|
1501
|
+
statusCode: coerceNumber(row.status_code) ?? 0,
|
|
1502
|
+
outcome: row.outcome ?? '',
|
|
1503
|
+
durationMs: coerceNumber(row.duration_ms) ?? 0,
|
|
1504
|
+
commitSeq: coerceNumber(row.commit_seq),
|
|
1505
|
+
operationCount: coerceNumber(row.operation_count),
|
|
1506
|
+
rowCount: coerceNumber(row.row_count),
|
|
1507
|
+
tables: options.dialect.dbToArray(row.tables),
|
|
1508
|
+
errorMessage: row.error_message ?? null,
|
|
1509
|
+
createdAt: row.created_at ?? '',
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
return c.json(event, 200);
|
|
1513
|
+
}
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
// -------------------------------------------------------------------------
|
|
1517
|
+
// DELETE /events - Clear all events
|
|
1518
|
+
// -------------------------------------------------------------------------
|
|
1519
|
+
|
|
1520
|
+
routes.delete(
|
|
1521
|
+
'/events',
|
|
1522
|
+
describeRoute({
|
|
1523
|
+
tags: ['console'],
|
|
1524
|
+
summary: 'Clear all events',
|
|
1525
|
+
responses: {
|
|
1526
|
+
200: {
|
|
1527
|
+
description: 'Clear result',
|
|
1528
|
+
content: {
|
|
1529
|
+
'application/json': {
|
|
1530
|
+
schema: resolver(ConsoleClearEventsResultSchema),
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
},
|
|
1534
|
+
401: {
|
|
1535
|
+
description: 'Unauthenticated',
|
|
1536
|
+
content: {
|
|
1537
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1538
|
+
},
|
|
1539
|
+
},
|
|
1540
|
+
},
|
|
1541
|
+
}),
|
|
1542
|
+
async (c) => {
|
|
1543
|
+
const auth = await requireAuth(c);
|
|
1544
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1545
|
+
|
|
1546
|
+
const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
|
|
1547
|
+
|
|
1548
|
+
const deletedCount = Number(res?.numDeletedRows ?? 0);
|
|
1549
|
+
|
|
1550
|
+
logSyncEvent({
|
|
1551
|
+
event: 'console.clear_events',
|
|
1552
|
+
consoleUserId: auth.consoleUserId,
|
|
1553
|
+
deletedCount,
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
const result: ConsoleClearEventsResult = { deletedCount };
|
|
1557
|
+
return c.json(result, 200);
|
|
1558
|
+
}
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
// -------------------------------------------------------------------------
|
|
1562
|
+
// POST /events/prune - Prune old events
|
|
1563
|
+
// -------------------------------------------------------------------------
|
|
1564
|
+
|
|
1565
|
+
routes.post(
|
|
1566
|
+
'/events/prune',
|
|
1567
|
+
describeRoute({
|
|
1568
|
+
tags: ['console'],
|
|
1569
|
+
summary: 'Prune old events',
|
|
1570
|
+
responses: {
|
|
1571
|
+
200: {
|
|
1572
|
+
description: 'Prune result',
|
|
1573
|
+
content: {
|
|
1574
|
+
'application/json': {
|
|
1575
|
+
schema: resolver(ConsolePruneEventsResultSchema),
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
},
|
|
1579
|
+
401: {
|
|
1580
|
+
description: 'Unauthenticated',
|
|
1581
|
+
content: {
|
|
1582
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1583
|
+
},
|
|
1584
|
+
},
|
|
1585
|
+
},
|
|
1586
|
+
}),
|
|
1587
|
+
async (c) => {
|
|
1588
|
+
const auth = await requireAuth(c);
|
|
1589
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1590
|
+
|
|
1591
|
+
// Prune events older than 7 days or keep max 10000 events
|
|
1592
|
+
const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
1593
|
+
|
|
1594
|
+
// Delete by date first
|
|
1595
|
+
const resByDate = await db
|
|
1596
|
+
.deleteFrom('sync_request_events')
|
|
1597
|
+
.where('created_at', '<', cutoffDate.toISOString())
|
|
1598
|
+
.executeTakeFirst();
|
|
1599
|
+
|
|
1600
|
+
let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
|
|
1601
|
+
|
|
1602
|
+
// Then delete oldest if we still have more than 10000 events
|
|
1603
|
+
const countRow = await db
|
|
1604
|
+
.selectFrom('sync_request_events')
|
|
1605
|
+
.select(({ fn }) => fn.countAll().as('total'))
|
|
1606
|
+
.executeTakeFirst();
|
|
1607
|
+
|
|
1608
|
+
const total = coerceNumber(countRow?.total) ?? 0;
|
|
1609
|
+
const maxEvents = 10000;
|
|
1610
|
+
|
|
1611
|
+
if (total > maxEvents) {
|
|
1612
|
+
// Find event_id cutoff to keep only newest maxEvents
|
|
1613
|
+
const cutoffRow = await db
|
|
1614
|
+
.selectFrom('sync_request_events')
|
|
1615
|
+
.select(['event_id'])
|
|
1616
|
+
.orderBy('event_id', 'desc')
|
|
1617
|
+
.offset(maxEvents)
|
|
1618
|
+
.limit(1)
|
|
1619
|
+
.executeTakeFirst();
|
|
1620
|
+
|
|
1621
|
+
if (cutoffRow) {
|
|
1622
|
+
const cutoffEventId = coerceNumber(cutoffRow.event_id);
|
|
1623
|
+
if (cutoffEventId !== null) {
|
|
1624
|
+
const resByCount = await db
|
|
1625
|
+
.deleteFrom('sync_request_events')
|
|
1626
|
+
.where('event_id', '<=', cutoffEventId)
|
|
1627
|
+
.executeTakeFirst();
|
|
1628
|
+
|
|
1629
|
+
deletedCount += Number(resByCount?.numDeletedRows ?? 0);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
logSyncEvent({
|
|
1635
|
+
event: 'console.prune_events',
|
|
1636
|
+
consoleUserId: auth.consoleUserId,
|
|
1637
|
+
deletedCount,
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
const result: ConsolePruneEventsResult = { deletedCount };
|
|
1641
|
+
return c.json(result, 200);
|
|
1642
|
+
}
|
|
1643
|
+
);
|
|
1644
|
+
|
|
1645
|
+
// -------------------------------------------------------------------------
|
|
1646
|
+
// GET /api-keys - List all API keys
|
|
1647
|
+
// -------------------------------------------------------------------------
|
|
1648
|
+
|
|
1649
|
+
routes.get(
|
|
1650
|
+
'/api-keys',
|
|
1651
|
+
describeRoute({
|
|
1652
|
+
tags: ['console'],
|
|
1653
|
+
summary: 'List API keys',
|
|
1654
|
+
responses: {
|
|
1655
|
+
200: {
|
|
1656
|
+
description: 'Paginated API key list',
|
|
1657
|
+
content: {
|
|
1658
|
+
'application/json': {
|
|
1659
|
+
schema: resolver(
|
|
1660
|
+
ConsolePaginatedResponseSchema(ConsoleApiKeySchema)
|
|
1661
|
+
),
|
|
1662
|
+
},
|
|
1663
|
+
},
|
|
1664
|
+
},
|
|
1665
|
+
401: {
|
|
1666
|
+
description: 'Unauthenticated',
|
|
1667
|
+
content: {
|
|
1668
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
},
|
|
1672
|
+
}),
|
|
1673
|
+
zValidator('query', apiKeysQuerySchema),
|
|
1674
|
+
async (c) => {
|
|
1675
|
+
const auth = await requireAuth(c);
|
|
1676
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1677
|
+
|
|
1678
|
+
const { limit, offset, type: keyType } = c.req.valid('query');
|
|
1679
|
+
|
|
1680
|
+
let query = db
|
|
1681
|
+
.selectFrom('sync_api_keys')
|
|
1682
|
+
.select([
|
|
1683
|
+
'key_id',
|
|
1684
|
+
'key_prefix',
|
|
1685
|
+
'name',
|
|
1686
|
+
'key_type',
|
|
1687
|
+
'scope_keys',
|
|
1688
|
+
'actor_id',
|
|
1689
|
+
'created_at',
|
|
1690
|
+
'expires_at',
|
|
1691
|
+
'last_used_at',
|
|
1692
|
+
'revoked_at',
|
|
1693
|
+
]);
|
|
1694
|
+
|
|
1695
|
+
let countQuery = db
|
|
1696
|
+
.selectFrom('sync_api_keys')
|
|
1697
|
+
.select(({ fn }) => fn.countAll().as('total'));
|
|
1698
|
+
|
|
1699
|
+
if (keyType) {
|
|
1700
|
+
query = query.where('key_type', '=', keyType);
|
|
1701
|
+
countQuery = countQuery.where('key_type', '=', keyType);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const [rows, countRow] = await Promise.all([
|
|
1705
|
+
query
|
|
1706
|
+
.orderBy('created_at', 'desc')
|
|
1707
|
+
.limit(limit)
|
|
1708
|
+
.offset(offset)
|
|
1709
|
+
.execute(),
|
|
1710
|
+
countQuery.executeTakeFirst(),
|
|
1711
|
+
]);
|
|
1712
|
+
|
|
1713
|
+
const items: ConsoleApiKey[] = rows.map((row) => ({
|
|
1714
|
+
keyId: row.key_id ?? '',
|
|
1715
|
+
keyPrefix: row.key_prefix ?? '',
|
|
1716
|
+
name: row.name ?? '',
|
|
1717
|
+
keyType: row.key_type as ApiKeyType,
|
|
1718
|
+
scopeKeys: options.dialect.dbToArray(row.scope_keys),
|
|
1719
|
+
actorId: row.actor_id ?? null,
|
|
1720
|
+
createdAt: row.created_at ?? '',
|
|
1721
|
+
expiresAt: row.expires_at ?? null,
|
|
1722
|
+
lastUsedAt: row.last_used_at ?? null,
|
|
1723
|
+
revokedAt: row.revoked_at ?? null,
|
|
1724
|
+
}));
|
|
1725
|
+
|
|
1726
|
+
const totalCount = coerceNumber(countRow?.total) ?? 0;
|
|
1727
|
+
|
|
1728
|
+
const response: ConsolePaginatedResponse<ConsoleApiKey> = {
|
|
1729
|
+
items,
|
|
1730
|
+
total: totalCount,
|
|
1731
|
+
offset,
|
|
1732
|
+
limit,
|
|
1733
|
+
};
|
|
1734
|
+
|
|
1735
|
+
c.header('X-Total-Count', String(totalCount));
|
|
1736
|
+
return c.json(response, 200);
|
|
1737
|
+
}
|
|
1738
|
+
);
|
|
1739
|
+
|
|
1740
|
+
// -------------------------------------------------------------------------
|
|
1741
|
+
// POST /api-keys - Create new API key
|
|
1742
|
+
// -------------------------------------------------------------------------
|
|
1743
|
+
|
|
1744
|
+
routes.post(
|
|
1745
|
+
'/api-keys',
|
|
1746
|
+
describeRoute({
|
|
1747
|
+
tags: ['console'],
|
|
1748
|
+
summary: 'Create API key',
|
|
1749
|
+
responses: {
|
|
1750
|
+
201: {
|
|
1751
|
+
description: 'Created API key',
|
|
1752
|
+
content: {
|
|
1753
|
+
'application/json': {
|
|
1754
|
+
schema: resolver(ConsoleApiKeyCreateResponseSchema),
|
|
1755
|
+
},
|
|
1756
|
+
},
|
|
1757
|
+
},
|
|
1758
|
+
400: {
|
|
1759
|
+
description: 'Invalid request',
|
|
1760
|
+
content: {
|
|
1761
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1762
|
+
},
|
|
1763
|
+
},
|
|
1764
|
+
401: {
|
|
1765
|
+
description: 'Unauthenticated',
|
|
1766
|
+
content: {
|
|
1767
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1768
|
+
},
|
|
1769
|
+
},
|
|
1770
|
+
},
|
|
1771
|
+
}),
|
|
1772
|
+
zValidator('json', ConsoleApiKeyCreateRequestSchema),
|
|
1773
|
+
async (c) => {
|
|
1774
|
+
const auth = await requireAuth(c);
|
|
1775
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1776
|
+
|
|
1777
|
+
const body = c.req.valid('json');
|
|
1778
|
+
|
|
1779
|
+
// Generate key components
|
|
1780
|
+
const keyId = generateKeyId();
|
|
1781
|
+
const secretKey = generateSecretKey(body.keyType);
|
|
1782
|
+
const keyHash = await hashApiKey(secretKey);
|
|
1783
|
+
const keyPrefix = secretKey.slice(0, 12);
|
|
1784
|
+
|
|
1785
|
+
// Calculate expiry
|
|
1786
|
+
let expiresAt: string | null = null;
|
|
1787
|
+
if (body.expiresInDays && body.expiresInDays > 0) {
|
|
1788
|
+
expiresAt = new Date(
|
|
1789
|
+
Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000
|
|
1790
|
+
).toISOString();
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
const scopeKeys = body.scopeKeys ?? [];
|
|
1794
|
+
const now = new Date().toISOString();
|
|
1795
|
+
|
|
1796
|
+
// Insert into database
|
|
1797
|
+
await db
|
|
1798
|
+
.insertInto('sync_api_keys')
|
|
1799
|
+
.values({
|
|
1800
|
+
key_id: keyId,
|
|
1801
|
+
key_hash: keyHash,
|
|
1802
|
+
key_prefix: keyPrefix,
|
|
1803
|
+
name: body.name,
|
|
1804
|
+
key_type: body.keyType,
|
|
1805
|
+
scope_keys: options.dialect.arrayToDb(scopeKeys),
|
|
1806
|
+
actor_id: body.actorId ?? null,
|
|
1807
|
+
created_at: now,
|
|
1808
|
+
expires_at: expiresAt,
|
|
1809
|
+
last_used_at: null,
|
|
1810
|
+
revoked_at: null,
|
|
1811
|
+
})
|
|
1812
|
+
.execute();
|
|
1813
|
+
|
|
1814
|
+
logSyncEvent({
|
|
1815
|
+
event: 'console.create_api_key',
|
|
1816
|
+
consoleUserId: auth.consoleUserId,
|
|
1817
|
+
keyId,
|
|
1818
|
+
keyType: body.keyType,
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
const key: ConsoleApiKey = {
|
|
1822
|
+
keyId,
|
|
1823
|
+
keyPrefix,
|
|
1824
|
+
name: body.name,
|
|
1825
|
+
keyType: body.keyType,
|
|
1826
|
+
scopeKeys,
|
|
1827
|
+
actorId: body.actorId ?? null,
|
|
1828
|
+
createdAt: now,
|
|
1829
|
+
expiresAt,
|
|
1830
|
+
lastUsedAt: null,
|
|
1831
|
+
revokedAt: null,
|
|
1832
|
+
};
|
|
1833
|
+
|
|
1834
|
+
const response: ConsoleApiKeyCreateResponse = {
|
|
1835
|
+
key,
|
|
1836
|
+
secretKey,
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
return c.json(response, 201);
|
|
1840
|
+
}
|
|
1841
|
+
);
|
|
1842
|
+
|
|
1843
|
+
// -------------------------------------------------------------------------
|
|
1844
|
+
// GET /api-keys/:id - Get single API key
|
|
1845
|
+
// -------------------------------------------------------------------------
|
|
1846
|
+
|
|
1847
|
+
routes.get(
|
|
1848
|
+
'/api-keys/:id',
|
|
1849
|
+
describeRoute({
|
|
1850
|
+
tags: ['console'],
|
|
1851
|
+
summary: 'Get API key',
|
|
1852
|
+
responses: {
|
|
1853
|
+
200: {
|
|
1854
|
+
description: 'API key details',
|
|
1855
|
+
content: {
|
|
1856
|
+
'application/json': { schema: resolver(ConsoleApiKeySchema) },
|
|
1857
|
+
},
|
|
1858
|
+
},
|
|
1859
|
+
401: {
|
|
1860
|
+
description: 'Unauthenticated',
|
|
1861
|
+
content: {
|
|
1862
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1863
|
+
},
|
|
1864
|
+
},
|
|
1865
|
+
404: {
|
|
1866
|
+
description: 'Not found',
|
|
1867
|
+
content: {
|
|
1868
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1869
|
+
},
|
|
1870
|
+
},
|
|
1871
|
+
},
|
|
1872
|
+
}),
|
|
1873
|
+
zValidator('param', apiKeyIdParamSchema),
|
|
1874
|
+
async (c) => {
|
|
1875
|
+
const auth = await requireAuth(c);
|
|
1876
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1877
|
+
|
|
1878
|
+
const { id: keyId } = c.req.valid('param');
|
|
1879
|
+
|
|
1880
|
+
const row = await db
|
|
1881
|
+
.selectFrom('sync_api_keys')
|
|
1882
|
+
.select([
|
|
1883
|
+
'key_id',
|
|
1884
|
+
'key_prefix',
|
|
1885
|
+
'name',
|
|
1886
|
+
'key_type',
|
|
1887
|
+
'scope_keys',
|
|
1888
|
+
'actor_id',
|
|
1889
|
+
'created_at',
|
|
1890
|
+
'expires_at',
|
|
1891
|
+
'last_used_at',
|
|
1892
|
+
'revoked_at',
|
|
1893
|
+
])
|
|
1894
|
+
.where('key_id', '=', keyId)
|
|
1895
|
+
.executeTakeFirst();
|
|
1896
|
+
|
|
1897
|
+
if (!row) {
|
|
1898
|
+
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const key: ConsoleApiKey = {
|
|
1902
|
+
keyId: row.key_id ?? '',
|
|
1903
|
+
keyPrefix: row.key_prefix ?? '',
|
|
1904
|
+
name: row.name ?? '',
|
|
1905
|
+
keyType: row.key_type as ApiKeyType,
|
|
1906
|
+
scopeKeys: options.dialect.dbToArray(row.scope_keys),
|
|
1907
|
+
actorId: row.actor_id ?? null,
|
|
1908
|
+
createdAt: row.created_at ?? '',
|
|
1909
|
+
expiresAt: row.expires_at ?? null,
|
|
1910
|
+
lastUsedAt: row.last_used_at ?? null,
|
|
1911
|
+
revokedAt: row.revoked_at ?? null,
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
return c.json(key, 200);
|
|
1915
|
+
}
|
|
1916
|
+
);
|
|
1917
|
+
|
|
1918
|
+
// -------------------------------------------------------------------------
|
|
1919
|
+
// DELETE /api-keys/:id - Revoke API key (soft delete)
|
|
1920
|
+
// -------------------------------------------------------------------------
|
|
1921
|
+
|
|
1922
|
+
routes.delete(
|
|
1923
|
+
'/api-keys/:id',
|
|
1924
|
+
describeRoute({
|
|
1925
|
+
tags: ['console'],
|
|
1926
|
+
summary: 'Revoke API key',
|
|
1927
|
+
responses: {
|
|
1928
|
+
200: {
|
|
1929
|
+
description: 'Revoke result',
|
|
1930
|
+
content: {
|
|
1931
|
+
'application/json': {
|
|
1932
|
+
schema: resolver(ConsoleApiKeyRevokeResponseSchema),
|
|
1933
|
+
},
|
|
1934
|
+
},
|
|
1935
|
+
},
|
|
1936
|
+
401: {
|
|
1937
|
+
description: 'Unauthenticated',
|
|
1938
|
+
content: {
|
|
1939
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1940
|
+
},
|
|
1941
|
+
},
|
|
1942
|
+
},
|
|
1943
|
+
}),
|
|
1944
|
+
zValidator('param', apiKeyIdParamSchema),
|
|
1945
|
+
async (c) => {
|
|
1946
|
+
const auth = await requireAuth(c);
|
|
1947
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1948
|
+
|
|
1949
|
+
const { id: keyId } = c.req.valid('param');
|
|
1950
|
+
const now = new Date().toISOString();
|
|
1951
|
+
|
|
1952
|
+
const res = await db
|
|
1953
|
+
.updateTable('sync_api_keys')
|
|
1954
|
+
.set({ revoked_at: now })
|
|
1955
|
+
.where('key_id', '=', keyId)
|
|
1956
|
+
.where('revoked_at', 'is', null)
|
|
1957
|
+
.executeTakeFirst();
|
|
1958
|
+
|
|
1959
|
+
const revoked = Number(res?.numUpdatedRows ?? 0) > 0;
|
|
1960
|
+
|
|
1961
|
+
logSyncEvent({
|
|
1962
|
+
event: 'console.revoke_api_key',
|
|
1963
|
+
consoleUserId: auth.consoleUserId,
|
|
1964
|
+
keyId,
|
|
1965
|
+
revoked,
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
return c.json({ revoked }, 200);
|
|
1969
|
+
}
|
|
1970
|
+
);
|
|
1971
|
+
|
|
1972
|
+
// -------------------------------------------------------------------------
|
|
1973
|
+
// POST /api-keys/:id/rotate - Rotate API key
|
|
1974
|
+
// -------------------------------------------------------------------------
|
|
1975
|
+
|
|
1976
|
+
routes.post(
|
|
1977
|
+
'/api-keys/:id/rotate',
|
|
1978
|
+
describeRoute({
|
|
1979
|
+
tags: ['console'],
|
|
1980
|
+
summary: 'Rotate API key',
|
|
1981
|
+
responses: {
|
|
1982
|
+
200: {
|
|
1983
|
+
description: 'Rotated API key',
|
|
1984
|
+
content: {
|
|
1985
|
+
'application/json': {
|
|
1986
|
+
schema: resolver(ConsoleApiKeyCreateResponseSchema),
|
|
1987
|
+
},
|
|
1988
|
+
},
|
|
1989
|
+
},
|
|
1990
|
+
401: {
|
|
1991
|
+
description: 'Unauthenticated',
|
|
1992
|
+
content: {
|
|
1993
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1994
|
+
},
|
|
1995
|
+
},
|
|
1996
|
+
404: {
|
|
1997
|
+
description: 'Not found',
|
|
1998
|
+
content: {
|
|
1999
|
+
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2000
|
+
},
|
|
2001
|
+
},
|
|
2002
|
+
},
|
|
2003
|
+
}),
|
|
2004
|
+
zValidator('param', apiKeyIdParamSchema),
|
|
2005
|
+
async (c) => {
|
|
2006
|
+
const auth = await requireAuth(c);
|
|
2007
|
+
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2008
|
+
|
|
2009
|
+
const { id: keyId } = c.req.valid('param');
|
|
2010
|
+
const now = new Date().toISOString();
|
|
2011
|
+
|
|
2012
|
+
// Get existing key
|
|
2013
|
+
const existingRow = await db
|
|
2014
|
+
.selectFrom('sync_api_keys')
|
|
2015
|
+
.select([
|
|
2016
|
+
'key_id',
|
|
2017
|
+
'name',
|
|
2018
|
+
'key_type',
|
|
2019
|
+
'scope_keys',
|
|
2020
|
+
'actor_id',
|
|
2021
|
+
'expires_at',
|
|
2022
|
+
])
|
|
2023
|
+
.where('key_id', '=', keyId)
|
|
2024
|
+
.where('revoked_at', 'is', null)
|
|
2025
|
+
.executeTakeFirst();
|
|
2026
|
+
|
|
2027
|
+
if (!existingRow) {
|
|
2028
|
+
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Revoke old key
|
|
2032
|
+
await db
|
|
2033
|
+
.updateTable('sync_api_keys')
|
|
2034
|
+
.set({ revoked_at: now })
|
|
2035
|
+
.where('key_id', '=', keyId)
|
|
2036
|
+
.execute();
|
|
2037
|
+
|
|
2038
|
+
// Create new key with same properties
|
|
2039
|
+
const newKeyId = generateKeyId();
|
|
2040
|
+
const keyType = existingRow.key_type as ApiKeyType;
|
|
2041
|
+
const secretKey = generateSecretKey(keyType);
|
|
2042
|
+
const keyHash = await hashApiKey(secretKey);
|
|
2043
|
+
const keyPrefix = secretKey.slice(0, 12);
|
|
2044
|
+
|
|
2045
|
+
const scopeKeys = options.dialect.dbToArray(existingRow.scope_keys);
|
|
2046
|
+
|
|
2047
|
+
await db
|
|
2048
|
+
.insertInto('sync_api_keys')
|
|
2049
|
+
.values({
|
|
2050
|
+
key_id: newKeyId,
|
|
2051
|
+
key_hash: keyHash,
|
|
2052
|
+
key_prefix: keyPrefix,
|
|
2053
|
+
name: existingRow.name,
|
|
2054
|
+
key_type: keyType,
|
|
2055
|
+
scope_keys: options.dialect.arrayToDb(scopeKeys),
|
|
2056
|
+
actor_id: existingRow.actor_id ?? null,
|
|
2057
|
+
created_at: now,
|
|
2058
|
+
expires_at: existingRow.expires_at,
|
|
2059
|
+
last_used_at: null,
|
|
2060
|
+
revoked_at: null,
|
|
2061
|
+
})
|
|
2062
|
+
.execute();
|
|
2063
|
+
|
|
2064
|
+
logSyncEvent({
|
|
2065
|
+
event: 'console.rotate_api_key',
|
|
2066
|
+
consoleUserId: auth.consoleUserId,
|
|
2067
|
+
oldKeyId: keyId,
|
|
2068
|
+
newKeyId,
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
const key: ConsoleApiKey = {
|
|
2072
|
+
keyId: newKeyId,
|
|
2073
|
+
keyPrefix,
|
|
2074
|
+
name: existingRow.name,
|
|
2075
|
+
keyType,
|
|
2076
|
+
scopeKeys,
|
|
2077
|
+
actorId: existingRow.actor_id ?? null,
|
|
2078
|
+
createdAt: now,
|
|
2079
|
+
expiresAt: existingRow.expires_at ?? null,
|
|
2080
|
+
lastUsedAt: null,
|
|
2081
|
+
revokedAt: null,
|
|
2082
|
+
};
|
|
2083
|
+
|
|
2084
|
+
const response: ConsoleApiKeyCreateResponse = {
|
|
2085
|
+
key,
|
|
2086
|
+
secretKey,
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
return c.json(response, 200);
|
|
2090
|
+
}
|
|
2091
|
+
);
|
|
2092
|
+
|
|
2093
|
+
return routes;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// ===========================================================================
|
|
2097
|
+
// API Key Utilities
|
|
2098
|
+
// ===========================================================================
|
|
2099
|
+
|
|
2100
|
+
function generateKeyId(): string {
|
|
2101
|
+
const bytes = new Uint8Array(16);
|
|
2102
|
+
crypto.getRandomValues(bytes);
|
|
2103
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function generateSecretKey(keyType: ApiKeyType): string {
|
|
2107
|
+
const bytes = new Uint8Array(24);
|
|
2108
|
+
crypto.getRandomValues(bytes);
|
|
2109
|
+
const random = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(
|
|
2110
|
+
''
|
|
2111
|
+
);
|
|
2112
|
+
return `sk_${keyType}_${random}`;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
async function hashApiKey(secretKey: string): Promise<string> {
|
|
2116
|
+
const encoder = new TextEncoder();
|
|
2117
|
+
const data = encoder.encode(secretKey);
|
|
2118
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
2119
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
2120
|
+
return Array.from(hashArray, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Creates a simple token-based authenticator for local development.
|
|
2125
|
+
* The token can be set via SYNC_CONSOLE_TOKEN env var or passed directly.
|
|
2126
|
+
*/
|
|
2127
|
+
export function createTokenAuthenticator(
|
|
2128
|
+
token?: string
|
|
2129
|
+
): (c: Context) => Promise<ConsoleAuthResult | null> {
|
|
2130
|
+
const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
|
|
2131
|
+
|
|
2132
|
+
return async (c: Context) => {
|
|
2133
|
+
if (!expectedToken) {
|
|
2134
|
+
// No token configured, allow all requests (not recommended for production)
|
|
2135
|
+
return { consoleUserId: 'anonymous' };
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Check Authorization header
|
|
2139
|
+
const authHeader = c.req.header('Authorization');
|
|
2140
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
2141
|
+
const bearerToken = authHeader.slice(7);
|
|
2142
|
+
if (bearerToken === expectedToken) {
|
|
2143
|
+
return { consoleUserId: 'token' };
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// Check query parameter
|
|
2148
|
+
const queryToken = c.req.query('token');
|
|
2149
|
+
if (queryToken === expectedToken) {
|
|
2150
|
+
return { consoleUserId: 'token' };
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
return null;
|
|
2154
|
+
};
|
|
2155
|
+
}
|