@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.
Files changed (50) hide show
  1. package/dist/blobs.d.ts +10 -4
  2. package/dist/blobs.d.ts.map +1 -1
  3. package/dist/blobs.js +260 -26
  4. package/dist/blobs.js.map +1 -1
  5. package/dist/console/gateway.d.ts +4 -0
  6. package/dist/console/gateway.d.ts.map +1 -1
  7. package/dist/console/gateway.js +97 -60
  8. package/dist/console/gateway.js.map +1 -1
  9. package/dist/console/route-descriptor.d.ts +6 -0
  10. package/dist/console/route-descriptor.d.ts.map +1 -0
  11. package/dist/console/route-descriptor.js +16 -0
  12. package/dist/console/route-descriptor.js.map +1 -0
  13. package/dist/console/routes.d.ts.map +1 -1
  14. package/dist/console/routes.js +153 -108
  15. package/dist/console/routes.js.map +1 -1
  16. package/dist/console/schema-errors.d.ts +2 -0
  17. package/dist/console/schema-errors.d.ts.map +1 -0
  18. package/dist/console/schema-errors.js +17 -0
  19. package/dist/console/schema-errors.js.map +1 -0
  20. package/dist/console/schemas.js +1 -1
  21. package/dist/console/schemas.js.map +1 -1
  22. package/dist/console/types.d.ts +32 -0
  23. package/dist/console/types.d.ts.map +1 -1
  24. package/dist/create-server.d.ts.map +1 -1
  25. package/dist/create-server.js +13 -10
  26. package/dist/create-server.js.map +1 -1
  27. package/dist/proxy/routes.d.ts +10 -0
  28. package/dist/proxy/routes.d.ts.map +1 -1
  29. package/dist/proxy/routes.js +57 -6
  30. package/dist/proxy/routes.js.map +1 -1
  31. package/dist/routes.d.ts +21 -0
  32. package/dist/routes.d.ts.map +1 -1
  33. package/dist/routes.js +338 -352
  34. package/dist/routes.js.map +1 -1
  35. package/package.json +7 -6
  36. package/src/__tests__/blob-routes.test.ts +286 -18
  37. package/src/__tests__/console-gateway-live-routes.test.ts +61 -1
  38. package/src/__tests__/console-routes.test.ts +30 -1
  39. package/src/__tests__/create-server.test.ts +237 -1
  40. package/src/__tests__/pull-chunk-storage.test.ts +98 -0
  41. package/src/blobs.ts +360 -34
  42. package/src/console/gateway.ts +335 -288
  43. package/src/console/route-descriptor.ts +22 -0
  44. package/src/console/routes.ts +327 -248
  45. package/src/console/schema-errors.ts +23 -0
  46. package/src/console/schemas.ts +1 -1
  47. package/src/console/types.ts +32 -0
  48. package/src/create-server.ts +13 -10
  49. package/src/proxy/routes.ts +73 -9
  50. 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')