@myrialabs/clopen 0.1.10 → 0.2.0

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 (71) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +20 -0
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  9. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  10. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  11. package/backend/lib/database/migrations/index.ts +21 -0
  12. package/backend/lib/database/queries/auth-queries.ts +201 -0
  13. package/backend/lib/database/queries/index.ts +2 -1
  14. package/backend/lib/engine/adapters/opencode/server.ts +1 -1
  15. package/backend/lib/mcp/config.ts +13 -18
  16. package/backend/lib/mcp/index.ts +9 -0
  17. package/backend/lib/mcp/remote-server.ts +132 -0
  18. package/backend/lib/mcp/servers/helper.ts +49 -3
  19. package/backend/lib/mcp/servers/index.ts +3 -2
  20. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  21. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  22. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  23. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  24. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  25. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  26. package/backend/lib/utils/ws.ts +65 -1
  27. package/backend/ws/auth/index.ts +17 -0
  28. package/backend/ws/auth/invites.ts +84 -0
  29. package/backend/ws/auth/login.ts +269 -0
  30. package/backend/ws/auth/status.ts +41 -0
  31. package/backend/ws/auth/users.ts +32 -0
  32. package/backend/ws/engine/claude/accounts.ts +3 -1
  33. package/backend/ws/engine/utils.ts +38 -6
  34. package/backend/ws/index.ts +4 -4
  35. package/backend/ws/preview/browser/interact.ts +27 -5
  36. package/bin/clopen.ts +39 -0
  37. package/bun.lock +113 -51
  38. package/frontend/App.svelte +47 -29
  39. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  40. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  41. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  42. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  43. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  44. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  45. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  46. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  47. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  48. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  49. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  50. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  51. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  52. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  53. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  54. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  55. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  56. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  57. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  58. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  59. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  60. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  61. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  62. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  63. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  64. package/frontend/lib/stores/ui/update.svelte.ts +2 -2
  65. package/package.json +8 -6
  66. package/shared/types/stores/settings.ts +16 -2
  67. package/shared/utils/logger.ts +1 -0
  68. package/shared/utils/ws-client.ts +30 -13
  69. package/shared/utils/ws-server.ts +42 -4
  70. package/backend/lib/mcp/stdio-server.ts +0 -103
  71. package/backend/ws/mcp/index.ts +0 -61
@@ -623,41 +623,115 @@ export class BrowserTabManager extends EventEmitter {
623
623
  page.setDefaultNavigationTimeout(30000);
624
624
 
625
625
  // Configure page for stability
626
- await page.setExtraHTTPHeaders({
627
- 'Accept-Language': 'en-US,en;q=0.9',
628
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
629
- });
630
-
631
- // Optimize font loading to prevent screenshot timeouts
632
- await page.evaluateOnNewDocument(() => {
633
- // Disable font loading wait
634
- Object.defineProperty(document, 'fonts', {
635
- value: {
636
- ready: Promise.resolve(),
637
- load: () => Promise.resolve([]),
638
- check: () => true,
639
- addEventListener: () => {},
640
- removeEventListener: () => {}
641
- }
642
- });
643
- });
626
+ // Note: Do NOT set HTTP headers manually here (like Accept-Language).
627
+ // Setting extra headers alters the HTTP/2 pseudo-header order and capitalization
628
+ // which immediately gets flagged by Cloudflare's TLS/Fingerprint matching algorithms.
644
629
 
645
- // Inject audio capture script BEFORE page loads to intercept AudioContext
646
- await this.audioCapture.setupAudioCapture(page, DEFAULT_STREAMING_CONFIG.audio);
630
+ // Audio capture is injected post-navigation in BrowserVideoCapture.startStreaming()
631
+ // to avoid Cloudflare fingerprint detection of AudioContext constructor patching.
647
632
 
648
633
  // Simplified cursor tracking for visual feedback only
649
634
  await this.injectCursorTracking(page);
635
+
636
+ // Suppress Cloudflare Turnstile error callbacks to prevent sites from showing
637
+ // "CAPTCHA verification failed" popups in headless Chrome (error 600010).
638
+ //
639
+ // Strategy: Let the real Turnstile script load and define window.turnstile normally
640
+ // (needed for CF Managed Challenge auto-pass via StealthPlugin fingerprinting).
641
+ // Intercept window.turnstile assignment via getter/setter and patch render()/execute()
642
+ // to replace error-callback/expired-callback with no-ops before they are registered.
643
+ // Also strip data-error-callback attributes from DOM elements via MutationObserver
644
+ // to cover implicit render (data-sitekey) usage.
645
+ //
646
+ // This does NOT block challenges.cloudflare.com — CF Managed Challenge needs that
647
+ // URL to run its JS verification. Only the error reporting path is suppressed.
648
+ await page.evaluateOnNewDocument(function () {
649
+ (function () {
650
+
651
+ let _turnstile: any;
652
+
653
+ function patchOptions(options: Record<string, unknown>) {
654
+ return Object.assign({}, options, {
655
+ 'error-callback': function () {},
656
+ 'expired-callback': function () {}
657
+ });
658
+ }
659
+
660
+
661
+ function patchApi(api: any) {
662
+ if (!api || typeof api !== 'object') return api;
663
+ ['render', 'execute'].forEach(function (method: string) {
664
+ if (typeof api[method] === 'function') {
665
+ const orig = api[method].bind(api);
666
+ api[method] = function (container: unknown, opts: Record<string, unknown>) {
667
+ return orig(container, patchOptions(opts || {}));
668
+ };
669
+ }
670
+ });
671
+ return api;
672
+ }
673
+
674
+ try {
675
+ Object.defineProperty(window, 'turnstile', {
676
+ configurable: true,
677
+ enumerable: true,
678
+
679
+ get() { return _turnstile; },
680
+
681
+ set(val: any) { _turnstile = patchApi(val); }
682
+ });
683
+ } catch {
684
+ // Property already defined or can't be intercepted
685
+ }
686
+
687
+ // Strip data-error-callback / data-expired-callback from Turnstile elements
688
+ // before implicit render reads them, so no error handler is registered.
689
+ function stripErrorAttrs(el: Element) {
690
+ el.removeAttribute('data-error-callback');
691
+ el.removeAttribute('data-expired-callback');
692
+ }
693
+
694
+ const mo = new MutationObserver(function (mutations) {
695
+ mutations.forEach(function (m) {
696
+ m.addedNodes.forEach(function (node) {
697
+ if (!(node instanceof Element)) return;
698
+ if (node.hasAttribute('data-error-callback') || node.hasAttribute('data-expired-callback')) {
699
+ stripErrorAttrs(node);
700
+ }
701
+ node.querySelectorAll('[data-error-callback],[data-expired-callback]').forEach(stripErrorAttrs);
702
+ });
703
+ });
704
+ });
705
+ if (document.documentElement) {
706
+ mo.observe(document.documentElement, { childList: true, subtree: true });
707
+ }
708
+ })();
709
+ });
710
+
711
+ // Auto-dismiss native browser dialogs to prevent page hang in headless mode.
712
+ // alert() → accept (one-way informational, safe to dismiss)
713
+ // confirm()/prompt() → dismiss/cancel (avoid unintended side effects)
714
+ // CAPTCHA-related alerts are silently dismissed.
715
+ page.on('dialog', async (dialog) => {
716
+ const type = dialog.type();
717
+ if (type === 'alert') {
718
+ await dialog.accept().catch(() => {});
719
+ } else {
720
+ await dialog.dismiss().catch(() => {});
721
+ }
722
+ });
650
723
  }
651
724
 
652
725
  /**
653
726
  * Inject cursor tracking script
654
727
  */
655
728
  private async injectCursorTracking(page: Page) {
656
- await page.evaluateOnNewDocument(cursorTrackingScript);
729
+ // Temporarily disabled mapping logic as CloudFlare frequently flags evaluateOnNewDocument injected tracking events
730
+ // await page.evaluateOnNewDocument(cursorTrackingScript);
657
731
  }
658
732
 
659
733
  /**
660
- * Navigate with retry
734
+ * Navigate with retry, including Cloudflare auto-pass detection and CAPTCHA popup dismissal.
661
735
  */
662
736
  private async navigateWithRetry(page: Page, url: string): Promise<string> {
663
737
  let retries = 3;
@@ -669,7 +743,9 @@ export class BrowserTabManager extends EventEmitter {
669
743
  waitUntil: 'domcontentloaded',
670
744
  timeout: 30000
671
745
  });
672
- actualUrl = page.url();
746
+ actualUrl = await this.waitForCloudflareIfPresent(page);
747
+ // Dismiss any CAPTCHA failure popups from embedded Turnstile widgets
748
+ await this.dismissCaptchaPopupsIfPresent(page);
673
749
  break;
674
750
  } catch (error) {
675
751
  retries--;
@@ -684,6 +760,168 @@ export class BrowserTabManager extends EventEmitter {
684
760
  return actualUrl;
685
761
  }
686
762
 
763
+ /**
764
+ * Detect Cloudflare challenge page and wait for auto-pass redirect.
765
+ * Loops up to MAX_CF_RETRIES times to handle infinite verify loops where
766
+ * Cloudflare keeps redirecting back to a new challenge after each pass.
767
+ */
768
+ private async waitForCloudflareIfPresent(page: Page): Promise<string> {
769
+ const MAX_CF_RETRIES = 5;
770
+
771
+ for (let attempt = 0; attempt < MAX_CF_RETRIES; attempt++) {
772
+ let isChallenge = false;
773
+
774
+ try {
775
+ isChallenge = await page.evaluate(() => {
776
+ const title = document.title;
777
+ const bodyText = (document.body?.innerText || '').slice(0, 500).toLowerCase();
778
+ return (
779
+ // Old automated CF challenge
780
+ title === 'Just a moment...' ||
781
+ // Newer interactive CF challenge ("Performing security verification")
782
+ title.toLowerCase().includes('security verification') ||
783
+ bodyText.includes('verify you are human') ||
784
+ bodyText.includes('performing security verification') ||
785
+ // CF challenge DOM elements (reliable, present on challenge pages only)
786
+ document.getElementById('challenge-running') !== null ||
787
+ document.getElementById('cf-challenge-running') !== null ||
788
+ document.getElementById('challenge-form') !== null ||
789
+ document.querySelector('#challenge-stage') !== null
790
+ );
791
+ });
792
+ } catch {
793
+ // Page not evaluable (navigating, closed) — not a CF challenge
794
+ break;
795
+ }
796
+
797
+ if (!isChallenge) {
798
+ break;
799
+ }
800
+
801
+ debug.log('preview', `🛡️ Cloudflare challenge detected (attempt ${attempt + 1}/${MAX_CF_RETRIES}), waiting for auto-pass...`);
802
+
803
+ try {
804
+ await page.waitForNavigation({
805
+ waitUntil: 'domcontentloaded',
806
+ timeout: 20000
807
+ });
808
+ debug.log('preview', `✅ Cloudflare navigation → ${page.url()}`);
809
+ } catch {
810
+ debug.warn('preview', `⚠️ Cloudflare auto-pass timed out on attempt ${attempt + 1}, proceeding`);
811
+ break;
812
+ }
813
+ }
814
+
815
+ return page.url();
816
+ }
817
+
818
+ /**
819
+ * Inject a persistent non-blocking watcher into the page that auto-dismisses
820
+ * CAPTCHA failure popups whenever they appear (Cloudflare Turnstile error 600010,
821
+ * reCAPTCHA failures, etc.).
822
+ *
823
+ * Uses MutationObserver + setInterval so it catches popups regardless of when they
824
+ * appear after page load. Returns immediately — the watcher runs inside the page.
825
+ */
826
+ private async dismissCaptchaPopupsIfPresent(page: Page): Promise<void> {
827
+ try {
828
+ await page.evaluate(() => {
829
+ const CAPTCHA_WORDS = ['captcha', 'turnstile', 'human verification', 'robot', 'bot detected'];
830
+ const FAIL_WORDS = ['failed', 'error', 'invalid', 'verification failed', 'try again', 'unable to verify'];
831
+ const DISMISS_LABELS = ['ok', 'close', 'dismiss', 'cancel', 'retry', 'try again', 'continue', 'got it'];
832
+
833
+ const isCaptchaText = (text: string): boolean => {
834
+ const t = text.toLowerCase();
835
+ return (
836
+ CAPTCHA_WORDS.some(w => t.includes(w)) &&
837
+ FAIL_WORDS.some(w => t.includes(w))
838
+ );
839
+ };
840
+
841
+ const tryDismiss = (): boolean => {
842
+ // Strategy 1: click a dismiss button whose ancestor contains CAPTCHA failure text
843
+ const buttons = Array.from(document.querySelectorAll<HTMLElement>(
844
+ 'button, input[type="button"], input[type="submit"], a[role="button"]'
845
+ ));
846
+ for (const btn of buttons) {
847
+ const label = (
848
+ btn instanceof HTMLInputElement ? btn.value : btn.innerText || btn.textContent || ''
849
+ ).trim().toLowerCase();
850
+ if (!DISMISS_LABELS.includes(label)) continue;
851
+
852
+ let el: Element | null = btn.parentElement;
853
+ while (el && el !== document.body) {
854
+ if (isCaptchaText((el as HTMLElement).innerText || '')) {
855
+ (btn as HTMLElement).click();
856
+ return true;
857
+ }
858
+ el = el.parentElement;
859
+ }
860
+ }
861
+
862
+ // Strategy 2: hide any visible modal/overlay containing CAPTCHA failure text
863
+ const overlaySelectors = [
864
+ '[class*="modal"]', '[class*="popup"]', '[class*="dialog"]',
865
+ '[class*="overlay"]', '[class*="alert"]', '[class*="notification"]',
866
+ '[role="dialog"]', '[role="alertdialog"]', '[role="alert"]'
867
+ ];
868
+ const overlays = document.querySelectorAll<HTMLElement>(overlaySelectors.join(','));
869
+ for (const overlay of overlays) {
870
+ if (!isCaptchaText(overlay.innerText || '')) continue;
871
+ const style = overlay.style;
872
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
873
+
874
+ // Try clicking a close button first
875
+ const closeBtn = overlay.querySelector<HTMLElement>(
876
+ 'button, [class*="close"], [aria-label*="lose"], [aria-label*="ismiss"]'
877
+ );
878
+ if (closeBtn) {
879
+ closeBtn.click();
880
+ } else {
881
+ style.display = 'none';
882
+ }
883
+ return true;
884
+ }
885
+
886
+ return false;
887
+ };
888
+
889
+ // Run immediately in case popup is already present
890
+ if (tryDismiss()) return;
891
+
892
+ // Set up persistent watcher — fires on any DOM mutation
893
+ const observer = new MutationObserver(() => {
894
+ if (tryDismiss()) {
895
+ observer.disconnect();
896
+ clearInterval(ticker);
897
+ }
898
+ });
899
+
900
+ // Also poll via interval as safety net (MutationObserver may miss text changes)
901
+ const ticker = setInterval(() => {
902
+ if (tryDismiss()) {
903
+ clearInterval(ticker);
904
+ observer.disconnect();
905
+ }
906
+ }, 400);
907
+
908
+ if (document.body) {
909
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
910
+ }
911
+
912
+ // Self-cleanup after 30 seconds to avoid memory leaks
913
+ setTimeout(() => {
914
+ observer.disconnect();
915
+ clearInterval(ticker);
916
+ }, 30000);
917
+ });
918
+
919
+ debug.log('preview', '🔔 CAPTCHA auto-dismiss watcher injected into page');
920
+ } catch {
921
+ // Page closed or navigated away — ignore
922
+ }
923
+ }
924
+
687
925
  /**
688
926
  * Setup browser event handlers
689
927
  */
@@ -31,6 +31,7 @@ import type { Page } from 'puppeteer';
31
31
  import type { BrowserTab, StreamingConfig } from './types';
32
32
  import { DEFAULT_STREAMING_CONFIG } from './types';
33
33
  import { videoEncoderScript } from './scripts/video-stream';
34
+ import { audioCaptureScript } from './scripts/audio-stream';
34
35
  import { debug } from '$shared/utils/logger';
35
36
 
36
37
  interface VideoStreamSession {
@@ -157,7 +158,8 @@ export class BrowserVideoCapture extends EventEmitter {
157
158
  // Inject persistent video encoder script (survives navigation)
158
159
  // Only inject once per page instance
159
160
  if (!videoSession.scriptInjected) {
160
- await page.evaluateOnNewDocument(videoEncoderScript, videoConfig);
161
+ // Temporarily disable evaluateOnNewDocument for evasion test
162
+ // await page.evaluateOnNewDocument(videoEncoderScript, videoConfig);
161
163
  videoSession.scriptInjected = true;
162
164
  debug.log('webcodecs', `Persistent video encoder script injected for ${sessionId}`);
163
165
  }
@@ -166,6 +168,12 @@ export class BrowserVideoCapture extends EventEmitter {
166
168
  // (evaluateOnNewDocument only runs on NEXT navigation)
167
169
  await page.evaluate(videoEncoderScript, videoConfig);
168
170
 
171
+ // Inject audio capture script post-navigation to avoid CF detection.
172
+ // Using page.evaluate() instead of evaluateOnNewDocument() ensures
173
+ // AudioContext patching happens AFTER Cloudflare challenges pass,
174
+ // preventing fingerprint detection of constructor interception.
175
+ await page.evaluate(audioCaptureScript, config.audio);
176
+
169
177
  // Verify peer was created
170
178
  const peerExists = await page.evaluate(() => {
171
179
  return typeof (window as any).__webCodecsPeer?.startStreaming === 'function';
@@ -570,6 +578,9 @@ export class BrowserVideoCapture extends EventEmitter {
570
578
 
571
579
  await page.evaluate(videoEncoderScript, videoConfig);
572
580
 
581
+ // Re-inject audio capture script for new page context (post-navigation)
582
+ await page.evaluate(audioCaptureScript, config.audio);
583
+
573
584
  // Verify peer was re-created
574
585
  const peerExists = await page.evaluate(() => {
575
586
  return typeof (window as any).__webCodecsPeer?.startStreaming === 'function';
@@ -590,6 +601,25 @@ export class BrowserVideoCapture extends EventEmitter {
590
601
  return false;
591
602
  }
592
603
 
604
+ // Re-initialize and start audio capture after navigation
605
+ try {
606
+ const audioReady = await page.evaluate(async () => {
607
+ const encoder = (window as any).__audioEncoder;
608
+ if (!encoder) return false;
609
+ const initiated = await encoder.init();
610
+ if (initiated) return encoder.start();
611
+ return false;
612
+ });
613
+
614
+ if (audioReady) {
615
+ debug.log('webcodecs', 'Audio re-initialized after navigation');
616
+ } else {
617
+ debug.warn('webcodecs', 'Audio not available after navigation, continuing with video only');
618
+ }
619
+ } catch {
620
+ debug.warn('webcodecs', 'Audio re-init failed after navigation, continuing with video only');
621
+ }
622
+
593
623
  // Restart CDP screencast
594
624
  const cdp = (session as any).__webCodecsCdp;
595
625
  if (cdp) {
@@ -631,6 +661,11 @@ export class BrowserVideoCapture extends EventEmitter {
631
661
 
632
662
  if (session?.page && !session.page.isClosed()) {
633
663
  try {
664
+ // Stop audio encoder
665
+ await session.page.evaluate(() => {
666
+ (window as any).__audioEncoder?.stop();
667
+ }).catch(() => {});
668
+
634
669
  // Stop peer
635
670
  await session.page.evaluate(() => {
636
671
  (window as any).__webCodecsPeer?.stopStreaming();
@@ -67,6 +67,12 @@ interface ConnectionState {
67
67
  chatSessionIds: Set<string>;
68
68
  /** Cleanup functions called automatically on unregister (connection close) */
69
69
  cleanups: Set<() => void>;
70
+ /** Whether this connection has been authenticated */
71
+ authenticated: boolean;
72
+ /** User role (admin or member) — set by auth handler */
73
+ role: 'admin' | 'member' | null;
74
+ /** Hash of the session token used for this connection */
75
+ sessionTokenHash: string | null;
70
76
  }
71
77
 
72
78
  /**
@@ -140,7 +146,7 @@ class WSServer {
140
146
  const id = crypto.randomUUID();
141
147
  this.rawToId.set(raw, id);
142
148
  this.connections.set(id, conn);
143
- this.connectionState.set(id, { userId: null, projectId: null, chatSessionIds: new Set(), cleanups: new Set() });
149
+ this.connectionState.set(id, { userId: null, projectId: null, chatSessionIds: new Set(), cleanups: new Set(), authenticated: false, role: null, sessionTokenHash: null });
144
150
 
145
151
  this.metrics.totalConnections = this.connections.size;
146
152
  debug.log('websocket', `Connection registered: ${id} (total: ${this.connections.size})`);
@@ -516,6 +522,64 @@ class WSServer {
516
522
  return this.connectionState.get(id);
517
523
  }
518
524
 
525
+ /**
526
+ * Set authentication state for a connection.
527
+ * Called by auth handlers after successful login/setup/invite.
528
+ */
529
+ setAuth(conn: WSConnection, userId: string, role: 'admin' | 'member', sessionTokenHash: string): void {
530
+ const wsId = this.ensureRegistered(conn);
531
+ const state = this.connectionState.get(wsId);
532
+ if (state) {
533
+ state.authenticated = true;
534
+ state.role = role;
535
+ state.sessionTokenHash = sessionTokenHash;
536
+ }
537
+ // Also set userId via existing method (handles room management)
538
+ this.setUser(conn, userId);
539
+ debug.log('websocket', `Connection ${wsId} authenticated: userId=${userId}, role=${role}`);
540
+ }
541
+
542
+ /**
543
+ * Get the role for a connection.
544
+ */
545
+ getRole(conn: WSConnection): 'admin' | 'member' | null {
546
+ const id = this.resolveId(conn);
547
+ if (!id) return null;
548
+ return this.connectionState.get(id)?.role ?? null;
549
+ }
550
+
551
+ /**
552
+ * Check if a connection is authenticated.
553
+ */
554
+ isAuthenticated(conn: WSConnection): boolean {
555
+ const id = this.resolveId(conn);
556
+ if (!id) return false;
557
+ return this.connectionState.get(id)?.authenticated ?? false;
558
+ }
559
+
560
+ /**
561
+ * Get remote IP address of a connection.
562
+ */
563
+ getRemoteAddress(conn: WSConnection): string {
564
+ const raw = (conn as any).raw;
565
+ return raw?.remoteAddress ?? 'unknown';
566
+ }
567
+
568
+ /**
569
+ * Clear authentication state for a connection (logout).
570
+ */
571
+ clearAuth(conn: WSConnection): void {
572
+ const id = this.resolveId(conn);
573
+ if (!id) return;
574
+ const state = this.connectionState.get(id);
575
+ if (state) {
576
+ state.authenticated = false;
577
+ state.role = null;
578
+ state.sessionTokenHash = null;
579
+ }
580
+ debug.log('websocket', `Connection ${id} auth cleared`);
581
+ }
582
+
519
583
  /**
520
584
  * Check if connection can receive data (backpressure check)
521
585
  */
@@ -0,0 +1,17 @@
1
+ import { createRouter } from '$shared/utils/ws-server';
2
+ import { t } from 'elysia';
3
+ import { statusHandler } from './status';
4
+ import { loginHandler } from './login';
5
+ import { inviteHandler } from './invites';
6
+ import { usersHandler } from './users';
7
+
8
+ export const authRouter = createRouter()
9
+ .merge(statusHandler)
10
+ .merge(loginHandler)
11
+ .merge(inviteHandler)
12
+ .merge(usersHandler)
13
+ // Declare auth:error event (emitted by auth gate in WSRouter)
14
+ .emit('auth:error', t.Object({
15
+ error: t.String(),
16
+ blockedAction: t.String()
17
+ }));
@@ -0,0 +1,84 @@
1
+ import { t } from 'elysia';
2
+ import { createRouter } from '$shared/utils/ws-server';
3
+ import { createInvite, listInvites, revokeInvite } from '$backend/lib/auth/auth-service';
4
+ import { ws } from '$backend/lib/utils/ws';
5
+
6
+ export const inviteHandler = createRouter()
7
+ // Create invite token (admin only — enforced by auth gate)
8
+ .http('auth:create-invite', {
9
+ data: t.Object({
10
+ label: t.Optional(t.String()),
11
+ maxUses: t.Optional(t.Number({ minimum: 0 })),
12
+ expiresInMinutes: t.Optional(t.Number({ minimum: 1 }))
13
+ }),
14
+ response: t.Object({
15
+ inviteToken: t.String(),
16
+ invite: t.Object({
17
+ id: t.String(),
18
+ role: t.String(),
19
+ label: t.Union([t.String(), t.Null()]),
20
+ max_uses: t.Number(),
21
+ use_count: t.Number(),
22
+ expires_at: t.Union([t.String(), t.Null()]),
23
+ created_at: t.String()
24
+ })
25
+ })
26
+ }, async ({ data, conn }) => {
27
+ const userId = ws.getUserId(conn);
28
+ const result = createInvite(userId, {
29
+ label: data.label,
30
+ maxUses: data.maxUses,
31
+ expiresInMinutes: data.expiresInMinutes
32
+ });
33
+
34
+ return {
35
+ inviteToken: result.inviteToken,
36
+ invite: {
37
+ id: result.invite!.id,
38
+ role: result.invite!.role,
39
+ label: result.invite!.label,
40
+ max_uses: result.invite!.max_uses,
41
+ use_count: result.invite!.use_count,
42
+ expires_at: result.invite!.expires_at,
43
+ created_at: result.invite!.created_at
44
+ }
45
+ };
46
+ })
47
+
48
+ // List all invites (admin only)
49
+ .http('auth:list-invites', {
50
+ data: t.Object({}),
51
+ response: t.Array(t.Object({
52
+ id: t.String(),
53
+ role: t.String(),
54
+ label: t.Union([t.String(), t.Null()]),
55
+ max_uses: t.Number(),
56
+ use_count: t.Number(),
57
+ expires_at: t.Union([t.String(), t.Null()]),
58
+ created_at: t.String()
59
+ }))
60
+ }, async () => {
61
+ const invites = listInvites();
62
+ return invites.map(inv => ({
63
+ id: inv.id,
64
+ role: inv.role,
65
+ label: inv.label,
66
+ max_uses: inv.max_uses,
67
+ use_count: inv.use_count,
68
+ expires_at: inv.expires_at,
69
+ created_at: inv.created_at
70
+ }));
71
+ })
72
+
73
+ // Revoke invite (admin only)
74
+ .http('auth:revoke-invite', {
75
+ data: t.Object({
76
+ id: t.String({ minLength: 1 })
77
+ }),
78
+ response: t.Object({
79
+ success: t.Boolean()
80
+ })
81
+ }, async ({ data }) => {
82
+ revokeInvite(data.id);
83
+ return { success: true };
84
+ });