@myrialabs/clopen 0.1.10 → 0.2.1
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/README.md +23 -1
- package/backend/index.ts +20 -0
- package/backend/lib/auth/auth-service.ts +484 -0
- package/backend/lib/auth/index.ts +4 -0
- package/backend/lib/auth/permissions.ts +63 -0
- package/backend/lib/auth/rate-limiter.ts +145 -0
- package/backend/lib/auth/tokens.ts +53 -0
- package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
- package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
- package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
- package/backend/lib/database/migrations/index.ts +21 -0
- package/backend/lib/database/queries/auth-queries.ts +201 -0
- package/backend/lib/database/queries/index.ts +2 -1
- package/backend/lib/engine/adapters/opencode/server.ts +1 -1
- package/backend/lib/mcp/config.ts +13 -18
- package/backend/lib/mcp/index.ts +9 -0
- package/backend/lib/mcp/remote-server.ts +132 -0
- package/backend/lib/mcp/servers/helper.ts +49 -3
- package/backend/lib/mcp/servers/index.ts +3 -2
- package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
- package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
- package/backend/lib/preview/browser/browser-pool.ts +73 -176
- package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
- package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
- package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
- package/backend/lib/utils/ws.ts +87 -1
- package/backend/ws/auth/index.ts +21 -0
- package/backend/ws/auth/invites.ts +84 -0
- package/backend/ws/auth/login.ts +283 -0
- package/backend/ws/auth/status.ts +41 -0
- package/backend/ws/auth/users.ts +32 -0
- package/backend/ws/engine/claude/accounts.ts +3 -1
- package/backend/ws/engine/utils.ts +38 -6
- package/backend/ws/index.ts +4 -4
- package/backend/ws/preview/browser/interact.ts +27 -5
- package/bin/clopen.ts +39 -0
- package/bun.lock +113 -51
- package/frontend/App.svelte +47 -29
- package/frontend/lib/components/auth/InvitePage.svelte +215 -0
- package/frontend/lib/components/auth/LoginPage.svelte +129 -0
- package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
- package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
- package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
- package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
- package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
- package/frontend/lib/components/settings/SettingsView.svelte +21 -7
- package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
- package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
- package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
- package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
- package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
- package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
- package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
- package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
- package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
- package/frontend/lib/stores/features/auth.svelte.ts +308 -0
- package/frontend/lib/stores/features/settings.svelte.ts +53 -9
- package/frontend/lib/stores/features/user.svelte.ts +26 -68
- package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
- package/frontend/lib/stores/ui/update.svelte.ts +2 -2
- package/package.json +8 -6
- package/shared/types/stores/settings.ts +16 -2
- package/shared/utils/logger.ts +1 -0
- package/shared/utils/ws-client.ts +30 -13
- package/shared/utils/ws-server.ts +42 -4
- package/backend/lib/mcp/stdio-server.ts +0 -103
- 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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
//
|
|
646
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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();
|
package/backend/lib/utils/ws.ts
CHANGED
|
@@ -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,86 @@ 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
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Clear authentication state for ALL active connections.
|
|
585
|
+
* Used when switching auth mode to 'required' — invalidates every
|
|
586
|
+
* connection's in-memory auth so the auth gate blocks subsequent messages.
|
|
587
|
+
* Returns the number of connections that were cleared.
|
|
588
|
+
*/
|
|
589
|
+
clearAllAuth(): number {
|
|
590
|
+
let cleared = 0;
|
|
591
|
+
for (const [id, state] of this.connectionState) {
|
|
592
|
+
if (state.authenticated) {
|
|
593
|
+
state.authenticated = false;
|
|
594
|
+
state.role = null;
|
|
595
|
+
state.sessionTokenHash = null;
|
|
596
|
+
cleared++;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (cleared > 0) {
|
|
600
|
+
debug.log('websocket', `Cleared auth on ${cleared} active connection(s)`);
|
|
601
|
+
}
|
|
602
|
+
return cleared;
|
|
603
|
+
}
|
|
604
|
+
|
|
519
605
|
/**
|
|
520
606
|
* Check if connection can receive data (backpressure check)
|
|
521
607
|
*/
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}))
|
|
18
|
+
// Declare auth:force-logout event (emitted when auth mode switches to required)
|
|
19
|
+
.emit('auth:force-logout', t.Object({
|
|
20
|
+
reason: t.String()
|
|
21
|
+
}));
|
|
@@ -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
|
+
});
|