@syncular/server-hono 0.0.1-100
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/api-key-auth.d.ts +49 -0
- package/dist/api-key-auth.d.ts.map +1 -0
- package/dist/api-key-auth.js +110 -0
- package/dist/api-key-auth.js.map +1 -0
- package/dist/blobs.d.ts +69 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +383 -0
- package/dist/blobs.js.map +1 -0
- package/dist/console/index.d.ts +8 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +7 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/routes.d.ts +106 -0
- package/dist/console/routes.d.ts.map +1 -0
- package/dist/console/routes.js +1612 -0
- package/dist/console/routes.js.map +1 -0
- package/dist/console/schemas.d.ts +308 -0
- package/dist/console/schemas.d.ts.map +1 -0
- package/dist/console/schemas.js +201 -0
- package/dist/console/schemas.js.map +1 -0
- package/dist/create-server.d.ts +80 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +100 -0
- package/dist/create-server.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/openapi.d.ts +45 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +59 -0
- package/dist/openapi.js.map +1 -0
- package/dist/proxy/connection-manager.d.ts +78 -0
- package/dist/proxy/connection-manager.d.ts.map +1 -0
- package/dist/proxy/connection-manager.js +251 -0
- package/dist/proxy/connection-manager.js.map +1 -0
- package/dist/proxy/index.d.ts +8 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +8 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/routes.d.ts +74 -0
- package/dist/proxy/routes.d.ts.map +1 -0
- package/dist/proxy/routes.js +147 -0
- package/dist/proxy/routes.js.map +1 -0
- package/dist/rate-limit.d.ts +101 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +186 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/routes.d.ts +126 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +884 -0
- package/dist/routes.js.map +1 -0
- package/dist/ws.d.ts +230 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +601 -0
- package/dist/ws.js.map +1 -0
- package/package.json +73 -0
- package/src/__tests__/create-server.test.ts +187 -0
- package/src/__tests__/pull-chunk-storage.test.ts +572 -0
- package/src/__tests__/rate-limit.test.ts +78 -0
- package/src/__tests__/realtime-bridge.test.ts +131 -0
- package/src/__tests__/sync-rate-limit-routing.test.ts +181 -0
- package/src/__tests__/ws-connection-manager.test.ts +176 -0
- package/src/api-key-auth.ts +179 -0
- package/src/blobs.ts +534 -0
- package/src/console/index.ts +17 -0
- package/src/console/routes.ts +2155 -0
- package/src/console/schemas.ts +299 -0
- package/src/create-server.ts +186 -0
- package/src/index.ts +42 -0
- package/src/openapi.ts +74 -0
- package/src/proxy/connection-manager.ts +340 -0
- package/src/proxy/index.ts +8 -0
- package/src/proxy/routes.ts +223 -0
- package/src/rate-limit.ts +321 -0
- package/src/routes.ts +1305 -0
- package/src/ws.ts +789 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import { createPgliteDb } from '@syncular/dialect-pglite';
|
|
3
|
+
import {
|
|
4
|
+
ensureSyncSchema,
|
|
5
|
+
InMemorySyncRealtimeBroadcaster,
|
|
6
|
+
type SyncCoreDb,
|
|
7
|
+
} from '@syncular/server';
|
|
8
|
+
import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
|
|
9
|
+
import { defineWebSocketHelper } from 'hono/ws';
|
|
10
|
+
import {
|
|
11
|
+
createSyncRoutes,
|
|
12
|
+
getSyncRealtimeUnsubscribe,
|
|
13
|
+
getSyncWebSocketConnectionManager,
|
|
14
|
+
} from '../routes';
|
|
15
|
+
|
|
16
|
+
describe('realtime broadcaster bridge', () => {
|
|
17
|
+
it('notifies local WebSocket connections when another instance publishes a commit', async () => {
|
|
18
|
+
const db = createPgliteDb<SyncCoreDb>();
|
|
19
|
+
const dialect = createPostgresServerDialect();
|
|
20
|
+
await ensureSyncSchema(db, dialect);
|
|
21
|
+
|
|
22
|
+
const commit = await db
|
|
23
|
+
.insertInto('sync_commits')
|
|
24
|
+
.values({
|
|
25
|
+
partition_id: 'default',
|
|
26
|
+
actor_id: 'u1',
|
|
27
|
+
client_id: 'client-1',
|
|
28
|
+
client_commit_id: 'c1',
|
|
29
|
+
meta: null,
|
|
30
|
+
result_json: null,
|
|
31
|
+
})
|
|
32
|
+
.returning(['commit_seq'])
|
|
33
|
+
.executeTakeFirstOrThrow();
|
|
34
|
+
|
|
35
|
+
const commitSeq = Number(commit.commit_seq);
|
|
36
|
+
|
|
37
|
+
await db
|
|
38
|
+
.insertInto('sync_changes')
|
|
39
|
+
.values({
|
|
40
|
+
commit_seq: commitSeq,
|
|
41
|
+
partition_id: 'default',
|
|
42
|
+
table: 'tasks',
|
|
43
|
+
row_id: 't1',
|
|
44
|
+
op: 'upsert',
|
|
45
|
+
row_json: { id: 't1' },
|
|
46
|
+
row_version: 1,
|
|
47
|
+
scopes: { user_id: 'u1' },
|
|
48
|
+
})
|
|
49
|
+
.execute();
|
|
50
|
+
|
|
51
|
+
const broadcaster = new InMemorySyncRealtimeBroadcaster();
|
|
52
|
+
const upgradeWebSocket = defineWebSocketHelper(async () => {});
|
|
53
|
+
|
|
54
|
+
const routes1 = createSyncRoutes({
|
|
55
|
+
db,
|
|
56
|
+
dialect,
|
|
57
|
+
handlers: [],
|
|
58
|
+
authenticate: async () => ({ actorId: 'u1' }),
|
|
59
|
+
sync: {
|
|
60
|
+
websocket: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
upgradeWebSocket,
|
|
63
|
+
heartbeatIntervalMs: 0,
|
|
64
|
+
},
|
|
65
|
+
realtime: { broadcaster, instanceId: 'i1' },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const routes2 = createSyncRoutes({
|
|
70
|
+
db,
|
|
71
|
+
dialect,
|
|
72
|
+
handlers: [],
|
|
73
|
+
authenticate: async () => ({ actorId: 'u1' }),
|
|
74
|
+
sync: {
|
|
75
|
+
websocket: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
upgradeWebSocket,
|
|
78
|
+
heartbeatIntervalMs: 0,
|
|
79
|
+
},
|
|
80
|
+
realtime: { broadcaster, instanceId: 'i2' },
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const mgr2 = getSyncWebSocketConnectionManager(routes2);
|
|
85
|
+
expect(mgr2).toBeTruthy();
|
|
86
|
+
|
|
87
|
+
const onSync = mock((_cursor: number) => {});
|
|
88
|
+
|
|
89
|
+
mgr2!.register(
|
|
90
|
+
{
|
|
91
|
+
actorId: 'u1',
|
|
92
|
+
clientId: 'client-2',
|
|
93
|
+
get isOpen() {
|
|
94
|
+
return true;
|
|
95
|
+
},
|
|
96
|
+
sendSync: onSync,
|
|
97
|
+
sendHeartbeat: mock(() => {}),
|
|
98
|
+
sendPresence: mock(() => {}),
|
|
99
|
+
sendError: mock(() => {}),
|
|
100
|
+
close: mock(() => {}),
|
|
101
|
+
},
|
|
102
|
+
['default::user:u1']
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Publish without scopeKeys to exercise DB lookup on the receiving instance.
|
|
106
|
+
await broadcaster.publish({
|
|
107
|
+
type: 'commit',
|
|
108
|
+
commitSeq,
|
|
109
|
+
sourceInstanceId: 'i1',
|
|
110
|
+
});
|
|
111
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
112
|
+
|
|
113
|
+
expect(onSync).toHaveBeenCalledWith(commitSeq);
|
|
114
|
+
|
|
115
|
+
// Echo suppression: instance2 ignores events it originated.
|
|
116
|
+
onSync.mockClear();
|
|
117
|
+
await broadcaster.publish({
|
|
118
|
+
type: 'commit',
|
|
119
|
+
commitSeq,
|
|
120
|
+
sourceInstanceId: 'i2',
|
|
121
|
+
});
|
|
122
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
123
|
+
|
|
124
|
+
expect(onSync).not.toHaveBeenCalled();
|
|
125
|
+
|
|
126
|
+
getSyncRealtimeUnsubscribe(routes1)?.();
|
|
127
|
+
getSyncRealtimeUnsubscribe(routes2)?.();
|
|
128
|
+
await broadcaster.close();
|
|
129
|
+
await db.destroy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
createServerHandler,
|
|
4
|
+
ensureSyncSchema,
|
|
5
|
+
type SyncCoreDb,
|
|
6
|
+
} from '@syncular/server';
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import type { Kysely } from 'kysely';
|
|
9
|
+
import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
|
|
10
|
+
import { createSqliteServerDialect } from '../../../server-dialect-sqlite/src';
|
|
11
|
+
import { resetRateLimitStore } from '../rate-limit';
|
|
12
|
+
import { createSyncRoutes } from '../routes';
|
|
13
|
+
|
|
14
|
+
interface TasksTable {
|
|
15
|
+
id: string;
|
|
16
|
+
user_id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
server_version: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ServerDb extends SyncCoreDb {
|
|
22
|
+
tasks: TasksTable;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ClientDb {
|
|
26
|
+
tasks: TasksTable;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('createSyncRoutes rate limit routing', () => {
|
|
30
|
+
let db: Kysely<ServerDb>;
|
|
31
|
+
const dialect = createSqliteServerDialect();
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
db = createBunSqliteDb<ServerDb>({ path: ':memory:' });
|
|
35
|
+
await ensureSyncSchema(db, dialect);
|
|
36
|
+
|
|
37
|
+
await db.schema
|
|
38
|
+
.createTable('tasks')
|
|
39
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
40
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
41
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
42
|
+
.addColumn('server_version', 'integer', (col) =>
|
|
43
|
+
col.notNull().defaultTo(0)
|
|
44
|
+
)
|
|
45
|
+
.execute();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
resetRateLimitStore();
|
|
50
|
+
await db.destroy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const tasksHandler = createServerHandler<ServerDb, ClientDb, 'tasks'>({
|
|
54
|
+
table: 'tasks',
|
|
55
|
+
scopes: ['user:{user_id}'],
|
|
56
|
+
resolveScopes: async (ctx) => ({ user_id: [ctx.actorId] }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const pullPayload = {
|
|
60
|
+
limitCommits: 10,
|
|
61
|
+
subscriptions: [
|
|
62
|
+
{
|
|
63
|
+
id: 'sub-1',
|
|
64
|
+
shape: 'tasks',
|
|
65
|
+
scopes: { user_id: 'u1' },
|
|
66
|
+
cursor: -1,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function pushPayload(clientCommitId: string, rowId: string) {
|
|
72
|
+
return {
|
|
73
|
+
clientCommitId,
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
operations: [
|
|
76
|
+
{
|
|
77
|
+
table: 'tasks',
|
|
78
|
+
row_id: rowId,
|
|
79
|
+
op: 'upsert' as const,
|
|
80
|
+
base_version: null,
|
|
81
|
+
payload: {
|
|
82
|
+
id: rowId,
|
|
83
|
+
user_id: 'u1',
|
|
84
|
+
title: `Task ${rowId}`,
|
|
85
|
+
server_version: 0,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createJsonRequest(body: object): Request {
|
|
93
|
+
return new Request('http://localhost/sync', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'content-type': 'application/json' },
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('does not consume push quota for pull-only requests', async () => {
|
|
101
|
+
const routes = createSyncRoutes({
|
|
102
|
+
db,
|
|
103
|
+
dialect,
|
|
104
|
+
handlers: [tasksHandler],
|
|
105
|
+
authenticate: async () => ({ actorId: 'u1' }),
|
|
106
|
+
sync: {
|
|
107
|
+
rateLimit: {
|
|
108
|
+
pull: { maxRequests: 1, windowMs: 60_000 },
|
|
109
|
+
push: { maxRequests: 1, windowMs: 60_000 },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const app = new Hono();
|
|
115
|
+
app.route('/sync', routes);
|
|
116
|
+
|
|
117
|
+
const pullFirst = await app.request(
|
|
118
|
+
createJsonRequest({
|
|
119
|
+
clientId: 'client-1',
|
|
120
|
+
pull: pullPayload,
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
const pushFirst = await app.request(
|
|
124
|
+
createJsonRequest({
|
|
125
|
+
clientId: 'client-1',
|
|
126
|
+
push: pushPayload('commit-1', 't1'),
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
const pullSecond = await app.request(
|
|
130
|
+
createJsonRequest({
|
|
131
|
+
clientId: 'client-1',
|
|
132
|
+
pull: pullPayload,
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
const pushSecond = await app.request(
|
|
136
|
+
createJsonRequest({
|
|
137
|
+
clientId: 'client-1',
|
|
138
|
+
push: pushPayload('commit-2', 't2'),
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(pullFirst.status).toBe(200);
|
|
143
|
+
expect(pushFirst.status).toBe(200);
|
|
144
|
+
expect(pullSecond.status).toBe(429);
|
|
145
|
+
expect(pushSecond.status).toBe(429);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('authenticates once per combined sync request with rate limiting enabled', async () => {
|
|
149
|
+
let authCalls = 0;
|
|
150
|
+
|
|
151
|
+
const routes = createSyncRoutes({
|
|
152
|
+
db,
|
|
153
|
+
dialect,
|
|
154
|
+
handlers: [tasksHandler],
|
|
155
|
+
authenticate: async () => {
|
|
156
|
+
authCalls += 1;
|
|
157
|
+
return { actorId: 'u1' };
|
|
158
|
+
},
|
|
159
|
+
sync: {
|
|
160
|
+
rateLimit: {
|
|
161
|
+
pull: { maxRequests: 10, windowMs: 60_000 },
|
|
162
|
+
push: { maxRequests: 10, windowMs: 60_000 },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const app = new Hono();
|
|
168
|
+
app.route('/sync', routes);
|
|
169
|
+
|
|
170
|
+
const response = await app.request(
|
|
171
|
+
createJsonRequest({
|
|
172
|
+
clientId: 'client-1',
|
|
173
|
+
push: pushPayload('commit-combined-1', 't-combined-1'),
|
|
174
|
+
pull: pullPayload,
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(response.status).toBe(200);
|
|
179
|
+
expect(authCalls).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { type WebSocketConnection, WebSocketConnectionManager } from '../ws';
|
|
3
|
+
|
|
4
|
+
function createConn(args: {
|
|
5
|
+
actorId: string;
|
|
6
|
+
clientId: string;
|
|
7
|
+
onSync: (cursor: number) => void;
|
|
8
|
+
}): WebSocketConnection {
|
|
9
|
+
let open = true;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
get isOpen() {
|
|
13
|
+
return open;
|
|
14
|
+
},
|
|
15
|
+
actorId: args.actorId,
|
|
16
|
+
clientId: args.clientId,
|
|
17
|
+
transportPath: 'direct',
|
|
18
|
+
sendSync(cursor) {
|
|
19
|
+
if (!open) return;
|
|
20
|
+
args.onSync(cursor);
|
|
21
|
+
},
|
|
22
|
+
sendHeartbeat() {},
|
|
23
|
+
sendPresence() {},
|
|
24
|
+
sendError() {
|
|
25
|
+
open = false;
|
|
26
|
+
},
|
|
27
|
+
close() {
|
|
28
|
+
open = false;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('WebSocketConnectionManager (scopes)', () => {
|
|
34
|
+
it('notifies connections based on scope overlap', () => {
|
|
35
|
+
const mgr = new WebSocketConnectionManager({ heartbeatIntervalMs: 0 });
|
|
36
|
+
const seen: Array<{ clientId: string; cursor: number }> = [];
|
|
37
|
+
|
|
38
|
+
const c1 = createConn({
|
|
39
|
+
actorId: 'u1',
|
|
40
|
+
clientId: 'c1',
|
|
41
|
+
onSync: (cursor) => seen.push({ clientId: 'c1', cursor }),
|
|
42
|
+
});
|
|
43
|
+
const c2 = createConn({
|
|
44
|
+
actorId: 'u2',
|
|
45
|
+
clientId: 'c2',
|
|
46
|
+
onSync: (cursor) => seen.push({ clientId: 'c2', cursor }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
mgr.register(c1, ['user:u1']);
|
|
50
|
+
mgr.register(c2, ['user:u2']);
|
|
51
|
+
|
|
52
|
+
mgr.notifyScopeKeys(['user:u1'], 10);
|
|
53
|
+
expect(seen).toEqual([{ clientId: 'c1', cursor: 10 }]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('updates scopes for a connected client', () => {
|
|
57
|
+
const mgr = new WebSocketConnectionManager({ heartbeatIntervalMs: 0 });
|
|
58
|
+
const seen: number[] = [];
|
|
59
|
+
|
|
60
|
+
const c1 = createConn({
|
|
61
|
+
actorId: 'u1',
|
|
62
|
+
clientId: 'c1',
|
|
63
|
+
onSync: (cursor) => seen.push(cursor),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
mgr.register(c1, ['project:p1']);
|
|
67
|
+
mgr.notifyScopeKeys(['project:p1'], 1);
|
|
68
|
+
|
|
69
|
+
mgr.updateClientScopeKeys('c1', ['project:p2']);
|
|
70
|
+
mgr.notifyScopeKeys(['project:p1'], 2);
|
|
71
|
+
mgr.notifyScopeKeys(['project:p2'], 3);
|
|
72
|
+
|
|
73
|
+
expect(seen).toEqual([1, 3]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('dedupes notifications across multiple matching scopes', () => {
|
|
77
|
+
const mgr = new WebSocketConnectionManager({ heartbeatIntervalMs: 0 });
|
|
78
|
+
const seen: number[] = [];
|
|
79
|
+
|
|
80
|
+
const c1 = createConn({
|
|
81
|
+
actorId: 'u1',
|
|
82
|
+
clientId: 'c1',
|
|
83
|
+
onSync: (cursor) => seen.push(cursor),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
mgr.register(c1, ['a', 'b']);
|
|
87
|
+
mgr.notifyScopeKeys(['a', 'b'], 5);
|
|
88
|
+
|
|
89
|
+
expect(seen).toEqual([5]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('supports excluding a client id', () => {
|
|
93
|
+
const mgr = new WebSocketConnectionManager({ heartbeatIntervalMs: 0 });
|
|
94
|
+
const seen: string[] = [];
|
|
95
|
+
|
|
96
|
+
const c1 = createConn({
|
|
97
|
+
actorId: 'u1',
|
|
98
|
+
clientId: 'c1',
|
|
99
|
+
onSync: () => seen.push('c1'),
|
|
100
|
+
});
|
|
101
|
+
const c2 = createConn({
|
|
102
|
+
actorId: 'u1',
|
|
103
|
+
clientId: 'c2',
|
|
104
|
+
onSync: () => seen.push('c2'),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
mgr.register(c1, ['s']);
|
|
108
|
+
mgr.register(c2, ['s']);
|
|
109
|
+
|
|
110
|
+
mgr.notifyScopeKeys(['s'], 123, { excludeClientIds: ['c1'] });
|
|
111
|
+
expect(seen).toEqual(['c2']);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('stops notifying after unregister', () => {
|
|
115
|
+
const mgr = new WebSocketConnectionManager({ heartbeatIntervalMs: 0 });
|
|
116
|
+
const seen: number[] = [];
|
|
117
|
+
|
|
118
|
+
const c1 = createConn({
|
|
119
|
+
actorId: 'u1',
|
|
120
|
+
clientId: 'c1',
|
|
121
|
+
onSync: (cursor) => seen.push(cursor),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const unregister = mgr.register(c1, ['s']);
|
|
125
|
+
mgr.notifyScopeKeys(['s'], 1);
|
|
126
|
+
|
|
127
|
+
unregister();
|
|
128
|
+
mgr.notifyScopeKeys(['s'], 2);
|
|
129
|
+
|
|
130
|
+
expect(seen).toEqual([1]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('allows presence join only for authorized scope keys', () => {
|
|
134
|
+
const mgr = new WebSocketConnectionManager({ heartbeatIntervalMs: 0 });
|
|
135
|
+
const c1 = createConn({
|
|
136
|
+
actorId: 'u1',
|
|
137
|
+
clientId: 'c1',
|
|
138
|
+
onSync: () => {},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
mgr.register(c1, ['user:u1']);
|
|
142
|
+
|
|
143
|
+
const denied = mgr.joinPresence('c1', 'user:u2', { status: 'denied' });
|
|
144
|
+
expect(denied).toBe(false);
|
|
145
|
+
expect(mgr.getPresence('user:u2')).toEqual([]);
|
|
146
|
+
|
|
147
|
+
const allowed = mgr.joinPresence('c1', 'user:u1', { status: 'ok' });
|
|
148
|
+
expect(allowed).toBe(true);
|
|
149
|
+
expect(mgr.getPresence('user:u1')).toHaveLength(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('allows presence update only for authorized scope keys', () => {
|
|
153
|
+
const mgr = new WebSocketConnectionManager({ heartbeatIntervalMs: 0 });
|
|
154
|
+
const c1 = createConn({
|
|
155
|
+
actorId: 'u1',
|
|
156
|
+
clientId: 'c1',
|
|
157
|
+
onSync: () => {},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
mgr.register(c1, ['user:u1']);
|
|
161
|
+
expect(mgr.joinPresence('c1', 'user:u1', { status: 'initial' })).toBe(true);
|
|
162
|
+
|
|
163
|
+
const denied = mgr.updatePresenceMetadata('c1', 'user:u2', {
|
|
164
|
+
status: 'denied',
|
|
165
|
+
});
|
|
166
|
+
expect(denied).toBe(false);
|
|
167
|
+
|
|
168
|
+
const allowed = mgr.updatePresenceMetadata('c1', 'user:u1', {
|
|
169
|
+
status: 'updated',
|
|
170
|
+
});
|
|
171
|
+
expect(allowed).toBe(true);
|
|
172
|
+
expect(mgr.getPresence('user:u1')[0]?.metadata).toEqual({
|
|
173
|
+
status: 'updated',
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/server-hono - API Key Authentication Helper
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for validating API keys in relay/proxy routes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ServerSyncDialect } from '@syncular/server';
|
|
8
|
+
import type { Context } from 'hono';
|
|
9
|
+
import { type Kysely, sql } from 'kysely';
|
|
10
|
+
import { type ApiKeyType, ApiKeyTypeSchema } from './console/schemas';
|
|
11
|
+
|
|
12
|
+
interface SyncApiKeysTable {
|
|
13
|
+
key_id: string;
|
|
14
|
+
key_hash: string;
|
|
15
|
+
key_prefix: string;
|
|
16
|
+
name: string;
|
|
17
|
+
key_type: string;
|
|
18
|
+
scope_keys: unknown | null;
|
|
19
|
+
actor_id: string | null;
|
|
20
|
+
created_at: string;
|
|
21
|
+
expires_at: string | null;
|
|
22
|
+
last_used_at: string | null;
|
|
23
|
+
revoked_at: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ApiKeyDb = {
|
|
27
|
+
sync_api_keys: SyncApiKeysTable;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface ValidateApiKeyResult {
|
|
31
|
+
keyId: string;
|
|
32
|
+
keyType: ApiKeyType;
|
|
33
|
+
actorId: string | null;
|
|
34
|
+
scopeKeys: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validates an API key from Authorization header.
|
|
39
|
+
* Updates last_used_at on successful validation.
|
|
40
|
+
*/
|
|
41
|
+
export async function validateApiKey<DB extends ApiKeyDb>(
|
|
42
|
+
db: Kysely<DB>,
|
|
43
|
+
dialect: ServerSyncDialect,
|
|
44
|
+
authHeader: string | undefined
|
|
45
|
+
): Promise<ValidateApiKeyResult | null> {
|
|
46
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const secretKey = authHeader.slice(7);
|
|
51
|
+
if (!secretKey || !secretKey.startsWith('sk_')) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Hash the provided key
|
|
56
|
+
const keyHash = await hashApiKey(secretKey);
|
|
57
|
+
|
|
58
|
+
// Look up key by hash
|
|
59
|
+
const rowResult = await sql<{
|
|
60
|
+
key_id: string;
|
|
61
|
+
key_type: string;
|
|
62
|
+
actor_id: string | null;
|
|
63
|
+
scope_keys: unknown | null;
|
|
64
|
+
expires_at: string | null;
|
|
65
|
+
revoked_at: string | null;
|
|
66
|
+
}>`
|
|
67
|
+
select key_id, key_type, actor_id, scope_keys, expires_at, revoked_at
|
|
68
|
+
from ${sql.table('sync_api_keys')}
|
|
69
|
+
where key_hash = ${keyHash}
|
|
70
|
+
limit 1
|
|
71
|
+
`.execute(db);
|
|
72
|
+
const row = rowResult.rows[0];
|
|
73
|
+
|
|
74
|
+
if (!row) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parsedKeyType = ApiKeyTypeSchema.safeParse(row.key_type);
|
|
79
|
+
if (!parsedKeyType.success) return null;
|
|
80
|
+
|
|
81
|
+
// Check if revoked
|
|
82
|
+
if (row.revoked_at) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check if expired
|
|
87
|
+
if (row.expires_at) {
|
|
88
|
+
const expiresAt = new Date(row.expires_at);
|
|
89
|
+
if (expiresAt < new Date()) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Update last_used_at
|
|
95
|
+
const now = new Date().toISOString();
|
|
96
|
+
await sql`
|
|
97
|
+
update ${sql.table('sync_api_keys')}
|
|
98
|
+
set last_used_at = ${now}
|
|
99
|
+
where key_id = ${row.key_id}
|
|
100
|
+
`.execute(db);
|
|
101
|
+
|
|
102
|
+
// Parse scopes
|
|
103
|
+
const scopeKeys = dialect.dbToArray(row.scope_keys);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
keyId: row.key_id,
|
|
107
|
+
keyType: parsedKeyType.data,
|
|
108
|
+
actorId: row.actor_id,
|
|
109
|
+
scopeKeys,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates an authenticator for relay/proxy routes.
|
|
115
|
+
* Returns actorId from the API key if valid and allowed.
|
|
116
|
+
*/
|
|
117
|
+
export function createApiKeyAuthenticator<DB extends ApiKeyDb>(
|
|
118
|
+
db: Kysely<DB>,
|
|
119
|
+
dialect: ServerSyncDialect,
|
|
120
|
+
allowedTypes: ApiKeyType[]
|
|
121
|
+
): (c: Context) => Promise<{ actorId: string } | null> {
|
|
122
|
+
return async (c: Context) => {
|
|
123
|
+
const authHeader = c.req.header('Authorization');
|
|
124
|
+
const result = await validateApiKey(db, dialect, authHeader);
|
|
125
|
+
|
|
126
|
+
if (!result) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check if key type is allowed
|
|
131
|
+
if (!allowedTypes.includes(result.keyType)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Return actorId (use key's actorId if set, otherwise use a default)
|
|
136
|
+
return {
|
|
137
|
+
actorId: result.actorId ?? `api-key:${result.keyId}`,
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Middleware that validates API key and attaches result to context.
|
|
144
|
+
*/
|
|
145
|
+
export function apiKeyAuthMiddleware<DB extends ApiKeyDb>(
|
|
146
|
+
db: Kysely<DB>,
|
|
147
|
+
dialect: ServerSyncDialect,
|
|
148
|
+
allowedTypes: ApiKeyType[]
|
|
149
|
+
) {
|
|
150
|
+
return async (
|
|
151
|
+
c: Context,
|
|
152
|
+
next: () => Promise<void>
|
|
153
|
+
): Promise<Response | undefined> => {
|
|
154
|
+
const authHeader = c.req.header('Authorization');
|
|
155
|
+
const result = await validateApiKey(db, dialect, authHeader);
|
|
156
|
+
|
|
157
|
+
if (!result) {
|
|
158
|
+
return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!allowedTypes.includes(result.keyType)) {
|
|
162
|
+
return c.json({ error: 'FORBIDDEN', message: 'Invalid key type' }, 403);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Attach to context for downstream handlers
|
|
166
|
+
c.set('apiKey', result);
|
|
167
|
+
|
|
168
|
+
await next();
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Hash function (same as in routes.ts)
|
|
173
|
+
async function hashApiKey(secretKey: string): Promise<string> {
|
|
174
|
+
const encoder = new TextEncoder();
|
|
175
|
+
const data = encoder.encode(secretKey);
|
|
176
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
177
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
178
|
+
return Array.from(hashArray, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
179
|
+
}
|