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

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/src/routes.ts CHANGED
@@ -52,12 +52,7 @@ import { Hono } from 'hono';
52
52
 
53
53
  import type { UpgradeWebSocket } from 'hono/ws';
54
54
  import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
55
- import {
56
- type Kysely,
57
- type SelectQueryBuilder,
58
- type SqlBool,
59
- sql,
60
- } from 'kysely';
55
+ import { type Kysely, sql } from 'kysely';
61
56
  import { z } from 'zod';
62
57
  import { isBenignConsoleSchemaError } from './console/schema-errors';
63
58
  import {
@@ -65,8 +60,10 @@ import {
65
60
  DEFAULT_SYNC_RATE_LIMITS,
66
61
  type SyncRateLimitConfig,
67
62
  } from './rate-limit';
63
+ import { isWebSocketOriginAllowed } from './websocket-origin';
68
64
  import {
69
65
  createWebSocketConnection,
66
+ createWebSocketConnectionOwnerKey,
70
67
  type WebSocketConnection,
71
68
  WebSocketConnectionManager,
72
69
  } from './ws';
@@ -119,7 +116,9 @@ export interface SyncWebSocketConfig {
119
116
  messageRateWindowMs?: number;
120
117
  /**
121
118
  * Optional list of allowed websocket origins.
122
- * Use '*' to allow all origins.
119
+ * - undefined: allow same-origin browser upgrades and origin-less non-browser clients
120
+ * - '*': allow all origins
121
+ * - string[]: exact origin match (scheme + host + port)
123
122
  */
124
123
  allowedOrigins?: string[] | '*';
125
124
  }
@@ -1414,6 +1413,55 @@ export function createSyncRoutes<
1414
1413
  const clientId = body.clientId;
1415
1414
  const requestId = readRequestId(c);
1416
1415
  const traceContext = readTraceContext(c);
1416
+ const connectionOwnerKey = createWebSocketConnectionOwnerKey({
1417
+ partitionId,
1418
+ actorId: auth.actorId,
1419
+ clientId,
1420
+ });
1421
+
1422
+ const clientState = await readClientState(
1423
+ options.db,
1424
+ partitionId,
1425
+ clientId
1426
+ );
1427
+ let allowStaleScopeRebind = false;
1428
+ if (
1429
+ body.pull &&
1430
+ !body.push &&
1431
+ clientState.ownerActorId !== null &&
1432
+ clientState.ownerActorId !== auth.actorId
1433
+ ) {
1434
+ const resolved = await resolveEffectiveScopesForSubscriptions({
1435
+ db: options.db,
1436
+ auth,
1437
+ subscriptions: body.pull.subscriptions,
1438
+ handlers: handlerRegistry,
1439
+ scopeCache: options.scopeCache,
1440
+ });
1441
+ allowStaleScopeRebind = resolved.every(
1442
+ (subscription) => subscription.status === 'revoked'
1443
+ );
1444
+ }
1445
+
1446
+ if (
1447
+ !allowStaleScopeRebind &&
1448
+ (clientState.hasConflict || clientState.ownerActorId !== null)
1449
+ ) {
1450
+ if (
1451
+ clientState.ownerActorId !== auth.actorId ||
1452
+ clientState.hasConflict
1453
+ ) {
1454
+ return c.json(
1455
+ {
1456
+ error: 'INVALID_CLIENT_ID',
1457
+ message: clientState.hasConflict
1458
+ ? 'clientId has conflicting ownership history'
1459
+ : 'clientId is already bound to a different actor',
1460
+ },
1461
+ 400
1462
+ );
1463
+ }
1464
+ }
1417
1465
 
1418
1466
  let pushResponse:
1419
1467
  | undefined
@@ -1527,34 +1575,32 @@ export function createSyncRoutes<
1527
1575
  throw err;
1528
1576
  }
1529
1577
 
1530
- // Fire-and-forget bookkeeping
1531
- void recordClientCursor(options.db, options.dialect, {
1532
- partitionId,
1533
- clientId,
1534
- actorId: auth.actorId,
1535
- cursor: pullResult.clientCursor,
1536
- effectiveScopes: pullResult.effectiveScopes,
1537
- })
1538
- .then(() => {
1539
- emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
1540
- action: 'cursor_recorded',
1541
- partitionId,
1542
- actorId: auth.actorId,
1543
- clientId,
1544
- cursor: pullResult.clientCursor,
1545
- }));
1546
- })
1547
- .catch((error) => {
1548
- logAsyncFailureOnce('sync.client_cursor_record_failed', {
1549
- event: 'sync.client_cursor_record_failed',
1550
- userId: auth.actorId,
1551
- clientId,
1552
- error: error instanceof Error ? error.message : String(error),
1553
- });
1578
+ try {
1579
+ await recordClientCursor(options.db, options.dialect, {
1580
+ partitionId,
1581
+ clientId,
1582
+ actorId: auth.actorId,
1583
+ cursor: pullResult.clientCursor,
1584
+ effectiveScopes: pullResult.effectiveScopes,
1585
+ });
1586
+ emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
1587
+ action: 'cursor_recorded',
1588
+ partitionId,
1589
+ actorId: auth.actorId,
1590
+ clientId,
1591
+ cursor: pullResult.clientCursor,
1592
+ }));
1593
+ } catch (error) {
1594
+ logAsyncFailureOnce('sync.client_cursor_record_failed', {
1595
+ event: 'sync.client_cursor_record_failed',
1596
+ userId: auth.actorId,
1597
+ clientId,
1598
+ error: error instanceof Error ? error.message : String(error),
1554
1599
  });
1600
+ }
1555
1601
 
1556
- wsConnectionManager?.updateClientScopeKeys(
1557
- clientId,
1602
+ wsConnectionManager?.updateConnectionScopeKeys(
1603
+ connectionOwnerKey,
1558
1604
  applyPartitionToScopeKeys(
1559
1605
  partitionId,
1560
1606
  scopeValuesToScopeKeys(pullResult.effectiveScopes)
@@ -1726,39 +1772,47 @@ export function createSyncRoutes<
1726
1772
  return c.json({ error: 'NOT_FOUND' }, 404);
1727
1773
  }
1728
1774
 
1729
- if (requestedChunkScopes) {
1730
- try {
1731
- const resolved = await resolveEffectiveScopesForSubscriptions({
1732
- db: options.db,
1733
- auth,
1734
- subscriptions: [
1735
- {
1736
- id: 'snapshot-chunk-authz',
1737
- table: chunk.scope,
1738
- scopes: requestedChunkScopes,
1739
- cursor: 0,
1740
- },
1741
- ],
1742
- handlers: handlerRegistry,
1743
- scopeCache: options.scopeCache,
1744
- });
1745
- const scopeAuth = resolved[0];
1746
- if (!scopeAuth || scopeAuth.status !== 'active') {
1747
- return c.json({ error: 'FORBIDDEN' }, 403);
1748
- }
1775
+ if (!requestedChunkScopes) {
1776
+ return c.json(
1777
+ {
1778
+ error: 'INVALID_REQUEST',
1779
+ message: 'Snapshot chunk scope values are required',
1780
+ },
1781
+ 400
1782
+ );
1783
+ }
1749
1784
 
1750
- const scopeKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(
1751
- scopeAuth.scopes
1752
- )}`;
1753
- if (scopeKey !== chunk.scopeKey) {
1754
- return c.json({ error: 'FORBIDDEN' }, 403);
1755
- }
1756
- } catch (error) {
1757
- if (error instanceof InvalidSubscriptionScopeError) {
1758
- return c.json({ error: 'FORBIDDEN' }, 403);
1759
- }
1760
- throw error;
1785
+ try {
1786
+ const resolved = await resolveEffectiveScopesForSubscriptions({
1787
+ db: options.db,
1788
+ auth,
1789
+ subscriptions: [
1790
+ {
1791
+ id: 'snapshot-chunk-authz',
1792
+ table: chunk.scope,
1793
+ scopes: requestedChunkScopes,
1794
+ cursor: 0,
1795
+ },
1796
+ ],
1797
+ handlers: handlerRegistry,
1798
+ scopeCache: options.scopeCache,
1799
+ });
1800
+ const scopeAuth = resolved[0];
1801
+ if (!scopeAuth || scopeAuth.status !== 'active') {
1802
+ return c.json({ error: 'FORBIDDEN' }, 403);
1761
1803
  }
1804
+
1805
+ const scopeKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(
1806
+ scopeAuth.scopes
1807
+ )}`;
1808
+ if (scopeKey !== chunk.scopeKey) {
1809
+ return c.json({ error: 'FORBIDDEN' }, 403);
1810
+ }
1811
+ } catch (error) {
1812
+ if (error instanceof InvalidSubscriptionScopeError) {
1813
+ return c.json({ error: 'FORBIDDEN' }, 403);
1814
+ }
1815
+ throw error;
1762
1816
  }
1763
1817
 
1764
1818
  const etag = `"sha256:${chunk.sha256}"`;
@@ -1769,7 +1823,7 @@ export function createSyncRoutes<
1769
1823
  headers: {
1770
1824
  ETag: etag,
1771
1825
  'Cache-Control': 'private, max-age=0',
1772
- Vary: 'Authorization',
1826
+ Vary: 'Authorization, X-Syncular-Snapshot-Scopes',
1773
1827
  },
1774
1828
  });
1775
1829
  }
@@ -1782,7 +1836,7 @@ export function createSyncRoutes<
1782
1836
  'Content-Length': String(chunk.byteLength),
1783
1837
  ETag: etag,
1784
1838
  'Cache-Control': 'private, max-age=0',
1785
- Vary: 'Authorization',
1839
+ Vary: 'Authorization, X-Syncular-Snapshot-Scopes',
1786
1840
  'X-Sync-Chunk-Id': chunk.chunkId,
1787
1841
  'X-Sync-Chunk-Sha256': chunk.sha256,
1788
1842
  'X-Sync-Chunk-Encoding': chunk.encoding,
@@ -1819,31 +1873,39 @@ export function createSyncRoutes<
1819
1873
  c,
1820
1874
  c.req.query('transportPath')
1821
1875
  );
1876
+ const connectionOwnerKey = createWebSocketConnectionOwnerKey({
1877
+ partitionId,
1878
+ actorId: auth.actorId,
1879
+ clientId,
1880
+ });
1822
1881
 
1823
1882
  // Load last-known effective scopes for this client (best-effort).
1824
1883
  // Keeps /realtime lightweight and avoids sending large subscription payloads over the URL.
1825
1884
  let initialScopeKeys: string[] = [];
1826
1885
  try {
1827
- const cursorsQ = options.db.selectFrom(
1828
- 'sync_client_cursors'
1829
- ) as SelectQueryBuilder<
1830
- DB,
1831
- 'sync_client_cursors',
1832
- // biome-ignore lint/complexity/noBannedTypes: Kysely uses `{}` as the initial "no selected columns yet" marker.
1833
- {}
1834
- >;
1835
-
1836
- const row = await cursorsQ
1837
- .selectAll()
1838
- .where(sql<SqlBool>`partition_id = ${partitionId}`)
1839
- .where(sql<SqlBool>`client_id = ${clientId}`)
1840
- .executeTakeFirst();
1841
-
1842
- if (row && row.actor_id !== auth.actorId) {
1843
- return c.json({ error: 'FORBIDDEN' }, 403);
1886
+ const clientState = await readClientState(
1887
+ options.db,
1888
+ partitionId,
1889
+ clientId
1890
+ );
1891
+ if (clientState.hasConflict || clientState.ownerActorId !== null) {
1892
+ if (
1893
+ clientState.ownerActorId !== auth.actorId ||
1894
+ clientState.hasConflict
1895
+ ) {
1896
+ return c.json(
1897
+ {
1898
+ error: 'INVALID_CLIENT_ID',
1899
+ message: clientState.hasConflict
1900
+ ? 'clientId has conflicting ownership history'
1901
+ : 'clientId is already bound to a different actor',
1902
+ },
1903
+ 400
1904
+ );
1905
+ }
1844
1906
  }
1845
1907
 
1846
- const raw = row?.effective_scopes;
1908
+ const raw = clientState.effectiveScopes;
1847
1909
  let parsed: unknown = raw;
1848
1910
  if (typeof raw === 'string') {
1849
1911
  try {
@@ -1884,7 +1946,7 @@ export function createSyncRoutes<
1884
1946
 
1885
1947
  if (
1886
1948
  maxConnectionsPerClient > 0 &&
1887
- wsConnectionManager.getConnectionCount(clientId) >=
1949
+ wsConnectionManager.getScopedConnectionCount(connectionOwnerKey) >=
1888
1950
  maxConnectionsPerClient
1889
1951
  ) {
1890
1952
  logSyncEvent({
@@ -1900,7 +1962,7 @@ export function createSyncRoutes<
1900
1962
  let unregister: (() => void) | null = null;
1901
1963
  let connRef: ReturnType<typeof createWebSocketConnection> | null = null;
1902
1964
  const connectionCountBeforeUpgrade =
1903
- wsConnectionManager.getConnectionCount(clientId);
1965
+ wsConnectionManager.getScopedConnectionCount(connectionOwnerKey);
1904
1966
  let sessionStartedAtMs: number | null = null;
1905
1967
  let sessionEnded = false;
1906
1968
 
@@ -1965,6 +2027,7 @@ export function createSyncRoutes<
1965
2027
  const conn = createWebSocketConnection(ws, {
1966
2028
  actorId: auth.actorId,
1967
2029
  clientId,
2030
+ ownerKey: connectionOwnerKey,
1968
2031
  transportPath: realtimeTransportPath,
1969
2032
  });
1970
2033
  connRef = conn;
@@ -2052,7 +2115,7 @@ export function createSyncRoutes<
2052
2115
  case 'join':
2053
2116
  if (
2054
2117
  !wsConnectionManager.joinPresence(
2055
- clientId,
2118
+ connectionOwnerKey,
2056
2119
  scopeKey,
2057
2120
  msg.metadata
2058
2121
  )
@@ -2071,17 +2134,17 @@ export function createSyncRoutes<
2071
2134
  }
2072
2135
  break;
2073
2136
  case 'leave':
2074
- wsConnectionManager.leavePresence(clientId, scopeKey);
2137
+ wsConnectionManager.leavePresence(connectionOwnerKey, scopeKey);
2075
2138
  break;
2076
2139
  case 'update':
2077
2140
  if (
2078
2141
  !wsConnectionManager.updatePresenceMetadata(
2079
- clientId,
2142
+ connectionOwnerKey,
2080
2143
  scopeKey,
2081
2144
  msg.metadata ?? {}
2082
2145
  ) &&
2083
- !wsConnectionManager.isClientSubscribedToScopeKey(
2084
- clientId,
2146
+ !wsConnectionManager.isConnectionSubscribedToScopeKey(
2147
+ connectionOwnerKey,
2085
2148
  scopeKey
2086
2149
  )
2087
2150
  ) {
@@ -2360,24 +2423,6 @@ function clampInt(value: number, min: number, max: number): number {
2360
2423
  return Math.max(min, Math.min(max, value));
2361
2424
  }
2362
2425
 
2363
- function isWebSocketOriginAllowed(
2364
- c: Context,
2365
- allowedOrigins?: string[] | '*'
2366
- ): boolean {
2367
- if (!allowedOrigins) return true;
2368
- if (allowedOrigins === '*') return true;
2369
-
2370
- const origin = c.req.header('origin');
2371
- if (!origin) return false;
2372
-
2373
- try {
2374
- const normalizedOrigin = new URL(origin).origin;
2375
- return allowedOrigins.includes(normalizedOrigin);
2376
- } catch {
2377
- return false;
2378
- }
2379
- }
2380
-
2381
2426
  function measureWebSocketMessageBytes(data: unknown): number {
2382
2427
  if (typeof data === 'string') {
2383
2428
  return new TextEncoder().encode(data).byteLength;
@@ -2501,3 +2546,41 @@ async function readCommitScopeKeys<DB extends SyncCoreDb>(
2501
2546
 
2502
2547
  return Array.from(scopeKeys);
2503
2548
  }
2549
+
2550
+ async function readClientState<DB extends SyncCoreDb>(
2551
+ db: Kysely<DB>,
2552
+ partitionId: string,
2553
+ clientId: string
2554
+ ): Promise<{
2555
+ ownerActorId: string | null;
2556
+ effectiveScopes: unknown;
2557
+ hasConflict: boolean;
2558
+ }> {
2559
+ const [cursorResult, latestCommitResult] = await Promise.all([
2560
+ sql<{ actor_id: string | null; effective_scopes: unknown }>`
2561
+ SELECT actor_id, effective_scopes
2562
+ FROM sync_client_cursors
2563
+ WHERE partition_id = ${partitionId} AND client_id = ${clientId}
2564
+ LIMIT 1
2565
+ `.execute(db),
2566
+ sql<{ actor_id: string | null }>`
2567
+ SELECT actor_id
2568
+ FROM sync_commits
2569
+ WHERE partition_id = ${partitionId} AND client_id = ${clientId}
2570
+ ORDER BY commit_seq DESC
2571
+ LIMIT 1
2572
+ `.execute(db),
2573
+ ]);
2574
+ const cursorRow = cursorResult.rows[0];
2575
+ const latestCommitRow = latestCommitResult.rows[0];
2576
+
2577
+ // Cursor state reflects the current authenticated owner for a clientId.
2578
+ // Commit history is only used to seed ownership before the first pull.
2579
+ const ownerActorId = cursorRow?.actor_id ?? latestCommitRow?.actor_id ?? null;
2580
+
2581
+ return {
2582
+ ownerActorId,
2583
+ effectiveScopes: cursorRow?.effective_scopes ?? null,
2584
+ hasConflict: false,
2585
+ };
2586
+ }
@@ -0,0 +1,54 @@
1
+ import type { Context } from 'hono';
2
+
3
+ function normalizeOrigin(origin: string): string | null {
4
+ try {
5
+ return new URL(origin).origin;
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+
11
+ export function isRequestOriginAllowed(args: {
12
+ requestUrl: string;
13
+ originHeader?: string | null;
14
+ allowedOrigins?: string[] | '*';
15
+ }): boolean {
16
+ if (args.allowedOrigins === '*') {
17
+ return true;
18
+ }
19
+
20
+ const origin = args.originHeader;
21
+ if (Array.isArray(args.allowedOrigins)) {
22
+ if (!origin) return false;
23
+ const normalizedOrigin = normalizeOrigin(origin);
24
+ return normalizedOrigin
25
+ ? args.allowedOrigins.includes(normalizedOrigin)
26
+ : false;
27
+ }
28
+
29
+ if (!origin) {
30
+ return true;
31
+ }
32
+
33
+ const normalizedOrigin = normalizeOrigin(origin);
34
+ if (!normalizedOrigin) {
35
+ return false;
36
+ }
37
+
38
+ try {
39
+ return normalizedOrigin === new URL(args.requestUrl).origin;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export function isWebSocketOriginAllowed(
46
+ c: Context,
47
+ allowedOrigins?: string[] | '*'
48
+ ): boolean {
49
+ return isRequestOriginAllowed({
50
+ requestUrl: c.req.url,
51
+ originHeader: c.req.header('origin'),
52
+ allowedOrigins,
53
+ });
54
+ }