@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.js
CHANGED
|
@@ -384,16 +384,37 @@ async function resolveApiKeySession(publicClient, appId, apiKey) {
|
|
|
384
384
|
);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
// src/modules/
|
|
387
|
+
// src/modules/auth-page-flow.ts
|
|
388
388
|
import { expectObject } from "@mitralab.io/sdk-core";
|
|
389
389
|
var RESULT_TYPE = "mitra-oauth-result";
|
|
390
|
-
var
|
|
391
|
-
|
|
392
|
-
|
|
390
|
+
var FIVE_MINUTES_MS = 5 * 60 * 1e3;
|
|
391
|
+
var TEN_MINUTES_MS = 10 * 60 * 1e3;
|
|
392
|
+
var PROVIDERS = {
|
|
393
|
+
google: {
|
|
394
|
+
label: "Google",
|
|
395
|
+
exchangePath: "/api/v1/auth/google",
|
|
396
|
+
sendsRedirectUri: true,
|
|
397
|
+
redirectStorage: "sessionStorage",
|
|
398
|
+
popupTimeoutMs: FIVE_MINUTES_MS
|
|
399
|
+
},
|
|
400
|
+
microsoft: {
|
|
401
|
+
label: "Microsoft",
|
|
402
|
+
exchangePath: "/api/v1/auth/microsoft",
|
|
403
|
+
sendsRedirectUri: true,
|
|
404
|
+
redirectStorage: "sessionStorage",
|
|
405
|
+
popupTimeoutMs: FIVE_MINUTES_MS
|
|
406
|
+
},
|
|
407
|
+
email: {
|
|
408
|
+
label: "Email",
|
|
409
|
+
exchangePath: "/api/v1/auth/magic-link/exchange",
|
|
410
|
+
sendsRedirectUri: false,
|
|
411
|
+
redirectStorage: "localStorage",
|
|
412
|
+
pendingRequestTtlMs: TEN_MINUTES_MS,
|
|
413
|
+
popupTimeoutMs: TEN_MINUTES_MS
|
|
414
|
+
}
|
|
393
415
|
};
|
|
394
416
|
var POPUP_WIDTH = 480;
|
|
395
417
|
var POPUP_HEIGHT = 600;
|
|
396
|
-
var POPUP_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
397
418
|
var POPUP_CLOSED_POLL_MS = 500;
|
|
398
419
|
function expectAuthTokenResponse(value) {
|
|
399
420
|
const response = expectObject(
|
|
@@ -414,12 +435,13 @@ function expectAuthTokenResponse(value) {
|
|
|
414
435
|
tokenType: response.tokenType
|
|
415
436
|
};
|
|
416
437
|
}
|
|
417
|
-
var
|
|
438
|
+
var AuthPageFlow = class {
|
|
418
439
|
appId;
|
|
419
440
|
apiUrl;
|
|
420
441
|
configuredAuthPageUrl;
|
|
421
442
|
client;
|
|
422
443
|
provider;
|
|
444
|
+
profile;
|
|
423
445
|
providerLabel;
|
|
424
446
|
redirectStorageKey;
|
|
425
447
|
popupPromise = null;
|
|
@@ -429,7 +451,8 @@ var GoogleAuthFlow = class {
|
|
|
429
451
|
this.configuredAuthPageUrl = config.authPageUrl;
|
|
430
452
|
this.client = config.client;
|
|
431
453
|
this.provider = config.provider ?? "google";
|
|
432
|
-
this.
|
|
454
|
+
this.profile = PROVIDERS[this.provider];
|
|
455
|
+
this.providerLabel = this.profile.label;
|
|
433
456
|
this.redirectStorageKey = `mitra_${this.provider}_redirect_${config.appId}`;
|
|
434
457
|
}
|
|
435
458
|
signIn(options = {}) {
|
|
@@ -446,6 +469,19 @@ var GoogleAuthFlow = class {
|
|
|
446
469
|
});
|
|
447
470
|
return this.popupPromise;
|
|
448
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* Finishes a redirect this provider started, or returns `null` when the URL
|
|
474
|
+
* carries no result or carries one that belongs to another provider's flow.
|
|
475
|
+
*
|
|
476
|
+
* The state generated at the start of every flow is prefixed with the provider
|
|
477
|
+
* name, and the auth page echoes it verbatim, so a fragment identifies its own
|
|
478
|
+
* flow. An application that offers several methods can call every completion
|
|
479
|
+
* at startup, in any order, even while other methods have requests pending.
|
|
480
|
+
*
|
|
481
|
+
* A fragment of this flow is always consumed, including when it cannot be
|
|
482
|
+
* completed, so a failure is reported once instead of on every reload. A
|
|
483
|
+
* fragment of another flow is left exactly as it was found.
|
|
484
|
+
*/
|
|
449
485
|
async completeRedirect() {
|
|
450
486
|
const browserWindow = this.requireBrowser();
|
|
451
487
|
const params = new URLSearchParams(browserWindow.location.hash.replace(/^#/, ""));
|
|
@@ -454,17 +490,36 @@ var GoogleAuthFlow = class {
|
|
|
454
490
|
const error = params.get("errorMitra");
|
|
455
491
|
if (code === null && state === null && error === null) return null;
|
|
456
492
|
const context = this.readRedirectContext(browserWindow);
|
|
457
|
-
|
|
458
|
-
|
|
493
|
+
const hasState = state !== null && state.trim() !== "";
|
|
494
|
+
if (hasState && !this.ownsState(state)) return null;
|
|
495
|
+
if (!hasState && !context) return null;
|
|
496
|
+
if (!hasState) {
|
|
497
|
+
throw this.discardOwnRedirect(
|
|
498
|
+
browserWindow,
|
|
499
|
+
`${this.providerLabel} sign-in redirect is missing state.`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (context && this.hasExpired(context)) {
|
|
503
|
+
this.clearRedirectContext(browserWindow);
|
|
504
|
+
throw this.discardOwnRedirect(
|
|
505
|
+
browserWindow,
|
|
506
|
+
`${this.providerLabel} sign-in request expired before it was completed.`
|
|
507
|
+
);
|
|
459
508
|
}
|
|
460
509
|
if (context?.state !== state) {
|
|
461
|
-
throw
|
|
510
|
+
throw this.discardOwnRedirect(
|
|
511
|
+
browserWindow,
|
|
512
|
+
`Invalid ${this.providerLabel} sign-in state (possible CSRF).`
|
|
513
|
+
);
|
|
462
514
|
}
|
|
463
515
|
const expectedRedirectUri = this.getRedirectUri(
|
|
464
516
|
resolveAuthPageUrl(this.apiUrl, this.configuredAuthPageUrl, browserWindow)
|
|
465
517
|
);
|
|
466
518
|
if (context.redirectUri !== expectedRedirectUri) {
|
|
467
|
-
throw
|
|
519
|
+
throw this.discardOwnRedirect(
|
|
520
|
+
browserWindow,
|
|
521
|
+
`${this.providerLabel} sign-in redirect context is invalid.`
|
|
522
|
+
);
|
|
468
523
|
}
|
|
469
524
|
this.cleanRedirectFragment(browserWindow);
|
|
470
525
|
this.clearRedirectContext(browserWindow);
|
|
@@ -483,8 +538,13 @@ var GoogleAuthFlow = class {
|
|
|
483
538
|
this.configuredAuthPageUrl,
|
|
484
539
|
browserWindow
|
|
485
540
|
);
|
|
541
|
+
const completedInAnotherTab = this.profile.pendingRequestTtlMs !== void 0;
|
|
542
|
+
if (completedInAnotherTab) {
|
|
543
|
+
this.writeRedirectContext(browserWindow, this.newRedirectContext(state, authPageUrl));
|
|
544
|
+
}
|
|
486
545
|
const popup = this.openPopup(browserWindow, this.buildStartUrl(browserWindow, authPageUrl, state));
|
|
487
546
|
const result = await this.waitForPopupResult(browserWindow, popup, authPageUrl.origin, state);
|
|
547
|
+
if (completedInAnotherTab) this.clearRedirectContext(browserWindow);
|
|
488
548
|
if (result.code) return this.exchangeCode(result.code, this.getRedirectUri(authPageUrl));
|
|
489
549
|
return expectAuthTokenResponse(result.token);
|
|
490
550
|
}
|
|
@@ -495,20 +555,20 @@ var GoogleAuthFlow = class {
|
|
|
495
555
|
this.configuredAuthPageUrl,
|
|
496
556
|
browserWindow
|
|
497
557
|
);
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
558
|
+
if (!this.writeRedirectContext(browserWindow, this.newRedirectContext(state, authPageUrl))) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
`${this.providerLabel} sign-in redirect requires ${this.profile.redirectStorage}.`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
503
563
|
const startUrl = this.buildStartUrl(browserWindow, authPageUrl, state);
|
|
504
564
|
browserWindow.location.assign(startUrl.toString());
|
|
505
565
|
return new Promise(() => void 0);
|
|
506
566
|
}
|
|
507
567
|
async exchangeCode(code, redirectUri) {
|
|
508
|
-
const response = await this.client.post(
|
|
568
|
+
const response = await this.client.post(this.profile.exchangePath, {
|
|
509
569
|
appId: this.appId,
|
|
510
570
|
code,
|
|
511
|
-
redirectUri
|
|
571
|
+
...this.profile.sendsRedirectUri ? { redirectUri } : {}
|
|
512
572
|
});
|
|
513
573
|
return expectAuthTokenResponse(response);
|
|
514
574
|
}
|
|
@@ -525,13 +585,41 @@ var GoogleAuthFlow = class {
|
|
|
525
585
|
getRedirectUri(authPageUrl) {
|
|
526
586
|
return `${authPageUrl.origin}${authPageUrl.pathname}`;
|
|
527
587
|
}
|
|
588
|
+
newRedirectContext(state, authPageUrl) {
|
|
589
|
+
return {
|
|
590
|
+
state,
|
|
591
|
+
redirectUri: this.getRedirectUri(authPageUrl),
|
|
592
|
+
createdAt: Date.now()
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Reports a fragment of this flow that cannot be completed, dropping it from the
|
|
597
|
+
* URL first. Nobody else claims a fragment that names this flow, so leaving it
|
|
598
|
+
* there would make the application fail again on every reload.
|
|
599
|
+
*/
|
|
600
|
+
discardOwnRedirect(browserWindow, message) {
|
|
601
|
+
this.cleanRedirectFragment(browserWindow);
|
|
602
|
+
return new Error(message);
|
|
603
|
+
}
|
|
604
|
+
/** Whether a state echoed by the auth page was generated by this provider's flow. */
|
|
605
|
+
ownsState(state) {
|
|
606
|
+
return state.startsWith(`${this.provider}.`);
|
|
607
|
+
}
|
|
608
|
+
hasExpired(context) {
|
|
609
|
+
const ttlMs = this.profile.pendingRequestTtlMs;
|
|
610
|
+
if (ttlMs === void 0) return false;
|
|
611
|
+
if (!Number.isFinite(context.createdAt)) return true;
|
|
612
|
+
return Date.now() - context.createdAt > ttlMs;
|
|
613
|
+
}
|
|
614
|
+
/** A one-time state that names the flow that created it, so its fragment is recognizable. */
|
|
528
615
|
generateState() {
|
|
529
616
|
if (!globalThis.crypto?.getRandomValues) {
|
|
530
617
|
throw new Error(`${this.providerLabel} sign-in requires crypto.getRandomValues.`);
|
|
531
618
|
}
|
|
532
619
|
const bytes = new Uint8Array(16);
|
|
533
620
|
globalThis.crypto.getRandomValues(bytes);
|
|
534
|
-
|
|
621
|
+
const random = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
622
|
+
return `${this.provider}.${random}`;
|
|
535
623
|
}
|
|
536
624
|
openPopup(browserWindow, url) {
|
|
537
625
|
const outerWidth = browserWindow.outerWidth || browserWindow.screen.width;
|
|
@@ -540,7 +628,7 @@ var GoogleAuthFlow = class {
|
|
|
540
628
|
const top = Math.max(0, (browserWindow.screenY || 0) + (outerHeight - POPUP_HEIGHT) / 2);
|
|
541
629
|
const popup = browserWindow.open(
|
|
542
630
|
url.toString(),
|
|
543
|
-
|
|
631
|
+
`mitra-${this.provider}-auth`,
|
|
544
632
|
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},menubar=no,toolbar=no,status=no`
|
|
545
633
|
);
|
|
546
634
|
if (!popup) {
|
|
@@ -553,7 +641,7 @@ var GoogleAuthFlow = class {
|
|
|
553
641
|
const timeout = globalThis.setTimeout(() => {
|
|
554
642
|
cleanup();
|
|
555
643
|
reject(new Error(`${this.providerLabel} sign-in timed out.`));
|
|
556
|
-
},
|
|
644
|
+
}, this.profile.popupTimeoutMs);
|
|
557
645
|
const closedPoll = globalThis.setInterval(() => {
|
|
558
646
|
if (popup.closed) {
|
|
559
647
|
cleanup();
|
|
@@ -578,7 +666,7 @@ var GoogleAuthFlow = class {
|
|
|
578
666
|
const code = typeof data.code === "string" && data.code.trim() ? data.code : void 0;
|
|
579
667
|
if (!code && data.token === void 0) {
|
|
580
668
|
cleanup();
|
|
581
|
-
reject(new Error(
|
|
669
|
+
reject(new Error(`${this.providerLabel} auth page returned neither code nor token.`));
|
|
582
670
|
return;
|
|
583
671
|
}
|
|
584
672
|
cleanup();
|
|
@@ -593,22 +681,28 @@ var GoogleAuthFlow = class {
|
|
|
593
681
|
browserWindow.addEventListener("message", onMessage);
|
|
594
682
|
});
|
|
595
683
|
}
|
|
596
|
-
|
|
684
|
+
/** Writes the pending request, reporting whether storage accepted it. */
|
|
685
|
+
writeRedirectContext(browserWindow, context) {
|
|
597
686
|
try {
|
|
598
|
-
browserWindow.
|
|
687
|
+
browserWindow[this.profile.redirectStorage].setItem(
|
|
688
|
+
this.redirectStorageKey,
|
|
689
|
+
JSON.stringify(context)
|
|
690
|
+
);
|
|
691
|
+
return true;
|
|
599
692
|
} catch {
|
|
600
|
-
|
|
693
|
+
return false;
|
|
601
694
|
}
|
|
602
695
|
}
|
|
603
696
|
readRedirectContext(browserWindow) {
|
|
604
697
|
try {
|
|
605
|
-
const raw = browserWindow.
|
|
698
|
+
const raw = browserWindow[this.profile.redirectStorage].getItem(this.redirectStorageKey);
|
|
606
699
|
if (!raw) return null;
|
|
607
700
|
const value = JSON.parse(raw);
|
|
608
701
|
if (typeof value.state !== "string" || typeof value.redirectUri !== "string") return null;
|
|
609
702
|
return {
|
|
610
703
|
state: value.state,
|
|
611
|
-
redirectUri: value.redirectUri
|
|
704
|
+
redirectUri: value.redirectUri,
|
|
705
|
+
createdAt: typeof value.createdAt === "number" ? value.createdAt : Number.NaN
|
|
612
706
|
};
|
|
613
707
|
} catch {
|
|
614
708
|
return null;
|
|
@@ -616,7 +710,7 @@ var GoogleAuthFlow = class {
|
|
|
616
710
|
}
|
|
617
711
|
clearRedirectContext(browserWindow) {
|
|
618
712
|
try {
|
|
619
|
-
browserWindow.
|
|
713
|
+
browserWindow[this.profile.redirectStorage].removeItem(this.redirectStorageKey);
|
|
620
714
|
} catch {
|
|
621
715
|
}
|
|
622
716
|
}
|
|
@@ -686,6 +780,7 @@ var AuthModule = class {
|
|
|
686
780
|
currentUserApi;
|
|
687
781
|
googleAuth;
|
|
688
782
|
microsoftAuth;
|
|
783
|
+
emailAuth;
|
|
689
784
|
constructor(appId, iamBaseUrl, options = {}) {
|
|
690
785
|
this.appId = appId;
|
|
691
786
|
const trimmedIamBaseUrl = stripTrailingSlashes(iamBaseUrl);
|
|
@@ -702,19 +797,26 @@ var AuthModule = class {
|
|
|
702
797
|
onUnauthorized: (requestToken) => this.handleUnauthorized(requestToken)
|
|
703
798
|
});
|
|
704
799
|
this.currentUserApi = createAuthModule(this.authedClient, coreErrors);
|
|
705
|
-
this.googleAuth = new
|
|
800
|
+
this.googleAuth = new AuthPageFlow({
|
|
706
801
|
appId,
|
|
707
802
|
apiUrl,
|
|
708
803
|
authPageUrl: options.authPageUrl,
|
|
709
804
|
client: this.publicClient
|
|
710
805
|
});
|
|
711
|
-
this.microsoftAuth = new
|
|
806
|
+
this.microsoftAuth = new AuthPageFlow({
|
|
712
807
|
appId,
|
|
713
808
|
apiUrl,
|
|
714
809
|
authPageUrl: options.authPageUrl,
|
|
715
810
|
client: this.publicClient,
|
|
716
811
|
provider: "microsoft"
|
|
717
812
|
});
|
|
813
|
+
this.emailAuth = new AuthPageFlow({
|
|
814
|
+
appId,
|
|
815
|
+
apiUrl,
|
|
816
|
+
authPageUrl: options.authPageUrl,
|
|
817
|
+
client: this.publicClient,
|
|
818
|
+
provider: "email"
|
|
819
|
+
});
|
|
718
820
|
this.loadFromStorage();
|
|
719
821
|
const readAccessToken = () => this.#accessToken;
|
|
720
822
|
sessionPorts.set(this, {
|
|
@@ -740,10 +842,10 @@ var AuthModule = class {
|
|
|
740
842
|
get isAuthenticated() {
|
|
741
843
|
return this._currentUser !== null && this.#accessToken !== null;
|
|
742
844
|
}
|
|
743
|
-
/** @deprecated Email/password authentication is not implemented by IAM. Use
|
|
845
|
+
/** @deprecated Email/password authentication is not implemented by IAM. Use signInWithEmail() or SSO. */
|
|
744
846
|
async signIn(_credentials) {
|
|
745
847
|
throw new MitraApiError(
|
|
746
|
-
"Email/password authentication is not available. Use signInWithGoogle() or signInWithMicrosoft().",
|
|
848
|
+
"Email/password authentication is not available. Use signInWithEmail(), signInWithGoogle() or signInWithMicrosoft().",
|
|
747
849
|
0,
|
|
748
850
|
"UNSUPPORTED_AUTH_METHOD"
|
|
749
851
|
);
|
|
@@ -829,8 +931,10 @@ var AuthModule = class {
|
|
|
829
931
|
*
|
|
830
932
|
* The method consumes and clears the fragment and stored CSRF context, sends
|
|
831
933
|
* the single-use code directly to IAM, persists both tokens, calls `auth.me()`,
|
|
832
|
-
* and notifies auth-state listeners. It returns `null` when the current URL
|
|
833
|
-
*
|
|
934
|
+
* and notifies auth-state listeners. It returns `null` when the current URL
|
|
935
|
+
* carries no redirect result, and also when it carries one this flow never
|
|
936
|
+
* started, so an application that offers several methods can call every
|
|
937
|
+
* completion at startup. Redirect errors must carry the same `stateMitra`
|
|
834
938
|
* stored at the start of the flow before their message is exposed or consumed.
|
|
835
939
|
*
|
|
836
940
|
* @returns The authenticated user, or `null` when no redirect result is present.
|
|
@@ -866,10 +970,54 @@ var AuthModule = class {
|
|
|
866
970
|
const tokenResponse = await this.microsoftAuth.completeRedirect();
|
|
867
971
|
return tokenResponse ? this.establishSession(tokenResponse) : null;
|
|
868
972
|
}
|
|
869
|
-
/**
|
|
973
|
+
/**
|
|
974
|
+
* Signs in with a one-time code sent by email: the same auth-page handshake as
|
|
975
|
+
* SSO, where the platform page collects the address and the code, and IAM
|
|
976
|
+
* hands back a single-use exchange code redeemed at `/auth/magic-link/exchange`.
|
|
977
|
+
* Popup by default; redirect mode navigates away.
|
|
978
|
+
*
|
|
979
|
+
* The message also carries a link. Because that link opens a new tab, the
|
|
980
|
+
* pending request is kept in `localStorage` for 10 minutes - the one-time state
|
|
981
|
+
* and the auth page URL, never a token - so
|
|
982
|
+
* {@link completeEmailSignInRedirect} can finish the flow in that tab.
|
|
983
|
+
*
|
|
984
|
+
* @param options - Popup or redirect mode.
|
|
985
|
+
* @returns The authenticated and hydrated user in popup mode.
|
|
986
|
+
* @throws {MitraApiError} When IAM rejects the exchange code.
|
|
987
|
+
* @throws {Error} When the browser blocks or cancels the popup, the flow times
|
|
988
|
+
* out, or the response fails origin, source, state, or shape validation. A
|
|
989
|
+
* person who finishes through the link instead of the popup leaves this call
|
|
990
|
+
* to time out, which the application should treat as a cancelled popup.
|
|
991
|
+
*
|
|
992
|
+
* @example
|
|
993
|
+
* ```typescript
|
|
994
|
+
* const user = await mitra.auth.signInWithEmail();
|
|
995
|
+
* ```
|
|
996
|
+
*/
|
|
997
|
+
async signInWithEmail(options = {}) {
|
|
998
|
+
return this.establishSession(await this.emailAuth.signIn(options));
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Completes an email sign-in from `#codeMitra` and `#stateMitra`, during
|
|
1002
|
+
* application startup like {@link completeGoogleSignInRedirect}.
|
|
1003
|
+
*
|
|
1004
|
+
* It covers both ways the flow comes back, and they are the same check: a
|
|
1005
|
+
* redirect in the tab that started it, and the tab the link in the message
|
|
1006
|
+
* opened. Both carry the one-time state this SDK generated, which the second
|
|
1007
|
+
* tab matches against the pending request kept in `localStorage`. A request
|
|
1008
|
+
* older than 10 minutes is discarded instead of completed.
|
|
1009
|
+
*
|
|
1010
|
+
* @returns The authenticated user, or `null` when the URL carries no result or
|
|
1011
|
+
* carries one that belongs to another sign-in method's flow.
|
|
1012
|
+
*/
|
|
1013
|
+
async completeEmailSignInRedirect() {
|
|
1014
|
+
const tokenResponse = await this.emailAuth.completeRedirect();
|
|
1015
|
+
return tokenResponse ? this.establishSession(tokenResponse) : null;
|
|
1016
|
+
}
|
|
1017
|
+
/** @deprecated Email/password registration is not implemented by IAM. Use signInWithEmail() or SSO. */
|
|
870
1018
|
async signUp(_data) {
|
|
871
1019
|
throw new MitraApiError(
|
|
872
|
-
"Email/password registration is not available. Use signInWithGoogle() or signInWithMicrosoft().",
|
|
1020
|
+
"Email/password registration is not available. Use signInWithEmail(), signInWithGoogle() or signInWithMicrosoft().",
|
|
873
1021
|
0,
|
|
874
1022
|
"UNSUPPORTED_AUTH_METHOD"
|
|
875
1023
|
);
|
|
@@ -1623,11 +1771,10 @@ function holdSendsWhileOffline(session, outbox) {
|
|
|
1623
1771
|
|
|
1624
1772
|
// src/modules/agent-session.ts
|
|
1625
1773
|
var CONNECT_TIMEOUT_MS = 15e3;
|
|
1626
|
-
var CHANNEL_BOOT_TIMEOUT_MS = 9e4;
|
|
1627
|
-
var CHANNEL_BOOT_RETRY_MS = 2e3;
|
|
1628
1774
|
var SILENCE_TIMEOUT_MS = 6e4;
|
|
1629
1775
|
var RECONNECT_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 16e3];
|
|
1630
1776
|
var SUPERSEDED_CLOSE_CODE = 4409;
|
|
1777
|
+
var SOCKET_OPEN = 1;
|
|
1631
1778
|
var SilenceWatchdog = class {
|
|
1632
1779
|
constructor(onSilence) {
|
|
1633
1780
|
this.onSilence = onSilence;
|
|
@@ -1743,6 +1890,8 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1743
1890
|
*/
|
|
1744
1891
|
boxCursors = /* @__PURE__ */ new Map();
|
|
1745
1892
|
boxAddresses = /* @__PURE__ */ new Map();
|
|
1893
|
+
/** The box socket a task is talking on right now; absent while it is lost or being redialed. */
|
|
1894
|
+
directSockets = /* @__PURE__ */ new Map();
|
|
1746
1895
|
async open(taskId, observer, signal, transport = "auto") {
|
|
1747
1896
|
if (transport === "http") return this.openSse(taskId, observer, signal);
|
|
1748
1897
|
if (transport === "websocket") return this.openWebSocket(taskId, observer, signal);
|
|
@@ -1774,6 +1923,23 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1774
1923
|
}
|
|
1775
1924
|
};
|
|
1776
1925
|
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Writes a message or an interrupt on the box socket when the task is on the direct channel
|
|
1928
|
+
* and that socket is open right now. The box accepts the core's input as its own inbound
|
|
1929
|
+
* frame, asks the copilot for admission itself and answers on this same socket with the
|
|
1930
|
+
* frames the observer already reads, so the copilot's host socket is off the message path.
|
|
1931
|
+
* False sends the caller to REST: no direct channel, a socket lost or mid-redial, or an
|
|
1932
|
+
* approval, which stays on REST as it is.
|
|
1933
|
+
*
|
|
1934
|
+
* A frame written on a socket that closes before the box acknowledges it is not sent again
|
|
1935
|
+
* over REST: the box may have admitted the turn already, and a second copy would start it
|
|
1936
|
+
* twice. The redial replays the box log, which is the same recovery a REST 202 gets when its
|
|
1937
|
+
* turn is lost.
|
|
1938
|
+
*/
|
|
1939
|
+
sendOnChannel(taskId, input) {
|
|
1940
|
+
if (input.type === "approval_response") return false;
|
|
1941
|
+
return this.directSockets.get(taskId)?.send(JSON.stringify(input)) ?? false;
|
|
1942
|
+
}
|
|
1777
1943
|
async requireFreshToken() {
|
|
1778
1944
|
const fresh = await this.auth.ensureFreshSession();
|
|
1779
1945
|
const token = this.auth.accessToken;
|
|
@@ -1790,28 +1956,28 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1790
1956
|
requestDirectChannel(taskId, token, signal) {
|
|
1791
1957
|
return this.askDirectChannel(taskId, token, signal).catch(() => null);
|
|
1792
1958
|
}
|
|
1793
|
-
/**
|
|
1959
|
+
/**
|
|
1960
|
+
* Like `requestDirectChannel`, but a request the network lost is thrown, not a refusal.
|
|
1961
|
+
*
|
|
1962
|
+
* One request. The copilot holds it while the box boots and answers 200 with the channel, or
|
|
1963
|
+
* an error status once the box cannot be had. A 202 comes only from a copilot older than that
|
|
1964
|
+
* contract, which used to mean "still booting, ask again": there is no channel to open, and
|
|
1965
|
+
* the conversation follows the copilot socket. Polling here again would put the wait back on
|
|
1966
|
+
* the client that the server now owns.
|
|
1967
|
+
*/
|
|
1794
1968
|
async askDirectChannel(taskId, token, signal) {
|
|
1969
|
+
if (signal?.aborted) return null;
|
|
1795
1970
|
const url = `${this.apiUrl}/copilot/api/v1/tasks/${encodeURIComponent(taskId)}/channel`;
|
|
1796
|
-
const
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
await sleep(CHANNEL_BOOT_RETRY_MS, signal);
|
|
1807
|
-
continue;
|
|
1808
|
-
}
|
|
1809
|
-
if (!response.ok) return null;
|
|
1810
|
-
try {
|
|
1811
|
-
return toDirectChannel(await response.json(), this.apiUrl);
|
|
1812
|
-
} catch {
|
|
1813
|
-
return null;
|
|
1814
|
-
}
|
|
1971
|
+
const response = await globalThis.fetch(url, {
|
|
1972
|
+
method: "POST",
|
|
1973
|
+
headers: { Authorization: `Bearer ${stripBearer2(token)}` },
|
|
1974
|
+
...signal ? { signal } : {}
|
|
1975
|
+
});
|
|
1976
|
+
if (response.status === 202 || !response.ok) return null;
|
|
1977
|
+
try {
|
|
1978
|
+
return toDirectChannel(await response.json(), this.apiUrl);
|
|
1979
|
+
} catch {
|
|
1980
|
+
return null;
|
|
1815
1981
|
}
|
|
1816
1982
|
}
|
|
1817
1983
|
async openWebSocket(taskId, observer, signal) {
|
|
@@ -1860,6 +2026,11 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1860
2026
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1861
2027
|
let inTurn = false;
|
|
1862
2028
|
let current = null;
|
|
2029
|
+
const track = (socket) => {
|
|
2030
|
+
if (socket) this.directSockets.set(taskId, socket);
|
|
2031
|
+
else if (current && this.directSockets.get(taskId) === current) this.directSockets.delete(taskId);
|
|
2032
|
+
current = socket;
|
|
2033
|
+
};
|
|
1863
2034
|
const onFrame = (event) => {
|
|
1864
2035
|
if (typeof event.sequence === "number") {
|
|
1865
2036
|
const seen = this.boxCursors.get(taskId) ?? 0;
|
|
@@ -1869,22 +2040,21 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1869
2040
|
observer.onEvent(event);
|
|
1870
2041
|
};
|
|
1871
2042
|
const onLost = (error, code) => {
|
|
1872
|
-
|
|
2043
|
+
track(null);
|
|
1873
2044
|
if (link.signal.aborted) return;
|
|
1874
2045
|
if (!inTurn || code === SUPERSEDED_CLOSE_CODE) {
|
|
1875
2046
|
observer.onDisconnect(error);
|
|
1876
2047
|
return;
|
|
1877
2048
|
}
|
|
1878
|
-
void this.redial(taskId, error, link.signal, observer, { onFrame, onLost }).then(
|
|
1879
|
-
current = socket;
|
|
1880
|
-
});
|
|
2049
|
+
void this.redial(taskId, error, link.signal, observer, { onFrame, onLost }).then(track);
|
|
1881
2050
|
};
|
|
1882
|
-
|
|
2051
|
+
track(await this.dial(channel.wsUrl, { signal: link.signal, onFrame, onLost }));
|
|
1883
2052
|
return {
|
|
1884
2053
|
close: () => {
|
|
1885
2054
|
signal?.removeEventListener("abort", onAbort);
|
|
1886
2055
|
link.abort();
|
|
1887
2056
|
current?.close();
|
|
2057
|
+
track(null);
|
|
1888
2058
|
}
|
|
1889
2059
|
};
|
|
1890
2060
|
}
|
|
@@ -1969,6 +2139,15 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1969
2139
|
removeAbortListener();
|
|
1970
2140
|
socket.close(1e3, "Client closed");
|
|
1971
2141
|
};
|
|
2142
|
+
const send = (frame) => {
|
|
2143
|
+
if (intentionalClose || socket.readyState !== SOCKET_OPEN) return false;
|
|
2144
|
+
try {
|
|
2145
|
+
socket.send(frame);
|
|
2146
|
+
return true;
|
|
2147
|
+
} catch {
|
|
2148
|
+
return false;
|
|
2149
|
+
}
|
|
2150
|
+
};
|
|
1972
2151
|
const onAbort = () => {
|
|
1973
2152
|
if (!opened) {
|
|
1974
2153
|
rejectHandshake(signal?.reason instanceof Error ? signal.reason : new Error("Agent WebSocket connection aborted."));
|
|
@@ -1992,7 +2171,7 @@ var BrowserAgentTaskEventSource = class {
|
|
|
1992
2171
|
} catch {
|
|
1993
2172
|
}
|
|
1994
2173
|
}
|
|
1995
|
-
resolve({ close });
|
|
2174
|
+
resolve({ close, send });
|
|
1996
2175
|
};
|
|
1997
2176
|
socket.onerror = () => {
|
|
1998
2177
|
if (!opened) rejectHandshake(new Error("Failed to connect to the Agent WebSocket."));
|
|
@@ -2099,6 +2278,10 @@ var BrowserAgentTaskEventSource = class {
|
|
|
2099
2278
|
};
|
|
2100
2279
|
|
|
2101
2280
|
// src/modules/agent-tasks.ts
|
|
2281
|
+
function bornOnBox(options) {
|
|
2282
|
+
if (!("create" in options) || options.runtime || options.transport === "http") return options;
|
|
2283
|
+
return { ...options, runtime: "T3" };
|
|
2284
|
+
}
|
|
2102
2285
|
function createBrowserAgentTasksModule(httpClient, auth, apiUrl) {
|
|
2103
2286
|
const tasks = createAgentTasksModule(httpClient, coreErrors);
|
|
2104
2287
|
const source = new BrowserAgentTaskEventSource(auth, apiUrl);
|
|
@@ -2124,11 +2307,14 @@ function createBrowserAgentTasksModule(httpClient, auth, apiUrl) {
|
|
|
2124
2307
|
}
|
|
2125
2308
|
};
|
|
2126
2309
|
const manager = createAgentTaskSessionManager({
|
|
2127
|
-
tasks: {
|
|
2310
|
+
tasks: {
|
|
2311
|
+
...tasks,
|
|
2312
|
+
sendInput: (taskId, input) => source.sendOnChannel(taskId, input) ? Promise.resolve() : outbox.sendInput(taskId, input)
|
|
2313
|
+
},
|
|
2128
2314
|
eventSource
|
|
2129
2315
|
});
|
|
2130
2316
|
return withAgentTaskSessions(tasks, {
|
|
2131
|
-
session: (options) => holdSendsWhileOffline(manager.session(options), outbox)
|
|
2317
|
+
session: (options) => holdSendsWhileOffline(manager.session(bornOnBox(options)), outbox)
|
|
2132
2318
|
});
|
|
2133
2319
|
}
|
|
2134
2320
|
|
|
@@ -2192,7 +2378,12 @@ function expectAppInfoResponse(value) {
|
|
|
2192
2378
|
}
|
|
2193
2379
|
return {
|
|
2194
2380
|
dataSourceId: response.dataSourceId,
|
|
2195
|
-
allowSignup: response.allowSignup
|
|
2381
|
+
allowSignup: response.allowSignup,
|
|
2382
|
+
// Unlike the fields above, a missing or non-boolean value is read as `false`
|
|
2383
|
+
// instead of failing `init()`: a Code Studio older than the email login gate
|
|
2384
|
+
// answers without this field, and an app that cannot prove it is enabled
|
|
2385
|
+
// should stop offering the option, not stop starting.
|
|
2386
|
+
emailLoginEnabled: response.emailLoginEnabled === true
|
|
2196
2387
|
};
|
|
2197
2388
|
}
|
|
2198
2389
|
function createClient(config) {
|
|
@@ -2260,6 +2451,7 @@ function createClient(config) {
|
|
|
2260
2451
|
legacyBridge.connect();
|
|
2261
2452
|
let initialized = false;
|
|
2262
2453
|
let allowSignup = true;
|
|
2454
|
+
let emailLoginEnabled = false;
|
|
2263
2455
|
async function init() {
|
|
2264
2456
|
if (initialized) return;
|
|
2265
2457
|
const publicClient = new HttpClient({
|
|
@@ -2275,6 +2467,7 @@ function createClient(config) {
|
|
|
2275
2467
|
entitiesModule.setDataSourceId(appInfo.dataSourceId);
|
|
2276
2468
|
}
|
|
2277
2469
|
allowSignup = appInfo.allowSignup;
|
|
2470
|
+
emailLoginEnabled = appInfo.emailLoginEnabled;
|
|
2278
2471
|
initialized = true;
|
|
2279
2472
|
}
|
|
2280
2473
|
return {
|
|
@@ -2290,6 +2483,9 @@ function createClient(config) {
|
|
|
2290
2483
|
get allowSignup() {
|
|
2291
2484
|
return allowSignup;
|
|
2292
2485
|
},
|
|
2486
|
+
get emailLoginEnabled() {
|
|
2487
|
+
return emailLoginEnabled;
|
|
2488
|
+
},
|
|
2293
2489
|
config
|
|
2294
2490
|
};
|
|
2295
2491
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mitralab.io/platform-sdk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4-beta.0",
|
|
4
4
|
"description": "JavaScript/TypeScript SDK for building apps on the Mitra Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"url": "https://github.com/mitralab-dev/mitra-platform-sdk/issues"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@mitralab.io/sdk-core": "0.2.
|
|
65
|
+
"@mitralab.io/sdk-core": "0.2.4",
|
|
66
66
|
"mitra-interactions-sdk": "1.0.60-beta.48"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|