@syncular/server-hono 0.0.1-60
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 +78 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +99 -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 +788 -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 +189 -0
- package/src/__tests__/rate-limit.test.ts +78 -0
- package/src/__tests__/realtime-bridge.test.ts +131 -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 +180 -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 +1186 -0
- package/src/ws.ts +789 -0
|
@@ -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
|
+
}
|