@syncular/server-hono 0.0.6-171 → 0.0.6-177

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.
@@ -12,7 +12,10 @@ 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';
15
- import type { WebSocketConnection } from '../ws';
15
+ import {
16
+ createWebSocketConnectionOwnerKey,
17
+ type WebSocketConnection,
18
+ } from '../ws';
16
19
 
17
20
  interface TasksTable {
18
21
  id: string;
@@ -92,6 +95,11 @@ describe('createSyncServer console configuration', () => {
92
95
  return {
93
96
  actorId: args.actorId,
94
97
  clientId: args.clientId,
98
+ ownerKey: createWebSocketConnectionOwnerKey({
99
+ partitionId: 'default',
100
+ actorId: args.actorId,
101
+ clientId: args.clientId,
102
+ }),
95
103
  transportPath: 'direct',
96
104
  get isOpen() {
97
105
  return true;
@@ -132,15 +140,18 @@ describe('createSyncServer console configuration', () => {
132
140
  function createPushRequest(args?: {
133
141
  requestId?: string;
134
142
  title?: string;
143
+ clientId?: string;
144
+ headers?: Record<string, string>;
135
145
  }): Request {
136
146
  return new Request('http://localhost/sync', {
137
147
  method: 'POST',
138
148
  headers: {
139
149
  'content-type': 'application/json',
140
150
  ...(args?.requestId ? { 'x-request-id': args.requestId } : {}),
151
+ ...args?.headers,
141
152
  },
142
153
  body: JSON.stringify({
143
- clientId: 'client-1',
154
+ clientId: args?.clientId ?? 'client-1',
144
155
  push: {
145
156
  clientCommitId: 'commit-1',
146
157
  schemaVersion: 1,
@@ -162,6 +173,34 @@ describe('createSyncServer console configuration', () => {
162
173
  });
163
174
  }
164
175
 
176
+ function createPullRequest(args: {
177
+ clientId: string;
178
+ userId: string;
179
+ subscriptionUserId: string;
180
+ }): Request {
181
+ return new Request('http://localhost/sync', {
182
+ method: 'POST',
183
+ headers: {
184
+ 'content-type': 'application/json',
185
+ 'x-user-id': args.userId,
186
+ },
187
+ body: JSON.stringify({
188
+ clientId: args.clientId,
189
+ pull: {
190
+ limitCommits: 10,
191
+ subscriptions: [
192
+ {
193
+ id: 'tasks-sub',
194
+ table: 'tasks',
195
+ scopes: { user_id: args.subscriptionUserId },
196
+ cursor: -1,
197
+ },
198
+ ],
199
+ },
200
+ }),
201
+ });
202
+ }
203
+
165
204
  function parseSnapshotValue(value: unknown): unknown {
166
205
  if (typeof value !== 'string') {
167
206
  return value;
@@ -428,6 +467,152 @@ describe('createSyncServer console configuration', () => {
428
467
  });
429
468
  });
430
469
 
470
+ it('allows same-origin realtime websocket upgrades when allowedOrigins is unset', async () => {
471
+ const options = createOptions();
472
+ let capturedEvents: WSEvents | null = null;
473
+ const upgradeWebSocket = defineWebSocketHelper(async (_c, events) => {
474
+ capturedEvents = events;
475
+ return new Response(null, { status: 200 });
476
+ });
477
+
478
+ const server = createSyncServer({
479
+ ...options,
480
+ upgradeWebSocket,
481
+ });
482
+
483
+ const app = new Hono();
484
+ app.route('/sync', server.syncRoutes);
485
+
486
+ const response = await app.request(
487
+ 'http://localhost/sync/realtime?clientId=client-origin-default',
488
+ {
489
+ headers: {
490
+ Origin: 'http://localhost',
491
+ },
492
+ }
493
+ );
494
+
495
+ expect(response.status).toBe(200);
496
+ expect(capturedEvents).not.toBeNull();
497
+ });
498
+
499
+ it('rejects realtime hijack attempts before websocket upgrade', async () => {
500
+ const options = createOptions();
501
+ let capturedEvents: WSEvents | null = null;
502
+ const upgradeWebSocket = defineWebSocketHelper(async (_c, events) => {
503
+ capturedEvents = events;
504
+ return new Response(null, { status: 200 });
505
+ });
506
+
507
+ const server = createSyncServer({
508
+ ...options,
509
+ upgradeWebSocket,
510
+ sync: {
511
+ ...options.sync,
512
+ authenticate: async (request) => {
513
+ const actorId = request.headers.get('x-user-id');
514
+ return actorId ? { actorId } : null;
515
+ },
516
+ },
517
+ });
518
+
519
+ const app = new Hono();
520
+ app.route('/sync', server.syncRoutes);
521
+
522
+ const seedResponse = await app.request(
523
+ createPushRequest({
524
+ clientId: 'shared-client',
525
+ headers: {
526
+ 'x-user-id': 'u1',
527
+ },
528
+ })
529
+ );
530
+ expect(seedResponse.status).toBe(200);
531
+
532
+ const hijackResponse = await app.request(
533
+ 'http://localhost/sync/realtime?clientId=shared-client',
534
+ {
535
+ headers: {
536
+ 'x-user-id': 'u2',
537
+ Origin: 'http://localhost',
538
+ },
539
+ }
540
+ );
541
+
542
+ expect(hijackResponse.status).toBe(400);
543
+ expect(await hijackResponse.json()).toEqual({
544
+ error: 'INVALID_CLIENT_ID',
545
+ message: 'clientId is already bound to a different actor',
546
+ });
547
+ expect(capturedEvents).toBeNull();
548
+ });
549
+
550
+ it('allows stale-scope rebinding after a fully revoked pull', async () => {
551
+ const options = createOptions();
552
+ const server = createSyncServer({
553
+ ...options,
554
+ sync: {
555
+ ...options.sync,
556
+ authenticate: async (request) => {
557
+ const actorId = request.headers.get('x-user-id');
558
+ return actorId ? { actorId } : null;
559
+ },
560
+ },
561
+ });
562
+
563
+ const app = new Hono();
564
+ app.route('/sync', server.syncRoutes);
565
+
566
+ const initialPull = await app.request(
567
+ createPullRequest({
568
+ clientId: 'shared-client',
569
+ userId: 'u1',
570
+ subscriptionUserId: 'u1',
571
+ })
572
+ );
573
+ expect(initialPull.status).toBe(200);
574
+
575
+ const revokedPull = await app.request(
576
+ createPullRequest({
577
+ clientId: 'shared-client',
578
+ userId: 'u2',
579
+ subscriptionUserId: 'u1',
580
+ })
581
+ );
582
+ expect(revokedPull.status).toBe(200);
583
+ expect(await revokedPull.json()).toMatchObject({
584
+ ok: true,
585
+ pull: {
586
+ subscriptions: [
587
+ {
588
+ id: 'tasks-sub',
589
+ status: 'revoked',
590
+ },
591
+ ],
592
+ },
593
+ });
594
+
595
+ const reboundPull = await app.request(
596
+ createPullRequest({
597
+ clientId: 'shared-client',
598
+ userId: 'u2',
599
+ subscriptionUserId: 'u2',
600
+ })
601
+ );
602
+ expect(reboundPull.status).toBe(200);
603
+ expect(await reboundPull.json()).toMatchObject({
604
+ ok: true,
605
+ pull: {
606
+ subscriptions: [
607
+ {
608
+ id: 'tasks-sub',
609
+ status: 'active',
610
+ },
611
+ ],
612
+ },
613
+ });
614
+ });
615
+
431
616
  it('forwards websocket allowedOrigins from factory to console live route', async () => {
432
617
  const options = createOptions();
433
618
  const upgradeWebSocket = defineWebSocketHelper(async () => {});
@@ -430,7 +430,7 @@ describe('createSyncRoutes chunkStorage wiring', () => {
430
430
  },
431
431
  })
432
432
  );
433
- expect(noScopesResponse.status).toBe(200);
433
+ expect(noScopesResponse.status).toBe(400);
434
434
 
435
435
  const wrongScopesResponse = await app.request(
436
436
  new Request(`http://localhost/sync/snapshot-chunks/${chunkId}`, {
@@ -458,6 +458,119 @@ describe('createSyncRoutes chunkStorage wiring', () => {
458
458
  ]);
459
459
  });
460
460
 
461
+ it('rejects reusing a client id from another actor', async () => {
462
+ const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
463
+ table: 'tasks',
464
+ scopes: ['user:{user_id}'],
465
+ resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
466
+ apply: async ({ tx }, change) => {
467
+ if (change.op !== 'upsert' || !change.row_json) {
468
+ return { ok: true };
469
+ }
470
+
471
+ const row = change.row_json;
472
+ if (
473
+ typeof row !== 'object' ||
474
+ row === null ||
475
+ typeof row.id !== 'string' ||
476
+ typeof row.user_id !== 'string' ||
477
+ typeof row.title !== 'string' ||
478
+ typeof row.server_version !== 'number'
479
+ ) {
480
+ throw new Error('Unexpected task payload');
481
+ }
482
+
483
+ await tx
484
+ .insertInto('tasks')
485
+ .values({
486
+ id: row.id,
487
+ user_id: row.user_id,
488
+ title: row.title,
489
+ server_version: row.server_version,
490
+ })
491
+ .execute();
492
+
493
+ return { ok: true };
494
+ },
495
+ });
496
+
497
+ const routes = createSyncRoutes({
498
+ db,
499
+ dialect,
500
+ handlers: [tasksHandler],
501
+ authenticate: async (c) => {
502
+ const actorId = c.req.header('x-user-id');
503
+ return actorId ? { actorId } : null;
504
+ },
505
+ });
506
+
507
+ const app = new Hono();
508
+ app.route('/sync', routes);
509
+
510
+ const firstResponse = await app.request(
511
+ new Request('http://localhost/sync', {
512
+ method: 'POST',
513
+ headers: {
514
+ 'content-type': 'application/json',
515
+ 'x-user-id': 'u1',
516
+ },
517
+ body: JSON.stringify({
518
+ clientId: 'shared-client',
519
+ push: {
520
+ clientCommitId: 'commit-u1',
521
+ schemaVersion: 1,
522
+ operations: [
523
+ {
524
+ table: 'tasks',
525
+ row_id: 'task-shared',
526
+ op: 'upsert',
527
+ base_version: null,
528
+ payload: {
529
+ id: 'task-shared',
530
+ user_id: 'u1',
531
+ title: 'Owned by u1',
532
+ server_version: 0,
533
+ },
534
+ },
535
+ ],
536
+ },
537
+ }),
538
+ })
539
+ );
540
+
541
+ expect(firstResponse.status).toBe(200);
542
+
543
+ const reusedClientResponse = await app.request(
544
+ new Request('http://localhost/sync', {
545
+ method: 'POST',
546
+ headers: {
547
+ 'content-type': 'application/json',
548
+ 'x-user-id': 'u2',
549
+ },
550
+ body: JSON.stringify({
551
+ clientId: 'shared-client',
552
+ pull: {
553
+ limitCommits: 10,
554
+ subscriptions: [
555
+ {
556
+ id: 'sub-1',
557
+ table: 'tasks',
558
+ scopes: { user_id: 'u2' },
559
+ cursor: -1,
560
+ },
561
+ ],
562
+ },
563
+ }),
564
+ })
565
+ );
566
+
567
+ expect(reusedClientResponse.status).toBe(400);
568
+ expect(await reusedClientResponse.json()).toEqual({
569
+ error: 'INVALID_CLIENT_ID',
570
+ message: 'clientId is already bound to a different actor',
571
+ });
572
+ });
573
+
461
574
  it('bundles multiple snapshot pages into one stored chunk', async () => {
462
575
  await db
463
576
  .insertInto('tasks')
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { isRequestOriginAllowed } from '../websocket-origin';
3
+
4
+ describe('websocket origin policy', () => {
5
+ it('allows same-origin browser upgrades by default', () => {
6
+ expect(
7
+ isRequestOriginAllowed({
8
+ requestUrl: 'http://localhost/sync/realtime?clientId=client-1',
9
+ originHeader: 'http://localhost',
10
+ })
11
+ ).toBe(true);
12
+ });
13
+
14
+ it('rejects cross-origin browser upgrades by default', () => {
15
+ expect(
16
+ isRequestOriginAllowed({
17
+ requestUrl: 'http://localhost/sync/realtime?clientId=client-1',
18
+ originHeader: 'https://evil.syncular.test',
19
+ })
20
+ ).toBe(false);
21
+ });
22
+
23
+ it('allows origin-less non-browser requests by default', () => {
24
+ expect(
25
+ isRequestOriginAllowed({
26
+ requestUrl: 'http://localhost/sync/realtime?clientId=client-1',
27
+ })
28
+ ).toBe(true);
29
+ });
30
+
31
+ it('requires an exact match when allowedOrigins is configured', () => {
32
+ expect(
33
+ isRequestOriginAllowed({
34
+ requestUrl: 'http://localhost/sync/realtime?clientId=client-1',
35
+ originHeader: 'https://app.syncular.test',
36
+ allowedOrigins: ['https://app.syncular.test'],
37
+ })
38
+ ).toBe(true);
39
+
40
+ expect(
41
+ isRequestOriginAllowed({
42
+ requestUrl: 'http://localhost/sync/realtime?clientId=client-1',
43
+ originHeader: 'https://evil.syncular.test',
44
+ allowedOrigins: ['https://app.syncular.test'],
45
+ })
46
+ ).toBe(false);
47
+
48
+ expect(
49
+ isRequestOriginAllowed({
50
+ requestUrl: 'http://localhost/sync/realtime?clientId=client-1',
51
+ allowedOrigins: ['https://app.syncular.test'],
52
+ })
53
+ ).toBe(false);
54
+ });
55
+ });
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import { type WebSocketConnection, WebSocketConnectionManager } from '../ws';
2
+ import {
3
+ createWebSocketConnectionOwnerKey,
4
+ type WebSocketConnection,
5
+ WebSocketConnectionManager,
6
+ } from '../ws';
3
7
 
4
8
  function createConn(args: {
5
9
  actorId: string;
@@ -14,6 +18,11 @@ function createConn(args: {
14
18
  },
15
19
  actorId: args.actorId,
16
20
  clientId: args.clientId,
21
+ ownerKey: createWebSocketConnectionOwnerKey({
22
+ partitionId: 'default',
23
+ actorId: args.actorId,
24
+ clientId: args.clientId,
25
+ }),
17
26
  transportPath: 'direct',
18
27
  sendSync(cursor) {
19
28
  if (!open) return;
@@ -66,7 +75,7 @@ describe('WebSocketConnectionManager (scopes)', () => {
66
75
  mgr.register(c1, ['project:p1']);
67
76
  mgr.notifyScopeKeys(['project:p1'], 1);
68
77
 
69
- mgr.updateClientScopeKeys('c1', ['project:p2']);
78
+ mgr.updateConnectionScopeKeys(c1.ownerKey, ['project:p2']);
70
79
  mgr.notifyScopeKeys(['project:p1'], 2);
71
80
  mgr.notifyScopeKeys(['project:p2'], 3);
72
81
 
@@ -140,11 +149,15 @@ describe('WebSocketConnectionManager (scopes)', () => {
140
149
 
141
150
  mgr.register(c1, ['user:u1']);
142
151
 
143
- const denied = mgr.joinPresence('c1', 'user:u2', { status: 'denied' });
152
+ const denied = mgr.joinPresence(c1.ownerKey, 'user:u2', {
153
+ status: 'denied',
154
+ });
144
155
  expect(denied).toBe(false);
145
156
  expect(mgr.getPresence('user:u2')).toEqual([]);
146
157
 
147
- const allowed = mgr.joinPresence('c1', 'user:u1', { status: 'ok' });
158
+ const allowed = mgr.joinPresence(c1.ownerKey, 'user:u1', {
159
+ status: 'ok',
160
+ });
148
161
  expect(allowed).toBe(true);
149
162
  expect(mgr.getPresence('user:u1')).toHaveLength(1);
150
163
  });
@@ -158,14 +171,16 @@ describe('WebSocketConnectionManager (scopes)', () => {
158
171
  });
159
172
 
160
173
  mgr.register(c1, ['user:u1']);
161
- expect(mgr.joinPresence('c1', 'user:u1', { status: 'initial' })).toBe(true);
174
+ expect(
175
+ mgr.joinPresence(c1.ownerKey, 'user:u1', { status: 'initial' })
176
+ ).toBe(true);
162
177
 
163
- const denied = mgr.updatePresenceMetadata('c1', 'user:u2', {
178
+ const denied = mgr.updatePresenceMetadata(c1.ownerKey, 'user:u2', {
164
179
  status: 'denied',
165
180
  });
166
181
  expect(denied).toBe(false);
167
182
 
168
- const allowed = mgr.updatePresenceMetadata('c1', 'user:u1', {
183
+ const allowed = mgr.updatePresenceMetadata(c1.ownerKey, 'user:u1', {
169
184
  status: 'updated',
170
185
  });
171
186
  expect(allowed).toBe(true);
@@ -4,6 +4,7 @@ import { cors } from 'hono/cors';
4
4
  import type { UpgradeWebSocket } from 'hono/ws';
5
5
  import { resolver, validator as zValidator } from 'hono-openapi';
6
6
  import { z } from 'zod';
7
+ import { isWebSocketOriginAllowed } from '../websocket-origin';
7
8
  import {
8
9
  closeUnauthenticatedSocket,
9
10
  parseBearerToken,
@@ -91,6 +92,11 @@ export interface CreateConsoleGatewayRoutesOptions {
91
92
  maxMessageBytes?: number;
92
93
  maxMessagesPerWindow?: number;
93
94
  messageRateWindowMs?: number;
95
+ /**
96
+ * - undefined: allow same-origin browser upgrades and origin-less non-browser clients
97
+ * - '*': allow all origins
98
+ * - string[]: exact origin match (scheme + host + port)
99
+ */
94
100
  allowedOrigins?: string[] | '*';
95
101
  };
96
102
  }
@@ -3009,24 +3015,6 @@ export function createConsoleGatewayRoutes(
3009
3015
  return routes;
3010
3016
  }
3011
3017
 
3012
- function isWebSocketOriginAllowed(
3013
- c: Context,
3014
- allowedOrigins?: string[] | '*'
3015
- ): boolean {
3016
- if (!allowedOrigins) return true;
3017
- if (allowedOrigins === '*') return true;
3018
-
3019
- const origin = c.req.header('origin');
3020
- if (!origin) return false;
3021
-
3022
- try {
3023
- const normalizedOrigin = new URL(origin).origin;
3024
- return allowedOrigins.includes(normalizedOrigin);
3025
- } catch {
3026
- return false;
3027
- }
3028
- }
3029
-
3030
3018
  function measureWebSocketMessageBytes(data: unknown): number {
3031
3019
  if (typeof data === 'string') {
3032
3020
  return new TextEncoder().encode(data).byteLength;
@@ -20,9 +20,9 @@ import type { SqlFamily, SyncCoreDb, SyncServerAuth } from '@syncular/server';
20
20
  import {
21
21
  coerceNumber,
22
22
  compactChanges,
23
- computePruneWatermarkCommitSeq,
24
23
  notifyExternalDataChange,
25
24
  parseJsonValue,
25
+ previewPruneSync,
26
26
  pruneSync,
27
27
  readSyncStats,
28
28
  } from '@syncular/server';
@@ -32,6 +32,7 @@ import { cors } from 'hono/cors';
32
32
  import { resolver, validator as zValidator } from 'hono-openapi';
33
33
  import { type Generated, type Kysely, type Selectable, sql } from 'kysely';
34
34
  import { z } from 'zod';
35
+ import { isWebSocketOriginAllowed } from '../websocket-origin';
35
36
  import {
36
37
  closeUnauthenticatedSocket,
37
38
  parseBearerToken,
@@ -2072,19 +2073,15 @@ export function createConsoleRoutes<
2072
2073
  },
2073
2074
  }),
2074
2075
  async (c) => {
2075
- const watermarkCommitSeq = await computePruneWatermarkCommitSeq(
2076
- options.db,
2077
- options.prune
2076
+ const previews = await previewPruneSync(options.db, options.prune);
2077
+ const watermarkCommitSeq = previews.reduce(
2078
+ (max, preview) => Math.max(max, preview.watermarkCommitSeq),
2079
+ 0
2080
+ );
2081
+ const commitsToDelete = previews.reduce(
2082
+ (total, preview) => total + preview.commitsToDelete,
2083
+ 0
2078
2084
  );
2079
-
2080
- // Count commits that would be deleted
2081
- const countRow = await db
2082
- .selectFrom('sync_commits')
2083
- .select(({ fn }) => fn.countAll().as('count'))
2084
- .where('commit_seq', '<=', watermarkCommitSeq)
2085
- .executeTakeFirst();
2086
-
2087
- const commitsToDelete = coerceNumber(countRow?.count) ?? 0;
2088
2085
 
2089
2086
  const preview: ConsolePrunePreview = {
2090
2087
  watermarkCommitSeq,
@@ -2119,15 +2116,19 @@ export function createConsoleRoutes<
2119
2116
  },
2120
2117
  }),
2121
2118
  async (c) => {
2122
- const watermarkCommitSeq = await computePruneWatermarkCommitSeq(
2123
- options.db,
2124
- options.prune
2119
+ const previews = await previewPruneSync(options.db, options.prune);
2120
+ const watermarkCommitSeq = previews.reduce(
2121
+ (max, preview) => Math.max(max, preview.watermarkCommitSeq),
2122
+ 0
2125
2123
  );
2126
-
2127
- const deletedCommits = await pruneSync(options.db, {
2128
- watermarkCommitSeq,
2129
- keepNewestCommits: options.prune?.keepNewestCommits,
2130
- });
2124
+ let deletedCommits = 0;
2125
+ for (const preview of previews) {
2126
+ deletedCommits += await pruneSync(options.db, {
2127
+ partitionId: preview.partitionId,
2128
+ watermarkCommitSeq: preview.watermarkCommitSeq,
2129
+ keepNewestCommits: options.prune?.keepNewestCommits,
2130
+ });
2131
+ }
2131
2132
 
2132
2133
  logSyncEvent({
2133
2134
  event: 'console.prune',
@@ -3780,24 +3781,6 @@ export function createConsoleRoutes<
3780
3781
  return routes;
3781
3782
  }
3782
3783
 
3783
- function isWebSocketOriginAllowed(
3784
- c: Context,
3785
- allowedOrigins?: string[] | '*'
3786
- ): boolean {
3787
- if (!allowedOrigins) return true;
3788
- if (allowedOrigins === '*') return true;
3789
-
3790
- const origin = c.req.header('origin');
3791
- if (!origin) return false;
3792
-
3793
- try {
3794
- const normalizedOrigin = new URL(origin).origin;
3795
- return allowedOrigins.includes(normalizedOrigin);
3796
- } catch {
3797
- return false;
3798
- }
3799
- }
3800
-
3801
3784
  function measureWebSocketMessageBytes(data: unknown): number {
3802
3785
  if (typeof data === 'string') {
3803
3786
  return new TextEncoder().encode(data).byteLength;
@@ -203,7 +203,7 @@ export interface CreateConsoleRoutesOptions<
203
203
  messageRateWindowMs?: number;
204
204
  /**
205
205
  * Optional list of allowed websocket origins.
206
- * - undefined: allow all origins
206
+ * - undefined: allow same-origin browser upgrades and origin-less non-browser clients
207
207
  * - '*': allow all origins
208
208
  * - string[]: exact origin match (scheme + host + port)
209
209
  */
@@ -20,6 +20,7 @@ import type { Context } from 'hono';
20
20
  import { Hono } from 'hono';
21
21
  import type { UpgradeWebSocket, WSContext } from 'hono/ws';
22
22
  import type { Kysely } from 'kysely';
23
+ import { isWebSocketOriginAllowed } from '../websocket-origin';
23
24
  import { ProxyConnectionManager } from './connection-manager';
24
25
 
25
26
  /**
@@ -65,7 +66,9 @@ interface CreateProxyRoutesConfig<DB extends SyncCoreDb = SyncCoreDb> {
65
66
  maxMessageBytes?: number;
66
67
  /**
67
68
  * Optional list of allowed websocket origins.
68
- * Use '*' to allow all origins.
69
+ * - undefined: allow same-origin browser upgrades and origin-less non-browser clients
70
+ * - '*': allow all origins
71
+ * - string[]: exact origin match (scheme + host + port)
69
72
  */
70
73
  allowedOrigins?: string[] | '*';
71
74
  }
@@ -233,24 +236,6 @@ export function createProxyRoutes<DB extends SyncCoreDb>(
233
236
  return app;
234
237
  }
235
238
 
236
- function isWebSocketOriginAllowed(
237
- c: Context,
238
- allowedOrigins?: string[] | '*'
239
- ): boolean {
240
- if (!allowedOrigins) return true;
241
- if (allowedOrigins === '*') return true;
242
-
243
- const origin = c.req.header('origin');
244
- if (!origin) return false;
245
-
246
- try {
247
- const normalizedOrigin = new URL(origin).origin;
248
- return allowedOrigins.includes(normalizedOrigin);
249
- } catch {
250
- return false;
251
- }
252
- }
253
-
254
239
  function measureWebSocketMessageBytes(data: unknown): number {
255
240
  if (typeof data === 'string') {
256
241
  return new TextEncoder().encode(data).byteLength;