@syncular/server-hono 0.0.6-95 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -23
- package/dist/admin-page.d.ts +14 -0
- package/dist/admin-page.js +217 -0
- package/dist/admin.d.ts +39 -0
- package/dist/admin.js +142 -0
- package/dist/index.d.ts +16 -14
- package/dist/index.js +134 -23
- package/package.json +19 -53
- package/src/admin-page.ts +217 -0
- package/src/admin.ts +193 -0
- package/src/index.ts +175 -31
- package/dist/api-key-auth.d.ts +0 -49
- package/dist/api-key-auth.d.ts.map +0 -1
- package/dist/api-key-auth.js +0 -110
- package/dist/api-key-auth.js.map +0 -1
- package/dist/blobs.d.ts +0 -69
- package/dist/blobs.d.ts.map +0 -1
- package/dist/blobs.js +0 -383
- package/dist/blobs.js.map +0 -1
- package/dist/console/gateway.d.ts +0 -33
- package/dist/console/gateway.d.ts.map +0 -1
- package/dist/console/gateway.js +0 -2534
- package/dist/console/gateway.js.map +0 -1
- package/dist/console/index.d.ts +0 -11
- package/dist/console/index.d.ts.map +0 -1
- package/dist/console/index.js +0 -11
- package/dist/console/index.js.map +0 -1
- package/dist/console/routes.d.ts +0 -33
- package/dist/console/routes.d.ts.map +0 -1
- package/dist/console/routes.js +0 -2890
- package/dist/console/routes.js.map +0 -1
- package/dist/console/schemas.d.ts +0 -490
- package/dist/console/schemas.d.ts.map +0 -1
- package/dist/console/schemas.js +0 -303
- package/dist/console/schemas.js.map +0 -1
- package/dist/console/types.d.ts +0 -142
- package/dist/console/types.d.ts.map +0 -1
- package/dist/console/types.js +0 -2
- package/dist/console/types.js.map +0 -1
- package/dist/console/ui.d.ts +0 -38
- package/dist/console/ui.d.ts.map +0 -1
- package/dist/console/ui.js +0 -43
- package/dist/console/ui.js.map +0 -1
- package/dist/create-server.d.ts +0 -68
- package/dist/create-server.d.ts.map +0 -1
- package/dist/create-server.js +0 -109
- package/dist/create-server.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/openapi.d.ts +0 -45
- package/dist/openapi.d.ts.map +0 -1
- package/dist/openapi.js +0 -59
- package/dist/openapi.js.map +0 -1
- package/dist/proxy/connection-manager.d.ts +0 -78
- package/dist/proxy/connection-manager.d.ts.map +0 -1
- package/dist/proxy/connection-manager.js +0 -251
- package/dist/proxy/connection-manager.js.map +0 -1
- package/dist/proxy/index.d.ts +0 -8
- package/dist/proxy/index.d.ts.map +0 -1
- package/dist/proxy/index.js +0 -8
- package/dist/proxy/index.js.map +0 -1
- package/dist/proxy/routes.d.ts +0 -74
- package/dist/proxy/routes.d.ts.map +0 -1
- package/dist/proxy/routes.js +0 -147
- package/dist/proxy/routes.js.map +0 -1
- package/dist/rate-limit.d.ts +0 -101
- package/dist/rate-limit.d.ts.map +0 -1
- package/dist/rate-limit.js +0 -186
- package/dist/rate-limit.js.map +0 -1
- package/dist/routes.d.ts +0 -145
- package/dist/routes.d.ts.map +0 -1
- package/dist/routes.js +0 -1471
- package/dist/routes.js.map +0 -1
- package/dist/ws.d.ts +0 -235
- package/dist/ws.d.ts.map +0 -1
- package/dist/ws.js +0 -614
- package/dist/ws.js.map +0 -1
- package/src/__tests__/blob-routes.test.ts +0 -424
- package/src/__tests__/console-gateway-live-routes.test.ts +0 -297
- package/src/__tests__/console-gateway-routes.test.ts +0 -1364
- package/src/__tests__/console-routes.test.ts +0 -1543
- package/src/__tests__/console-ui.test.ts +0 -114
- package/src/__tests__/create-server.test.ts +0 -342
- package/src/__tests__/pull-chunk-storage.test.ts +0 -576
- package/src/__tests__/rate-limit.test.ts +0 -78
- package/src/__tests__/realtime-bridge.test.ts +0 -135
- package/src/__tests__/sync-rate-limit-routing.test.ts +0 -185
- package/src/__tests__/ws-connection-manager.test.ts +0 -176
- package/src/api-key-auth.ts +0 -179
- package/src/blobs.ts +0 -534
- package/src/console/gateway.ts +0 -3603
- package/src/console/index.ts +0 -11
- package/src/console/routes.ts +0 -3748
- package/src/console/schemas.ts +0 -434
- package/src/console/types.ts +0 -151
- package/src/console/ui.ts +0 -100
- package/src/create-server.ts +0 -206
- package/src/openapi.ts +0 -74
- package/src/proxy/connection-manager.ts +0 -340
- package/src/proxy/index.ts +0 -8
- package/src/proxy/routes.ts +0 -223
- package/src/rate-limit.ts +0 -321
- package/src/routes.ts +0 -2007
- package/src/ws.ts +0 -802
package/dist/console/routes.js
DELETED
|
@@ -1,2890 +0,0 @@
|
|
|
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
|
-
import { logSyncEvent } from '@syncular/core';
|
|
18
|
-
import { compactChanges, computePruneWatermarkCommitSeq, notifyExternalDataChange, pruneSync, readSyncStats, } from '@syncular/server';
|
|
19
|
-
import { Hono } from 'hono';
|
|
20
|
-
import { cors } from 'hono/cors';
|
|
21
|
-
import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
|
|
22
|
-
import { sql } from 'kysely';
|
|
23
|
-
import { z } from 'zod';
|
|
24
|
-
import { ApiKeyTypeSchema, ConsoleApiKeyBulkRevokeRequestSchema, ConsoleApiKeyBulkRevokeResponseSchema, ConsoleApiKeyCreateRequestSchema, ConsoleApiKeyCreateResponseSchema, ConsoleApiKeyRevokeResponseSchema, ConsoleApiKeySchema, ConsoleBlobDeleteResponseSchema, ConsoleBlobListQuerySchema, ConsoleBlobListResponseSchema, ConsoleClearEventsResultSchema, ConsoleClientSchema, ConsoleCommitDetailSchema, ConsoleCommitListItemSchema, ConsoleCompactResultSchema, ConsoleEvictResultSchema, ConsoleHandlerSchema, ConsoleOperationEventSchema, ConsoleOperationsQuerySchema, ConsolePaginatedResponseSchema, ConsolePaginationQuerySchema, ConsolePartitionedPaginationQuerySchema, ConsolePartitionQuerySchema, ConsolePruneEventsResultSchema, ConsolePrunePreviewSchema, ConsolePruneResultSchema, ConsoleRequestEventSchema, ConsoleRequestPayloadSchema, ConsoleTimelineItemSchema, ConsoleTimelineQuerySchema, LatencyQuerySchema, LatencyStatsResponseSchema, SyncStatsSchema, TimeseriesQuerySchema, TimeseriesStatsResponseSchema, } from './schemas.js';
|
|
25
|
-
/**
|
|
26
|
-
* Create a simple console event emitter for broadcasting live events.
|
|
27
|
-
*/
|
|
28
|
-
export function createConsoleEventEmitter(options) {
|
|
29
|
-
const listeners = new Set();
|
|
30
|
-
const history = [];
|
|
31
|
-
const maxHistory = Math.max(1, options?.maxHistory ?? 500);
|
|
32
|
-
return {
|
|
33
|
-
addListener(listener) {
|
|
34
|
-
listeners.add(listener);
|
|
35
|
-
},
|
|
36
|
-
removeListener(listener) {
|
|
37
|
-
listeners.delete(listener);
|
|
38
|
-
},
|
|
39
|
-
emit(event) {
|
|
40
|
-
history.push(event);
|
|
41
|
-
if (history.length > maxHistory) {
|
|
42
|
-
history.splice(0, history.length - maxHistory);
|
|
43
|
-
}
|
|
44
|
-
for (const listener of listeners) {
|
|
45
|
-
try {
|
|
46
|
-
listener(event);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
// Ignore errors in listeners
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
replay(replayOptions) {
|
|
54
|
-
const sinceMs = replayOptions?.since
|
|
55
|
-
? Date.parse(replayOptions.since)
|
|
56
|
-
: Number.NaN;
|
|
57
|
-
const hasSince = Number.isFinite(sinceMs);
|
|
58
|
-
const normalizedPartitionId = replayOptions?.partitionId?.trim();
|
|
59
|
-
const hasPartitionFilter = Boolean(normalizedPartitionId);
|
|
60
|
-
const filteredByTime = hasSince
|
|
61
|
-
? history.filter((event) => {
|
|
62
|
-
const eventMs = Date.parse(event.timestamp);
|
|
63
|
-
return Number.isFinite(eventMs) && eventMs > sinceMs;
|
|
64
|
-
})
|
|
65
|
-
: history;
|
|
66
|
-
const filtered = hasPartitionFilter
|
|
67
|
-
? filteredByTime.filter((event) => {
|
|
68
|
-
const eventPartitionId = event.data.partitionId;
|
|
69
|
-
return (typeof eventPartitionId === 'string' &&
|
|
70
|
-
eventPartitionId === normalizedPartitionId);
|
|
71
|
-
})
|
|
72
|
-
: filteredByTime;
|
|
73
|
-
const normalizedLimit = replayOptions?.limit && replayOptions.limit > 0
|
|
74
|
-
? Math.floor(replayOptions.limit)
|
|
75
|
-
: 100;
|
|
76
|
-
const limited = filtered.slice(-normalizedLimit);
|
|
77
|
-
return [...limited];
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
function coerceNumber(value) {
|
|
82
|
-
if (value === null || value === undefined)
|
|
83
|
-
return null;
|
|
84
|
-
if (typeof value === 'number')
|
|
85
|
-
return Number.isFinite(value) ? value : null;
|
|
86
|
-
if (typeof value === 'bigint')
|
|
87
|
-
return Number.isFinite(Number(value)) ? Number(value) : null;
|
|
88
|
-
if (typeof value === 'string') {
|
|
89
|
-
const n = Number(value);
|
|
90
|
-
return Number.isFinite(n) ? n : null;
|
|
91
|
-
}
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
function parseDate(value) {
|
|
95
|
-
if (!value)
|
|
96
|
-
return null;
|
|
97
|
-
const parsed = Date.parse(value);
|
|
98
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
99
|
-
}
|
|
100
|
-
function includesSearchTerm(value, searchTerm) {
|
|
101
|
-
if (!searchTerm)
|
|
102
|
-
return true;
|
|
103
|
-
if (!value)
|
|
104
|
-
return false;
|
|
105
|
-
return value.toLowerCase().includes(searchTerm);
|
|
106
|
-
}
|
|
107
|
-
function parseJsonValue(value) {
|
|
108
|
-
if (value === null || value === undefined)
|
|
109
|
-
return null;
|
|
110
|
-
if (typeof value !== 'string')
|
|
111
|
-
return value;
|
|
112
|
-
try {
|
|
113
|
-
return JSON.parse(value);
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return value;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
function parseScopesSummary(value) {
|
|
120
|
-
const parsed = parseJsonValue(value);
|
|
121
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
const summary = {};
|
|
125
|
-
for (const [key, entry] of Object.entries(parsed)) {
|
|
126
|
-
if (typeof entry === 'string') {
|
|
127
|
-
summary[key] = entry;
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
if (!Array.isArray(entry))
|
|
131
|
-
continue;
|
|
132
|
-
summary[key] = entry.filter((value) => typeof value === 'string');
|
|
133
|
-
}
|
|
134
|
-
return Object.keys(summary).length > 0 ? summary : null;
|
|
135
|
-
}
|
|
136
|
-
function getClientActivityState(args) {
|
|
137
|
-
if (args.connectionCount > 0) {
|
|
138
|
-
return 'active';
|
|
139
|
-
}
|
|
140
|
-
const updatedAtMs = parseDate(args.updatedAt);
|
|
141
|
-
if (updatedAtMs === null) {
|
|
142
|
-
return 'stale';
|
|
143
|
-
}
|
|
144
|
-
const ageMs = Date.now() - updatedAtMs;
|
|
145
|
-
if (ageMs <= 60_000) {
|
|
146
|
-
return 'active';
|
|
147
|
-
}
|
|
148
|
-
if (ageMs <= 5 * 60_000) {
|
|
149
|
-
return 'idle';
|
|
150
|
-
}
|
|
151
|
-
return 'stale';
|
|
152
|
-
}
|
|
153
|
-
function rangeToMs(range) {
|
|
154
|
-
if (range === '1h')
|
|
155
|
-
return 60 * 60 * 1000;
|
|
156
|
-
if (range === '6h')
|
|
157
|
-
return 6 * 60 * 60 * 1000;
|
|
158
|
-
if (range === '24h')
|
|
159
|
-
return 24 * 60 * 60 * 1000;
|
|
160
|
-
if (range === '7d')
|
|
161
|
-
return 7 * 24 * 60 * 60 * 1000;
|
|
162
|
-
return 30 * 24 * 60 * 60 * 1000;
|
|
163
|
-
}
|
|
164
|
-
function intervalToMs(interval) {
|
|
165
|
-
if (interval === 'minute')
|
|
166
|
-
return 60 * 1000;
|
|
167
|
-
if (interval === 'hour')
|
|
168
|
-
return 60 * 60 * 1000;
|
|
169
|
-
return 24 * 60 * 60 * 1000;
|
|
170
|
-
}
|
|
171
|
-
function intervalToSqliteBucketFormat(interval) {
|
|
172
|
-
if (interval === 'minute')
|
|
173
|
-
return '%Y-%m-%dT%H:%M:00.000Z';
|
|
174
|
-
if (interval === 'hour')
|
|
175
|
-
return '%Y-%m-%dT%H:00:00.000Z';
|
|
176
|
-
return '%Y-%m-%dT00:00:00.000Z';
|
|
177
|
-
}
|
|
178
|
-
function createEmptyTimeseriesAccumulator() {
|
|
179
|
-
return {
|
|
180
|
-
pushCount: 0,
|
|
181
|
-
pullCount: 0,
|
|
182
|
-
errorCount: 0,
|
|
183
|
-
totalLatency: 0,
|
|
184
|
-
eventCount: 0,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
function createTimeseriesBucketMap(args) {
|
|
188
|
-
const map = new Map();
|
|
189
|
-
const bucketCount = Math.ceil(args.rangeMs / args.intervalMs);
|
|
190
|
-
for (let i = 0; i < bucketCount; i++) {
|
|
191
|
-
const bucketTimestamp = new Date(args.startTime.getTime() + i * args.intervalMs).toISOString();
|
|
192
|
-
map.set(bucketTimestamp, createEmptyTimeseriesAccumulator());
|
|
193
|
-
}
|
|
194
|
-
return map;
|
|
195
|
-
}
|
|
196
|
-
function normalizeBucketTimestamp(value) {
|
|
197
|
-
if (value instanceof Date) {
|
|
198
|
-
return value.toISOString();
|
|
199
|
-
}
|
|
200
|
-
if (typeof value !== 'string') {
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
const parsed = Date.parse(value);
|
|
204
|
-
if (!Number.isFinite(parsed))
|
|
205
|
-
return null;
|
|
206
|
-
return new Date(parsed).toISOString();
|
|
207
|
-
}
|
|
208
|
-
function finalizeTimeseriesBuckets(bucketMap) {
|
|
209
|
-
return Array.from(bucketMap.entries())
|
|
210
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
211
|
-
.map(([timestamp, data]) => ({
|
|
212
|
-
timestamp,
|
|
213
|
-
pushCount: data.pushCount,
|
|
214
|
-
pullCount: data.pullCount,
|
|
215
|
-
errorCount: data.errorCount,
|
|
216
|
-
avgLatencyMs: data.eventCount > 0 ? data.totalLatency / data.eventCount : 0,
|
|
217
|
-
}));
|
|
218
|
-
}
|
|
219
|
-
function calculatePercentiles(latencies) {
|
|
220
|
-
if (latencies.length === 0) {
|
|
221
|
-
return { p50: 0, p90: 0, p99: 0 };
|
|
222
|
-
}
|
|
223
|
-
const sorted = [...latencies].sort((a, b) => a - b);
|
|
224
|
-
const getPercentile = (p) => {
|
|
225
|
-
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
|
226
|
-
return sorted[Math.max(0, index)] ?? 0;
|
|
227
|
-
};
|
|
228
|
-
return {
|
|
229
|
-
p50: getPercentile(50),
|
|
230
|
-
p90: getPercentile(90),
|
|
231
|
-
p99: getPercentile(99),
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
// ============================================================================
|
|
235
|
-
// Route Schemas
|
|
236
|
-
// ============================================================================
|
|
237
|
-
const ErrorResponseSchema = z.object({
|
|
238
|
-
error: z.string(),
|
|
239
|
-
message: z.string().optional(),
|
|
240
|
-
});
|
|
241
|
-
const commitSeqParamSchema = z.object({ seq: z.coerce.number().int() });
|
|
242
|
-
const clientIdParamSchema = z.object({ id: z.string().min(1) });
|
|
243
|
-
const eventIdParamSchema = z.object({ id: z.coerce.number().int() });
|
|
244
|
-
const apiKeyIdParamSchema = z.object({ id: z.string().min(1) });
|
|
245
|
-
const eventsQuerySchema = ConsolePartitionedPaginationQuerySchema.extend({
|
|
246
|
-
eventType: z.enum(['push', 'pull']).optional(),
|
|
247
|
-
actorId: z.string().optional(),
|
|
248
|
-
clientId: z.string().optional(),
|
|
249
|
-
requestId: z.string().optional(),
|
|
250
|
-
traceId: z.string().optional(),
|
|
251
|
-
outcome: z.string().optional(),
|
|
252
|
-
});
|
|
253
|
-
const commitDetailQuerySchema = ConsolePartitionQuerySchema;
|
|
254
|
-
const eventDetailQuerySchema = ConsolePartitionQuerySchema;
|
|
255
|
-
const evictClientQuerySchema = ConsolePartitionQuerySchema;
|
|
256
|
-
const apiKeyStatusSchema = z.enum(['active', 'revoked', 'expiring']);
|
|
257
|
-
const apiKeysQuerySchema = ConsolePaginationQuerySchema.extend({
|
|
258
|
-
type: ApiKeyTypeSchema.optional(),
|
|
259
|
-
status: apiKeyStatusSchema.optional(),
|
|
260
|
-
expiresWithinDays: z.coerce.number().int().min(1).max(365).optional(),
|
|
261
|
-
});
|
|
262
|
-
const handlersResponseSchema = z.object({
|
|
263
|
-
items: z.array(ConsoleHandlerSchema),
|
|
264
|
-
});
|
|
265
|
-
export function createConsoleRoutes(options) {
|
|
266
|
-
const routes = new Hono();
|
|
267
|
-
routes.onError((error, context) => {
|
|
268
|
-
const message = error instanceof Error ? error.message : 'Unknown console error';
|
|
269
|
-
console.error('[console] route error', error);
|
|
270
|
-
return context.json({
|
|
271
|
-
error: 'CONSOLE_ROUTE_ERROR',
|
|
272
|
-
message,
|
|
273
|
-
}, 500);
|
|
274
|
-
});
|
|
275
|
-
const db = options.db;
|
|
276
|
-
const metricsAggregationMode = options.metrics?.aggregationMode ?? 'auto';
|
|
277
|
-
const rawFallbackMaxEvents = Math.max(1, options.metrics?.rawFallbackMaxEvents ?? 5000);
|
|
278
|
-
// Ensure console schema exists before handlers query console tables.
|
|
279
|
-
const consoleSchemaReadyPromise = (options.consoleSchemaReady ??
|
|
280
|
-
options.dialect.ensureConsoleSchema?.(options.db) ??
|
|
281
|
-
Promise.resolve()).catch((err) => {
|
|
282
|
-
console.error('[console] Failed to ensure console schema:', err);
|
|
283
|
-
throw err;
|
|
284
|
-
});
|
|
285
|
-
// CORS configuration
|
|
286
|
-
const corsOrigins = options.corsOrigins ?? [
|
|
287
|
-
'http://localhost:5173',
|
|
288
|
-
'https://console.sync.dev',
|
|
289
|
-
];
|
|
290
|
-
const allowWildcardCors = corsOrigins === '*';
|
|
291
|
-
routes.use('*', cors({
|
|
292
|
-
origin: allowWildcardCors ? '*' : corsOrigins,
|
|
293
|
-
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
294
|
-
allowHeaders: [
|
|
295
|
-
'Content-Type',
|
|
296
|
-
'Authorization',
|
|
297
|
-
'X-Syncular-Transport-Path',
|
|
298
|
-
'Baggage',
|
|
299
|
-
'Sentry-Trace',
|
|
300
|
-
'Traceparent',
|
|
301
|
-
'Tracestate',
|
|
302
|
-
],
|
|
303
|
-
exposeHeaders: ['X-Total-Count'],
|
|
304
|
-
credentials: !allowWildcardCors,
|
|
305
|
-
}));
|
|
306
|
-
const ensureConsoleSchemaReady = async (c) => {
|
|
307
|
-
try {
|
|
308
|
-
await consoleSchemaReadyPromise;
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
catch {
|
|
312
|
-
return c.json({ error: 'CONSOLE_SCHEMA_UNAVAILABLE' }, 503);
|
|
313
|
-
}
|
|
314
|
-
};
|
|
315
|
-
routes.use('*', async (c, next) => {
|
|
316
|
-
const readyError = await ensureConsoleSchemaReady(c);
|
|
317
|
-
if (readyError) {
|
|
318
|
-
return readyError;
|
|
319
|
-
}
|
|
320
|
-
await next();
|
|
321
|
-
});
|
|
322
|
-
// Auth middleware
|
|
323
|
-
const requireAuth = async (c) => {
|
|
324
|
-
const auth = await options.authenticate(c);
|
|
325
|
-
if (!auth) {
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
return auth;
|
|
329
|
-
};
|
|
330
|
-
const requestEventSelectColumns = [
|
|
331
|
-
'event_id',
|
|
332
|
-
'partition_id',
|
|
333
|
-
'request_id',
|
|
334
|
-
'trace_id',
|
|
335
|
-
'span_id',
|
|
336
|
-
'event_type',
|
|
337
|
-
'sync_path',
|
|
338
|
-
'transport_path',
|
|
339
|
-
'actor_id',
|
|
340
|
-
'client_id',
|
|
341
|
-
'status_code',
|
|
342
|
-
'outcome',
|
|
343
|
-
'response_status',
|
|
344
|
-
'error_code',
|
|
345
|
-
'duration_ms',
|
|
346
|
-
'commit_seq',
|
|
347
|
-
'operation_count',
|
|
348
|
-
'row_count',
|
|
349
|
-
'subscription_count',
|
|
350
|
-
'scopes_summary',
|
|
351
|
-
'tables',
|
|
352
|
-
'error_message',
|
|
353
|
-
'payload_ref',
|
|
354
|
-
'created_at',
|
|
355
|
-
];
|
|
356
|
-
const mapRequestEvent = (row) => ({
|
|
357
|
-
eventId: coerceNumber(row.event_id) ?? 0,
|
|
358
|
-
partitionId: row.partition_id ?? 'default',
|
|
359
|
-
requestId: row.request_id ?? '',
|
|
360
|
-
traceId: row.trace_id ?? null,
|
|
361
|
-
spanId: row.span_id ?? null,
|
|
362
|
-
eventType: row.event_type === 'push' ? 'push' : 'pull',
|
|
363
|
-
syncPath: row.sync_path === 'ws-push' ? 'ws-push' : 'http-combined',
|
|
364
|
-
transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
|
|
365
|
-
actorId: row.actor_id ?? '',
|
|
366
|
-
clientId: row.client_id ?? '',
|
|
367
|
-
statusCode: coerceNumber(row.status_code) ?? 0,
|
|
368
|
-
outcome: row.outcome ?? '',
|
|
369
|
-
responseStatus: row.response_status ?? 'unknown',
|
|
370
|
-
errorCode: row.error_code ?? null,
|
|
371
|
-
durationMs: coerceNumber(row.duration_ms) ?? 0,
|
|
372
|
-
commitSeq: coerceNumber(row.commit_seq),
|
|
373
|
-
operationCount: coerceNumber(row.operation_count),
|
|
374
|
-
rowCount: coerceNumber(row.row_count),
|
|
375
|
-
subscriptionCount: coerceNumber(row.subscription_count),
|
|
376
|
-
scopesSummary: parseScopesSummary(row.scopes_summary),
|
|
377
|
-
tables: options.dialect.dbToArray(row.tables),
|
|
378
|
-
errorMessage: row.error_message ?? null,
|
|
379
|
-
payloadRef: row.payload_ref ?? null,
|
|
380
|
-
createdAt: row.created_at ?? '',
|
|
381
|
-
});
|
|
382
|
-
const operationEventSelectColumns = [
|
|
383
|
-
'operation_id',
|
|
384
|
-
'operation_type',
|
|
385
|
-
'console_user_id',
|
|
386
|
-
'partition_id',
|
|
387
|
-
'target_client_id',
|
|
388
|
-
'request_payload',
|
|
389
|
-
'result_payload',
|
|
390
|
-
'created_at',
|
|
391
|
-
];
|
|
392
|
-
const mapOperationEvent = (row) => ({
|
|
393
|
-
operationId: coerceNumber(row.operation_id) ?? 0,
|
|
394
|
-
operationType: row.operation_type === 'prune' ||
|
|
395
|
-
row.operation_type === 'compact' ||
|
|
396
|
-
row.operation_type === 'notify_data_change' ||
|
|
397
|
-
row.operation_type === 'evict_client'
|
|
398
|
-
? row.operation_type
|
|
399
|
-
: 'prune',
|
|
400
|
-
consoleUserId: row.console_user_id ?? null,
|
|
401
|
-
partitionId: row.partition_id ?? null,
|
|
402
|
-
targetClientId: row.target_client_id ?? null,
|
|
403
|
-
requestPayload: parseJsonValue(row.request_payload),
|
|
404
|
-
resultPayload: parseJsonValue(row.result_payload),
|
|
405
|
-
createdAt: row.created_at ?? '',
|
|
406
|
-
});
|
|
407
|
-
const deleteUnreferencedPayloadSnapshots = async () => {
|
|
408
|
-
const result = await db
|
|
409
|
-
.deleteFrom('sync_request_payloads')
|
|
410
|
-
.where('payload_ref', 'not in', db
|
|
411
|
-
.selectFrom('sync_request_events')
|
|
412
|
-
.select('payload_ref')
|
|
413
|
-
.where('payload_ref', 'is not', null))
|
|
414
|
-
.executeTakeFirst();
|
|
415
|
-
return Number(result?.numDeletedRows ?? 0);
|
|
416
|
-
};
|
|
417
|
-
const recordOperationEvent = async (event) => {
|
|
418
|
-
await db
|
|
419
|
-
.insertInto('sync_operation_events')
|
|
420
|
-
.values({
|
|
421
|
-
operation_type: event.operationType,
|
|
422
|
-
console_user_id: event.consoleUserId ?? null,
|
|
423
|
-
partition_id: event.partitionId ?? null,
|
|
424
|
-
target_client_id: event.targetClientId ?? null,
|
|
425
|
-
request_payload: event.requestPayload === undefined
|
|
426
|
-
? null
|
|
427
|
-
: JSON.stringify(event.requestPayload),
|
|
428
|
-
result_payload: event.resultPayload === undefined
|
|
429
|
-
? null
|
|
430
|
-
: JSON.stringify(event.resultPayload),
|
|
431
|
-
})
|
|
432
|
-
.execute();
|
|
433
|
-
};
|
|
434
|
-
const shouldUseRawMetrics = async (startIso, partitionId) => {
|
|
435
|
-
if (metricsAggregationMode === 'raw') {
|
|
436
|
-
return true;
|
|
437
|
-
}
|
|
438
|
-
if (metricsAggregationMode === 'aggregated') {
|
|
439
|
-
return false;
|
|
440
|
-
}
|
|
441
|
-
let countQuery = db
|
|
442
|
-
.selectFrom('sync_request_events')
|
|
443
|
-
.select(({ fn }) => fn.countAll().as('total'))
|
|
444
|
-
.where('created_at', '>=', startIso);
|
|
445
|
-
if (partitionId) {
|
|
446
|
-
countQuery = countQuery.where('partition_id', '=', partitionId);
|
|
447
|
-
}
|
|
448
|
-
const countRow = await countQuery.executeTakeFirst();
|
|
449
|
-
const total = coerceNumber(countRow?.total) ?? 0;
|
|
450
|
-
return total <= rawFallbackMaxEvents;
|
|
451
|
-
};
|
|
452
|
-
// -------------------------------------------------------------------------
|
|
453
|
-
// GET /stats
|
|
454
|
-
// -------------------------------------------------------------------------
|
|
455
|
-
routes.get('/stats', describeRoute({
|
|
456
|
-
tags: ['console'],
|
|
457
|
-
summary: 'Get sync statistics',
|
|
458
|
-
responses: {
|
|
459
|
-
200: {
|
|
460
|
-
description: 'Sync statistics',
|
|
461
|
-
content: {
|
|
462
|
-
'application/json': { schema: resolver(SyncStatsSchema) },
|
|
463
|
-
},
|
|
464
|
-
},
|
|
465
|
-
401: {
|
|
466
|
-
description: 'Unauthenticated',
|
|
467
|
-
content: {
|
|
468
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
469
|
-
},
|
|
470
|
-
},
|
|
471
|
-
},
|
|
472
|
-
}), zValidator('query', ConsolePartitionQuerySchema), async (c) => {
|
|
473
|
-
const auth = await requireAuth(c);
|
|
474
|
-
if (!auth)
|
|
475
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
476
|
-
const { partitionId } = c.req.valid('query');
|
|
477
|
-
const stats = await readSyncStats(options.db, {
|
|
478
|
-
partitionId,
|
|
479
|
-
});
|
|
480
|
-
logSyncEvent({
|
|
481
|
-
event: 'console.stats',
|
|
482
|
-
consoleUserId: auth.consoleUserId,
|
|
483
|
-
});
|
|
484
|
-
return c.json(stats, 200);
|
|
485
|
-
});
|
|
486
|
-
// -------------------------------------------------------------------------
|
|
487
|
-
// GET /stats/timeseries
|
|
488
|
-
// -------------------------------------------------------------------------
|
|
489
|
-
routes.get('/stats/timeseries', describeRoute({
|
|
490
|
-
tags: ['console'],
|
|
491
|
-
summary: 'Get time-series statistics',
|
|
492
|
-
responses: {
|
|
493
|
-
200: {
|
|
494
|
-
description: 'Time-series statistics',
|
|
495
|
-
content: {
|
|
496
|
-
'application/json': {
|
|
497
|
-
schema: resolver(TimeseriesStatsResponseSchema),
|
|
498
|
-
},
|
|
499
|
-
},
|
|
500
|
-
},
|
|
501
|
-
401: {
|
|
502
|
-
description: 'Unauthenticated',
|
|
503
|
-
content: {
|
|
504
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
505
|
-
},
|
|
506
|
-
},
|
|
507
|
-
},
|
|
508
|
-
}), zValidator('query', TimeseriesQuerySchema), async (c) => {
|
|
509
|
-
const auth = await requireAuth(c);
|
|
510
|
-
if (!auth)
|
|
511
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
512
|
-
const { interval, range, partitionId } = c.req.valid('query');
|
|
513
|
-
const rangeMs = rangeToMs(range);
|
|
514
|
-
const startTime = new Date(Date.now() - rangeMs);
|
|
515
|
-
const startIso = startTime.toISOString();
|
|
516
|
-
const intervalMs = intervalToMs(interval);
|
|
517
|
-
const bucketMap = createTimeseriesBucketMap({
|
|
518
|
-
startTime,
|
|
519
|
-
rangeMs,
|
|
520
|
-
intervalMs,
|
|
521
|
-
});
|
|
522
|
-
const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
|
|
523
|
-
if (useRawMetrics) {
|
|
524
|
-
let eventsQuery = db
|
|
525
|
-
.selectFrom('sync_request_events')
|
|
526
|
-
.select(['event_type', 'duration_ms', 'outcome', 'created_at'])
|
|
527
|
-
.where('created_at', '>=', startIso);
|
|
528
|
-
if (partitionId) {
|
|
529
|
-
eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
|
|
530
|
-
}
|
|
531
|
-
const events = await eventsQuery.orderBy('created_at', 'asc').execute();
|
|
532
|
-
for (const event of events) {
|
|
533
|
-
const eventTime = parseDate(event.created_at);
|
|
534
|
-
if (eventTime === null)
|
|
535
|
-
continue;
|
|
536
|
-
const bucketIndex = Math.floor((eventTime - startTime.getTime()) / intervalMs);
|
|
537
|
-
const bucketTime = new Date(startTime.getTime() + bucketIndex * intervalMs).toISOString();
|
|
538
|
-
let bucket = bucketMap.get(bucketTime);
|
|
539
|
-
if (!bucket) {
|
|
540
|
-
bucket = createEmptyTimeseriesAccumulator();
|
|
541
|
-
bucketMap.set(bucketTime, bucket);
|
|
542
|
-
}
|
|
543
|
-
if (event.event_type === 'push') {
|
|
544
|
-
bucket.pushCount++;
|
|
545
|
-
}
|
|
546
|
-
else if (event.event_type === 'pull') {
|
|
547
|
-
bucket.pullCount++;
|
|
548
|
-
}
|
|
549
|
-
if (event.outcome === 'error') {
|
|
550
|
-
bucket.errorCount++;
|
|
551
|
-
}
|
|
552
|
-
const durationMs = coerceNumber(event.duration_ms);
|
|
553
|
-
if (durationMs !== null) {
|
|
554
|
-
bucket.totalLatency += durationMs;
|
|
555
|
-
bucket.eventCount++;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
else {
|
|
560
|
-
const partitionFilter = partitionId
|
|
561
|
-
? sql `and partition_id = ${partitionId}`
|
|
562
|
-
: sql ``;
|
|
563
|
-
if (options.dialect.family === 'sqlite') {
|
|
564
|
-
const bucketFormat = intervalToSqliteBucketFormat(interval);
|
|
565
|
-
const rowsResult = await sql `
|
|
566
|
-
select
|
|
567
|
-
strftime(${bucketFormat}, created_at) as bucket,
|
|
568
|
-
sum(case when event_type = 'push' then 1 else 0 end) as push_count,
|
|
569
|
-
sum(case when event_type = 'pull' then 1 else 0 end) as pull_count,
|
|
570
|
-
sum(case when outcome = 'error' then 1 else 0 end) as error_count,
|
|
571
|
-
avg(duration_ms) as avg_latency_ms
|
|
572
|
-
from ${sql.table('sync_request_events')}
|
|
573
|
-
where created_at >= ${startIso}
|
|
574
|
-
${partitionFilter}
|
|
575
|
-
group by 1
|
|
576
|
-
order by 1 asc
|
|
577
|
-
`.execute(options.db);
|
|
578
|
-
for (const row of rowsResult.rows) {
|
|
579
|
-
const bucketTimestamp = normalizeBucketTimestamp(row.bucket);
|
|
580
|
-
if (!bucketTimestamp)
|
|
581
|
-
continue;
|
|
582
|
-
let bucket = bucketMap.get(bucketTimestamp);
|
|
583
|
-
if (!bucket) {
|
|
584
|
-
bucket = createEmptyTimeseriesAccumulator();
|
|
585
|
-
bucketMap.set(bucketTimestamp, bucket);
|
|
586
|
-
}
|
|
587
|
-
const pushCount = coerceNumber(row.push_count) ?? 0;
|
|
588
|
-
const pullCount = coerceNumber(row.pull_count) ?? 0;
|
|
589
|
-
const errorCount = coerceNumber(row.error_count) ?? 0;
|
|
590
|
-
const avgLatencyMs = coerceNumber(row.avg_latency_ms);
|
|
591
|
-
const rowEventCount = pushCount + pullCount;
|
|
592
|
-
bucket.pushCount += pushCount;
|
|
593
|
-
bucket.pullCount += pullCount;
|
|
594
|
-
bucket.errorCount += errorCount;
|
|
595
|
-
if (avgLatencyMs !== null && rowEventCount > 0) {
|
|
596
|
-
bucket.totalLatency += avgLatencyMs * rowEventCount;
|
|
597
|
-
bucket.eventCount += rowEventCount;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
else {
|
|
602
|
-
const rowsResult = await sql `
|
|
603
|
-
select
|
|
604
|
-
date_trunc(${interval}, created_at::timestamptz) as bucket,
|
|
605
|
-
count(*) filter (where event_type = 'push') as push_count,
|
|
606
|
-
count(*) filter (where event_type = 'pull') as pull_count,
|
|
607
|
-
count(*) filter (where outcome = 'error') as error_count,
|
|
608
|
-
avg(duration_ms) as avg_latency_ms
|
|
609
|
-
from ${sql.table('sync_request_events')}
|
|
610
|
-
where created_at >= ${startIso}
|
|
611
|
-
${partitionFilter}
|
|
612
|
-
group by 1
|
|
613
|
-
order by 1 asc
|
|
614
|
-
`.execute(options.db);
|
|
615
|
-
for (const row of rowsResult.rows) {
|
|
616
|
-
const bucketTimestamp = normalizeBucketTimestamp(row.bucket);
|
|
617
|
-
if (!bucketTimestamp)
|
|
618
|
-
continue;
|
|
619
|
-
let bucket = bucketMap.get(bucketTimestamp);
|
|
620
|
-
if (!bucket) {
|
|
621
|
-
bucket = createEmptyTimeseriesAccumulator();
|
|
622
|
-
bucketMap.set(bucketTimestamp, bucket);
|
|
623
|
-
}
|
|
624
|
-
const pushCount = coerceNumber(row.push_count) ?? 0;
|
|
625
|
-
const pullCount = coerceNumber(row.pull_count) ?? 0;
|
|
626
|
-
const errorCount = coerceNumber(row.error_count) ?? 0;
|
|
627
|
-
const avgLatencyMs = coerceNumber(row.avg_latency_ms);
|
|
628
|
-
const rowEventCount = pushCount + pullCount;
|
|
629
|
-
bucket.pushCount += pushCount;
|
|
630
|
-
bucket.pullCount += pullCount;
|
|
631
|
-
bucket.errorCount += errorCount;
|
|
632
|
-
if (avgLatencyMs !== null && rowEventCount > 0) {
|
|
633
|
-
bucket.totalLatency += avgLatencyMs * rowEventCount;
|
|
634
|
-
bucket.eventCount += rowEventCount;
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
const buckets = finalizeTimeseriesBuckets(bucketMap);
|
|
640
|
-
const response = {
|
|
641
|
-
buckets,
|
|
642
|
-
interval,
|
|
643
|
-
range,
|
|
644
|
-
};
|
|
645
|
-
return c.json(response, 200);
|
|
646
|
-
});
|
|
647
|
-
// -------------------------------------------------------------------------
|
|
648
|
-
// GET /stats/latency
|
|
649
|
-
// -------------------------------------------------------------------------
|
|
650
|
-
routes.get('/stats/latency', describeRoute({
|
|
651
|
-
tags: ['console'],
|
|
652
|
-
summary: 'Get latency percentiles',
|
|
653
|
-
responses: {
|
|
654
|
-
200: {
|
|
655
|
-
description: 'Latency percentiles',
|
|
656
|
-
content: {
|
|
657
|
-
'application/json': {
|
|
658
|
-
schema: resolver(LatencyStatsResponseSchema),
|
|
659
|
-
},
|
|
660
|
-
},
|
|
661
|
-
},
|
|
662
|
-
401: {
|
|
663
|
-
description: 'Unauthenticated',
|
|
664
|
-
content: {
|
|
665
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
666
|
-
},
|
|
667
|
-
},
|
|
668
|
-
},
|
|
669
|
-
}), zValidator('query', LatencyQuerySchema), async (c) => {
|
|
670
|
-
const auth = await requireAuth(c);
|
|
671
|
-
if (!auth)
|
|
672
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
673
|
-
const { range, partitionId } = c.req.valid('query');
|
|
674
|
-
const rangeMs = rangeToMs(range);
|
|
675
|
-
const startTime = new Date(Date.now() - rangeMs);
|
|
676
|
-
const startIso = startTime.toISOString();
|
|
677
|
-
const useRawMetrics = await shouldUseRawMetrics(startIso, partitionId);
|
|
678
|
-
if (!useRawMetrics && options.dialect.family !== 'sqlite') {
|
|
679
|
-
const partitionFilter = partitionId
|
|
680
|
-
? sql `and partition_id = ${partitionId}`
|
|
681
|
-
: sql ``;
|
|
682
|
-
const rowsResult = await sql `
|
|
683
|
-
select
|
|
684
|
-
event_type,
|
|
685
|
-
percentile_disc(0.5) within group (order by duration_ms) as p50,
|
|
686
|
-
percentile_disc(0.9) within group (order by duration_ms) as p90,
|
|
687
|
-
percentile_disc(0.99) within group (order by duration_ms) as p99
|
|
688
|
-
from ${sql.table('sync_request_events')}
|
|
689
|
-
where created_at >= ${startIso}
|
|
690
|
-
${partitionFilter}
|
|
691
|
-
group by event_type
|
|
692
|
-
`.execute(options.db);
|
|
693
|
-
const push = { p50: 0, p90: 0, p99: 0 };
|
|
694
|
-
const pull = { p50: 0, p90: 0, p99: 0 };
|
|
695
|
-
for (const row of rowsResult.rows) {
|
|
696
|
-
const eventType = row.event_type === 'push' ? 'push' : 'pull';
|
|
697
|
-
const target = eventType === 'push' ? push : pull;
|
|
698
|
-
target.p50 = coerceNumber(row.p50) ?? 0;
|
|
699
|
-
target.p90 = coerceNumber(row.p90) ?? 0;
|
|
700
|
-
target.p99 = coerceNumber(row.p99) ?? 0;
|
|
701
|
-
}
|
|
702
|
-
const aggregatedResponse = {
|
|
703
|
-
push,
|
|
704
|
-
pull,
|
|
705
|
-
range,
|
|
706
|
-
};
|
|
707
|
-
return c.json(aggregatedResponse, 200);
|
|
708
|
-
}
|
|
709
|
-
// Raw fallback path (default for local/dev and SQLite)
|
|
710
|
-
let eventsQuery = db
|
|
711
|
-
.selectFrom('sync_request_events')
|
|
712
|
-
.select(['event_type', 'duration_ms'])
|
|
713
|
-
.where('created_at', '>=', startIso);
|
|
714
|
-
if (partitionId) {
|
|
715
|
-
eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
|
|
716
|
-
}
|
|
717
|
-
const events = await eventsQuery.execute();
|
|
718
|
-
const pushLatencies = [];
|
|
719
|
-
const pullLatencies = [];
|
|
720
|
-
for (const event of events) {
|
|
721
|
-
const durationMs = coerceNumber(event.duration_ms);
|
|
722
|
-
if (durationMs !== null) {
|
|
723
|
-
if (event.event_type === 'push') {
|
|
724
|
-
pushLatencies.push(durationMs);
|
|
725
|
-
}
|
|
726
|
-
else if (event.event_type === 'pull') {
|
|
727
|
-
pullLatencies.push(durationMs);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
const response = {
|
|
732
|
-
push: calculatePercentiles(pushLatencies),
|
|
733
|
-
pull: calculatePercentiles(pullLatencies),
|
|
734
|
-
range,
|
|
735
|
-
};
|
|
736
|
-
return c.json(response, 200);
|
|
737
|
-
});
|
|
738
|
-
// -------------------------------------------------------------------------
|
|
739
|
-
// GET /timeline
|
|
740
|
-
// -------------------------------------------------------------------------
|
|
741
|
-
routes.get('/timeline', describeRoute({
|
|
742
|
-
tags: ['console'],
|
|
743
|
-
summary: 'List timeline items',
|
|
744
|
-
responses: {
|
|
745
|
-
200: {
|
|
746
|
-
description: 'Paginated merged timeline',
|
|
747
|
-
content: {
|
|
748
|
-
'application/json': {
|
|
749
|
-
schema: resolver(ConsolePaginatedResponseSchema(ConsoleTimelineItemSchema)),
|
|
750
|
-
},
|
|
751
|
-
},
|
|
752
|
-
},
|
|
753
|
-
401: {
|
|
754
|
-
description: 'Unauthenticated',
|
|
755
|
-
content: {
|
|
756
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
757
|
-
},
|
|
758
|
-
},
|
|
759
|
-
},
|
|
760
|
-
}), zValidator('query', ConsoleTimelineQuerySchema), async (c) => {
|
|
761
|
-
const auth = await requireAuth(c);
|
|
762
|
-
if (!auth)
|
|
763
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
764
|
-
const { limit, offset, view, partitionId, eventType, actorId, clientId, requestId, traceId, table, outcome, search, from, to, } = c.req.valid('query');
|
|
765
|
-
const items = [];
|
|
766
|
-
const normalizedSearchTerm = search?.trim().toLowerCase() || null;
|
|
767
|
-
const normalizedTable = table?.trim() || null;
|
|
768
|
-
if (view !== 'events' &&
|
|
769
|
-
!eventType &&
|
|
770
|
-
!outcome &&
|
|
771
|
-
!requestId &&
|
|
772
|
-
!traceId) {
|
|
773
|
-
let commitsQuery = db
|
|
774
|
-
.selectFrom('sync_commits')
|
|
775
|
-
.select([
|
|
776
|
-
'commit_seq',
|
|
777
|
-
'actor_id',
|
|
778
|
-
'client_id',
|
|
779
|
-
'client_commit_id',
|
|
780
|
-
'created_at',
|
|
781
|
-
'change_count',
|
|
782
|
-
'affected_tables',
|
|
783
|
-
]);
|
|
784
|
-
if (partitionId) {
|
|
785
|
-
commitsQuery = commitsQuery.where('partition_id', '=', partitionId);
|
|
786
|
-
}
|
|
787
|
-
if (actorId) {
|
|
788
|
-
commitsQuery = commitsQuery.where('actor_id', '=', actorId);
|
|
789
|
-
}
|
|
790
|
-
if (clientId) {
|
|
791
|
-
commitsQuery = commitsQuery.where('client_id', '=', clientId);
|
|
792
|
-
}
|
|
793
|
-
if (from) {
|
|
794
|
-
commitsQuery = commitsQuery.where('created_at', '>=', from);
|
|
795
|
-
}
|
|
796
|
-
if (to) {
|
|
797
|
-
commitsQuery = commitsQuery.where('created_at', '<=', to);
|
|
798
|
-
}
|
|
799
|
-
const commitRows = await commitsQuery.execute();
|
|
800
|
-
for (const row of commitRows) {
|
|
801
|
-
const commit = {
|
|
802
|
-
commitSeq: coerceNumber(row.commit_seq) ?? 0,
|
|
803
|
-
actorId: row.actor_id ?? '',
|
|
804
|
-
clientId: row.client_id ?? '',
|
|
805
|
-
clientCommitId: row.client_commit_id ?? '',
|
|
806
|
-
createdAt: row.created_at ?? '',
|
|
807
|
-
changeCount: coerceNumber(row.change_count) ?? 0,
|
|
808
|
-
affectedTables: options.dialect.dbToArray(row.affected_tables),
|
|
809
|
-
};
|
|
810
|
-
items.push({
|
|
811
|
-
type: 'commit',
|
|
812
|
-
timestamp: commit.createdAt,
|
|
813
|
-
commit,
|
|
814
|
-
event: null,
|
|
815
|
-
});
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
if (view !== 'commits') {
|
|
819
|
-
let eventsQuery = db
|
|
820
|
-
.selectFrom('sync_request_events')
|
|
821
|
-
.select(requestEventSelectColumns);
|
|
822
|
-
if (partitionId) {
|
|
823
|
-
eventsQuery = eventsQuery.where('partition_id', '=', partitionId);
|
|
824
|
-
}
|
|
825
|
-
if (eventType) {
|
|
826
|
-
eventsQuery = eventsQuery.where('event_type', '=', eventType);
|
|
827
|
-
}
|
|
828
|
-
if (actorId) {
|
|
829
|
-
eventsQuery = eventsQuery.where('actor_id', '=', actorId);
|
|
830
|
-
}
|
|
831
|
-
if (clientId) {
|
|
832
|
-
eventsQuery = eventsQuery.where('client_id', '=', clientId);
|
|
833
|
-
}
|
|
834
|
-
if (requestId) {
|
|
835
|
-
eventsQuery = eventsQuery.where('request_id', '=', requestId);
|
|
836
|
-
}
|
|
837
|
-
if (traceId) {
|
|
838
|
-
eventsQuery = eventsQuery.where('trace_id', '=', traceId);
|
|
839
|
-
}
|
|
840
|
-
if (outcome) {
|
|
841
|
-
eventsQuery = eventsQuery.where('outcome', '=', outcome);
|
|
842
|
-
}
|
|
843
|
-
if (from) {
|
|
844
|
-
eventsQuery = eventsQuery.where('created_at', '>=', from);
|
|
845
|
-
}
|
|
846
|
-
if (to) {
|
|
847
|
-
eventsQuery = eventsQuery.where('created_at', '<=', to);
|
|
848
|
-
}
|
|
849
|
-
const eventRows = await eventsQuery.execute();
|
|
850
|
-
for (const row of eventRows) {
|
|
851
|
-
const event = mapRequestEvent(row);
|
|
852
|
-
items.push({
|
|
853
|
-
type: 'event',
|
|
854
|
-
timestamp: event.createdAt,
|
|
855
|
-
commit: null,
|
|
856
|
-
event,
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
const filteredItems = items.filter((item) => {
|
|
861
|
-
if (item.type === 'commit') {
|
|
862
|
-
const commit = item.commit;
|
|
863
|
-
if (!commit)
|
|
864
|
-
return false;
|
|
865
|
-
if (normalizedTable &&
|
|
866
|
-
!(commit.affectedTables ?? []).includes(normalizedTable)) {
|
|
867
|
-
return false;
|
|
868
|
-
}
|
|
869
|
-
if (!normalizedSearchTerm)
|
|
870
|
-
return true;
|
|
871
|
-
const searchableCommitFields = [
|
|
872
|
-
String(commit.commitSeq),
|
|
873
|
-
commit.actorId,
|
|
874
|
-
commit.clientId,
|
|
875
|
-
commit.clientCommitId,
|
|
876
|
-
...(commit.affectedTables ?? []),
|
|
877
|
-
];
|
|
878
|
-
return searchableCommitFields.some((field) => includesSearchTerm(field, normalizedSearchTerm));
|
|
879
|
-
}
|
|
880
|
-
const event = item.event;
|
|
881
|
-
if (!event)
|
|
882
|
-
return false;
|
|
883
|
-
if (normalizedTable &&
|
|
884
|
-
!(event.tables ?? []).includes(normalizedTable)) {
|
|
885
|
-
return false;
|
|
886
|
-
}
|
|
887
|
-
if (!normalizedSearchTerm)
|
|
888
|
-
return true;
|
|
889
|
-
const searchableEventFields = [
|
|
890
|
-
String(event.eventId),
|
|
891
|
-
event.requestId,
|
|
892
|
-
event.traceId ?? '',
|
|
893
|
-
event.actorId,
|
|
894
|
-
event.clientId,
|
|
895
|
-
event.outcome,
|
|
896
|
-
event.responseStatus,
|
|
897
|
-
event.errorCode ?? '',
|
|
898
|
-
event.errorMessage ?? '',
|
|
899
|
-
...(event.tables ?? []),
|
|
900
|
-
];
|
|
901
|
-
return searchableEventFields.some((field) => includesSearchTerm(field, normalizedSearchTerm));
|
|
902
|
-
});
|
|
903
|
-
filteredItems.sort((a, b) => (parseDate(b.timestamp) ?? 0) - (parseDate(a.timestamp) ?? 0));
|
|
904
|
-
const total = filteredItems.length;
|
|
905
|
-
const pagedItems = filteredItems.slice(offset, offset + limit);
|
|
906
|
-
const response = {
|
|
907
|
-
items: pagedItems,
|
|
908
|
-
total,
|
|
909
|
-
offset,
|
|
910
|
-
limit,
|
|
911
|
-
};
|
|
912
|
-
c.header('X-Total-Count', String(total));
|
|
913
|
-
return c.json(response, 200);
|
|
914
|
-
});
|
|
915
|
-
// -------------------------------------------------------------------------
|
|
916
|
-
// GET /commits
|
|
917
|
-
// -------------------------------------------------------------------------
|
|
918
|
-
routes.get('/commits', describeRoute({
|
|
919
|
-
tags: ['console'],
|
|
920
|
-
summary: 'List commits',
|
|
921
|
-
responses: {
|
|
922
|
-
200: {
|
|
923
|
-
description: 'Paginated commit list',
|
|
924
|
-
content: {
|
|
925
|
-
'application/json': {
|
|
926
|
-
schema: resolver(ConsolePaginatedResponseSchema(ConsoleCommitListItemSchema)),
|
|
927
|
-
},
|
|
928
|
-
},
|
|
929
|
-
},
|
|
930
|
-
401: {
|
|
931
|
-
description: 'Unauthenticated',
|
|
932
|
-
content: {
|
|
933
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
934
|
-
},
|
|
935
|
-
},
|
|
936
|
-
},
|
|
937
|
-
}), zValidator('query', ConsolePartitionedPaginationQuerySchema), async (c) => {
|
|
938
|
-
const auth = await requireAuth(c);
|
|
939
|
-
if (!auth)
|
|
940
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
941
|
-
const { limit, offset, partitionId } = c.req.valid('query');
|
|
942
|
-
let query = db
|
|
943
|
-
.selectFrom('sync_commits')
|
|
944
|
-
.select([
|
|
945
|
-
'commit_seq',
|
|
946
|
-
'actor_id',
|
|
947
|
-
'client_id',
|
|
948
|
-
'client_commit_id',
|
|
949
|
-
'created_at',
|
|
950
|
-
'change_count',
|
|
951
|
-
'affected_tables',
|
|
952
|
-
]);
|
|
953
|
-
let countQuery = db
|
|
954
|
-
.selectFrom('sync_commits')
|
|
955
|
-
.select(({ fn }) => fn.countAll().as('total'));
|
|
956
|
-
if (partitionId) {
|
|
957
|
-
query = query.where('partition_id', '=', partitionId);
|
|
958
|
-
countQuery = countQuery.where('partition_id', '=', partitionId);
|
|
959
|
-
}
|
|
960
|
-
const [rows, countRow] = await Promise.all([
|
|
961
|
-
query
|
|
962
|
-
.orderBy('commit_seq', 'desc')
|
|
963
|
-
.limit(limit)
|
|
964
|
-
.offset(offset)
|
|
965
|
-
.execute(),
|
|
966
|
-
countQuery.executeTakeFirst(),
|
|
967
|
-
]);
|
|
968
|
-
const items = rows.map((row) => ({
|
|
969
|
-
commitSeq: coerceNumber(row.commit_seq) ?? 0,
|
|
970
|
-
actorId: row.actor_id ?? '',
|
|
971
|
-
clientId: row.client_id ?? '',
|
|
972
|
-
clientCommitId: row.client_commit_id ?? '',
|
|
973
|
-
createdAt: row.created_at ?? '',
|
|
974
|
-
changeCount: coerceNumber(row.change_count) ?? 0,
|
|
975
|
-
affectedTables: options.dialect.dbToArray(row.affected_tables),
|
|
976
|
-
}));
|
|
977
|
-
const total = coerceNumber(countRow?.total) ?? 0;
|
|
978
|
-
const response = {
|
|
979
|
-
items,
|
|
980
|
-
total,
|
|
981
|
-
offset,
|
|
982
|
-
limit,
|
|
983
|
-
};
|
|
984
|
-
c.header('X-Total-Count', String(total));
|
|
985
|
-
return c.json(response, 200);
|
|
986
|
-
});
|
|
987
|
-
// -------------------------------------------------------------------------
|
|
988
|
-
// GET /commits/:seq
|
|
989
|
-
// -------------------------------------------------------------------------
|
|
990
|
-
routes.get('/commits/:seq', describeRoute({
|
|
991
|
-
tags: ['console'],
|
|
992
|
-
summary: 'Get commit details',
|
|
993
|
-
responses: {
|
|
994
|
-
200: {
|
|
995
|
-
description: 'Commit with changes',
|
|
996
|
-
content: {
|
|
997
|
-
'application/json': { schema: resolver(ConsoleCommitDetailSchema) },
|
|
998
|
-
},
|
|
999
|
-
},
|
|
1000
|
-
400: {
|
|
1001
|
-
description: 'Invalid request',
|
|
1002
|
-
content: {
|
|
1003
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1004
|
-
},
|
|
1005
|
-
},
|
|
1006
|
-
401: {
|
|
1007
|
-
description: 'Unauthenticated',
|
|
1008
|
-
content: {
|
|
1009
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1010
|
-
},
|
|
1011
|
-
},
|
|
1012
|
-
404: {
|
|
1013
|
-
description: 'Not found',
|
|
1014
|
-
content: {
|
|
1015
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1016
|
-
},
|
|
1017
|
-
},
|
|
1018
|
-
},
|
|
1019
|
-
}), zValidator('param', commitSeqParamSchema), zValidator('query', commitDetailQuerySchema), async (c) => {
|
|
1020
|
-
const auth = await requireAuth(c);
|
|
1021
|
-
if (!auth)
|
|
1022
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1023
|
-
const { seq } = c.req.valid('param');
|
|
1024
|
-
const { partitionId } = c.req.valid('query');
|
|
1025
|
-
let commitQuery = db
|
|
1026
|
-
.selectFrom('sync_commits')
|
|
1027
|
-
.select([
|
|
1028
|
-
'commit_seq',
|
|
1029
|
-
'actor_id',
|
|
1030
|
-
'client_id',
|
|
1031
|
-
'client_commit_id',
|
|
1032
|
-
'created_at',
|
|
1033
|
-
'change_count',
|
|
1034
|
-
'affected_tables',
|
|
1035
|
-
])
|
|
1036
|
-
.where('commit_seq', '=', seq);
|
|
1037
|
-
if (partitionId) {
|
|
1038
|
-
commitQuery = commitQuery.where('partition_id', '=', partitionId);
|
|
1039
|
-
}
|
|
1040
|
-
const commitRow = await commitQuery.executeTakeFirst();
|
|
1041
|
-
if (!commitRow) {
|
|
1042
|
-
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
1043
|
-
}
|
|
1044
|
-
let changesQuery = db
|
|
1045
|
-
.selectFrom('sync_changes')
|
|
1046
|
-
.select([
|
|
1047
|
-
'change_id',
|
|
1048
|
-
'table',
|
|
1049
|
-
'row_id',
|
|
1050
|
-
'op',
|
|
1051
|
-
'row_json',
|
|
1052
|
-
'row_version',
|
|
1053
|
-
'scopes',
|
|
1054
|
-
])
|
|
1055
|
-
.where('commit_seq', '=', seq);
|
|
1056
|
-
if (partitionId) {
|
|
1057
|
-
changesQuery = changesQuery.where('partition_id', '=', partitionId);
|
|
1058
|
-
}
|
|
1059
|
-
const changeRows = await changesQuery
|
|
1060
|
-
.orderBy('change_id', 'asc')
|
|
1061
|
-
.execute();
|
|
1062
|
-
const changes = changeRows.map((row) => ({
|
|
1063
|
-
changeId: coerceNumber(row.change_id) ?? 0,
|
|
1064
|
-
table: row.table ?? '',
|
|
1065
|
-
rowId: row.row_id ?? '',
|
|
1066
|
-
op: row.op === 'delete' ? 'delete' : 'upsert',
|
|
1067
|
-
rowJson: typeof row.row_json === 'string'
|
|
1068
|
-
? (() => {
|
|
1069
|
-
try {
|
|
1070
|
-
return JSON.parse(row.row_json);
|
|
1071
|
-
}
|
|
1072
|
-
catch {
|
|
1073
|
-
return row.row_json;
|
|
1074
|
-
}
|
|
1075
|
-
})()
|
|
1076
|
-
: row.row_json,
|
|
1077
|
-
rowVersion: coerceNumber(row.row_version),
|
|
1078
|
-
scopes: typeof row.scopes === 'string'
|
|
1079
|
-
? JSON.parse(row.scopes || '{}')
|
|
1080
|
-
: (row.scopes ?? {}),
|
|
1081
|
-
}));
|
|
1082
|
-
const commit = {
|
|
1083
|
-
commitSeq: coerceNumber(commitRow.commit_seq) ?? 0,
|
|
1084
|
-
actorId: commitRow.actor_id ?? '',
|
|
1085
|
-
clientId: commitRow.client_id ?? '',
|
|
1086
|
-
clientCommitId: commitRow.client_commit_id ?? '',
|
|
1087
|
-
createdAt: commitRow.created_at ?? '',
|
|
1088
|
-
changeCount: coerceNumber(commitRow.change_count) ?? 0,
|
|
1089
|
-
affectedTables: Array.isArray(commitRow.affected_tables)
|
|
1090
|
-
? commitRow.affected_tables
|
|
1091
|
-
: typeof commitRow.affected_tables === 'string'
|
|
1092
|
-
? JSON.parse(commitRow.affected_tables || '[]')
|
|
1093
|
-
: [],
|
|
1094
|
-
changes,
|
|
1095
|
-
};
|
|
1096
|
-
return c.json(commit, 200);
|
|
1097
|
-
});
|
|
1098
|
-
// -------------------------------------------------------------------------
|
|
1099
|
-
// GET /clients
|
|
1100
|
-
// -------------------------------------------------------------------------
|
|
1101
|
-
routes.get('/clients', describeRoute({
|
|
1102
|
-
tags: ['console'],
|
|
1103
|
-
summary: 'List clients',
|
|
1104
|
-
responses: {
|
|
1105
|
-
200: {
|
|
1106
|
-
description: 'Paginated client list',
|
|
1107
|
-
content: {
|
|
1108
|
-
'application/json': {
|
|
1109
|
-
schema: resolver(ConsolePaginatedResponseSchema(ConsoleClientSchema)),
|
|
1110
|
-
},
|
|
1111
|
-
},
|
|
1112
|
-
},
|
|
1113
|
-
401: {
|
|
1114
|
-
description: 'Unauthenticated',
|
|
1115
|
-
content: {
|
|
1116
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1117
|
-
},
|
|
1118
|
-
},
|
|
1119
|
-
},
|
|
1120
|
-
}), zValidator('query', ConsolePartitionedPaginationQuerySchema), async (c) => {
|
|
1121
|
-
const auth = await requireAuth(c);
|
|
1122
|
-
if (!auth)
|
|
1123
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1124
|
-
const { limit, offset, partitionId } = c.req.valid('query');
|
|
1125
|
-
let clientsQuery = db
|
|
1126
|
-
.selectFrom('sync_client_cursors')
|
|
1127
|
-
.select([
|
|
1128
|
-
'client_id',
|
|
1129
|
-
'actor_id',
|
|
1130
|
-
'cursor',
|
|
1131
|
-
'effective_scopes',
|
|
1132
|
-
'updated_at',
|
|
1133
|
-
]);
|
|
1134
|
-
let countQuery = db
|
|
1135
|
-
.selectFrom('sync_client_cursors')
|
|
1136
|
-
.select(({ fn }) => fn.countAll().as('total'));
|
|
1137
|
-
let maxCommitSeqQuery = db
|
|
1138
|
-
.selectFrom('sync_commits')
|
|
1139
|
-
.select(({ fn }) => fn.max('commit_seq').as('max_commit_seq'));
|
|
1140
|
-
if (partitionId) {
|
|
1141
|
-
clientsQuery = clientsQuery.where('partition_id', '=', partitionId);
|
|
1142
|
-
countQuery = countQuery.where('partition_id', '=', partitionId);
|
|
1143
|
-
maxCommitSeqQuery = maxCommitSeqQuery.where('partition_id', '=', partitionId);
|
|
1144
|
-
}
|
|
1145
|
-
const [rows, countRow, maxCommitSeqRow] = await Promise.all([
|
|
1146
|
-
clientsQuery
|
|
1147
|
-
.orderBy('updated_at', 'desc')
|
|
1148
|
-
.limit(limit)
|
|
1149
|
-
.offset(offset)
|
|
1150
|
-
.execute(),
|
|
1151
|
-
countQuery.executeTakeFirst(),
|
|
1152
|
-
maxCommitSeqQuery.executeTakeFirst(),
|
|
1153
|
-
]);
|
|
1154
|
-
const maxCommitSeq = coerceNumber(maxCommitSeqRow?.max_commit_seq) ?? 0;
|
|
1155
|
-
const pagedClientIds = rows
|
|
1156
|
-
.map((row) => row.client_id)
|
|
1157
|
-
.filter((clientId) => typeof clientId === 'string');
|
|
1158
|
-
const latestEventsByClientId = new Map();
|
|
1159
|
-
if (pagedClientIds.length > 0) {
|
|
1160
|
-
let recentEventsQuery = db
|
|
1161
|
-
.selectFrom('sync_request_events')
|
|
1162
|
-
.select([
|
|
1163
|
-
'client_id',
|
|
1164
|
-
'event_type',
|
|
1165
|
-
'outcome',
|
|
1166
|
-
'created_at',
|
|
1167
|
-
'transport_path',
|
|
1168
|
-
])
|
|
1169
|
-
.where('client_id', 'in', pagedClientIds);
|
|
1170
|
-
if (partitionId) {
|
|
1171
|
-
recentEventsQuery = recentEventsQuery.where('partition_id', '=', partitionId);
|
|
1172
|
-
}
|
|
1173
|
-
const recentEventRows = await recentEventsQuery
|
|
1174
|
-
.orderBy('created_at', 'desc')
|
|
1175
|
-
.execute();
|
|
1176
|
-
for (const row of recentEventRows) {
|
|
1177
|
-
const clientId = row.client_id;
|
|
1178
|
-
if (!clientId || latestEventsByClientId.has(clientId)) {
|
|
1179
|
-
continue;
|
|
1180
|
-
}
|
|
1181
|
-
const eventType = row.event_type === 'push' ? 'push' : 'pull';
|
|
1182
|
-
latestEventsByClientId.set(clientId, {
|
|
1183
|
-
createdAt: row.created_at ?? '',
|
|
1184
|
-
eventType,
|
|
1185
|
-
outcome: row.outcome ?? '',
|
|
1186
|
-
transportPath: row.transport_path === 'relay' ? 'relay' : 'direct',
|
|
1187
|
-
});
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
const items = rows.map((row) => {
|
|
1191
|
-
const clientId = row.client_id ?? '';
|
|
1192
|
-
const cursor = coerceNumber(row.cursor) ?? 0;
|
|
1193
|
-
const latestEvent = latestEventsByClientId.get(clientId);
|
|
1194
|
-
const connectionCount = options.wsConnectionManager?.getConnectionCount(clientId) ?? 0;
|
|
1195
|
-
const connectionPath = options.wsConnectionManager?.getClientTransportPath(clientId) ??
|
|
1196
|
-
latestEvent?.transportPath ??
|
|
1197
|
-
'direct';
|
|
1198
|
-
return {
|
|
1199
|
-
clientId,
|
|
1200
|
-
actorId: row.actor_id ?? '',
|
|
1201
|
-
cursor,
|
|
1202
|
-
lagCommitCount: Math.max(0, maxCommitSeq - cursor),
|
|
1203
|
-
connectionPath,
|
|
1204
|
-
connectionMode: connectionCount > 0 ? 'realtime' : 'polling',
|
|
1205
|
-
realtimeConnectionCount: connectionCount,
|
|
1206
|
-
isRealtimeConnected: connectionCount > 0,
|
|
1207
|
-
activityState: getClientActivityState({
|
|
1208
|
-
connectionCount,
|
|
1209
|
-
updatedAt: row.updated_at,
|
|
1210
|
-
}),
|
|
1211
|
-
lastRequestAt: latestEvent?.createdAt ?? null,
|
|
1212
|
-
lastRequestType: latestEvent?.eventType ?? null,
|
|
1213
|
-
lastRequestOutcome: latestEvent?.outcome ?? null,
|
|
1214
|
-
effectiveScopes: options.dialect.dbToScopes(row.effective_scopes),
|
|
1215
|
-
updatedAt: row.updated_at ?? '',
|
|
1216
|
-
};
|
|
1217
|
-
});
|
|
1218
|
-
const total = coerceNumber(countRow?.total) ?? 0;
|
|
1219
|
-
const response = {
|
|
1220
|
-
items,
|
|
1221
|
-
total,
|
|
1222
|
-
offset,
|
|
1223
|
-
limit,
|
|
1224
|
-
};
|
|
1225
|
-
c.header('X-Total-Count', String(total));
|
|
1226
|
-
return c.json(response, 200);
|
|
1227
|
-
});
|
|
1228
|
-
// -------------------------------------------------------------------------
|
|
1229
|
-
// GET /handlers
|
|
1230
|
-
// -------------------------------------------------------------------------
|
|
1231
|
-
routes.get('/handlers', describeRoute({
|
|
1232
|
-
tags: ['console'],
|
|
1233
|
-
summary: 'List registered handlers',
|
|
1234
|
-
responses: {
|
|
1235
|
-
200: {
|
|
1236
|
-
description: 'Handler list',
|
|
1237
|
-
content: {
|
|
1238
|
-
'application/json': { schema: resolver(handlersResponseSchema) },
|
|
1239
|
-
},
|
|
1240
|
-
},
|
|
1241
|
-
401: {
|
|
1242
|
-
description: 'Unauthenticated',
|
|
1243
|
-
content: {
|
|
1244
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1245
|
-
},
|
|
1246
|
-
},
|
|
1247
|
-
},
|
|
1248
|
-
}), async (c) => {
|
|
1249
|
-
const auth = await requireAuth(c);
|
|
1250
|
-
if (!auth)
|
|
1251
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1252
|
-
const items = options.handlers.map((handler) => ({
|
|
1253
|
-
table: handler.table,
|
|
1254
|
-
dependsOn: handler.dependsOn,
|
|
1255
|
-
snapshotChunkTtlMs: handler.snapshotChunkTtlMs,
|
|
1256
|
-
}));
|
|
1257
|
-
return c.json({ items }, 200);
|
|
1258
|
-
});
|
|
1259
|
-
// -------------------------------------------------------------------------
|
|
1260
|
-
// GET /operations - Operation audit log
|
|
1261
|
-
// -------------------------------------------------------------------------
|
|
1262
|
-
routes.get('/operations', describeRoute({
|
|
1263
|
-
tags: ['console'],
|
|
1264
|
-
summary: 'List operation audit events',
|
|
1265
|
-
responses: {
|
|
1266
|
-
200: {
|
|
1267
|
-
description: 'Paginated operation events',
|
|
1268
|
-
content: {
|
|
1269
|
-
'application/json': {
|
|
1270
|
-
schema: resolver(ConsolePaginatedResponseSchema(ConsoleOperationEventSchema)),
|
|
1271
|
-
},
|
|
1272
|
-
},
|
|
1273
|
-
},
|
|
1274
|
-
401: {
|
|
1275
|
-
description: 'Unauthenticated',
|
|
1276
|
-
content: {
|
|
1277
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1278
|
-
},
|
|
1279
|
-
},
|
|
1280
|
-
},
|
|
1281
|
-
}), zValidator('query', ConsoleOperationsQuerySchema), async (c) => {
|
|
1282
|
-
const auth = await requireAuth(c);
|
|
1283
|
-
if (!auth)
|
|
1284
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1285
|
-
const { limit, offset, operationType, partitionId } = c.req.valid('query');
|
|
1286
|
-
let query = db
|
|
1287
|
-
.selectFrom('sync_operation_events')
|
|
1288
|
-
.select(operationEventSelectColumns);
|
|
1289
|
-
let countQuery = db
|
|
1290
|
-
.selectFrom('sync_operation_events')
|
|
1291
|
-
.select(({ fn }) => fn.countAll().as('total'));
|
|
1292
|
-
if (operationType) {
|
|
1293
|
-
query = query.where('operation_type', '=', operationType);
|
|
1294
|
-
countQuery = countQuery.where('operation_type', '=', operationType);
|
|
1295
|
-
}
|
|
1296
|
-
if (partitionId) {
|
|
1297
|
-
query = query.where('partition_id', '=', partitionId);
|
|
1298
|
-
countQuery = countQuery.where('partition_id', '=', partitionId);
|
|
1299
|
-
}
|
|
1300
|
-
const [rows, countRow] = await Promise.all([
|
|
1301
|
-
query
|
|
1302
|
-
.orderBy('created_at', 'desc')
|
|
1303
|
-
.limit(limit)
|
|
1304
|
-
.offset(offset)
|
|
1305
|
-
.execute(),
|
|
1306
|
-
countQuery.executeTakeFirst(),
|
|
1307
|
-
]);
|
|
1308
|
-
const items = rows.map((row) => mapOperationEvent(row));
|
|
1309
|
-
const total = coerceNumber(countRow?.total) ?? 0;
|
|
1310
|
-
const response = {
|
|
1311
|
-
items,
|
|
1312
|
-
total,
|
|
1313
|
-
offset,
|
|
1314
|
-
limit,
|
|
1315
|
-
};
|
|
1316
|
-
c.header('X-Total-Count', String(total));
|
|
1317
|
-
return c.json(response, 200);
|
|
1318
|
-
});
|
|
1319
|
-
// -------------------------------------------------------------------------
|
|
1320
|
-
// POST /prune/preview
|
|
1321
|
-
// -------------------------------------------------------------------------
|
|
1322
|
-
routes.post('/prune/preview', describeRoute({
|
|
1323
|
-
tags: ['console'],
|
|
1324
|
-
summary: 'Preview pruning',
|
|
1325
|
-
responses: {
|
|
1326
|
-
200: {
|
|
1327
|
-
description: 'Prune preview',
|
|
1328
|
-
content: {
|
|
1329
|
-
'application/json': { schema: resolver(ConsolePrunePreviewSchema) },
|
|
1330
|
-
},
|
|
1331
|
-
},
|
|
1332
|
-
401: {
|
|
1333
|
-
description: 'Unauthenticated',
|
|
1334
|
-
content: {
|
|
1335
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1336
|
-
},
|
|
1337
|
-
},
|
|
1338
|
-
},
|
|
1339
|
-
}), async (c) => {
|
|
1340
|
-
const auth = await requireAuth(c);
|
|
1341
|
-
if (!auth)
|
|
1342
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1343
|
-
const watermarkCommitSeq = await computePruneWatermarkCommitSeq(options.db, options.prune);
|
|
1344
|
-
// Count commits that would be deleted
|
|
1345
|
-
const countRow = await db
|
|
1346
|
-
.selectFrom('sync_commits')
|
|
1347
|
-
.select(({ fn }) => fn.countAll().as('count'))
|
|
1348
|
-
.where('commit_seq', '<=', watermarkCommitSeq)
|
|
1349
|
-
.executeTakeFirst();
|
|
1350
|
-
const commitsToDelete = coerceNumber(countRow?.count) ?? 0;
|
|
1351
|
-
const preview = {
|
|
1352
|
-
watermarkCommitSeq,
|
|
1353
|
-
commitsToDelete,
|
|
1354
|
-
};
|
|
1355
|
-
return c.json(preview, 200);
|
|
1356
|
-
});
|
|
1357
|
-
// -------------------------------------------------------------------------
|
|
1358
|
-
// POST /prune
|
|
1359
|
-
// -------------------------------------------------------------------------
|
|
1360
|
-
routes.post('/prune', describeRoute({
|
|
1361
|
-
tags: ['console'],
|
|
1362
|
-
summary: 'Trigger pruning',
|
|
1363
|
-
responses: {
|
|
1364
|
-
200: {
|
|
1365
|
-
description: 'Prune result',
|
|
1366
|
-
content: {
|
|
1367
|
-
'application/json': { schema: resolver(ConsolePruneResultSchema) },
|
|
1368
|
-
},
|
|
1369
|
-
},
|
|
1370
|
-
401: {
|
|
1371
|
-
description: 'Unauthenticated',
|
|
1372
|
-
content: {
|
|
1373
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1374
|
-
},
|
|
1375
|
-
},
|
|
1376
|
-
},
|
|
1377
|
-
}), async (c) => {
|
|
1378
|
-
const auth = await requireAuth(c);
|
|
1379
|
-
if (!auth)
|
|
1380
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1381
|
-
const watermarkCommitSeq = await computePruneWatermarkCommitSeq(options.db, options.prune);
|
|
1382
|
-
const deletedCommits = await pruneSync(options.db, {
|
|
1383
|
-
watermarkCommitSeq,
|
|
1384
|
-
keepNewestCommits: options.prune?.keepNewestCommits,
|
|
1385
|
-
});
|
|
1386
|
-
logSyncEvent({
|
|
1387
|
-
event: 'console.prune',
|
|
1388
|
-
consoleUserId: auth.consoleUserId,
|
|
1389
|
-
deletedCommits,
|
|
1390
|
-
watermarkCommitSeq,
|
|
1391
|
-
});
|
|
1392
|
-
await recordOperationEvent({
|
|
1393
|
-
operationType: 'prune',
|
|
1394
|
-
consoleUserId: auth.consoleUserId,
|
|
1395
|
-
requestPayload: {
|
|
1396
|
-
watermarkCommitSeq,
|
|
1397
|
-
keepNewestCommits: options.prune?.keepNewestCommits ?? null,
|
|
1398
|
-
},
|
|
1399
|
-
resultPayload: { deletedCommits, watermarkCommitSeq },
|
|
1400
|
-
});
|
|
1401
|
-
const result = { deletedCommits };
|
|
1402
|
-
return c.json(result, 200);
|
|
1403
|
-
});
|
|
1404
|
-
// -------------------------------------------------------------------------
|
|
1405
|
-
// POST /compact
|
|
1406
|
-
// -------------------------------------------------------------------------
|
|
1407
|
-
routes.post('/compact', describeRoute({
|
|
1408
|
-
tags: ['console'],
|
|
1409
|
-
summary: 'Trigger compaction',
|
|
1410
|
-
responses: {
|
|
1411
|
-
200: {
|
|
1412
|
-
description: 'Compact result',
|
|
1413
|
-
content: {
|
|
1414
|
-
'application/json': {
|
|
1415
|
-
schema: resolver(ConsoleCompactResultSchema),
|
|
1416
|
-
},
|
|
1417
|
-
},
|
|
1418
|
-
},
|
|
1419
|
-
401: {
|
|
1420
|
-
description: 'Unauthenticated',
|
|
1421
|
-
content: {
|
|
1422
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1423
|
-
},
|
|
1424
|
-
},
|
|
1425
|
-
},
|
|
1426
|
-
}), async (c) => {
|
|
1427
|
-
const auth = await requireAuth(c);
|
|
1428
|
-
if (!auth)
|
|
1429
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1430
|
-
const fullHistoryHours = options.compact?.fullHistoryHours ?? 24 * 7;
|
|
1431
|
-
const deletedChanges = await compactChanges(options.db, {
|
|
1432
|
-
dialect: options.dialect,
|
|
1433
|
-
options: { fullHistoryHours },
|
|
1434
|
-
});
|
|
1435
|
-
logSyncEvent({
|
|
1436
|
-
event: 'console.compact',
|
|
1437
|
-
consoleUserId: auth.consoleUserId,
|
|
1438
|
-
deletedChanges,
|
|
1439
|
-
fullHistoryHours,
|
|
1440
|
-
});
|
|
1441
|
-
await recordOperationEvent({
|
|
1442
|
-
operationType: 'compact',
|
|
1443
|
-
consoleUserId: auth.consoleUserId,
|
|
1444
|
-
requestPayload: { fullHistoryHours },
|
|
1445
|
-
resultPayload: { deletedChanges },
|
|
1446
|
-
});
|
|
1447
|
-
const result = { deletedChanges };
|
|
1448
|
-
return c.json(result, 200);
|
|
1449
|
-
});
|
|
1450
|
-
// -------------------------------------------------------------------------
|
|
1451
|
-
// POST /notify-data-change
|
|
1452
|
-
// -------------------------------------------------------------------------
|
|
1453
|
-
const NotifyDataChangeRequestSchema = z.object({
|
|
1454
|
-
tables: z.array(z.string().min(1)).min(1),
|
|
1455
|
-
partitionId: z.string().optional(),
|
|
1456
|
-
});
|
|
1457
|
-
const NotifyDataChangeResponseSchema = z.object({
|
|
1458
|
-
commitSeq: z.number(),
|
|
1459
|
-
tables: z.array(z.string()),
|
|
1460
|
-
deletedChunks: z.number(),
|
|
1461
|
-
});
|
|
1462
|
-
routes.post('/notify-data-change', describeRoute({
|
|
1463
|
-
tags: ['console'],
|
|
1464
|
-
summary: 'Notify external data change',
|
|
1465
|
-
description: 'Creates a synthetic commit to force re-bootstrap for affected tables. ' +
|
|
1466
|
-
'Use after pipeline imports or direct DB writes to notify connected clients.',
|
|
1467
|
-
responses: {
|
|
1468
|
-
200: {
|
|
1469
|
-
description: 'Notification result',
|
|
1470
|
-
content: {
|
|
1471
|
-
'application/json': {
|
|
1472
|
-
schema: resolver(NotifyDataChangeResponseSchema),
|
|
1473
|
-
},
|
|
1474
|
-
},
|
|
1475
|
-
},
|
|
1476
|
-
400: {
|
|
1477
|
-
description: 'Invalid request',
|
|
1478
|
-
content: {
|
|
1479
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1480
|
-
},
|
|
1481
|
-
},
|
|
1482
|
-
401: {
|
|
1483
|
-
description: 'Unauthenticated',
|
|
1484
|
-
content: {
|
|
1485
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1486
|
-
},
|
|
1487
|
-
},
|
|
1488
|
-
},
|
|
1489
|
-
}), zValidator('json', NotifyDataChangeRequestSchema), async (c) => {
|
|
1490
|
-
const auth = await requireAuth(c);
|
|
1491
|
-
if (!auth)
|
|
1492
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1493
|
-
const body = c.req.valid('json');
|
|
1494
|
-
const result = await notifyExternalDataChange({
|
|
1495
|
-
db: options.db,
|
|
1496
|
-
dialect: options.dialect,
|
|
1497
|
-
tables: body.tables,
|
|
1498
|
-
partitionId: body.partitionId,
|
|
1499
|
-
});
|
|
1500
|
-
logSyncEvent({
|
|
1501
|
-
event: 'console.notify_data_change',
|
|
1502
|
-
consoleUserId: auth.consoleUserId,
|
|
1503
|
-
tables: body.tables,
|
|
1504
|
-
commitSeq: result.commitSeq,
|
|
1505
|
-
deletedChunks: result.deletedChunks,
|
|
1506
|
-
});
|
|
1507
|
-
await recordOperationEvent({
|
|
1508
|
-
operationType: 'notify_data_change',
|
|
1509
|
-
consoleUserId: auth.consoleUserId,
|
|
1510
|
-
partitionId: body.partitionId ?? null,
|
|
1511
|
-
requestPayload: {
|
|
1512
|
-
tables: body.tables,
|
|
1513
|
-
partitionId: body.partitionId ?? null,
|
|
1514
|
-
},
|
|
1515
|
-
resultPayload: result,
|
|
1516
|
-
});
|
|
1517
|
-
// Wake all WS clients so they pull immediately
|
|
1518
|
-
if (options.wsConnectionManager) {
|
|
1519
|
-
options.wsConnectionManager.notifyAllClients(result.commitSeq);
|
|
1520
|
-
}
|
|
1521
|
-
return c.json(result, 200);
|
|
1522
|
-
});
|
|
1523
|
-
// -------------------------------------------------------------------------
|
|
1524
|
-
// DELETE /clients/:id
|
|
1525
|
-
// -------------------------------------------------------------------------
|
|
1526
|
-
routes.delete('/clients/:id', describeRoute({
|
|
1527
|
-
tags: ['console'],
|
|
1528
|
-
summary: 'Evict client',
|
|
1529
|
-
responses: {
|
|
1530
|
-
200: {
|
|
1531
|
-
description: 'Evict result',
|
|
1532
|
-
content: {
|
|
1533
|
-
'application/json': { schema: resolver(ConsoleEvictResultSchema) },
|
|
1534
|
-
},
|
|
1535
|
-
},
|
|
1536
|
-
400: {
|
|
1537
|
-
description: 'Invalid request',
|
|
1538
|
-
content: {
|
|
1539
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1540
|
-
},
|
|
1541
|
-
},
|
|
1542
|
-
401: {
|
|
1543
|
-
description: 'Unauthenticated',
|
|
1544
|
-
content: {
|
|
1545
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1546
|
-
},
|
|
1547
|
-
},
|
|
1548
|
-
},
|
|
1549
|
-
}), zValidator('param', clientIdParamSchema), zValidator('query', evictClientQuerySchema), async (c) => {
|
|
1550
|
-
const auth = await requireAuth(c);
|
|
1551
|
-
if (!auth)
|
|
1552
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1553
|
-
const { id: clientId } = c.req.valid('param');
|
|
1554
|
-
const { partitionId } = c.req.valid('query');
|
|
1555
|
-
let deleteQuery = db
|
|
1556
|
-
.deleteFrom('sync_client_cursors')
|
|
1557
|
-
.where('client_id', '=', clientId);
|
|
1558
|
-
if (partitionId) {
|
|
1559
|
-
deleteQuery = deleteQuery.where('partition_id', '=', partitionId);
|
|
1560
|
-
}
|
|
1561
|
-
const res = await deleteQuery.executeTakeFirst();
|
|
1562
|
-
const evicted = Number(res?.numDeletedRows ?? 0) > 0;
|
|
1563
|
-
logSyncEvent({
|
|
1564
|
-
event: 'console.evict_client',
|
|
1565
|
-
consoleUserId: auth.consoleUserId,
|
|
1566
|
-
clientId,
|
|
1567
|
-
evicted,
|
|
1568
|
-
});
|
|
1569
|
-
await recordOperationEvent({
|
|
1570
|
-
operationType: 'evict_client',
|
|
1571
|
-
consoleUserId: auth.consoleUserId,
|
|
1572
|
-
partitionId: partitionId ?? null,
|
|
1573
|
-
targetClientId: clientId,
|
|
1574
|
-
requestPayload: { clientId, partitionId: partitionId ?? null },
|
|
1575
|
-
resultPayload: { evicted },
|
|
1576
|
-
});
|
|
1577
|
-
const result = { evicted };
|
|
1578
|
-
return c.json(result, 200);
|
|
1579
|
-
});
|
|
1580
|
-
// -------------------------------------------------------------------------
|
|
1581
|
-
// GET /events - Paginated request events list
|
|
1582
|
-
// -------------------------------------------------------------------------
|
|
1583
|
-
routes.get('/events', describeRoute({
|
|
1584
|
-
tags: ['console'],
|
|
1585
|
-
summary: 'List request events',
|
|
1586
|
-
responses: {
|
|
1587
|
-
200: {
|
|
1588
|
-
description: 'Paginated event list',
|
|
1589
|
-
content: {
|
|
1590
|
-
'application/json': {
|
|
1591
|
-
schema: resolver(ConsolePaginatedResponseSchema(ConsoleRequestEventSchema)),
|
|
1592
|
-
},
|
|
1593
|
-
},
|
|
1594
|
-
},
|
|
1595
|
-
401: {
|
|
1596
|
-
description: 'Unauthenticated',
|
|
1597
|
-
content: {
|
|
1598
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1599
|
-
},
|
|
1600
|
-
},
|
|
1601
|
-
},
|
|
1602
|
-
}), zValidator('query', eventsQuerySchema), async (c) => {
|
|
1603
|
-
const auth = await requireAuth(c);
|
|
1604
|
-
if (!auth)
|
|
1605
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1606
|
-
const { limit, offset, partitionId, eventType, actorId, clientId, requestId, traceId, outcome, } = c.req.valid('query');
|
|
1607
|
-
let query = db
|
|
1608
|
-
.selectFrom('sync_request_events')
|
|
1609
|
-
.select(requestEventSelectColumns);
|
|
1610
|
-
let countQuery = db
|
|
1611
|
-
.selectFrom('sync_request_events')
|
|
1612
|
-
.select(({ fn }) => fn.countAll().as('total'));
|
|
1613
|
-
if (partitionId) {
|
|
1614
|
-
query = query.where('partition_id', '=', partitionId);
|
|
1615
|
-
countQuery = countQuery.where('partition_id', '=', partitionId);
|
|
1616
|
-
}
|
|
1617
|
-
if (eventType) {
|
|
1618
|
-
query = query.where('event_type', '=', eventType);
|
|
1619
|
-
countQuery = countQuery.where('event_type', '=', eventType);
|
|
1620
|
-
}
|
|
1621
|
-
if (actorId) {
|
|
1622
|
-
query = query.where('actor_id', '=', actorId);
|
|
1623
|
-
countQuery = countQuery.where('actor_id', '=', actorId);
|
|
1624
|
-
}
|
|
1625
|
-
if (clientId) {
|
|
1626
|
-
query = query.where('client_id', '=', clientId);
|
|
1627
|
-
countQuery = countQuery.where('client_id', '=', clientId);
|
|
1628
|
-
}
|
|
1629
|
-
if (requestId) {
|
|
1630
|
-
query = query.where('request_id', '=', requestId);
|
|
1631
|
-
countQuery = countQuery.where('request_id', '=', requestId);
|
|
1632
|
-
}
|
|
1633
|
-
if (traceId) {
|
|
1634
|
-
query = query.where('trace_id', '=', traceId);
|
|
1635
|
-
countQuery = countQuery.where('trace_id', '=', traceId);
|
|
1636
|
-
}
|
|
1637
|
-
if (outcome) {
|
|
1638
|
-
query = query.where('outcome', '=', outcome);
|
|
1639
|
-
countQuery = countQuery.where('outcome', '=', outcome);
|
|
1640
|
-
}
|
|
1641
|
-
const [rows, countRow] = await Promise.all([
|
|
1642
|
-
query
|
|
1643
|
-
.orderBy('created_at', 'desc')
|
|
1644
|
-
.limit(limit)
|
|
1645
|
-
.offset(offset)
|
|
1646
|
-
.execute(),
|
|
1647
|
-
countQuery.executeTakeFirst(),
|
|
1648
|
-
]);
|
|
1649
|
-
const items = rows.map((row) => mapRequestEvent(row));
|
|
1650
|
-
const total = coerceNumber(countRow?.total) ?? 0;
|
|
1651
|
-
const response = {
|
|
1652
|
-
items,
|
|
1653
|
-
total,
|
|
1654
|
-
offset,
|
|
1655
|
-
limit,
|
|
1656
|
-
};
|
|
1657
|
-
c.header('X-Total-Count', String(total));
|
|
1658
|
-
return c.json(response, 200);
|
|
1659
|
-
});
|
|
1660
|
-
// -------------------------------------------------------------------------
|
|
1661
|
-
// GET /events/live - WebSocket for live activity feed
|
|
1662
|
-
// NOTE: Must be defined BEFORE /events/:id to avoid route conflict
|
|
1663
|
-
// -------------------------------------------------------------------------
|
|
1664
|
-
if (options.eventEmitter &&
|
|
1665
|
-
options.websocket?.enabled &&
|
|
1666
|
-
options.websocket?.upgradeWebSocket) {
|
|
1667
|
-
const emitter = options.eventEmitter;
|
|
1668
|
-
const upgradeWebSocket = options.websocket.upgradeWebSocket;
|
|
1669
|
-
const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
|
|
1670
|
-
const wsState = new WeakMap();
|
|
1671
|
-
const closeUnauthenticated = (ws) => {
|
|
1672
|
-
try {
|
|
1673
|
-
ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
|
|
1674
|
-
}
|
|
1675
|
-
catch {
|
|
1676
|
-
// ignore send errors
|
|
1677
|
-
}
|
|
1678
|
-
ws.close(4001, 'Unauthenticated');
|
|
1679
|
-
};
|
|
1680
|
-
const cleanup = (ws) => {
|
|
1681
|
-
const state = wsState.get(ws);
|
|
1682
|
-
if (!state)
|
|
1683
|
-
return;
|
|
1684
|
-
if (state.listener) {
|
|
1685
|
-
emitter.removeListener(state.listener);
|
|
1686
|
-
}
|
|
1687
|
-
if (state.heartbeatInterval) {
|
|
1688
|
-
clearInterval(state.heartbeatInterval);
|
|
1689
|
-
}
|
|
1690
|
-
if (state.authTimeout) {
|
|
1691
|
-
clearTimeout(state.authTimeout);
|
|
1692
|
-
}
|
|
1693
|
-
wsState.delete(ws);
|
|
1694
|
-
};
|
|
1695
|
-
routes.get('/events/live', upgradeWebSocket(async (c) => {
|
|
1696
|
-
const authHeader = c.req.header('Authorization');
|
|
1697
|
-
const partitionId = c.req.query('partitionId')?.trim() || undefined;
|
|
1698
|
-
const replaySince = c.req.query('since');
|
|
1699
|
-
const replayLimitRaw = c.req.query('replayLimit');
|
|
1700
|
-
const replayLimitNumber = replayLimitRaw
|
|
1701
|
-
? Number.parseInt(replayLimitRaw, 10)
|
|
1702
|
-
: Number.NaN;
|
|
1703
|
-
const replayLimit = Number.isFinite(replayLimitNumber)
|
|
1704
|
-
? Math.max(1, Math.min(500, replayLimitNumber))
|
|
1705
|
-
: 100;
|
|
1706
|
-
const mockContext = {
|
|
1707
|
-
req: {
|
|
1708
|
-
header: (name) => name === 'Authorization' ? authHeader : undefined,
|
|
1709
|
-
query: () => undefined,
|
|
1710
|
-
},
|
|
1711
|
-
};
|
|
1712
|
-
const initialAuth = await options.authenticate(mockContext);
|
|
1713
|
-
const authenticateWithBearer = async (token) => {
|
|
1714
|
-
const trimmedToken = token.trim();
|
|
1715
|
-
if (!trimmedToken)
|
|
1716
|
-
return null;
|
|
1717
|
-
const authContext = {
|
|
1718
|
-
req: {
|
|
1719
|
-
header: (name) => name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
|
|
1720
|
-
query: () => undefined,
|
|
1721
|
-
},
|
|
1722
|
-
};
|
|
1723
|
-
return options.authenticate(authContext);
|
|
1724
|
-
};
|
|
1725
|
-
return {
|
|
1726
|
-
onOpen(_event, ws) {
|
|
1727
|
-
const state = {
|
|
1728
|
-
listener: null,
|
|
1729
|
-
heartbeatInterval: null,
|
|
1730
|
-
authTimeout: null,
|
|
1731
|
-
isAuthenticated: false,
|
|
1732
|
-
};
|
|
1733
|
-
wsState.set(ws, state);
|
|
1734
|
-
const startAuthenticatedSession = () => {
|
|
1735
|
-
if (state.isAuthenticated)
|
|
1736
|
-
return;
|
|
1737
|
-
state.isAuthenticated = true;
|
|
1738
|
-
if (state.authTimeout) {
|
|
1739
|
-
clearTimeout(state.authTimeout);
|
|
1740
|
-
state.authTimeout = null;
|
|
1741
|
-
}
|
|
1742
|
-
const listener = (event) => {
|
|
1743
|
-
if (partitionId) {
|
|
1744
|
-
const eventPartitionId = event.data.partitionId;
|
|
1745
|
-
if (typeof eventPartitionId !== 'string' ||
|
|
1746
|
-
eventPartitionId !== partitionId) {
|
|
1747
|
-
return;
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
try {
|
|
1751
|
-
ws.send(JSON.stringify(event));
|
|
1752
|
-
}
|
|
1753
|
-
catch {
|
|
1754
|
-
// Connection closed
|
|
1755
|
-
}
|
|
1756
|
-
};
|
|
1757
|
-
emitter.addListener(listener);
|
|
1758
|
-
state.listener = listener;
|
|
1759
|
-
ws.send(JSON.stringify({
|
|
1760
|
-
type: 'connected',
|
|
1761
|
-
timestamp: new Date().toISOString(),
|
|
1762
|
-
}));
|
|
1763
|
-
const replayEvents = emitter.replay({
|
|
1764
|
-
since: replaySince,
|
|
1765
|
-
limit: replayLimit,
|
|
1766
|
-
partitionId,
|
|
1767
|
-
});
|
|
1768
|
-
for (const replayEvent of replayEvents) {
|
|
1769
|
-
try {
|
|
1770
|
-
ws.send(JSON.stringify(replayEvent));
|
|
1771
|
-
}
|
|
1772
|
-
catch {
|
|
1773
|
-
// Connection closed
|
|
1774
|
-
break;
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
const heartbeatInterval = setInterval(() => {
|
|
1778
|
-
try {
|
|
1779
|
-
ws.send(JSON.stringify({
|
|
1780
|
-
type: 'heartbeat',
|
|
1781
|
-
timestamp: new Date().toISOString(),
|
|
1782
|
-
}));
|
|
1783
|
-
}
|
|
1784
|
-
catch {
|
|
1785
|
-
clearInterval(heartbeatInterval);
|
|
1786
|
-
}
|
|
1787
|
-
}, heartbeatIntervalMs);
|
|
1788
|
-
state.heartbeatInterval = heartbeatInterval;
|
|
1789
|
-
};
|
|
1790
|
-
if (initialAuth) {
|
|
1791
|
-
startAuthenticatedSession();
|
|
1792
|
-
return;
|
|
1793
|
-
}
|
|
1794
|
-
state.authTimeout = setTimeout(() => {
|
|
1795
|
-
const current = wsState.get(ws);
|
|
1796
|
-
if (!current || current.isAuthenticated) {
|
|
1797
|
-
return;
|
|
1798
|
-
}
|
|
1799
|
-
closeUnauthenticated(ws);
|
|
1800
|
-
cleanup(ws);
|
|
1801
|
-
}, 5_000);
|
|
1802
|
-
},
|
|
1803
|
-
async onMessage(event, ws) {
|
|
1804
|
-
const state = wsState.get(ws);
|
|
1805
|
-
if (!state || state.isAuthenticated) {
|
|
1806
|
-
return;
|
|
1807
|
-
}
|
|
1808
|
-
if (typeof event.data !== 'string') {
|
|
1809
|
-
closeUnauthenticated(ws);
|
|
1810
|
-
cleanup(ws);
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
let token = '';
|
|
1814
|
-
try {
|
|
1815
|
-
const parsed = JSON.parse(event.data);
|
|
1816
|
-
if (parsed.type === 'auth' &&
|
|
1817
|
-
typeof parsed.token === 'string' &&
|
|
1818
|
-
parsed.token.trim().length > 0) {
|
|
1819
|
-
token = parsed.token;
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
catch {
|
|
1823
|
-
// Ignore parse errors and close as unauthenticated below.
|
|
1824
|
-
}
|
|
1825
|
-
if (!token) {
|
|
1826
|
-
closeUnauthenticated(ws);
|
|
1827
|
-
cleanup(ws);
|
|
1828
|
-
return;
|
|
1829
|
-
}
|
|
1830
|
-
const auth = await authenticateWithBearer(token);
|
|
1831
|
-
const currentState = wsState.get(ws);
|
|
1832
|
-
if (!currentState || currentState.isAuthenticated) {
|
|
1833
|
-
return;
|
|
1834
|
-
}
|
|
1835
|
-
if (!auth) {
|
|
1836
|
-
closeUnauthenticated(ws);
|
|
1837
|
-
cleanup(ws);
|
|
1838
|
-
return;
|
|
1839
|
-
}
|
|
1840
|
-
currentState.isAuthenticated = true;
|
|
1841
|
-
if (currentState.authTimeout) {
|
|
1842
|
-
clearTimeout(currentState.authTimeout);
|
|
1843
|
-
currentState.authTimeout = null;
|
|
1844
|
-
}
|
|
1845
|
-
const listener = (liveEvent) => {
|
|
1846
|
-
if (partitionId) {
|
|
1847
|
-
const eventPartitionId = liveEvent.data.partitionId;
|
|
1848
|
-
if (typeof eventPartitionId !== 'string' ||
|
|
1849
|
-
eventPartitionId !== partitionId) {
|
|
1850
|
-
return;
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
try {
|
|
1854
|
-
ws.send(JSON.stringify(liveEvent));
|
|
1855
|
-
}
|
|
1856
|
-
catch {
|
|
1857
|
-
// Connection closed
|
|
1858
|
-
}
|
|
1859
|
-
};
|
|
1860
|
-
emitter.addListener(listener);
|
|
1861
|
-
currentState.listener = listener;
|
|
1862
|
-
ws.send(JSON.stringify({
|
|
1863
|
-
type: 'connected',
|
|
1864
|
-
timestamp: new Date().toISOString(),
|
|
1865
|
-
}));
|
|
1866
|
-
const replayEvents = emitter.replay({
|
|
1867
|
-
since: replaySince,
|
|
1868
|
-
limit: replayLimit,
|
|
1869
|
-
partitionId,
|
|
1870
|
-
});
|
|
1871
|
-
for (const replayEvent of replayEvents) {
|
|
1872
|
-
try {
|
|
1873
|
-
ws.send(JSON.stringify(replayEvent));
|
|
1874
|
-
}
|
|
1875
|
-
catch {
|
|
1876
|
-
// Connection closed
|
|
1877
|
-
break;
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
const heartbeatInterval = setInterval(() => {
|
|
1881
|
-
try {
|
|
1882
|
-
ws.send(JSON.stringify({
|
|
1883
|
-
type: 'heartbeat',
|
|
1884
|
-
timestamp: new Date().toISOString(),
|
|
1885
|
-
}));
|
|
1886
|
-
}
|
|
1887
|
-
catch {
|
|
1888
|
-
clearInterval(heartbeatInterval);
|
|
1889
|
-
}
|
|
1890
|
-
}, heartbeatIntervalMs);
|
|
1891
|
-
currentState.heartbeatInterval = heartbeatInterval;
|
|
1892
|
-
},
|
|
1893
|
-
onClose(_event, ws) {
|
|
1894
|
-
cleanup(ws);
|
|
1895
|
-
},
|
|
1896
|
-
onError(_event, ws) {
|
|
1897
|
-
cleanup(ws);
|
|
1898
|
-
},
|
|
1899
|
-
};
|
|
1900
|
-
}));
|
|
1901
|
-
}
|
|
1902
|
-
// -------------------------------------------------------------------------
|
|
1903
|
-
// GET /events/:id - Single event detail
|
|
1904
|
-
// -------------------------------------------------------------------------
|
|
1905
|
-
routes.get('/events/:id', describeRoute({
|
|
1906
|
-
tags: ['console'],
|
|
1907
|
-
summary: 'Get event details',
|
|
1908
|
-
responses: {
|
|
1909
|
-
200: {
|
|
1910
|
-
description: 'Event details',
|
|
1911
|
-
content: {
|
|
1912
|
-
'application/json': {
|
|
1913
|
-
schema: resolver(ConsoleRequestEventSchema),
|
|
1914
|
-
},
|
|
1915
|
-
},
|
|
1916
|
-
},
|
|
1917
|
-
400: {
|
|
1918
|
-
description: 'Invalid request',
|
|
1919
|
-
content: {
|
|
1920
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1921
|
-
},
|
|
1922
|
-
},
|
|
1923
|
-
401: {
|
|
1924
|
-
description: 'Unauthenticated',
|
|
1925
|
-
content: {
|
|
1926
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1927
|
-
},
|
|
1928
|
-
},
|
|
1929
|
-
404: {
|
|
1930
|
-
description: 'Not found',
|
|
1931
|
-
content: {
|
|
1932
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1933
|
-
},
|
|
1934
|
-
},
|
|
1935
|
-
},
|
|
1936
|
-
}), zValidator('param', eventIdParamSchema), zValidator('query', eventDetailQuerySchema), async (c) => {
|
|
1937
|
-
const auth = await requireAuth(c);
|
|
1938
|
-
if (!auth)
|
|
1939
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1940
|
-
const { id: eventId } = c.req.valid('param');
|
|
1941
|
-
const { partitionId } = c.req.valid('query');
|
|
1942
|
-
let eventQuery = db
|
|
1943
|
-
.selectFrom('sync_request_events')
|
|
1944
|
-
.select(requestEventSelectColumns)
|
|
1945
|
-
.where('event_id', '=', eventId);
|
|
1946
|
-
if (partitionId) {
|
|
1947
|
-
eventQuery = eventQuery.where('partition_id', '=', partitionId);
|
|
1948
|
-
}
|
|
1949
|
-
const row = await eventQuery.executeTakeFirst();
|
|
1950
|
-
if (!row) {
|
|
1951
|
-
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
1952
|
-
}
|
|
1953
|
-
return c.json(mapRequestEvent(row), 200);
|
|
1954
|
-
});
|
|
1955
|
-
// -------------------------------------------------------------------------
|
|
1956
|
-
// GET /events/:id/payload - payload snapshot detail (if retained)
|
|
1957
|
-
// -------------------------------------------------------------------------
|
|
1958
|
-
routes.get('/events/:id/payload', describeRoute({
|
|
1959
|
-
tags: ['console'],
|
|
1960
|
-
summary: 'Get event payload snapshot',
|
|
1961
|
-
responses: {
|
|
1962
|
-
200: {
|
|
1963
|
-
description: 'Payload snapshot details',
|
|
1964
|
-
content: {
|
|
1965
|
-
'application/json': {
|
|
1966
|
-
schema: resolver(ConsoleRequestPayloadSchema),
|
|
1967
|
-
},
|
|
1968
|
-
},
|
|
1969
|
-
},
|
|
1970
|
-
400: {
|
|
1971
|
-
description: 'Invalid request',
|
|
1972
|
-
content: {
|
|
1973
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1974
|
-
},
|
|
1975
|
-
},
|
|
1976
|
-
401: {
|
|
1977
|
-
description: 'Unauthenticated',
|
|
1978
|
-
content: {
|
|
1979
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1980
|
-
},
|
|
1981
|
-
},
|
|
1982
|
-
404: {
|
|
1983
|
-
description: 'Not found',
|
|
1984
|
-
content: {
|
|
1985
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
1986
|
-
},
|
|
1987
|
-
},
|
|
1988
|
-
},
|
|
1989
|
-
}), zValidator('param', eventIdParamSchema), zValidator('query', eventDetailQuerySchema), async (c) => {
|
|
1990
|
-
const auth = await requireAuth(c);
|
|
1991
|
-
if (!auth)
|
|
1992
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1993
|
-
const { id: eventId } = c.req.valid('param');
|
|
1994
|
-
const { partitionId } = c.req.valid('query');
|
|
1995
|
-
let eventQuery = db
|
|
1996
|
-
.selectFrom('sync_request_events')
|
|
1997
|
-
.select(['payload_ref', 'partition_id'])
|
|
1998
|
-
.where('event_id', '=', eventId);
|
|
1999
|
-
if (partitionId) {
|
|
2000
|
-
eventQuery = eventQuery.where('partition_id', '=', partitionId);
|
|
2001
|
-
}
|
|
2002
|
-
const eventRow = await eventQuery.executeTakeFirst();
|
|
2003
|
-
if (!eventRow) {
|
|
2004
|
-
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
2005
|
-
}
|
|
2006
|
-
const payloadRef = eventRow.payload_ref;
|
|
2007
|
-
if (!payloadRef) {
|
|
2008
|
-
return c.json({ error: 'NOT_FOUND', message: 'No payload snapshot recorded' }, 404);
|
|
2009
|
-
}
|
|
2010
|
-
const payloadRow = await db
|
|
2011
|
-
.selectFrom('sync_request_payloads')
|
|
2012
|
-
.select([
|
|
2013
|
-
'payload_ref',
|
|
2014
|
-
'partition_id',
|
|
2015
|
-
'request_payload',
|
|
2016
|
-
'response_payload',
|
|
2017
|
-
'created_at',
|
|
2018
|
-
])
|
|
2019
|
-
.where('payload_ref', '=', payloadRef)
|
|
2020
|
-
.where('partition_id', '=', eventRow.partition_id)
|
|
2021
|
-
.executeTakeFirst();
|
|
2022
|
-
if (!payloadRow) {
|
|
2023
|
-
return c.json({ error: 'NOT_FOUND', message: 'Payload snapshot not available' }, 404);
|
|
2024
|
-
}
|
|
2025
|
-
const payload = {
|
|
2026
|
-
payloadRef: payloadRow.payload_ref,
|
|
2027
|
-
partitionId: payloadRow.partition_id,
|
|
2028
|
-
requestPayload: parseJsonValue(payloadRow.request_payload),
|
|
2029
|
-
responsePayload: parseJsonValue(payloadRow.response_payload),
|
|
2030
|
-
createdAt: payloadRow.created_at,
|
|
2031
|
-
};
|
|
2032
|
-
return c.json(payload, 200);
|
|
2033
|
-
});
|
|
2034
|
-
// -------------------------------------------------------------------------
|
|
2035
|
-
// DELETE /events - Clear all events
|
|
2036
|
-
// -------------------------------------------------------------------------
|
|
2037
|
-
routes.delete('/events', describeRoute({
|
|
2038
|
-
tags: ['console'],
|
|
2039
|
-
summary: 'Clear all events',
|
|
2040
|
-
responses: {
|
|
2041
|
-
200: {
|
|
2042
|
-
description: 'Clear result',
|
|
2043
|
-
content: {
|
|
2044
|
-
'application/json': {
|
|
2045
|
-
schema: resolver(ConsoleClearEventsResultSchema),
|
|
2046
|
-
},
|
|
2047
|
-
},
|
|
2048
|
-
},
|
|
2049
|
-
401: {
|
|
2050
|
-
description: 'Unauthenticated',
|
|
2051
|
-
content: {
|
|
2052
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2053
|
-
},
|
|
2054
|
-
},
|
|
2055
|
-
},
|
|
2056
|
-
}), async (c) => {
|
|
2057
|
-
const auth = await requireAuth(c);
|
|
2058
|
-
if (!auth)
|
|
2059
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2060
|
-
const res = await db.deleteFrom('sync_request_events').executeTakeFirst();
|
|
2061
|
-
const deletedCount = Number(res?.numDeletedRows ?? 0);
|
|
2062
|
-
const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
|
|
2063
|
-
logSyncEvent({
|
|
2064
|
-
event: 'console.clear_events',
|
|
2065
|
-
consoleUserId: auth.consoleUserId,
|
|
2066
|
-
deletedCount,
|
|
2067
|
-
payloadDeletedCount,
|
|
2068
|
-
});
|
|
2069
|
-
const result = { deletedCount };
|
|
2070
|
-
return c.json(result, 200);
|
|
2071
|
-
});
|
|
2072
|
-
// -------------------------------------------------------------------------
|
|
2073
|
-
// POST /events/prune - Prune old events
|
|
2074
|
-
// -------------------------------------------------------------------------
|
|
2075
|
-
routes.post('/events/prune', describeRoute({
|
|
2076
|
-
tags: ['console'],
|
|
2077
|
-
summary: 'Prune old events',
|
|
2078
|
-
responses: {
|
|
2079
|
-
200: {
|
|
2080
|
-
description: 'Prune result',
|
|
2081
|
-
content: {
|
|
2082
|
-
'application/json': {
|
|
2083
|
-
schema: resolver(ConsolePruneEventsResultSchema),
|
|
2084
|
-
},
|
|
2085
|
-
},
|
|
2086
|
-
},
|
|
2087
|
-
401: {
|
|
2088
|
-
description: 'Unauthenticated',
|
|
2089
|
-
content: {
|
|
2090
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2091
|
-
},
|
|
2092
|
-
},
|
|
2093
|
-
},
|
|
2094
|
-
}), async (c) => {
|
|
2095
|
-
const auth = await requireAuth(c);
|
|
2096
|
-
if (!auth)
|
|
2097
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2098
|
-
// Prune events older than 7 days or keep max 10000 events
|
|
2099
|
-
const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
2100
|
-
// Delete by date first
|
|
2101
|
-
const resByDate = await db
|
|
2102
|
-
.deleteFrom('sync_request_events')
|
|
2103
|
-
.where('created_at', '<', cutoffDate.toISOString())
|
|
2104
|
-
.executeTakeFirst();
|
|
2105
|
-
let deletedCount = Number(resByDate?.numDeletedRows ?? 0);
|
|
2106
|
-
// Then delete oldest if we still have more than 10000 events
|
|
2107
|
-
const countRow = await db
|
|
2108
|
-
.selectFrom('sync_request_events')
|
|
2109
|
-
.select(({ fn }) => fn.countAll().as('total'))
|
|
2110
|
-
.executeTakeFirst();
|
|
2111
|
-
const total = coerceNumber(countRow?.total) ?? 0;
|
|
2112
|
-
const maxEvents = 10000;
|
|
2113
|
-
if (total > maxEvents) {
|
|
2114
|
-
// Find event_id cutoff to keep only newest maxEvents
|
|
2115
|
-
const cutoffRow = await db
|
|
2116
|
-
.selectFrom('sync_request_events')
|
|
2117
|
-
.select(['event_id'])
|
|
2118
|
-
.orderBy('event_id', 'desc')
|
|
2119
|
-
.offset(maxEvents)
|
|
2120
|
-
.limit(1)
|
|
2121
|
-
.executeTakeFirst();
|
|
2122
|
-
if (cutoffRow) {
|
|
2123
|
-
const cutoffEventId = coerceNumber(cutoffRow.event_id);
|
|
2124
|
-
if (cutoffEventId !== null) {
|
|
2125
|
-
const resByCount = await db
|
|
2126
|
-
.deleteFrom('sync_request_events')
|
|
2127
|
-
.where('event_id', '<=', cutoffEventId)
|
|
2128
|
-
.executeTakeFirst();
|
|
2129
|
-
deletedCount += Number(resByCount?.numDeletedRows ?? 0);
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
|
-
}
|
|
2133
|
-
const payloadDeletedCount = await deleteUnreferencedPayloadSnapshots();
|
|
2134
|
-
logSyncEvent({
|
|
2135
|
-
event: 'console.prune_events',
|
|
2136
|
-
consoleUserId: auth.consoleUserId,
|
|
2137
|
-
deletedCount,
|
|
2138
|
-
payloadDeletedCount,
|
|
2139
|
-
});
|
|
2140
|
-
const result = { deletedCount };
|
|
2141
|
-
return c.json(result, 200);
|
|
2142
|
-
});
|
|
2143
|
-
// -------------------------------------------------------------------------
|
|
2144
|
-
// GET /api-keys - List all API keys
|
|
2145
|
-
// -------------------------------------------------------------------------
|
|
2146
|
-
routes.get('/api-keys', describeRoute({
|
|
2147
|
-
tags: ['console'],
|
|
2148
|
-
summary: 'List API keys',
|
|
2149
|
-
responses: {
|
|
2150
|
-
200: {
|
|
2151
|
-
description: 'Paginated API key list',
|
|
2152
|
-
content: {
|
|
2153
|
-
'application/json': {
|
|
2154
|
-
schema: resolver(ConsolePaginatedResponseSchema(ConsoleApiKeySchema)),
|
|
2155
|
-
},
|
|
2156
|
-
},
|
|
2157
|
-
},
|
|
2158
|
-
401: {
|
|
2159
|
-
description: 'Unauthenticated',
|
|
2160
|
-
content: {
|
|
2161
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2162
|
-
},
|
|
2163
|
-
},
|
|
2164
|
-
},
|
|
2165
|
-
}), zValidator('query', apiKeysQuerySchema), async (c) => {
|
|
2166
|
-
const auth = await requireAuth(c);
|
|
2167
|
-
if (!auth)
|
|
2168
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2169
|
-
const { limit, offset, type: keyType, status, expiresWithinDays, } = c.req.valid('query');
|
|
2170
|
-
let query = db
|
|
2171
|
-
.selectFrom('sync_api_keys')
|
|
2172
|
-
.select([
|
|
2173
|
-
'key_id',
|
|
2174
|
-
'key_prefix',
|
|
2175
|
-
'name',
|
|
2176
|
-
'key_type',
|
|
2177
|
-
'scope_keys',
|
|
2178
|
-
'actor_id',
|
|
2179
|
-
'created_at',
|
|
2180
|
-
'expires_at',
|
|
2181
|
-
'last_used_at',
|
|
2182
|
-
'revoked_at',
|
|
2183
|
-
]);
|
|
2184
|
-
let countQuery = db
|
|
2185
|
-
.selectFrom('sync_api_keys')
|
|
2186
|
-
.select(({ fn }) => fn.countAll().as('total'));
|
|
2187
|
-
if (keyType) {
|
|
2188
|
-
query = query.where('key_type', '=', keyType);
|
|
2189
|
-
countQuery = countQuery.where('key_type', '=', keyType);
|
|
2190
|
-
}
|
|
2191
|
-
const now = new Date();
|
|
2192
|
-
const nowIso = now.toISOString();
|
|
2193
|
-
const expiringThresholdIso = new Date(now.getTime() + (expiresWithinDays ?? 14) * 24 * 60 * 60 * 1000).toISOString();
|
|
2194
|
-
if (status === 'active') {
|
|
2195
|
-
query = query
|
|
2196
|
-
.where('revoked_at', 'is', null)
|
|
2197
|
-
.where((eb) => eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', nowIso)]));
|
|
2198
|
-
countQuery = countQuery
|
|
2199
|
-
.where('revoked_at', 'is', null)
|
|
2200
|
-
.where((eb) => eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', nowIso)]));
|
|
2201
|
-
}
|
|
2202
|
-
else if (status === 'revoked') {
|
|
2203
|
-
query = query.where('revoked_at', 'is not', null);
|
|
2204
|
-
countQuery = countQuery.where('revoked_at', 'is not', null);
|
|
2205
|
-
}
|
|
2206
|
-
else if (status === 'expiring') {
|
|
2207
|
-
query = query
|
|
2208
|
-
.where('revoked_at', 'is', null)
|
|
2209
|
-
.where('expires_at', '>', nowIso)
|
|
2210
|
-
.where('expires_at', '<=', expiringThresholdIso);
|
|
2211
|
-
countQuery = countQuery
|
|
2212
|
-
.where('revoked_at', 'is', null)
|
|
2213
|
-
.where('expires_at', '>', nowIso)
|
|
2214
|
-
.where('expires_at', '<=', expiringThresholdIso);
|
|
2215
|
-
}
|
|
2216
|
-
const [rows, countRow] = await Promise.all([
|
|
2217
|
-
query
|
|
2218
|
-
.orderBy('created_at', 'desc')
|
|
2219
|
-
.limit(limit)
|
|
2220
|
-
.offset(offset)
|
|
2221
|
-
.execute(),
|
|
2222
|
-
countQuery.executeTakeFirst(),
|
|
2223
|
-
]);
|
|
2224
|
-
const items = rows.map((row) => ({
|
|
2225
|
-
keyId: row.key_id ?? '',
|
|
2226
|
-
keyPrefix: row.key_prefix ?? '',
|
|
2227
|
-
name: row.name ?? '',
|
|
2228
|
-
keyType: row.key_type,
|
|
2229
|
-
scopeKeys: options.dialect.dbToArray(row.scope_keys),
|
|
2230
|
-
actorId: row.actor_id ?? null,
|
|
2231
|
-
createdAt: row.created_at ?? '',
|
|
2232
|
-
expiresAt: row.expires_at ?? null,
|
|
2233
|
-
lastUsedAt: row.last_used_at ?? null,
|
|
2234
|
-
revokedAt: row.revoked_at ?? null,
|
|
2235
|
-
}));
|
|
2236
|
-
const totalCount = coerceNumber(countRow?.total) ?? 0;
|
|
2237
|
-
const response = {
|
|
2238
|
-
items,
|
|
2239
|
-
total: totalCount,
|
|
2240
|
-
offset,
|
|
2241
|
-
limit,
|
|
2242
|
-
};
|
|
2243
|
-
c.header('X-Total-Count', String(totalCount));
|
|
2244
|
-
return c.json(response, 200);
|
|
2245
|
-
});
|
|
2246
|
-
// -------------------------------------------------------------------------
|
|
2247
|
-
// POST /api-keys - Create new API key
|
|
2248
|
-
// -------------------------------------------------------------------------
|
|
2249
|
-
routes.post('/api-keys', describeRoute({
|
|
2250
|
-
tags: ['console'],
|
|
2251
|
-
summary: 'Create API key',
|
|
2252
|
-
responses: {
|
|
2253
|
-
201: {
|
|
2254
|
-
description: 'Created API key',
|
|
2255
|
-
content: {
|
|
2256
|
-
'application/json': {
|
|
2257
|
-
schema: resolver(ConsoleApiKeyCreateResponseSchema),
|
|
2258
|
-
},
|
|
2259
|
-
},
|
|
2260
|
-
},
|
|
2261
|
-
400: {
|
|
2262
|
-
description: 'Invalid request',
|
|
2263
|
-
content: {
|
|
2264
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2265
|
-
},
|
|
2266
|
-
},
|
|
2267
|
-
401: {
|
|
2268
|
-
description: 'Unauthenticated',
|
|
2269
|
-
content: {
|
|
2270
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2271
|
-
},
|
|
2272
|
-
},
|
|
2273
|
-
},
|
|
2274
|
-
}), zValidator('json', ConsoleApiKeyCreateRequestSchema), async (c) => {
|
|
2275
|
-
const auth = await requireAuth(c);
|
|
2276
|
-
if (!auth)
|
|
2277
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2278
|
-
const body = c.req.valid('json');
|
|
2279
|
-
// Generate key components
|
|
2280
|
-
const keyId = generateKeyId();
|
|
2281
|
-
const secretKey = generateSecretKey(body.keyType);
|
|
2282
|
-
const keyHash = await hashApiKey(secretKey);
|
|
2283
|
-
const keyPrefix = secretKey.slice(0, 12);
|
|
2284
|
-
// Calculate expiry
|
|
2285
|
-
let expiresAt = null;
|
|
2286
|
-
if (body.expiresInDays && body.expiresInDays > 0) {
|
|
2287
|
-
expiresAt = new Date(Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000).toISOString();
|
|
2288
|
-
}
|
|
2289
|
-
const scopeKeys = body.scopeKeys ?? [];
|
|
2290
|
-
const now = new Date().toISOString();
|
|
2291
|
-
// Insert into database
|
|
2292
|
-
await db
|
|
2293
|
-
.insertInto('sync_api_keys')
|
|
2294
|
-
.values({
|
|
2295
|
-
key_id: keyId,
|
|
2296
|
-
key_hash: keyHash,
|
|
2297
|
-
key_prefix: keyPrefix,
|
|
2298
|
-
name: body.name,
|
|
2299
|
-
key_type: body.keyType,
|
|
2300
|
-
scope_keys: options.dialect.arrayToDb(scopeKeys),
|
|
2301
|
-
actor_id: body.actorId ?? null,
|
|
2302
|
-
created_at: now,
|
|
2303
|
-
expires_at: expiresAt,
|
|
2304
|
-
last_used_at: null,
|
|
2305
|
-
revoked_at: null,
|
|
2306
|
-
})
|
|
2307
|
-
.execute();
|
|
2308
|
-
logSyncEvent({
|
|
2309
|
-
event: 'console.create_api_key',
|
|
2310
|
-
consoleUserId: auth.consoleUserId,
|
|
2311
|
-
keyId,
|
|
2312
|
-
keyType: body.keyType,
|
|
2313
|
-
});
|
|
2314
|
-
const key = {
|
|
2315
|
-
keyId,
|
|
2316
|
-
keyPrefix,
|
|
2317
|
-
name: body.name,
|
|
2318
|
-
keyType: body.keyType,
|
|
2319
|
-
scopeKeys,
|
|
2320
|
-
actorId: body.actorId ?? null,
|
|
2321
|
-
createdAt: now,
|
|
2322
|
-
expiresAt,
|
|
2323
|
-
lastUsedAt: null,
|
|
2324
|
-
revokedAt: null,
|
|
2325
|
-
};
|
|
2326
|
-
const response = {
|
|
2327
|
-
key,
|
|
2328
|
-
secretKey,
|
|
2329
|
-
};
|
|
2330
|
-
return c.json(response, 201);
|
|
2331
|
-
});
|
|
2332
|
-
// -------------------------------------------------------------------------
|
|
2333
|
-
// GET /api-keys/:id - Get single API key
|
|
2334
|
-
// -------------------------------------------------------------------------
|
|
2335
|
-
routes.get('/api-keys/:id', describeRoute({
|
|
2336
|
-
tags: ['console'],
|
|
2337
|
-
summary: 'Get API key',
|
|
2338
|
-
responses: {
|
|
2339
|
-
200: {
|
|
2340
|
-
description: 'API key details',
|
|
2341
|
-
content: {
|
|
2342
|
-
'application/json': { schema: resolver(ConsoleApiKeySchema) },
|
|
2343
|
-
},
|
|
2344
|
-
},
|
|
2345
|
-
401: {
|
|
2346
|
-
description: 'Unauthenticated',
|
|
2347
|
-
content: {
|
|
2348
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2349
|
-
},
|
|
2350
|
-
},
|
|
2351
|
-
404: {
|
|
2352
|
-
description: 'Not found',
|
|
2353
|
-
content: {
|
|
2354
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2355
|
-
},
|
|
2356
|
-
},
|
|
2357
|
-
},
|
|
2358
|
-
}), zValidator('param', apiKeyIdParamSchema), async (c) => {
|
|
2359
|
-
const auth = await requireAuth(c);
|
|
2360
|
-
if (!auth)
|
|
2361
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2362
|
-
const { id: keyId } = c.req.valid('param');
|
|
2363
|
-
const row = await db
|
|
2364
|
-
.selectFrom('sync_api_keys')
|
|
2365
|
-
.select([
|
|
2366
|
-
'key_id',
|
|
2367
|
-
'key_prefix',
|
|
2368
|
-
'name',
|
|
2369
|
-
'key_type',
|
|
2370
|
-
'scope_keys',
|
|
2371
|
-
'actor_id',
|
|
2372
|
-
'created_at',
|
|
2373
|
-
'expires_at',
|
|
2374
|
-
'last_used_at',
|
|
2375
|
-
'revoked_at',
|
|
2376
|
-
])
|
|
2377
|
-
.where('key_id', '=', keyId)
|
|
2378
|
-
.executeTakeFirst();
|
|
2379
|
-
if (!row) {
|
|
2380
|
-
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
2381
|
-
}
|
|
2382
|
-
const key = {
|
|
2383
|
-
keyId: row.key_id ?? '',
|
|
2384
|
-
keyPrefix: row.key_prefix ?? '',
|
|
2385
|
-
name: row.name ?? '',
|
|
2386
|
-
keyType: row.key_type,
|
|
2387
|
-
scopeKeys: options.dialect.dbToArray(row.scope_keys),
|
|
2388
|
-
actorId: row.actor_id ?? null,
|
|
2389
|
-
createdAt: row.created_at ?? '',
|
|
2390
|
-
expiresAt: row.expires_at ?? null,
|
|
2391
|
-
lastUsedAt: row.last_used_at ?? null,
|
|
2392
|
-
revokedAt: row.revoked_at ?? null,
|
|
2393
|
-
};
|
|
2394
|
-
return c.json(key, 200);
|
|
2395
|
-
});
|
|
2396
|
-
// -------------------------------------------------------------------------
|
|
2397
|
-
// DELETE /api-keys/:id - Revoke API key (soft delete)
|
|
2398
|
-
// -------------------------------------------------------------------------
|
|
2399
|
-
routes.delete('/api-keys/:id', describeRoute({
|
|
2400
|
-
tags: ['console'],
|
|
2401
|
-
summary: 'Revoke API key',
|
|
2402
|
-
responses: {
|
|
2403
|
-
200: {
|
|
2404
|
-
description: 'Revoke result',
|
|
2405
|
-
content: {
|
|
2406
|
-
'application/json': {
|
|
2407
|
-
schema: resolver(ConsoleApiKeyRevokeResponseSchema),
|
|
2408
|
-
},
|
|
2409
|
-
},
|
|
2410
|
-
},
|
|
2411
|
-
401: {
|
|
2412
|
-
description: 'Unauthenticated',
|
|
2413
|
-
content: {
|
|
2414
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2415
|
-
},
|
|
2416
|
-
},
|
|
2417
|
-
},
|
|
2418
|
-
}), zValidator('param', apiKeyIdParamSchema), async (c) => {
|
|
2419
|
-
const auth = await requireAuth(c);
|
|
2420
|
-
if (!auth)
|
|
2421
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2422
|
-
const { id: keyId } = c.req.valid('param');
|
|
2423
|
-
const now = new Date().toISOString();
|
|
2424
|
-
const res = await db
|
|
2425
|
-
.updateTable('sync_api_keys')
|
|
2426
|
-
.set({ revoked_at: now })
|
|
2427
|
-
.where('key_id', '=', keyId)
|
|
2428
|
-
.where('revoked_at', 'is', null)
|
|
2429
|
-
.executeTakeFirst();
|
|
2430
|
-
const revoked = Number(res?.numUpdatedRows ?? 0) > 0;
|
|
2431
|
-
logSyncEvent({
|
|
2432
|
-
event: 'console.revoke_api_key',
|
|
2433
|
-
consoleUserId: auth.consoleUserId,
|
|
2434
|
-
keyId,
|
|
2435
|
-
revoked,
|
|
2436
|
-
});
|
|
2437
|
-
return c.json({ revoked }, 200);
|
|
2438
|
-
});
|
|
2439
|
-
// -------------------------------------------------------------------------
|
|
2440
|
-
// POST /api-keys/bulk-revoke - Revoke multiple API keys
|
|
2441
|
-
// -------------------------------------------------------------------------
|
|
2442
|
-
routes.post('/api-keys/bulk-revoke', describeRoute({
|
|
2443
|
-
tags: ['console'],
|
|
2444
|
-
summary: 'Bulk revoke API keys',
|
|
2445
|
-
responses: {
|
|
2446
|
-
200: {
|
|
2447
|
-
description: 'Bulk revoke result',
|
|
2448
|
-
content: {
|
|
2449
|
-
'application/json': {
|
|
2450
|
-
schema: resolver(ConsoleApiKeyBulkRevokeResponseSchema),
|
|
2451
|
-
},
|
|
2452
|
-
},
|
|
2453
|
-
},
|
|
2454
|
-
400: {
|
|
2455
|
-
description: 'Invalid request',
|
|
2456
|
-
content: {
|
|
2457
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2458
|
-
},
|
|
2459
|
-
},
|
|
2460
|
-
401: {
|
|
2461
|
-
description: 'Unauthenticated',
|
|
2462
|
-
content: {
|
|
2463
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2464
|
-
},
|
|
2465
|
-
},
|
|
2466
|
-
},
|
|
2467
|
-
}), zValidator('json', ConsoleApiKeyBulkRevokeRequestSchema), async (c) => {
|
|
2468
|
-
const auth = await requireAuth(c);
|
|
2469
|
-
if (!auth)
|
|
2470
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2471
|
-
const body = c.req.valid('json');
|
|
2472
|
-
const keyIds = [...new Set(body.keyIds.map((keyId) => keyId.trim()))]
|
|
2473
|
-
.filter((keyId) => keyId.length > 0)
|
|
2474
|
-
.slice(0, 200);
|
|
2475
|
-
if (keyIds.length === 0) {
|
|
2476
|
-
return c.json({ error: 'INVALID_REQUEST', message: 'No API key IDs provided' }, 400);
|
|
2477
|
-
}
|
|
2478
|
-
const now = new Date().toISOString();
|
|
2479
|
-
const existingRows = await db
|
|
2480
|
-
.selectFrom('sync_api_keys')
|
|
2481
|
-
.select(['key_id', 'revoked_at'])
|
|
2482
|
-
.where('key_id', 'in', keyIds)
|
|
2483
|
-
.execute();
|
|
2484
|
-
const existingById = new Map(existingRows.map((row) => [row.key_id, row.revoked_at]));
|
|
2485
|
-
const notFoundKeyIds = [];
|
|
2486
|
-
const alreadyRevokedKeyIds = [];
|
|
2487
|
-
const revokeCandidateKeyIds = [];
|
|
2488
|
-
for (const keyId of keyIds) {
|
|
2489
|
-
const revokedAt = existingById.get(keyId);
|
|
2490
|
-
if (revokedAt === undefined) {
|
|
2491
|
-
notFoundKeyIds.push(keyId);
|
|
2492
|
-
}
|
|
2493
|
-
else if (revokedAt !== null) {
|
|
2494
|
-
alreadyRevokedKeyIds.push(keyId);
|
|
2495
|
-
}
|
|
2496
|
-
else {
|
|
2497
|
-
revokeCandidateKeyIds.push(keyId);
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
let revokedCount = 0;
|
|
2501
|
-
if (revokeCandidateKeyIds.length > 0) {
|
|
2502
|
-
const updateResult = await db
|
|
2503
|
-
.updateTable('sync_api_keys')
|
|
2504
|
-
.set({ revoked_at: now })
|
|
2505
|
-
.where('key_id', 'in', revokeCandidateKeyIds)
|
|
2506
|
-
.where('revoked_at', 'is', null)
|
|
2507
|
-
.executeTakeFirst();
|
|
2508
|
-
revokedCount = Number(updateResult?.numUpdatedRows ?? 0);
|
|
2509
|
-
}
|
|
2510
|
-
const response = {
|
|
2511
|
-
requestedCount: keyIds.length,
|
|
2512
|
-
revokedCount,
|
|
2513
|
-
alreadyRevokedCount: alreadyRevokedKeyIds.length,
|
|
2514
|
-
notFoundCount: notFoundKeyIds.length,
|
|
2515
|
-
revokedKeyIds: revokeCandidateKeyIds,
|
|
2516
|
-
alreadyRevokedKeyIds,
|
|
2517
|
-
notFoundKeyIds,
|
|
2518
|
-
};
|
|
2519
|
-
logSyncEvent({
|
|
2520
|
-
event: 'console.bulk_revoke_api_keys',
|
|
2521
|
-
consoleUserId: auth.consoleUserId,
|
|
2522
|
-
requestedCount: response.requestedCount,
|
|
2523
|
-
revokedCount: response.revokedCount,
|
|
2524
|
-
alreadyRevokedCount: response.alreadyRevokedCount,
|
|
2525
|
-
notFoundCount: response.notFoundCount,
|
|
2526
|
-
});
|
|
2527
|
-
return c.json(response, 200);
|
|
2528
|
-
});
|
|
2529
|
-
// -------------------------------------------------------------------------
|
|
2530
|
-
// POST /api-keys/:id/rotate/stage - Stage rotate API key (keep old active)
|
|
2531
|
-
// -------------------------------------------------------------------------
|
|
2532
|
-
routes.post('/api-keys/:id/rotate/stage', describeRoute({
|
|
2533
|
-
tags: ['console'],
|
|
2534
|
-
summary: 'Stage rotate API key',
|
|
2535
|
-
responses: {
|
|
2536
|
-
200: {
|
|
2537
|
-
description: 'Staged API key replacement',
|
|
2538
|
-
content: {
|
|
2539
|
-
'application/json': {
|
|
2540
|
-
schema: resolver(ConsoleApiKeyCreateResponseSchema),
|
|
2541
|
-
},
|
|
2542
|
-
},
|
|
2543
|
-
},
|
|
2544
|
-
401: {
|
|
2545
|
-
description: 'Unauthenticated',
|
|
2546
|
-
content: {
|
|
2547
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2548
|
-
},
|
|
2549
|
-
},
|
|
2550
|
-
404: {
|
|
2551
|
-
description: 'Not found',
|
|
2552
|
-
content: {
|
|
2553
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2554
|
-
},
|
|
2555
|
-
},
|
|
2556
|
-
},
|
|
2557
|
-
}), zValidator('param', apiKeyIdParamSchema), async (c) => {
|
|
2558
|
-
const auth = await requireAuth(c);
|
|
2559
|
-
if (!auth)
|
|
2560
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2561
|
-
const { id: keyId } = c.req.valid('param');
|
|
2562
|
-
const now = new Date().toISOString();
|
|
2563
|
-
const existingRow = await db
|
|
2564
|
-
.selectFrom('sync_api_keys')
|
|
2565
|
-
.select([
|
|
2566
|
-
'name',
|
|
2567
|
-
'key_type',
|
|
2568
|
-
'scope_keys',
|
|
2569
|
-
'actor_id',
|
|
2570
|
-
'expires_at',
|
|
2571
|
-
'revoked_at',
|
|
2572
|
-
])
|
|
2573
|
-
.where('key_id', '=', keyId)
|
|
2574
|
-
.where('revoked_at', 'is', null)
|
|
2575
|
-
.executeTakeFirst();
|
|
2576
|
-
if (!existingRow) {
|
|
2577
|
-
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
2578
|
-
}
|
|
2579
|
-
const newKeyId = generateKeyId();
|
|
2580
|
-
const keyType = existingRow.key_type;
|
|
2581
|
-
const secretKey = generateSecretKey(keyType);
|
|
2582
|
-
const keyHash = await hashApiKey(secretKey);
|
|
2583
|
-
const keyPrefix = secretKey.slice(0, 12);
|
|
2584
|
-
const scopeKeys = options.dialect.dbToArray(existingRow.scope_keys);
|
|
2585
|
-
await db
|
|
2586
|
-
.insertInto('sync_api_keys')
|
|
2587
|
-
.values({
|
|
2588
|
-
key_id: newKeyId,
|
|
2589
|
-
key_hash: keyHash,
|
|
2590
|
-
key_prefix: keyPrefix,
|
|
2591
|
-
name: existingRow.name,
|
|
2592
|
-
key_type: keyType,
|
|
2593
|
-
scope_keys: options.dialect.arrayToDb(scopeKeys),
|
|
2594
|
-
actor_id: existingRow.actor_id ?? null,
|
|
2595
|
-
created_at: now,
|
|
2596
|
-
expires_at: existingRow.expires_at,
|
|
2597
|
-
last_used_at: null,
|
|
2598
|
-
revoked_at: null,
|
|
2599
|
-
})
|
|
2600
|
-
.execute();
|
|
2601
|
-
logSyncEvent({
|
|
2602
|
-
event: 'console.stage_rotate_api_key',
|
|
2603
|
-
consoleUserId: auth.consoleUserId,
|
|
2604
|
-
oldKeyId: keyId,
|
|
2605
|
-
newKeyId,
|
|
2606
|
-
});
|
|
2607
|
-
const key = {
|
|
2608
|
-
keyId: newKeyId,
|
|
2609
|
-
keyPrefix,
|
|
2610
|
-
name: existingRow.name,
|
|
2611
|
-
keyType,
|
|
2612
|
-
scopeKeys,
|
|
2613
|
-
actorId: existingRow.actor_id ?? null,
|
|
2614
|
-
createdAt: now,
|
|
2615
|
-
expiresAt: existingRow.expires_at ?? null,
|
|
2616
|
-
lastUsedAt: null,
|
|
2617
|
-
revokedAt: null,
|
|
2618
|
-
};
|
|
2619
|
-
const response = {
|
|
2620
|
-
key,
|
|
2621
|
-
secretKey,
|
|
2622
|
-
};
|
|
2623
|
-
return c.json(response, 200);
|
|
2624
|
-
});
|
|
2625
|
-
// -------------------------------------------------------------------------
|
|
2626
|
-
// POST /api-keys/:id/rotate - Rotate API key
|
|
2627
|
-
// -------------------------------------------------------------------------
|
|
2628
|
-
routes.post('/api-keys/:id/rotate', describeRoute({
|
|
2629
|
-
tags: ['console'],
|
|
2630
|
-
summary: 'Rotate API key',
|
|
2631
|
-
responses: {
|
|
2632
|
-
200: {
|
|
2633
|
-
description: 'Rotated API key',
|
|
2634
|
-
content: {
|
|
2635
|
-
'application/json': {
|
|
2636
|
-
schema: resolver(ConsoleApiKeyCreateResponseSchema),
|
|
2637
|
-
},
|
|
2638
|
-
},
|
|
2639
|
-
},
|
|
2640
|
-
401: {
|
|
2641
|
-
description: 'Unauthenticated',
|
|
2642
|
-
content: {
|
|
2643
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2644
|
-
},
|
|
2645
|
-
},
|
|
2646
|
-
404: {
|
|
2647
|
-
description: 'Not found',
|
|
2648
|
-
content: {
|
|
2649
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2650
|
-
},
|
|
2651
|
-
},
|
|
2652
|
-
},
|
|
2653
|
-
}), zValidator('param', apiKeyIdParamSchema), async (c) => {
|
|
2654
|
-
const auth = await requireAuth(c);
|
|
2655
|
-
if (!auth)
|
|
2656
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2657
|
-
const { id: keyId } = c.req.valid('param');
|
|
2658
|
-
const now = new Date().toISOString();
|
|
2659
|
-
// Get existing key
|
|
2660
|
-
const existingRow = await db
|
|
2661
|
-
.selectFrom('sync_api_keys')
|
|
2662
|
-
.select([
|
|
2663
|
-
'key_id',
|
|
2664
|
-
'name',
|
|
2665
|
-
'key_type',
|
|
2666
|
-
'scope_keys',
|
|
2667
|
-
'actor_id',
|
|
2668
|
-
'expires_at',
|
|
2669
|
-
])
|
|
2670
|
-
.where('key_id', '=', keyId)
|
|
2671
|
-
.where('revoked_at', 'is', null)
|
|
2672
|
-
.executeTakeFirst();
|
|
2673
|
-
if (!existingRow) {
|
|
2674
|
-
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
2675
|
-
}
|
|
2676
|
-
// Revoke old key
|
|
2677
|
-
await db
|
|
2678
|
-
.updateTable('sync_api_keys')
|
|
2679
|
-
.set({ revoked_at: now })
|
|
2680
|
-
.where('key_id', '=', keyId)
|
|
2681
|
-
.execute();
|
|
2682
|
-
// Create new key with same properties
|
|
2683
|
-
const newKeyId = generateKeyId();
|
|
2684
|
-
const keyType = existingRow.key_type;
|
|
2685
|
-
const secretKey = generateSecretKey(keyType);
|
|
2686
|
-
const keyHash = await hashApiKey(secretKey);
|
|
2687
|
-
const keyPrefix = secretKey.slice(0, 12);
|
|
2688
|
-
const scopeKeys = options.dialect.dbToArray(existingRow.scope_keys);
|
|
2689
|
-
await db
|
|
2690
|
-
.insertInto('sync_api_keys')
|
|
2691
|
-
.values({
|
|
2692
|
-
key_id: newKeyId,
|
|
2693
|
-
key_hash: keyHash,
|
|
2694
|
-
key_prefix: keyPrefix,
|
|
2695
|
-
name: existingRow.name,
|
|
2696
|
-
key_type: keyType,
|
|
2697
|
-
scope_keys: options.dialect.arrayToDb(scopeKeys),
|
|
2698
|
-
actor_id: existingRow.actor_id ?? null,
|
|
2699
|
-
created_at: now,
|
|
2700
|
-
expires_at: existingRow.expires_at,
|
|
2701
|
-
last_used_at: null,
|
|
2702
|
-
revoked_at: null,
|
|
2703
|
-
})
|
|
2704
|
-
.execute();
|
|
2705
|
-
logSyncEvent({
|
|
2706
|
-
event: 'console.rotate_api_key',
|
|
2707
|
-
consoleUserId: auth.consoleUserId,
|
|
2708
|
-
oldKeyId: keyId,
|
|
2709
|
-
newKeyId,
|
|
2710
|
-
});
|
|
2711
|
-
const key = {
|
|
2712
|
-
keyId: newKeyId,
|
|
2713
|
-
keyPrefix,
|
|
2714
|
-
name: existingRow.name,
|
|
2715
|
-
keyType,
|
|
2716
|
-
scopeKeys,
|
|
2717
|
-
actorId: existingRow.actor_id ?? null,
|
|
2718
|
-
createdAt: now,
|
|
2719
|
-
expiresAt: existingRow.expires_at ?? null,
|
|
2720
|
-
lastUsedAt: null,
|
|
2721
|
-
revokedAt: null,
|
|
2722
|
-
};
|
|
2723
|
-
const response = {
|
|
2724
|
-
key,
|
|
2725
|
-
secretKey,
|
|
2726
|
-
};
|
|
2727
|
-
return c.json(response, 200);
|
|
2728
|
-
});
|
|
2729
|
-
// -----------------------------------------------------------------------
|
|
2730
|
-
// Storage endpoints
|
|
2731
|
-
// -----------------------------------------------------------------------
|
|
2732
|
-
const bucket = options.blobBucket;
|
|
2733
|
-
routes.get('/storage', describeRoute({
|
|
2734
|
-
tags: ['console'],
|
|
2735
|
-
summary: 'List storage items',
|
|
2736
|
-
responses: {
|
|
2737
|
-
200: {
|
|
2738
|
-
description: 'Paginated list of storage items',
|
|
2739
|
-
content: {
|
|
2740
|
-
'application/json': {
|
|
2741
|
-
schema: resolver(ConsoleBlobListResponseSchema),
|
|
2742
|
-
},
|
|
2743
|
-
},
|
|
2744
|
-
},
|
|
2745
|
-
401: {
|
|
2746
|
-
description: 'Unauthenticated',
|
|
2747
|
-
content: {
|
|
2748
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2749
|
-
},
|
|
2750
|
-
},
|
|
2751
|
-
},
|
|
2752
|
-
}), zValidator('query', ConsoleBlobListQuerySchema), async (c) => {
|
|
2753
|
-
const auth = await requireAuth(c);
|
|
2754
|
-
if (!auth)
|
|
2755
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2756
|
-
if (!bucket) {
|
|
2757
|
-
return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
|
|
2758
|
-
}
|
|
2759
|
-
const { prefix, cursor, limit } = c.req.valid('query');
|
|
2760
|
-
const listed = await bucket.list({
|
|
2761
|
-
prefix: prefix || undefined,
|
|
2762
|
-
cursor: cursor || undefined,
|
|
2763
|
-
limit,
|
|
2764
|
-
});
|
|
2765
|
-
return c.json({
|
|
2766
|
-
items: listed.objects.map((obj) => ({
|
|
2767
|
-
key: obj.key,
|
|
2768
|
-
size: obj.size,
|
|
2769
|
-
uploaded: obj.uploaded.toISOString(),
|
|
2770
|
-
httpMetadata: obj.httpMetadata?.contentType
|
|
2771
|
-
? { contentType: obj.httpMetadata.contentType }
|
|
2772
|
-
: undefined,
|
|
2773
|
-
})),
|
|
2774
|
-
truncated: listed.truncated,
|
|
2775
|
-
cursor: listed.cursor ?? null,
|
|
2776
|
-
}, 200);
|
|
2777
|
-
});
|
|
2778
|
-
routes.get('/storage/:key{.+}/download', describeRoute({
|
|
2779
|
-
tags: ['console'],
|
|
2780
|
-
summary: 'Download a storage item',
|
|
2781
|
-
responses: {
|
|
2782
|
-
200: { description: 'Storage item contents' },
|
|
2783
|
-
401: {
|
|
2784
|
-
description: 'Unauthenticated',
|
|
2785
|
-
content: {
|
|
2786
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2787
|
-
},
|
|
2788
|
-
},
|
|
2789
|
-
404: {
|
|
2790
|
-
description: 'Blob not found',
|
|
2791
|
-
content: {
|
|
2792
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2793
|
-
},
|
|
2794
|
-
},
|
|
2795
|
-
},
|
|
2796
|
-
}), async (c) => {
|
|
2797
|
-
const auth = await requireAuth(c);
|
|
2798
|
-
if (!auth)
|
|
2799
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2800
|
-
if (!bucket) {
|
|
2801
|
-
return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
|
|
2802
|
-
}
|
|
2803
|
-
const key = decodeURIComponent(c.req.param('key'));
|
|
2804
|
-
const object = await bucket.get(key);
|
|
2805
|
-
if (!object) {
|
|
2806
|
-
return c.json({ error: 'BLOB_NOT_FOUND' }, 404);
|
|
2807
|
-
}
|
|
2808
|
-
const headers = new Headers();
|
|
2809
|
-
headers.set('Content-Length', String(object.size));
|
|
2810
|
-
headers.set('Content-Type', object.httpMetadata?.contentType ?? 'application/octet-stream');
|
|
2811
|
-
const filename = key.split('/').pop() || key;
|
|
2812
|
-
headers.set('Content-Disposition', `attachment; filename="${filename.replace(/"/g, '\\"')}"`);
|
|
2813
|
-
return new Response(object.body, {
|
|
2814
|
-
status: 200,
|
|
2815
|
-
headers,
|
|
2816
|
-
});
|
|
2817
|
-
});
|
|
2818
|
-
routes.delete('/storage/:key{.+}', describeRoute({
|
|
2819
|
-
tags: ['console'],
|
|
2820
|
-
summary: 'Delete a storage item',
|
|
2821
|
-
responses: {
|
|
2822
|
-
200: {
|
|
2823
|
-
description: 'Storage item deleted',
|
|
2824
|
-
content: {
|
|
2825
|
-
'application/json': {
|
|
2826
|
-
schema: resolver(ConsoleBlobDeleteResponseSchema),
|
|
2827
|
-
},
|
|
2828
|
-
},
|
|
2829
|
-
},
|
|
2830
|
-
401: {
|
|
2831
|
-
description: 'Unauthenticated',
|
|
2832
|
-
content: {
|
|
2833
|
-
'application/json': { schema: resolver(ErrorResponseSchema) },
|
|
2834
|
-
},
|
|
2835
|
-
},
|
|
2836
|
-
},
|
|
2837
|
-
}), async (c) => {
|
|
2838
|
-
const auth = await requireAuth(c);
|
|
2839
|
-
if (!auth)
|
|
2840
|
-
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
2841
|
-
if (!bucket) {
|
|
2842
|
-
return c.json({ error: 'BLOB_STORAGE_NOT_CONFIGURED' }, 501);
|
|
2843
|
-
}
|
|
2844
|
-
const key = decodeURIComponent(c.req.param('key'));
|
|
2845
|
-
await bucket.delete(key);
|
|
2846
|
-
return c.json({ deleted: true }, 200);
|
|
2847
|
-
});
|
|
2848
|
-
return routes;
|
|
2849
|
-
}
|
|
2850
|
-
// ===========================================================================
|
|
2851
|
-
// API Key Utilities
|
|
2852
|
-
// ===========================================================================
|
|
2853
|
-
function generateKeyId() {
|
|
2854
|
-
const bytes = new Uint8Array(16);
|
|
2855
|
-
crypto.getRandomValues(bytes);
|
|
2856
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
2857
|
-
}
|
|
2858
|
-
function generateSecretKey(keyType) {
|
|
2859
|
-
const bytes = new Uint8Array(24);
|
|
2860
|
-
crypto.getRandomValues(bytes);
|
|
2861
|
-
const random = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
2862
|
-
return `sk_${keyType}_${random}`;
|
|
2863
|
-
}
|
|
2864
|
-
async function hashApiKey(secretKey) {
|
|
2865
|
-
const encoder = new TextEncoder();
|
|
2866
|
-
const data = encoder.encode(secretKey);
|
|
2867
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
2868
|
-
const hashArray = new Uint8Array(hashBuffer);
|
|
2869
|
-
return Array.from(hashArray, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
2870
|
-
}
|
|
2871
|
-
/**
|
|
2872
|
-
* Creates a simple token-based authenticator for local development.
|
|
2873
|
-
* The token can be set via SYNC_CONSOLE_TOKEN env var or passed directly.
|
|
2874
|
-
*/
|
|
2875
|
-
export function createTokenAuthenticator(token) {
|
|
2876
|
-
const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
|
|
2877
|
-
return async (c) => {
|
|
2878
|
-
if (!expectedToken)
|
|
2879
|
-
return null;
|
|
2880
|
-
const authHeader = c.req.header('Authorization')?.trim();
|
|
2881
|
-
if (authHeader?.startsWith('Bearer ')) {
|
|
2882
|
-
const bearerToken = authHeader.slice(7).trim();
|
|
2883
|
-
if (bearerToken === expectedToken) {
|
|
2884
|
-
return { consoleUserId: 'token' };
|
|
2885
|
-
}
|
|
2886
|
-
}
|
|
2887
|
-
return null;
|
|
2888
|
-
};
|
|
2889
|
-
}
|
|
2890
|
-
//# sourceMappingURL=routes.js.map
|