@syncular/server-hono 0.0.6-84 → 0.0.6-86

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.
@@ -1639,9 +1639,31 @@ export function createConsoleRoutes(options) {
1639
1639
  const upgradeWebSocket = options.websocket.upgradeWebSocket;
1640
1640
  const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
1641
1641
  const wsState = new WeakMap();
1642
+ const closeUnauthenticated = (ws) => {
1643
+ try {
1644
+ ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
1645
+ }
1646
+ catch {
1647
+ // ignore send errors
1648
+ }
1649
+ ws.close(4001, 'Unauthenticated');
1650
+ };
1651
+ const cleanup = (ws) => {
1652
+ const state = wsState.get(ws);
1653
+ if (!state)
1654
+ return;
1655
+ if (state.listener) {
1656
+ emitter.removeListener(state.listener);
1657
+ }
1658
+ if (state.heartbeatInterval) {
1659
+ clearInterval(state.heartbeatInterval);
1660
+ }
1661
+ if (state.authTimeout) {
1662
+ clearTimeout(state.authTimeout);
1663
+ }
1664
+ wsState.delete(ws);
1665
+ };
1642
1666
  routes.get('/events/live', upgradeWebSocket(async (c) => {
1643
- // Auth check via query param (WebSocket doesn't support headers easily)
1644
- const token = c.req.query('token');
1645
1667
  const authHeader = c.req.header('Authorization');
1646
1668
  const partitionId = c.req.query('partitionId')?.trim() || undefined;
1647
1669
  const replaySince = c.req.query('since');
@@ -1655,34 +1677,159 @@ export function createConsoleRoutes(options) {
1655
1677
  const mockContext = {
1656
1678
  req: {
1657
1679
  header: (name) => name === 'Authorization' ? authHeader : undefined,
1658
- query: (name) => (name === 'token' ? token : undefined),
1680
+ query: () => undefined,
1659
1681
  },
1660
1682
  };
1661
- const auth = await options.authenticate(mockContext);
1683
+ const initialAuth = await options.authenticate(mockContext);
1684
+ const authenticateWithBearer = async (token) => {
1685
+ const trimmedToken = token.trim();
1686
+ if (!trimmedToken)
1687
+ return null;
1688
+ const authContext = {
1689
+ req: {
1690
+ header: (name) => name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
1691
+ query: () => undefined,
1692
+ },
1693
+ };
1694
+ return options.authenticate(authContext);
1695
+ };
1662
1696
  return {
1663
1697
  onOpen(_event, ws) {
1698
+ const state = {
1699
+ listener: null,
1700
+ heartbeatInterval: null,
1701
+ authTimeout: null,
1702
+ isAuthenticated: false,
1703
+ };
1704
+ wsState.set(ws, state);
1705
+ const startAuthenticatedSession = () => {
1706
+ if (state.isAuthenticated)
1707
+ return;
1708
+ state.isAuthenticated = true;
1709
+ if (state.authTimeout) {
1710
+ clearTimeout(state.authTimeout);
1711
+ state.authTimeout = null;
1712
+ }
1713
+ const listener = (event) => {
1714
+ if (partitionId) {
1715
+ const eventPartitionId = event.data.partitionId;
1716
+ if (typeof eventPartitionId !== 'string' ||
1717
+ eventPartitionId !== partitionId) {
1718
+ return;
1719
+ }
1720
+ }
1721
+ try {
1722
+ ws.send(JSON.stringify(event));
1723
+ }
1724
+ catch {
1725
+ // Connection closed
1726
+ }
1727
+ };
1728
+ emitter.addListener(listener);
1729
+ state.listener = listener;
1730
+ ws.send(JSON.stringify({
1731
+ type: 'connected',
1732
+ timestamp: new Date().toISOString(),
1733
+ }));
1734
+ const replayEvents = emitter.replay({
1735
+ since: replaySince,
1736
+ limit: replayLimit,
1737
+ partitionId,
1738
+ });
1739
+ for (const replayEvent of replayEvents) {
1740
+ try {
1741
+ ws.send(JSON.stringify(replayEvent));
1742
+ }
1743
+ catch {
1744
+ // Connection closed
1745
+ break;
1746
+ }
1747
+ }
1748
+ const heartbeatInterval = setInterval(() => {
1749
+ try {
1750
+ ws.send(JSON.stringify({
1751
+ type: 'heartbeat',
1752
+ timestamp: new Date().toISOString(),
1753
+ }));
1754
+ }
1755
+ catch {
1756
+ clearInterval(heartbeatInterval);
1757
+ }
1758
+ }, heartbeatIntervalMs);
1759
+ state.heartbeatInterval = heartbeatInterval;
1760
+ };
1761
+ if (initialAuth) {
1762
+ startAuthenticatedSession();
1763
+ return;
1764
+ }
1765
+ state.authTimeout = setTimeout(() => {
1766
+ const current = wsState.get(ws);
1767
+ if (!current || current.isAuthenticated) {
1768
+ return;
1769
+ }
1770
+ closeUnauthenticated(ws);
1771
+ cleanup(ws);
1772
+ }, 5_000);
1773
+ },
1774
+ async onMessage(event, ws) {
1775
+ const state = wsState.get(ws);
1776
+ if (!state || state.isAuthenticated) {
1777
+ return;
1778
+ }
1779
+ if (typeof event.data !== 'string') {
1780
+ closeUnauthenticated(ws);
1781
+ cleanup(ws);
1782
+ return;
1783
+ }
1784
+ let token = '';
1785
+ try {
1786
+ const parsed = JSON.parse(event.data);
1787
+ if (parsed.type === 'auth' &&
1788
+ typeof parsed.token === 'string' &&
1789
+ parsed.token.trim().length > 0) {
1790
+ token = parsed.token;
1791
+ }
1792
+ }
1793
+ catch {
1794
+ // Ignore parse errors and close as unauthenticated below.
1795
+ }
1796
+ if (!token) {
1797
+ closeUnauthenticated(ws);
1798
+ cleanup(ws);
1799
+ return;
1800
+ }
1801
+ const auth = await authenticateWithBearer(token);
1802
+ const currentState = wsState.get(ws);
1803
+ if (!currentState || currentState.isAuthenticated) {
1804
+ return;
1805
+ }
1664
1806
  if (!auth) {
1665
- ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
1666
- ws.close(4001, 'Unauthenticated');
1807
+ closeUnauthenticated(ws);
1808
+ cleanup(ws);
1667
1809
  return;
1668
1810
  }
1669
- const listener = (event) => {
1811
+ currentState.isAuthenticated = true;
1812
+ if (currentState.authTimeout) {
1813
+ clearTimeout(currentState.authTimeout);
1814
+ currentState.authTimeout = null;
1815
+ }
1816
+ const listener = (liveEvent) => {
1670
1817
  if (partitionId) {
1671
- const eventPartitionId = event.data.partitionId;
1818
+ const eventPartitionId = liveEvent.data.partitionId;
1672
1819
  if (typeof eventPartitionId !== 'string' ||
1673
1820
  eventPartitionId !== partitionId) {
1674
1821
  return;
1675
1822
  }
1676
1823
  }
1677
1824
  try {
1678
- ws.send(JSON.stringify(event));
1825
+ ws.send(JSON.stringify(liveEvent));
1679
1826
  }
1680
1827
  catch {
1681
1828
  // Connection closed
1682
1829
  }
1683
1830
  };
1684
1831
  emitter.addListener(listener);
1685
- // Send connected message
1832
+ currentState.listener = listener;
1686
1833
  ws.send(JSON.stringify({
1687
1834
  type: 'connected',
1688
1835
  timestamp: new Date().toISOString(),
@@ -1701,7 +1848,6 @@ export function createConsoleRoutes(options) {
1701
1848
  break;
1702
1849
  }
1703
1850
  }
1704
- // Start heartbeat
1705
1851
  const heartbeatInterval = setInterval(() => {
1706
1852
  try {
1707
1853
  ws.send(JSON.stringify({
@@ -1713,23 +1859,13 @@ export function createConsoleRoutes(options) {
1713
1859
  clearInterval(heartbeatInterval);
1714
1860
  }
1715
1861
  }, heartbeatIntervalMs);
1716
- wsState.set(ws, { listener, heartbeatInterval });
1862
+ currentState.heartbeatInterval = heartbeatInterval;
1717
1863
  },
1718
1864
  onClose(_event, ws) {
1719
- const state = wsState.get(ws);
1720
- if (!state)
1721
- return;
1722
- emitter.removeListener(state.listener);
1723
- clearInterval(state.heartbeatInterval);
1724
- wsState.delete(ws);
1865
+ cleanup(ws);
1725
1866
  },
1726
1867
  onError(_event, ws) {
1727
- const state = wsState.get(ws);
1728
- if (!state)
1729
- return;
1730
- emitter.removeListener(state.listener);
1731
- clearInterval(state.heartbeatInterval);
1732
- wsState.delete(ws);
1868
+ cleanup(ws);
1733
1869
  },
1734
1870
  };
1735
1871
  }));
@@ -2704,25 +2840,17 @@ async function hashApiKey(secretKey) {
2704
2840
  * The token can be set via SYNC_CONSOLE_TOKEN env var or passed directly.
2705
2841
  */
2706
2842
  export function createTokenAuthenticator(token) {
2707
- const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
2843
+ const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
2708
2844
  return async (c) => {
2709
- if (!expectedToken) {
2710
- // No token configured, allow all requests (not recommended for production)
2711
- return { consoleUserId: 'anonymous' };
2712
- }
2713
- // Check Authorization header
2714
- const authHeader = c.req.header('Authorization');
2845
+ if (!expectedToken)
2846
+ return null;
2847
+ const authHeader = c.req.header('Authorization')?.trim();
2715
2848
  if (authHeader?.startsWith('Bearer ')) {
2716
- const bearerToken = authHeader.slice(7);
2849
+ const bearerToken = authHeader.slice(7).trim();
2717
2850
  if (bearerToken === expectedToken) {
2718
2851
  return { consoleUserId: 'token' };
2719
2852
  }
2720
2853
  }
2721
- // Check query parameter
2722
- const queryToken = c.req.query('token');
2723
- if (queryToken === expectedToken) {
2724
- return { consoleUserId: 'token' };
2725
- }
2726
2854
  return null;
2727
2855
  };
2728
2856
  }