@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.
- package/dist/console/gateway.d.ts +5 -0
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +1 -16
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +15 -30
- package/dist/console/routes.js.map +1 -1
- package/dist/console/types.d.ts +1 -1
- package/dist/proxy/routes.d.ts +3 -1
- package/dist/proxy/routes.d.ts.map +1 -1
- package/dist/proxy/routes.js +1 -16
- package/dist/proxy/routes.js.map +1 -1
- package/dist/routes.d.ts +3 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +133 -77
- package/dist/routes.js.map +1 -1
- package/dist/websocket-origin.d.ts +8 -0
- package/dist/websocket-origin.d.ts.map +1 -0
- package/dist/websocket-origin.js +43 -0
- package/dist/websocket-origin.js.map +1 -0
- package/dist/ws.d.ts +24 -8
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +54 -44
- package/dist/ws.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/console-gateway-live-routes.test.ts +13 -0
- package/src/__tests__/create-server.test.ts +187 -2
- package/src/__tests__/pull-chunk-storage.test.ts +114 -1
- package/src/__tests__/websocket-origin.test.ts +55 -0
- package/src/__tests__/ws-connection-manager.test.ts +22 -7
- package/src/console/gateway.ts +6 -18
- package/src/console/routes.ts +22 -39
- package/src/console/types.ts +1 -1
- package/src/proxy/routes.ts +4 -19
- package/src/routes.ts +192 -109
- package/src/websocket-origin.ts +54 -0
- package/src/ws.ts +86 -45
|
@@ -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
|
|
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(
|
|
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 {
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
174
|
+
expect(
|
|
175
|
+
mgr.joinPresence(c1.ownerKey, 'user:u1', { status: 'initial' })
|
|
176
|
+
).toBe(true);
|
|
162
177
|
|
|
163
|
-
const denied = mgr.updatePresenceMetadata(
|
|
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(
|
|
183
|
+
const allowed = mgr.updatePresenceMetadata(c1.ownerKey, 'user:u1', {
|
|
169
184
|
status: 'updated',
|
|
170
185
|
});
|
|
171
186
|
expect(allowed).toBe(true);
|
package/src/console/gateway.ts
CHANGED
|
@@ -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;
|
package/src/console/routes.ts
CHANGED
|
@@ -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
|
|
2076
|
-
|
|
2077
|
-
|
|
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
|
|
2123
|
-
|
|
2124
|
-
|
|
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
|
|
2128
|
-
|
|
2129
|
-
|
|
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;
|
package/src/console/types.ts
CHANGED
|
@@ -203,7 +203,7 @@ export interface CreateConsoleRoutesOptions<
|
|
|
203
203
|
messageRateWindowMs?: number;
|
|
204
204
|
/**
|
|
205
205
|
* Optional list of allowed websocket origins.
|
|
206
|
-
* - undefined: allow
|
|
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
|
*/
|
package/src/proxy/routes.ts
CHANGED
|
@@ -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
|
-
*
|
|
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;
|