@mitralab.io/platform-sdk 1.1.2 → 1.1.4-beta.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.
- package/CHANGELOG.md +124 -57
- package/README.md +95 -8
- package/dist/index.cjs +263 -67
- package/dist/index.d.cts +77 -16
- package/dist/index.d.ts +77 -16
- package/dist/index.js +263 -67
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -434,16 +434,37 @@ async function resolveApiKeySession(publicClient, appId, apiKey) {
|
|
|
434
434
|
);
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
// src/modules/
|
|
437
|
+
// src/modules/auth-page-flow.ts
|
|
438
438
|
var import_sdk_core2 = require("@mitralab.io/sdk-core");
|
|
439
439
|
var RESULT_TYPE = "mitra-oauth-result";
|
|
440
|
-
var
|
|
441
|
-
|
|
442
|
-
|
|
440
|
+
var FIVE_MINUTES_MS = 5 * 60 * 1e3;
|
|
441
|
+
var TEN_MINUTES_MS = 10 * 60 * 1e3;
|
|
442
|
+
var PROVIDERS = {
|
|
443
|
+
google: {
|
|
444
|
+
label: "Google",
|
|
445
|
+
exchangePath: "/api/v1/auth/google",
|
|
446
|
+
sendsRedirectUri: true,
|
|
447
|
+
redirectStorage: "sessionStorage",
|
|
448
|
+
popupTimeoutMs: FIVE_MINUTES_MS
|
|
449
|
+
},
|
|
450
|
+
microsoft: {
|
|
451
|
+
label: "Microsoft",
|
|
452
|
+
exchangePath: "/api/v1/auth/microsoft",
|
|
453
|
+
sendsRedirectUri: true,
|
|
454
|
+
redirectStorage: "sessionStorage",
|
|
455
|
+
popupTimeoutMs: FIVE_MINUTES_MS
|
|
456
|
+
},
|
|
457
|
+
email: {
|
|
458
|
+
label: "Email",
|
|
459
|
+
exchangePath: "/api/v1/auth/magic-link/exchange",
|
|
460
|
+
sendsRedirectUri: false,
|
|
461
|
+
redirectStorage: "localStorage",
|
|
462
|
+
pendingRequestTtlMs: TEN_MINUTES_MS,
|
|
463
|
+
popupTimeoutMs: TEN_MINUTES_MS
|
|
464
|
+
}
|
|
443
465
|
};
|
|
444
466
|
var POPUP_WIDTH = 480;
|
|
445
467
|
var POPUP_HEIGHT = 600;
|
|
446
|
-
var POPUP_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
447
468
|
var POPUP_CLOSED_POLL_MS = 500;
|
|
448
469
|
function expectAuthTokenResponse(value) {
|
|
449
470
|
const response = (0, import_sdk_core2.expectObject)(
|
|
@@ -464,12 +485,13 @@ function expectAuthTokenResponse(value) {
|
|
|
464
485
|
tokenType: response.tokenType
|
|
465
486
|
};
|
|
466
487
|
}
|
|
467
|
-
var
|
|
488
|
+
var AuthPageFlow = class {
|
|
468
489
|
appId;
|
|
469
490
|
apiUrl;
|
|
470
491
|
configuredAuthPageUrl;
|
|
471
492
|
client;
|
|
472
493
|
provider;
|
|
494
|
+
profile;
|
|
473
495
|
providerLabel;
|
|
474
496
|
redirectStorageKey;
|
|
475
497
|
popupPromise = null;
|
|
@@ -479,7 +501,8 @@ var GoogleAuthFlow = class {
|
|
|
479
501
|
this.configuredAuthPageUrl = config.authPageUrl;
|
|
480
502
|
this.client = config.client;
|
|
481
503
|
this.provider = config.provider ?? "google";
|
|
482
|
-
this.
|
|
504
|
+
this.profile = PROVIDERS[this.provider];
|
|
505
|
+
this.providerLabel = this.profile.label;
|
|
483
506
|
this.redirectStorageKey = `mitra_${this.provider}_redirect_${config.appId}`;
|
|
484
507
|
}
|
|
485
508
|
signIn(options = {}) {
|
|
@@ -496,6 +519,19 @@ var GoogleAuthFlow = class {
|
|
|
496
519
|
});
|
|
497
520
|
return this.popupPromise;
|
|
498
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Finishes a redirect this provider started, or returns `null` when the URL
|
|
524
|
+
* carries no result or carries one that belongs to another provider's flow.
|
|
525
|
+
*
|
|
526
|
+
* The state generated at the start of every flow is prefixed with the provider
|
|
527
|
+
* name, and the auth page echoes it verbatim, so a fragment identifies its own
|
|
528
|
+
* flow. An application that offers several methods can call every completion
|
|
529
|
+
* at startup, in any order, even while other methods have requests pending.
|
|
530
|
+
*
|
|
531
|
+
* A fragment of this flow is always consumed, including when it cannot be
|
|
532
|
+
* completed, so a failure is reported once instead of on every reload. A
|
|
533
|
+
* fragment of another flow is left exactly as it was found.
|
|
534
|
+
*/
|
|
499
535
|
async completeRedirect() {
|
|
500
536
|
const browserWindow = this.requireBrowser();
|
|
501
537
|
const params = new URLSearchParams(browserWindow.location.hash.replace(/^#/, ""));
|
|
@@ -504,17 +540,36 @@ var GoogleAuthFlow = class {
|
|
|
504
540
|
const error = params.get("errorMitra");
|
|
505
541
|
if (code === null && state === null && error === null) return null;
|
|
506
542
|
const context = this.readRedirectContext(browserWindow);
|
|
507
|
-
|
|
508
|
-
|
|
543
|
+
const hasState = state !== null && state.trim() !== "";
|
|
544
|
+
if (hasState && !this.ownsState(state)) return null;
|
|
545
|
+
if (!hasState && !context) return null;
|
|
546
|
+
if (!hasState) {
|
|
547
|
+
throw this.discardOwnRedirect(
|
|
548
|
+
browserWindow,
|
|
549
|
+
`${this.providerLabel} sign-in redirect is missing state.`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if (context && this.hasExpired(context)) {
|
|
553
|
+
this.clearRedirectContext(browserWindow);
|
|
554
|
+
throw this.discardOwnRedirect(
|
|
555
|
+
browserWindow,
|
|
556
|
+
`${this.providerLabel} sign-in request expired before it was completed.`
|
|
557
|
+
);
|
|
509
558
|
}
|
|
510
559
|
if (context?.state !== state) {
|
|
511
|
-
throw
|
|
560
|
+
throw this.discardOwnRedirect(
|
|
561
|
+
browserWindow,
|
|
562
|
+
`Invalid ${this.providerLabel} sign-in state (possible CSRF).`
|
|
563
|
+
);
|
|
512
564
|
}
|
|
513
565
|
const expectedRedirectUri = this.getRedirectUri(
|
|
514
566
|
resolveAuthPageUrl(this.apiUrl, this.configuredAuthPageUrl, browserWindow)
|
|
515
567
|
);
|
|
516
568
|
if (context.redirectUri !== expectedRedirectUri) {
|
|
517
|
-
throw
|
|
569
|
+
throw this.discardOwnRedirect(
|
|
570
|
+
browserWindow,
|
|
571
|
+
`${this.providerLabel} sign-in redirect context is invalid.`
|
|
572
|
+
);
|
|
518
573
|
}
|
|
519
574
|
this.cleanRedirectFragment(browserWindow);
|
|
520
575
|
this.clearRedirectContext(browserWindow);
|
|
@@ -533,8 +588,13 @@ var GoogleAuthFlow = class {
|
|
|
533
588
|
this.configuredAuthPageUrl,
|
|
534
589
|
browserWindow
|
|
535
590
|
);
|
|
591
|
+
const completedInAnotherTab = this.profile.pendingRequestTtlMs !== void 0;
|
|
592
|
+
if (completedInAnotherTab) {
|
|
593
|
+
this.writeRedirectContext(browserWindow, this.newRedirectContext(state, authPageUrl));
|
|
594
|
+
}
|
|
536
595
|
const popup = this.openPopup(browserWindow, this.buildStartUrl(browserWindow, authPageUrl, state));
|
|
537
596
|
const result = await this.waitForPopupResult(browserWindow, popup, authPageUrl.origin, state);
|
|
597
|
+
if (completedInAnotherTab) this.clearRedirectContext(browserWindow);
|
|
538
598
|
if (result.code) return this.exchangeCode(result.code, this.getRedirectUri(authPageUrl));
|
|
539
599
|
return expectAuthTokenResponse(result.token);
|
|
540
600
|
}
|
|
@@ -545,20 +605,20 @@ var GoogleAuthFlow = class {
|
|
|
545
605
|
this.configuredAuthPageUrl,
|
|
546
606
|
browserWindow
|
|
547
607
|
);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
608
|
+
if (!this.writeRedirectContext(browserWindow, this.newRedirectContext(state, authPageUrl))) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
`${this.providerLabel} sign-in redirect requires ${this.profile.redirectStorage}.`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
553
613
|
const startUrl = this.buildStartUrl(browserWindow, authPageUrl, state);
|
|
554
614
|
browserWindow.location.assign(startUrl.toString());
|
|
555
615
|
return new Promise(() => void 0);
|
|
556
616
|
}
|
|
557
617
|
async exchangeCode(code, redirectUri) {
|
|
558
|
-
const response = await this.client.post(
|
|
618
|
+
const response = await this.client.post(this.profile.exchangePath, {
|
|
559
619
|
appId: this.appId,
|
|
560
620
|
code,
|
|
561
|
-
redirectUri
|
|
621
|
+
...this.profile.sendsRedirectUri ? { redirectUri } : {}
|
|
562
622
|
});
|
|
563
623
|
return expectAuthTokenResponse(response);
|
|
564
624
|
}
|
|
@@ -575,13 +635,41 @@ var GoogleAuthFlow = class {
|
|
|
575
635
|
getRedirectUri(authPageUrl) {
|
|
576
636
|
return `${authPageUrl.origin}${authPageUrl.pathname}`;
|
|
577
637
|
}
|
|
638
|
+
newRedirectContext(state, authPageUrl) {
|
|
639
|
+
return {
|
|
640
|
+
state,
|
|
641
|
+
redirectUri: this.getRedirectUri(authPageUrl),
|
|
642
|
+
createdAt: Date.now()
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Reports a fragment of this flow that cannot be completed, dropping it from the
|
|
647
|
+
* URL first. Nobody else claims a fragment that names this flow, so leaving it
|
|
648
|
+
* there would make the application fail again on every reload.
|
|
649
|
+
*/
|
|
650
|
+
discardOwnRedirect(browserWindow, message) {
|
|
651
|
+
this.cleanRedirectFragment(browserWindow);
|
|
652
|
+
return new Error(message);
|
|
653
|
+
}
|
|
654
|
+
/** Whether a state echoed by the auth page was generated by this provider's flow. */
|
|
655
|
+
ownsState(state) {
|
|
656
|
+
return state.startsWith(`${this.provider}.`);
|
|
657
|
+
}
|
|
658
|
+
hasExpired(context) {
|
|
659
|
+
const ttlMs = this.profile.pendingRequestTtlMs;
|
|
660
|
+
if (ttlMs === void 0) return false;
|
|
661
|
+
if (!Number.isFinite(context.createdAt)) return true;
|
|
662
|
+
return Date.now() - context.createdAt > ttlMs;
|
|
663
|
+
}
|
|
664
|
+
/** A one-time state that names the flow that created it, so its fragment is recognizable. */
|
|
578
665
|
generateState() {
|
|
579
666
|
if (!globalThis.crypto?.getRandomValues) {
|
|
580
667
|
throw new Error(`${this.providerLabel} sign-in requires crypto.getRandomValues.`);
|
|
581
668
|
}
|
|
582
669
|
const bytes = new Uint8Array(16);
|
|
583
670
|
globalThis.crypto.getRandomValues(bytes);
|
|
584
|
-
|
|
671
|
+
const random = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
672
|
+
return `${this.provider}.${random}`;
|
|
585
673
|
}
|
|
586
674
|
openPopup(browserWindow, url) {
|
|
587
675
|
const outerWidth = browserWindow.outerWidth || browserWindow.screen.width;
|
|
@@ -590,7 +678,7 @@ var GoogleAuthFlow = class {
|
|
|
590
678
|
const top = Math.max(0, (browserWindow.screenY || 0) + (outerHeight - POPUP_HEIGHT) / 2);
|
|
591
679
|
const popup = browserWindow.open(
|
|
592
680
|
url.toString(),
|
|
593
|
-
|
|
681
|
+
`mitra-${this.provider}-auth`,
|
|
594
682
|
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},menubar=no,toolbar=no,status=no`
|
|
595
683
|
);
|
|
596
684
|
if (!popup) {
|
|
@@ -603,7 +691,7 @@ var GoogleAuthFlow = class {
|
|
|
603
691
|
const timeout = globalThis.setTimeout(() => {
|
|
604
692
|
cleanup();
|
|
605
693
|
reject(new Error(`${this.providerLabel} sign-in timed out.`));
|
|
606
|
-
},
|
|
694
|
+
}, this.profile.popupTimeoutMs);
|
|
607
695
|
const closedPoll = globalThis.setInterval(() => {
|
|
608
696
|
if (popup.closed) {
|
|
609
697
|
cleanup();
|
|
@@ -628,7 +716,7 @@ var GoogleAuthFlow = class {
|
|
|
628
716
|
const code = typeof data.code === "string" && data.code.trim() ? data.code : void 0;
|
|
629
717
|
if (!code && data.token === void 0) {
|
|
630
718
|
cleanup();
|
|
631
|
-
reject(new Error(
|
|
719
|
+
reject(new Error(`${this.providerLabel} auth page returned neither code nor token.`));
|
|
632
720
|
return;
|
|
633
721
|
}
|
|
634
722
|
cleanup();
|
|
@@ -643,22 +731,28 @@ var GoogleAuthFlow = class {
|
|
|
643
731
|
browserWindow.addEventListener("message", onMessage);
|
|
644
732
|
});
|
|
645
733
|
}
|
|
646
|
-
|
|
734
|
+
/** Writes the pending request, reporting whether storage accepted it. */
|
|
735
|
+
writeRedirectContext(browserWindow, context) {
|
|
647
736
|
try {
|
|
648
|
-
browserWindow.
|
|
737
|
+
browserWindow[this.profile.redirectStorage].setItem(
|
|
738
|
+
this.redirectStorageKey,
|
|
739
|
+
JSON.stringify(context)
|
|
740
|
+
);
|
|
741
|
+
return true;
|
|
649
742
|
} catch {
|
|
650
|
-
|
|
743
|
+
return false;
|
|
651
744
|
}
|
|
652
745
|
}
|
|
653
746
|
readRedirectContext(browserWindow) {
|
|
654
747
|
try {
|
|
655
|
-
const raw = browserWindow.
|
|
748
|
+
const raw = browserWindow[this.profile.redirectStorage].getItem(this.redirectStorageKey);
|
|
656
749
|
if (!raw) return null;
|
|
657
750
|
const value = JSON.parse(raw);
|
|
658
751
|
if (typeof value.state !== "string" || typeof value.redirectUri !== "string") return null;
|
|
659
752
|
return {
|
|
660
753
|
state: value.state,
|
|
661
|
-
redirectUri: value.redirectUri
|
|
754
|
+
redirectUri: value.redirectUri,
|
|
755
|
+
createdAt: typeof value.createdAt === "number" ? value.createdAt : Number.NaN
|
|
662
756
|
};
|
|
663
757
|
} catch {
|
|
664
758
|
return null;
|
|
@@ -666,7 +760,7 @@ var GoogleAuthFlow = class {
|
|
|
666
760
|
}
|
|
667
761
|
clearRedirectContext(browserWindow) {
|
|
668
762
|
try {
|
|
669
|
-
browserWindow.
|
|
763
|
+
browserWindow[this.profile.redirectStorage].removeItem(this.redirectStorageKey);
|
|
670
764
|
} catch {
|
|
671
765
|
}
|
|
672
766
|
}
|
|
@@ -736,6 +830,7 @@ var AuthModule = class {
|
|
|
736
830
|
currentUserApi;
|
|
737
831
|
googleAuth;
|
|
738
832
|
microsoftAuth;
|
|
833
|
+
emailAuth;
|
|
739
834
|
constructor(appId, iamBaseUrl, options = {}) {
|
|
740
835
|
this.appId = appId;
|
|
741
836
|
const trimmedIamBaseUrl = stripTrailingSlashes(iamBaseUrl);
|
|
@@ -752,19 +847,26 @@ var AuthModule = class {
|
|
|
752
847
|
onUnauthorized: (requestToken) => this.handleUnauthorized(requestToken)
|
|
753
848
|
});
|
|
754
849
|
this.currentUserApi = (0, import_sdk_core3.createAuthModule)(this.authedClient, coreErrors);
|
|
755
|
-
this.googleAuth = new
|
|
850
|
+
this.googleAuth = new AuthPageFlow({
|
|
756
851
|
appId,
|
|
757
852
|
apiUrl,
|
|
758
853
|
authPageUrl: options.authPageUrl,
|
|
759
854
|
client: this.publicClient
|
|
760
855
|
});
|
|
761
|
-
this.microsoftAuth = new
|
|
856
|
+
this.microsoftAuth = new AuthPageFlow({
|
|
762
857
|
appId,
|
|
763
858
|
apiUrl,
|
|
764
859
|
authPageUrl: options.authPageUrl,
|
|
765
860
|
client: this.publicClient,
|
|
766
861
|
provider: "microsoft"
|
|
767
862
|
});
|
|
863
|
+
this.emailAuth = new AuthPageFlow({
|
|
864
|
+
appId,
|
|
865
|
+
apiUrl,
|
|
866
|
+
authPageUrl: options.authPageUrl,
|
|
867
|
+
client: this.publicClient,
|
|
868
|
+
provider: "email"
|
|
869
|
+
});
|
|
768
870
|
this.loadFromStorage();
|
|
769
871
|
const readAccessToken = () => this.#accessToken;
|
|
770
872
|
sessionPorts.set(this, {
|
|
@@ -790,10 +892,10 @@ var AuthModule = class {
|
|
|
790
892
|
get isAuthenticated() {
|
|
791
893
|
return this._currentUser !== null && this.#accessToken !== null;
|
|
792
894
|
}
|
|
793
|
-
/** @deprecated Email/password authentication is not implemented by IAM. Use
|
|
895
|
+
/** @deprecated Email/password authentication is not implemented by IAM. Use signInWithEmail() or SSO. */
|
|
794
896
|
async signIn(_credentials) {
|
|
795
897
|
throw new MitraApiError(
|
|
796
|
-
"Email/password authentication is not available. Use signInWithGoogle() or signInWithMicrosoft().",
|
|
898
|
+
"Email/password authentication is not available. Use signInWithEmail(), signInWithGoogle() or signInWithMicrosoft().",
|
|
797
899
|
0,
|
|
798
900
|
"UNSUPPORTED_AUTH_METHOD"
|
|
799
901
|
);
|
|
@@ -879,8 +981,10 @@ var AuthModule = class {
|
|
|
879
981
|
*
|
|
880
982
|
* The method consumes and clears the fragment and stored CSRF context, sends
|
|
881
983
|
* the single-use code directly to IAM, persists both tokens, calls `auth.me()`,
|
|
882
|
-
* and notifies auth-state listeners. It returns `null` when the current URL
|
|
883
|
-
*
|
|
984
|
+
* and notifies auth-state listeners. It returns `null` when the current URL
|
|
985
|
+
* carries no redirect result, and also when it carries one this flow never
|
|
986
|
+
* started, so an application that offers several methods can call every
|
|
987
|
+
* completion at startup. Redirect errors must carry the same `stateMitra`
|
|
884
988
|
* stored at the start of the flow before their message is exposed or consumed.
|
|
885
989
|
*
|
|
886
990
|
* @returns The authenticated user, or `null` when no redirect result is present.
|
|
@@ -916,10 +1020,54 @@ var AuthModule = class {
|
|
|
916
1020
|
const tokenResponse = await this.microsoftAuth.completeRedirect();
|
|
917
1021
|
return tokenResponse ? this.establishSession(tokenResponse) : null;
|
|
918
1022
|
}
|
|
919
|
-
/**
|
|
1023
|
+
/**
|
|
1024
|
+
* Signs in with a one-time code sent by email: the same auth-page handshake as
|
|
1025
|
+
* SSO, where the platform page collects the address and the code, and IAM
|
|
1026
|
+
* hands back a single-use exchange code redeemed at `/auth/magic-link/exchange`.
|
|
1027
|
+
* Popup by default; redirect mode navigates away.
|
|
1028
|
+
*
|
|
1029
|
+
* The message also carries a link. Because that link opens a new tab, the
|
|
1030
|
+
* pending request is kept in `localStorage` for 10 minutes - the one-time state
|
|
1031
|
+
* and the auth page URL, never a token - so
|
|
1032
|
+
* {@link completeEmailSignInRedirect} can finish the flow in that tab.
|
|
1033
|
+
*
|
|
1034
|
+
* @param options - Popup or redirect mode.
|
|
1035
|
+
* @returns The authenticated and hydrated user in popup mode.
|
|
1036
|
+
* @throws {MitraApiError} When IAM rejects the exchange code.
|
|
1037
|
+
* @throws {Error} When the browser blocks or cancels the popup, the flow times
|
|
1038
|
+
* out, or the response fails origin, source, state, or shape validation. A
|
|
1039
|
+
* person who finishes through the link instead of the popup leaves this call
|
|
1040
|
+
* to time out, which the application should treat as a cancelled popup.
|
|
1041
|
+
*
|
|
1042
|
+
* @example
|
|
1043
|
+
* ```typescript
|
|
1044
|
+
* const user = await mitra.auth.signInWithEmail();
|
|
1045
|
+
* ```
|
|
1046
|
+
*/
|
|
1047
|
+
async signInWithEmail(options = {}) {
|
|
1048
|
+
return this.establishSession(await this.emailAuth.signIn(options));
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Completes an email sign-in from `#codeMitra` and `#stateMitra`, during
|
|
1052
|
+
* application startup like {@link completeGoogleSignInRedirect}.
|
|
1053
|
+
*
|
|
1054
|
+
* It covers both ways the flow comes back, and they are the same check: a
|
|
1055
|
+
* redirect in the tab that started it, and the tab the link in the message
|
|
1056
|
+
* opened. Both carry the one-time state this SDK generated, which the second
|
|
1057
|
+
* tab matches against the pending request kept in `localStorage`. A request
|
|
1058
|
+
* older than 10 minutes is discarded instead of completed.
|
|
1059
|
+
*
|
|
1060
|
+
* @returns The authenticated user, or `null` when the URL carries no result or
|
|
1061
|
+
* carries one that belongs to another sign-in method's flow.
|
|
1062
|
+
*/
|
|
1063
|
+
async completeEmailSignInRedirect() {
|
|
1064
|
+
const tokenResponse = await this.emailAuth.completeRedirect();
|
|
1065
|
+
return tokenResponse ? this.establishSession(tokenResponse) : null;
|
|
1066
|
+
}
|
|
1067
|
+
/** @deprecated Email/password registration is not implemented by IAM. Use signInWithEmail() or SSO. */
|
|
920
1068
|
async signUp(_data) {
|
|
921
1069
|
throw new MitraApiError(
|
|
922
|
-
"Email/password registration is not available. Use signInWithGoogle() or signInWithMicrosoft().",
|
|
1070
|
+
"Email/password registration is not available. Use signInWithEmail(), signInWithGoogle() or signInWithMicrosoft().",
|
|
923
1071
|
0,
|
|
924
1072
|
"UNSUPPORTED_AUTH_METHOD"
|
|
925
1073
|
);
|
|
@@ -1660,11 +1808,10 @@ function holdSendsWhileOffline(session, outbox) {
|
|
|
1660
1808
|
|
|
1661
1809
|
// src/modules/agent-session.ts
|
|
1662
1810
|
var CONNECT_TIMEOUT_MS = 15e3;
|
|
1663
|
-
var CHANNEL_BOOT_TIMEOUT_MS = 9e4;
|
|
1664
|
-
var CHANNEL_BOOT_RETRY_MS = 2e3;
|
|
1665
1811
|
var SILENCE_TIMEOUT_MS = 6e4;
|
|
1666
1812
|
var RECONNECT_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 16e3];
|
|
1667
1813
|
var SUPERSEDED_CLOSE_CODE = 4409;
|
|
1814
|
+
var SOCKET_OPEN = 1;
|
|
1668
1815
|
var SilenceWatchdog = class {
|
|
1669
1816
|
constructor(onSilence) {
|
|
1670
1817
|
this.onSilence = onSilence;
|
|
@@ -1780,6 +1927,8 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1780
1927
|
*/
|
|
1781
1928
|
boxCursors = /* @__PURE__ */ new Map();
|
|
1782
1929
|
boxAddresses = /* @__PURE__ */ new Map();
|
|
1930
|
+
/** The box socket a task is talking on right now; absent while it is lost or being redialed. */
|
|
1931
|
+
directSockets = /* @__PURE__ */ new Map();
|
|
1783
1932
|
async open(taskId, observer, signal, transport = "auto") {
|
|
1784
1933
|
if (transport === "http") return this.openSse(taskId, observer, signal);
|
|
1785
1934
|
if (transport === "websocket") return this.openWebSocket(taskId, observer, signal);
|
|
@@ -1811,6 +1960,23 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1811
1960
|
}
|
|
1812
1961
|
};
|
|
1813
1962
|
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Writes a message or an interrupt on the box socket when the task is on the direct channel
|
|
1965
|
+
* and that socket is open right now. The box accepts the core's input as its own inbound
|
|
1966
|
+
* frame, asks the copilot for admission itself and answers on this same socket with the
|
|
1967
|
+
* frames the observer already reads, so the copilot's host socket is off the message path.
|
|
1968
|
+
* False sends the caller to REST: no direct channel, a socket lost or mid-redial, or an
|
|
1969
|
+
* approval, which stays on REST as it is.
|
|
1970
|
+
*
|
|
1971
|
+
* A frame written on a socket that closes before the box acknowledges it is not sent again
|
|
1972
|
+
* over REST: the box may have admitted the turn already, and a second copy would start it
|
|
1973
|
+
* twice. The redial replays the box log, which is the same recovery a REST 202 gets when its
|
|
1974
|
+
* turn is lost.
|
|
1975
|
+
*/
|
|
1976
|
+
sendOnChannel(taskId, input) {
|
|
1977
|
+
if (input.type === "approval_response") return false;
|
|
1978
|
+
return this.directSockets.get(taskId)?.send(JSON.stringify(input)) ?? false;
|
|
1979
|
+
}
|
|
1814
1980
|
async requireFreshToken() {
|
|
1815
1981
|
const fresh = await this.auth.ensureFreshSession();
|
|
1816
1982
|
const token = this.auth.accessToken;
|
|
@@ -1827,28 +1993,28 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1827
1993
|
requestDirectChannel(taskId, token, signal) {
|
|
1828
1994
|
return this.askDirectChannel(taskId, token, signal).catch(() => null);
|
|
1829
1995
|
}
|
|
1830
|
-
/**
|
|
1996
|
+
/**
|
|
1997
|
+
* Like `requestDirectChannel`, but a request the network lost is thrown, not a refusal.
|
|
1998
|
+
*
|
|
1999
|
+
* One request. The copilot holds it while the box boots and answers 200 with the channel, or
|
|
2000
|
+
* an error status once the box cannot be had. A 202 comes only from a copilot older than that
|
|
2001
|
+
* contract, which used to mean "still booting, ask again": there is no channel to open, and
|
|
2002
|
+
* the conversation follows the copilot socket. Polling here again would put the wait back on
|
|
2003
|
+
* the client that the server now owns.
|
|
2004
|
+
*/
|
|
1831
2005
|
async askDirectChannel(taskId, token, signal) {
|
|
2006
|
+
if (signal?.aborted) return null;
|
|
1832
2007
|
const url = `${this.apiUrl}/copilot/api/v1/tasks/${encodeURIComponent(taskId)}/channel`;
|
|
1833
|
-
const
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
await sleep(CHANNEL_BOOT_RETRY_MS, signal);
|
|
1844
|
-
continue;
|
|
1845
|
-
}
|
|
1846
|
-
if (!response.ok) return null;
|
|
1847
|
-
try {
|
|
1848
|
-
return toDirectChannel(await response.json(), this.apiUrl);
|
|
1849
|
-
} catch {
|
|
1850
|
-
return null;
|
|
1851
|
-
}
|
|
2008
|
+
const response = await globalThis.fetch(url, {
|
|
2009
|
+
method: "POST",
|
|
2010
|
+
headers: { Authorization: `Bearer ${stripBearer2(token)}` },
|
|
2011
|
+
...signal ? { signal } : {}
|
|
2012
|
+
});
|
|
2013
|
+
if (response.status === 202 || !response.ok) return null;
|
|
2014
|
+
try {
|
|
2015
|
+
return toDirectChannel(await response.json(), this.apiUrl);
|
|
2016
|
+
} catch {
|
|
2017
|
+
return null;
|
|
1852
2018
|
}
|
|
1853
2019
|
}
|
|
1854
2020
|
async openWebSocket(taskId, observer, signal) {
|
|
@@ -1897,6 +2063,11 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1897
2063
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1898
2064
|
let inTurn = false;
|
|
1899
2065
|
let current = null;
|
|
2066
|
+
const track = (socket) => {
|
|
2067
|
+
if (socket) this.directSockets.set(taskId, socket);
|
|
2068
|
+
else if (current && this.directSockets.get(taskId) === current) this.directSockets.delete(taskId);
|
|
2069
|
+
current = socket;
|
|
2070
|
+
};
|
|
1900
2071
|
const onFrame = (event) => {
|
|
1901
2072
|
if (typeof event.sequence === "number") {
|
|
1902
2073
|
const seen = this.boxCursors.get(taskId) ?? 0;
|
|
@@ -1906,22 +2077,21 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1906
2077
|
observer.onEvent(event);
|
|
1907
2078
|
};
|
|
1908
2079
|
const onLost = (error, code) => {
|
|
1909
|
-
|
|
2080
|
+
track(null);
|
|
1910
2081
|
if (link.signal.aborted) return;
|
|
1911
2082
|
if (!inTurn || code === SUPERSEDED_CLOSE_CODE) {
|
|
1912
2083
|
observer.onDisconnect(error);
|
|
1913
2084
|
return;
|
|
1914
2085
|
}
|
|
1915
|
-
void this.redial(taskId, error, link.signal, observer, { onFrame, onLost }).then(
|
|
1916
|
-
current = socket;
|
|
1917
|
-
});
|
|
2086
|
+
void this.redial(taskId, error, link.signal, observer, { onFrame, onLost }).then(track);
|
|
1918
2087
|
};
|
|
1919
|
-
|
|
2088
|
+
track(await this.dial(channel.wsUrl, { signal: link.signal, onFrame, onLost }));
|
|
1920
2089
|
return {
|
|
1921
2090
|
close: () => {
|
|
1922
2091
|
signal?.removeEventListener("abort", onAbort);
|
|
1923
2092
|
link.abort();
|
|
1924
2093
|
current?.close();
|
|
2094
|
+
track(null);
|
|
1925
2095
|
}
|
|
1926
2096
|
};
|
|
1927
2097
|
}
|
|
@@ -2006,6 +2176,15 @@ var BrowserAgentTaskEventSource = class {
|
|
|
2006
2176
|
removeAbortListener();
|
|
2007
2177
|
socket.close(1e3, "Client closed");
|
|
2008
2178
|
};
|
|
2179
|
+
const send = (frame) => {
|
|
2180
|
+
if (intentionalClose || socket.readyState !== SOCKET_OPEN) return false;
|
|
2181
|
+
try {
|
|
2182
|
+
socket.send(frame);
|
|
2183
|
+
return true;
|
|
2184
|
+
} catch {
|
|
2185
|
+
return false;
|
|
2186
|
+
}
|
|
2187
|
+
};
|
|
2009
2188
|
const onAbort = () => {
|
|
2010
2189
|
if (!opened) {
|
|
2011
2190
|
rejectHandshake(signal?.reason instanceof Error ? signal.reason : new Error("Agent WebSocket connection aborted."));
|
|
@@ -2029,7 +2208,7 @@ var BrowserAgentTaskEventSource = class {
|
|
|
2029
2208
|
} catch {
|
|
2030
2209
|
}
|
|
2031
2210
|
}
|
|
2032
|
-
resolve({ close });
|
|
2211
|
+
resolve({ close, send });
|
|
2033
2212
|
};
|
|
2034
2213
|
socket.onerror = () => {
|
|
2035
2214
|
if (!opened) rejectHandshake(new Error("Failed to connect to the Agent WebSocket."));
|
|
@@ -2136,6 +2315,10 @@ var BrowserAgentTaskEventSource = class {
|
|
|
2136
2315
|
};
|
|
2137
2316
|
|
|
2138
2317
|
// src/modules/agent-tasks.ts
|
|
2318
|
+
function bornOnBox(options) {
|
|
2319
|
+
if (!("create" in options) || options.runtime || options.transport === "http") return options;
|
|
2320
|
+
return { ...options, runtime: "T3" };
|
|
2321
|
+
}
|
|
2139
2322
|
function createBrowserAgentTasksModule(httpClient, auth, apiUrl) {
|
|
2140
2323
|
const tasks = (0, import_sdk_core8.createAgentTasksModule)(httpClient, coreErrors);
|
|
2141
2324
|
const source = new BrowserAgentTaskEventSource(auth, apiUrl);
|
|
@@ -2161,11 +2344,14 @@ function createBrowserAgentTasksModule(httpClient, auth, apiUrl) {
|
|
|
2161
2344
|
}
|
|
2162
2345
|
};
|
|
2163
2346
|
const manager = (0, import_sdk_core8.createAgentTaskSessionManager)({
|
|
2164
|
-
tasks: {
|
|
2347
|
+
tasks: {
|
|
2348
|
+
...tasks,
|
|
2349
|
+
sendInput: (taskId, input) => source.sendOnChannel(taskId, input) ? Promise.resolve() : outbox.sendInput(taskId, input)
|
|
2350
|
+
},
|
|
2165
2351
|
eventSource
|
|
2166
2352
|
});
|
|
2167
2353
|
return (0, import_sdk_core8.withAgentTaskSessions)(tasks, {
|
|
2168
|
-
session: (options) => holdSendsWhileOffline(manager.session(options), outbox)
|
|
2354
|
+
session: (options) => holdSendsWhileOffline(manager.session(bornOnBox(options)), outbox)
|
|
2169
2355
|
});
|
|
2170
2356
|
}
|
|
2171
2357
|
|
|
@@ -2227,7 +2413,12 @@ function expectAppInfoResponse(value) {
|
|
|
2227
2413
|
}
|
|
2228
2414
|
return {
|
|
2229
2415
|
dataSourceId: response.dataSourceId,
|
|
2230
|
-
allowSignup: response.allowSignup
|
|
2416
|
+
allowSignup: response.allowSignup,
|
|
2417
|
+
// Unlike the fields above, a missing or non-boolean value is read as `false`
|
|
2418
|
+
// instead of failing `init()`: a Code Studio older than the email login gate
|
|
2419
|
+
// answers without this field, and an app that cannot prove it is enabled
|
|
2420
|
+
// should stop offering the option, not stop starting.
|
|
2421
|
+
emailLoginEnabled: response.emailLoginEnabled === true
|
|
2231
2422
|
};
|
|
2232
2423
|
}
|
|
2233
2424
|
function createClient(config) {
|
|
@@ -2295,6 +2486,7 @@ function createClient(config) {
|
|
|
2295
2486
|
legacyBridge.connect();
|
|
2296
2487
|
let initialized = false;
|
|
2297
2488
|
let allowSignup = true;
|
|
2489
|
+
let emailLoginEnabled = false;
|
|
2298
2490
|
async function init() {
|
|
2299
2491
|
if (initialized) return;
|
|
2300
2492
|
const publicClient = new HttpClient({
|
|
@@ -2310,6 +2502,7 @@ function createClient(config) {
|
|
|
2310
2502
|
entitiesModule.setDataSourceId(appInfo.dataSourceId);
|
|
2311
2503
|
}
|
|
2312
2504
|
allowSignup = appInfo.allowSignup;
|
|
2505
|
+
emailLoginEnabled = appInfo.emailLoginEnabled;
|
|
2313
2506
|
initialized = true;
|
|
2314
2507
|
}
|
|
2315
2508
|
return {
|
|
@@ -2325,6 +2518,9 @@ function createClient(config) {
|
|
|
2325
2518
|
get allowSignup() {
|
|
2326
2519
|
return allowSignup;
|
|
2327
2520
|
},
|
|
2521
|
+
get emailLoginEnabled() {
|
|
2522
|
+
return emailLoginEnabled;
|
|
2523
|
+
},
|
|
2328
2524
|
config
|
|
2329
2525
|
};
|
|
2330
2526
|
}
|