@syncular/server-hono 0.0.6-158 → 0.0.6-165
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blobs.d.ts +10 -4
- package/dist/blobs.d.ts.map +1 -1
- package/dist/blobs.js +260 -26
- package/dist/blobs.js.map +1 -1
- package/dist/console/gateway.d.ts +4 -0
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +97 -60
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/route-descriptor.d.ts +6 -0
- package/dist/console/route-descriptor.d.ts.map +1 -0
- package/dist/console/route-descriptor.js +16 -0
- package/dist/console/route-descriptor.js.map +1 -0
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +153 -108
- package/dist/console/routes.js.map +1 -1
- package/dist/console/schema-errors.d.ts +2 -0
- package/dist/console/schema-errors.d.ts.map +1 -0
- package/dist/console/schema-errors.js +17 -0
- package/dist/console/schema-errors.js.map +1 -0
- package/dist/console/schemas.js +1 -1
- package/dist/console/schemas.js.map +1 -1
- package/dist/console/types.d.ts +32 -0
- package/dist/console/types.d.ts.map +1 -1
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +13 -10
- package/dist/create-server.js.map +1 -1
- package/dist/proxy/routes.d.ts +10 -0
- package/dist/proxy/routes.d.ts.map +1 -1
- package/dist/proxy/routes.js +57 -6
- package/dist/proxy/routes.js.map +1 -1
- package/dist/routes.d.ts +21 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +338 -352
- package/dist/routes.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/blob-routes.test.ts +286 -18
- package/src/__tests__/console-gateway-live-routes.test.ts +61 -1
- package/src/__tests__/console-routes.test.ts +30 -1
- package/src/__tests__/create-server.test.ts +237 -1
- package/src/__tests__/pull-chunk-storage.test.ts +98 -0
- package/src/blobs.ts +360 -34
- package/src/console/gateway.ts +335 -288
- package/src/console/route-descriptor.ts +22 -0
- package/src/console/routes.ts +327 -248
- package/src/console/schema-errors.ts +23 -0
- package/src/console/schemas.ts +1 -1
- package/src/console/types.ts +32 -0
- package/src/create-server.ts +13 -10
- package/src/proxy/routes.ts +73 -9
- package/src/routes.ts +449 -396
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from '@syncular/server';
|
|
9
9
|
import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
|
|
10
10
|
import { Hono } from 'hono';
|
|
11
|
-
import { defineWebSocketHelper } from 'hono/ws';
|
|
11
|
+
import { defineWebSocketHelper, WSContext, type WSEvents } from 'hono/ws';
|
|
12
12
|
import { type Kysely, sql } from 'kysely';
|
|
13
13
|
import { createSyncServer } from '../create-server';
|
|
14
14
|
import { getSyncWebSocketConnectionManager } from '../routes';
|
|
@@ -29,6 +29,10 @@ interface ClientDb {
|
|
|
29
29
|
tasks: TasksTable;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
describe('createSyncServer console configuration', () => {
|
|
33
37
|
let db: Kysely<ServerDb>;
|
|
34
38
|
let previousConsoleToken: string | undefined;
|
|
@@ -100,6 +104,31 @@ describe('createSyncServer console configuration', () => {
|
|
|
100
104
|
};
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
function createUpstreamSocketHarness() {
|
|
108
|
+
const messages: Array<Record<string, unknown>> = [];
|
|
109
|
+
const closes: Array<{ code?: number; reason?: string }> = [];
|
|
110
|
+
|
|
111
|
+
const ws = new WSContext({
|
|
112
|
+
readyState: 1,
|
|
113
|
+
send(data) {
|
|
114
|
+
if (typeof data !== 'string') return;
|
|
115
|
+
const parsed = JSON.parse(data);
|
|
116
|
+
if (isRecord(parsed)) {
|
|
117
|
+
messages.push(parsed);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
close(code, reason) {
|
|
121
|
+
closes.push({ code, reason });
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
ws,
|
|
127
|
+
messages,
|
|
128
|
+
closes,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
103
132
|
function createPushRequest(args?: {
|
|
104
133
|
requestId?: string;
|
|
105
134
|
title?: string;
|
|
@@ -226,6 +255,47 @@ describe('createSyncServer console configuration', () => {
|
|
|
226
255
|
expect(server.consoleRoutes).toBeDefined();
|
|
227
256
|
});
|
|
228
257
|
|
|
258
|
+
it('treats destroyed-driver console schema races as benign during startup', async () => {
|
|
259
|
+
const options = createOptions();
|
|
260
|
+
const dialect = createPostgresServerDialect();
|
|
261
|
+
dialect.ensureConsoleSchema = async () => {
|
|
262
|
+
throw new Error('driver has already been destroyed');
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const server = createSyncServer({
|
|
266
|
+
...options,
|
|
267
|
+
dialect,
|
|
268
|
+
console: {
|
|
269
|
+
token: 'console-token',
|
|
270
|
+
maintenance: {
|
|
271
|
+
autoPruneIntervalMs: Number.MAX_SAFE_INTEGER,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const app = new Hono();
|
|
277
|
+
app.route('/console', server.consoleRoutes!);
|
|
278
|
+
|
|
279
|
+
const originalConsoleError = console.error;
|
|
280
|
+
const consoleErrorCalls: unknown[][] = [];
|
|
281
|
+
console.error = (...args: unknown[]) => {
|
|
282
|
+
consoleErrorCalls.push(args);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const response = await app.request('http://localhost/console/storage', {
|
|
287
|
+
headers: { Authorization: 'Bearer console-token' },
|
|
288
|
+
});
|
|
289
|
+
expect(response.status).toBe(501);
|
|
290
|
+
expect(await response.json()).toEqual({
|
|
291
|
+
error: 'BLOB_STORAGE_NOT_CONFIGURED',
|
|
292
|
+
});
|
|
293
|
+
expect(consoleErrorCalls).toEqual([]);
|
|
294
|
+
} finally {
|
|
295
|
+
console.error = originalConsoleError;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
229
299
|
it('returns not implemented when blobBucket is not configured', async () => {
|
|
230
300
|
const options = createOptions();
|
|
231
301
|
const server = createSyncServer({
|
|
@@ -332,6 +402,172 @@ describe('createSyncServer console configuration', () => {
|
|
|
332
402
|
});
|
|
333
403
|
});
|
|
334
404
|
|
|
405
|
+
it('forwards websocket allowedOrigins from factory to realtime route', async () => {
|
|
406
|
+
const options = createOptions();
|
|
407
|
+
const upgradeWebSocket = defineWebSocketHelper(async () => {});
|
|
408
|
+
|
|
409
|
+
const server = createSyncServer({
|
|
410
|
+
...options,
|
|
411
|
+
upgradeWebSocket,
|
|
412
|
+
routes: {
|
|
413
|
+
websocket: {
|
|
414
|
+
allowedOrigins: ['https://allowed.syncular.test'],
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const app = new Hono();
|
|
420
|
+
app.route('/sync', server.syncRoutes);
|
|
421
|
+
|
|
422
|
+
const response = await app.request(
|
|
423
|
+
'http://localhost/sync/realtime?clientId=client-3'
|
|
424
|
+
);
|
|
425
|
+
expect(response.status).toBe(403);
|
|
426
|
+
expect(await response.json()).toEqual({
|
|
427
|
+
error: 'FORBIDDEN_ORIGIN',
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('forwards websocket allowedOrigins from factory to console live route', async () => {
|
|
432
|
+
const options = createOptions();
|
|
433
|
+
const upgradeWebSocket = defineWebSocketHelper(async () => {});
|
|
434
|
+
|
|
435
|
+
const server = createSyncServer({
|
|
436
|
+
...options,
|
|
437
|
+
upgradeWebSocket,
|
|
438
|
+
console: { token: 'console-token' },
|
|
439
|
+
routes: {
|
|
440
|
+
websocket: {
|
|
441
|
+
allowedOrigins: ['https://allowed.syncular.test'],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const app = new Hono();
|
|
447
|
+
app.route('/console', server.consoleRoutes!);
|
|
448
|
+
|
|
449
|
+
const response = await app.request('http://localhost/console/events/live');
|
|
450
|
+
expect(response.status).toBe(403);
|
|
451
|
+
expect(await response.json()).toEqual({
|
|
452
|
+
error: 'FORBIDDEN_ORIGIN',
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('enforces inbound websocket message rate limits per connection', async () => {
|
|
457
|
+
const options = createOptions();
|
|
458
|
+
let capturedEvents: WSEvents | null = null;
|
|
459
|
+
const upgradeWebSocket = defineWebSocketHelper(async (_c, events) => {
|
|
460
|
+
capturedEvents = events;
|
|
461
|
+
return new Response(null, { status: 200 });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const server = createSyncServer({
|
|
465
|
+
...options,
|
|
466
|
+
upgradeWebSocket,
|
|
467
|
+
routes: {
|
|
468
|
+
websocket: {
|
|
469
|
+
maxMessagesPerWindow: 2,
|
|
470
|
+
messageRateWindowMs: 60000,
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const app = new Hono();
|
|
476
|
+
app.route('/sync', server.syncRoutes);
|
|
477
|
+
|
|
478
|
+
const response = await app.request(
|
|
479
|
+
'http://localhost/sync/realtime?clientId=client-rate-limit'
|
|
480
|
+
);
|
|
481
|
+
expect(response.status).toBe(200);
|
|
482
|
+
|
|
483
|
+
const events = capturedEvents;
|
|
484
|
+
if (!events?.onOpen || !events.onMessage) {
|
|
485
|
+
throw new Error('Expected websocket handlers to be captured.');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const upstream = createUpstreamSocketHarness();
|
|
489
|
+
events.onOpen(new Event('open'), upstream.ws);
|
|
490
|
+
|
|
491
|
+
await events.onMessage(
|
|
492
|
+
new MessageEvent('message', { data: '{}' }),
|
|
493
|
+
upstream.ws
|
|
494
|
+
);
|
|
495
|
+
await events.onMessage(
|
|
496
|
+
new MessageEvent('message', { data: '{}' }),
|
|
497
|
+
upstream.ws
|
|
498
|
+
);
|
|
499
|
+
await events.onMessage(
|
|
500
|
+
new MessageEvent('message', { data: '{}' }),
|
|
501
|
+
upstream.ws
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const latestClose = upstream.closes[upstream.closes.length - 1];
|
|
505
|
+
expect(latestClose?.code).toBe(1011);
|
|
506
|
+
expect(latestClose?.reason).toBe('server error');
|
|
507
|
+
|
|
508
|
+
const errorMessage = upstream.messages.find(
|
|
509
|
+
(message) => message.event === 'error'
|
|
510
|
+
);
|
|
511
|
+
expect(errorMessage).toBeDefined();
|
|
512
|
+
if (!errorMessage || !isRecord(errorMessage.data)) {
|
|
513
|
+
throw new Error('Expected websocket error payload.');
|
|
514
|
+
}
|
|
515
|
+
expect(typeof errorMessage.data.error).toBe('string');
|
|
516
|
+
expect(String(errorMessage.data.error)).toContain(
|
|
517
|
+
'WebSocket message rate exceeded'
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('enforces inbound websocket message rate limits on console live route', async () => {
|
|
522
|
+
const options = createOptions();
|
|
523
|
+
let capturedEvents: WSEvents | null = null;
|
|
524
|
+
const upgradeWebSocket = defineWebSocketHelper(async (_c, events) => {
|
|
525
|
+
capturedEvents = events;
|
|
526
|
+
return new Response(null, { status: 200 });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const server = createSyncServer({
|
|
530
|
+
...options,
|
|
531
|
+
upgradeWebSocket,
|
|
532
|
+
console: { token: 'console-token' },
|
|
533
|
+
routes: {
|
|
534
|
+
websocket: {
|
|
535
|
+
maxMessagesPerWindow: 1,
|
|
536
|
+
messageRateWindowMs: 60000,
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const app = new Hono();
|
|
542
|
+
app.route('/console', server.consoleRoutes!);
|
|
543
|
+
|
|
544
|
+
const response = await app.request('http://localhost/console/events/live', {
|
|
545
|
+
headers: { Authorization: 'Bearer console-token' },
|
|
546
|
+
});
|
|
547
|
+
expect(response.status).toBe(200);
|
|
548
|
+
|
|
549
|
+
const events = capturedEvents;
|
|
550
|
+
if (!events?.onOpen || !events.onMessage) {
|
|
551
|
+
throw new Error('Expected websocket handlers to be captured.');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const upstream = createUpstreamSocketHarness();
|
|
555
|
+
events.onOpen(new Event('open'), upstream.ws);
|
|
556
|
+
|
|
557
|
+
await events.onMessage(
|
|
558
|
+
new MessageEvent('message', { data: '{}' }),
|
|
559
|
+
upstream.ws
|
|
560
|
+
);
|
|
561
|
+
await events.onMessage(
|
|
562
|
+
new MessageEvent('message', { data: '{}' }),
|
|
563
|
+
upstream.ws
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const latestClose = upstream.closes[upstream.closes.length - 1];
|
|
567
|
+
expect(latestClose?.code).toBe(1008);
|
|
568
|
+
expect(latestClose?.reason).toBe('message rate exceeded');
|
|
569
|
+
});
|
|
570
|
+
|
|
335
571
|
it('allows disabling request payload snapshots for privacy-sensitive deployments', async () => {
|
|
336
572
|
process.env.SYNC_CONSOLE_TOKEN = 'env-token';
|
|
337
573
|
const options = createOptions();
|
|
@@ -360,6 +360,104 @@ describe('createSyncRoutes chunkStorage wiring', () => {
|
|
|
360
360
|
]);
|
|
361
361
|
}, 10_000);
|
|
362
362
|
|
|
363
|
+
it('requires scope-bound authorization for snapshot chunk downloads', async () => {
|
|
364
|
+
await db
|
|
365
|
+
.insertInto('tasks')
|
|
366
|
+
.values({
|
|
367
|
+
id: 't1',
|
|
368
|
+
user_id: 'u1',
|
|
369
|
+
title: 'Task 1',
|
|
370
|
+
server_version: 1,
|
|
371
|
+
})
|
|
372
|
+
.execute();
|
|
373
|
+
|
|
374
|
+
const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
|
|
375
|
+
table: 'tasks',
|
|
376
|
+
scopes: ['user:{user_id}'],
|
|
377
|
+
resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const routes = createSyncRoutes({
|
|
381
|
+
db,
|
|
382
|
+
dialect,
|
|
383
|
+
handlers: [tasksHandler],
|
|
384
|
+
authenticate: async (c) => {
|
|
385
|
+
const actorId = c.req.header('x-user-id');
|
|
386
|
+
return actorId ? { actorId } : null;
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const app = new Hono();
|
|
391
|
+
app.route('/sync', routes);
|
|
392
|
+
|
|
393
|
+
const pullResponse = await app.request(
|
|
394
|
+
new Request('http://localhost/sync', {
|
|
395
|
+
method: 'POST',
|
|
396
|
+
headers: {
|
|
397
|
+
'content-type': 'application/json',
|
|
398
|
+
'x-user-id': 'u1',
|
|
399
|
+
},
|
|
400
|
+
body: JSON.stringify({
|
|
401
|
+
clientId: 'client-1',
|
|
402
|
+
pull: {
|
|
403
|
+
limitCommits: 10,
|
|
404
|
+
limitSnapshotRows: 100,
|
|
405
|
+
maxSnapshotPages: 1,
|
|
406
|
+
subscriptions: [
|
|
407
|
+
{
|
|
408
|
+
id: 'sub-1',
|
|
409
|
+
table: 'tasks',
|
|
410
|
+
scopes: { user_id: 'u1' },
|
|
411
|
+
cursor: -1,
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
},
|
|
415
|
+
}),
|
|
416
|
+
})
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
expect(pullResponse.status).toBe(200);
|
|
420
|
+
const combined = SyncCombinedResponseSchema.parse(
|
|
421
|
+
await pullResponse.json()
|
|
422
|
+
);
|
|
423
|
+
const parsed = combined.pull!;
|
|
424
|
+
const chunkId = mustGetFirstChunkId(parsed);
|
|
425
|
+
|
|
426
|
+
const noScopesResponse = await app.request(
|
|
427
|
+
new Request(`http://localhost/sync/snapshot-chunks/${chunkId}`, {
|
|
428
|
+
headers: {
|
|
429
|
+
'x-user-id': 'u1',
|
|
430
|
+
},
|
|
431
|
+
})
|
|
432
|
+
);
|
|
433
|
+
expect(noScopesResponse.status).toBe(200);
|
|
434
|
+
|
|
435
|
+
const wrongScopesResponse = await app.request(
|
|
436
|
+
new Request(`http://localhost/sync/snapshot-chunks/${chunkId}`, {
|
|
437
|
+
headers: {
|
|
438
|
+
'x-user-id': 'u1',
|
|
439
|
+
'x-syncular-snapshot-scopes': JSON.stringify({ user_id: 'u2' }),
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
);
|
|
443
|
+
expect(wrongScopesResponse.status).toBe(403);
|
|
444
|
+
|
|
445
|
+
const validScopesResponse = await app.request(
|
|
446
|
+
new Request(`http://localhost/sync/snapshot-chunks/${chunkId}`, {
|
|
447
|
+
headers: {
|
|
448
|
+
'x-user-id': 'u1',
|
|
449
|
+
'x-syncular-snapshot-scopes': JSON.stringify({ user_id: 'u1' }),
|
|
450
|
+
},
|
|
451
|
+
})
|
|
452
|
+
);
|
|
453
|
+
expect(validScopesResponse.status).toBe(200);
|
|
454
|
+
const chunkBytes = new Uint8Array(await validScopesResponse.arrayBuffer());
|
|
455
|
+
const rows = decodeSnapshotRows(gunzipSync(chunkBytes));
|
|
456
|
+
expect(rows).toEqual([
|
|
457
|
+
{ id: 't1', user_id: 'u1', title: 'Task 1', server_version: 1 },
|
|
458
|
+
]);
|
|
459
|
+
});
|
|
460
|
+
|
|
363
461
|
it('bundles multiple snapshot pages into one stored chunk', async () => {
|
|
364
462
|
await db
|
|
365
463
|
.insertInto('tasks')
|