@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.
Files changed (76) hide show
  1. package/dist/api-key-auth.d.ts +49 -0
  2. package/dist/api-key-auth.d.ts.map +1 -0
  3. package/dist/api-key-auth.js +110 -0
  4. package/dist/api-key-auth.js.map +1 -0
  5. package/dist/blobs.d.ts +69 -0
  6. package/dist/blobs.d.ts.map +1 -0
  7. package/dist/blobs.js +383 -0
  8. package/dist/blobs.js.map +1 -0
  9. package/dist/console/index.d.ts +8 -0
  10. package/dist/console/index.d.ts.map +1 -0
  11. package/dist/console/index.js +7 -0
  12. package/dist/console/index.js.map +1 -0
  13. package/dist/console/routes.d.ts +106 -0
  14. package/dist/console/routes.d.ts.map +1 -0
  15. package/dist/console/routes.js +1612 -0
  16. package/dist/console/routes.js.map +1 -0
  17. package/dist/console/schemas.d.ts +308 -0
  18. package/dist/console/schemas.d.ts.map +1 -0
  19. package/dist/console/schemas.js +201 -0
  20. package/dist/console/schemas.js.map +1 -0
  21. package/dist/create-server.d.ts +78 -0
  22. package/dist/create-server.d.ts.map +1 -0
  23. package/dist/create-server.js +99 -0
  24. package/dist/create-server.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +25 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/openapi.d.ts +45 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +59 -0
  32. package/dist/openapi.js.map +1 -0
  33. package/dist/proxy/connection-manager.d.ts +78 -0
  34. package/dist/proxy/connection-manager.d.ts.map +1 -0
  35. package/dist/proxy/connection-manager.js +251 -0
  36. package/dist/proxy/connection-manager.js.map +1 -0
  37. package/dist/proxy/index.d.ts +8 -0
  38. package/dist/proxy/index.d.ts.map +1 -0
  39. package/dist/proxy/index.js +8 -0
  40. package/dist/proxy/index.js.map +1 -0
  41. package/dist/proxy/routes.d.ts +74 -0
  42. package/dist/proxy/routes.d.ts.map +1 -0
  43. package/dist/proxy/routes.js +147 -0
  44. package/dist/proxy/routes.js.map +1 -0
  45. package/dist/rate-limit.d.ts +101 -0
  46. package/dist/rate-limit.d.ts.map +1 -0
  47. package/dist/rate-limit.js +186 -0
  48. package/dist/rate-limit.js.map +1 -0
  49. package/dist/routes.d.ts +126 -0
  50. package/dist/routes.d.ts.map +1 -0
  51. package/dist/routes.js +788 -0
  52. package/dist/routes.js.map +1 -0
  53. package/dist/ws.d.ts +230 -0
  54. package/dist/ws.d.ts.map +1 -0
  55. package/dist/ws.js +601 -0
  56. package/dist/ws.js.map +1 -0
  57. package/package.json +73 -0
  58. package/src/__tests__/create-server.test.ts +187 -0
  59. package/src/__tests__/pull-chunk-storage.test.ts +189 -0
  60. package/src/__tests__/rate-limit.test.ts +78 -0
  61. package/src/__tests__/realtime-bridge.test.ts +131 -0
  62. package/src/__tests__/ws-connection-manager.test.ts +176 -0
  63. package/src/api-key-auth.ts +179 -0
  64. package/src/blobs.ts +534 -0
  65. package/src/console/index.ts +17 -0
  66. package/src/console/routes.ts +2155 -0
  67. package/src/console/schemas.ts +299 -0
  68. package/src/create-server.ts +180 -0
  69. package/src/index.ts +42 -0
  70. package/src/openapi.ts +74 -0
  71. package/src/proxy/connection-manager.ts +340 -0
  72. package/src/proxy/index.ts +8 -0
  73. package/src/proxy/routes.ts +223 -0
  74. package/src/rate-limit.ts +321 -0
  75. package/src/routes.ts +1186 -0
  76. 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
+ }