@oxyhq/core 12.10.6 → 12.11.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +66 -6
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +66 -6
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/OxyServices.privacy.js +6 -0
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +18 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/OxyServices.user.d.ts +2 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +3 -3
- package/src/HttpService.ts +67 -6
- package/src/__tests__/inSessionRefresh.test.ts +67 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/OxyServices.privacy.ts +6 -0
- package/src/mixins/OxyServices.user.ts +3 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
|
@@ -18,6 +18,61 @@ const userIdentity_1 = require("../utils/userIdentity");
|
|
|
18
18
|
* returned `expiresAt` is authoritative; this is only the client-proposed value.
|
|
19
19
|
*/
|
|
20
20
|
const COMMONS_SIGN_IN_EXPIRY_MS = 5 * 60 * 1000;
|
|
21
|
+
/**
|
|
22
|
+
* @internal Narrow an untrusted `requesterLabel` from the approve-info response.
|
|
23
|
+
*
|
|
24
|
+
* Returns the trimmed label only when the server sent a real, non-empty string;
|
|
25
|
+
* everything else — absent (an API that predates the field), `null` (a native
|
|
26
|
+
* requester the server could not describe), or a non-string — degrades to
|
|
27
|
+
* `null`, so the approval UI drops the line instead of rendering a blank or a
|
|
28
|
+
* coerced value under the app it is about to authorize.
|
|
29
|
+
*/
|
|
30
|
+
function parseCommonsRequesterLabel(value) {
|
|
31
|
+
if (typeof value !== 'string') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const label = value.trim();
|
|
35
|
+
return label.length > 0 ? label : null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* @internal Narrow an untrusted `subjectAccount` from the approve-info response.
|
|
39
|
+
*
|
|
40
|
+
* Returns `null` unless the value is an object carrying a non-empty string `id`
|
|
41
|
+
* AND `username` — the two fields the approval UI needs to name the delegated
|
|
42
|
+
* account. A half-populated object is rejected whole rather than rendered with
|
|
43
|
+
* blanks, and `displayName` is only carried through when it is a string.
|
|
44
|
+
*/
|
|
45
|
+
function parseCommonsSubjectAccount(value) {
|
|
46
|
+
if (value === null || typeof value !== 'object') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const raw = value;
|
|
50
|
+
const { id, username, displayName } = raw;
|
|
51
|
+
if (typeof id !== 'string' || !id || typeof username !== 'string' || !username) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
username,
|
|
57
|
+
...(typeof displayName === 'string' ? { displayName } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* @internal Narrow an untrusted delivery-progress timestamp from the status
|
|
62
|
+
* response.
|
|
63
|
+
*
|
|
64
|
+
* Returns the ISO-8601 string unchanged when it is a real, parseable instant,
|
|
65
|
+
* and `null` for everything else — absent (an older API that has no delivery
|
|
66
|
+
* progress at all), empty, non-string, or unparseable. Progress is advisory, so
|
|
67
|
+
* degrading to "no progress yet" is always safe; surfacing a garbage timestamp
|
|
68
|
+
* to the waiting UI is not.
|
|
69
|
+
*/
|
|
70
|
+
function parseCommonsProgressTimestamp(value) {
|
|
71
|
+
if (typeof value !== 'string' || !value) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return Number.isFinite(Date.parse(value)) ? value : null;
|
|
75
|
+
}
|
|
21
76
|
/**
|
|
22
77
|
* Sentinel error raised when getServiceToken() is called with a known apiKey
|
|
23
78
|
* but a non-matching secret. Indicates either credential drift in the caller
|
|
@@ -466,11 +521,16 @@ function OxyServicesAuthMixin(Base) {
|
|
|
466
521
|
// A. Same-device shared-keychain SSO (`signInWithSharedIdentity`): a
|
|
467
522
|
// sibling native app silently mints its own session from the shared
|
|
468
523
|
// identity key. No user interaction.
|
|
469
|
-
// B. QR / app-to-app handoff: a relying party
|
|
470
|
-
// `
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
524
|
+
// B. QR / app-to-app / popup / push handoff: a relying party
|
|
525
|
+
// (`startCommonsSignIn` + `pollCommonsSignIn`, finalized with either
|
|
526
|
+
// `claimSessionByToken` or `finalizeCommonsOAuth`) and the approver /
|
|
527
|
+
// Commons (`getCommonsApprovalInfo` + `approveCommonsSignIn` /
|
|
528
|
+
// `denyCommonsSignIn`). The approver signs with its PRIMARY local key;
|
|
529
|
+
// the RP never sees the private key.
|
|
530
|
+
//
|
|
531
|
+
// ONE request serves every delivery surface. The delivery surface is
|
|
532
|
+
// not part of the model: only the request's purpose is, and it is fixed
|
|
533
|
+
// at creation by whether an OAuth binding was attached.
|
|
474
534
|
// =======================================================================
|
|
475
535
|
/**
|
|
476
536
|
* MECHANISM A — same-device shared-keychain SSO.
|
|
@@ -521,12 +581,23 @@ function OxyServicesAuthMixin(Base) {
|
|
|
521
581
|
* returns the server-issued public `authorizeCode` + ready-to-render
|
|
522
582
|
* `qrPayload`. Render the QR (web) / open the deep-link (same-device); the
|
|
523
583
|
* approver resolves the code and authorizes. Then poll with
|
|
524
|
-
* {@link pollCommonsSignIn} and
|
|
525
|
-
*
|
|
584
|
+
* {@link pollCommonsSignIn} and finalize.
|
|
585
|
+
*
|
|
586
|
+
* ONE request serves every delivery surface, and how it finalizes is decided
|
|
587
|
+
* here by whether an OAuth binding is attached:
|
|
588
|
+
* - no `oauth` (the default): a `device_sign_in` request — on `authorized`,
|
|
589
|
+
* exchange the `sessionToken` via the existing `claimSessionByToken`.
|
|
590
|
+
* - with `oauth`: an `oauth_authorization` request — on `authorized`, call
|
|
591
|
+
* {@link finalizeCommonsOAuth} with the same `sessionToken` to mint the
|
|
592
|
+
* single-use authorization code, then exchange it with PKCE.
|
|
526
593
|
*
|
|
527
594
|
* @param params.clientId - The RP's registered OAuth client id
|
|
528
595
|
* (ApplicationCredential publicKey); required so the server can resolve the
|
|
529
596
|
* requesting application's identity.
|
|
597
|
+
* @param params.oauth - Optional OAuth binding ({@link CommonsOAuthContext}).
|
|
598
|
+
* Carries only the redirect URI, the PKCE S256 challenge, the requested
|
|
599
|
+
* scope, and an optional delegated `subjectAccountId` — never the PKCE
|
|
600
|
+
* verifier, the OAuth `state`, or any token.
|
|
530
601
|
*/
|
|
531
602
|
async startCommonsSignIn(params) {
|
|
532
603
|
try {
|
|
@@ -535,7 +606,15 @@ function OxyServicesAuthMixin(Base) {
|
|
|
535
606
|
// QR. Reuses the platform-safe random generator.
|
|
536
607
|
const sessionToken = await signatureService_1.SignatureService.generateChallenge();
|
|
537
608
|
const expiresAt = Date.now() + COMMONS_SIGN_IN_EXPIRY_MS;
|
|
538
|
-
const res = await this.makeRequest('POST', '/auth/session/create', {
|
|
609
|
+
const res = await this.makeRequest('POST', '/auth/session/create', {
|
|
610
|
+
sessionToken,
|
|
611
|
+
expiresAt,
|
|
612
|
+
clientId: params.clientId,
|
|
613
|
+
// Omitted entirely when absent so the device-sign-in body stays
|
|
614
|
+
// exactly what it has always been (the server reads presence, not a
|
|
615
|
+
// null, to decide the request's purpose).
|
|
616
|
+
...(params.oauth ? { oauth: params.oauth } : {}),
|
|
617
|
+
},
|
|
539
618
|
// Public/pre-session (no bearer): skip the preflight so a stale
|
|
540
619
|
// near-expiry token cannot re-enter refreshAccessToken while the
|
|
541
620
|
// refresh handler is already in flight (self-await hang).
|
|
@@ -556,18 +635,162 @@ function OxyServicesAuthMixin(Base) {
|
|
|
556
635
|
* MECHANISM B (relying party) — poll a device-flow session for approval.
|
|
557
636
|
*
|
|
558
637
|
* Backstop for the auth socket. On `authorized` (with a `sessionId`), the
|
|
559
|
-
* caller
|
|
560
|
-
*
|
|
638
|
+
* caller finalizes: `claimSessionByToken` for a `device_sign_in` request,
|
|
639
|
+
* {@link finalizeCommonsOAuth} for an `oauth_authorization` one.
|
|
640
|
+
*
|
|
641
|
+
* Every field is narrowed fail-safe. `authorized` counts only as a literal
|
|
642
|
+
* `true`, the identifiers only as non-empty strings, and the delivery
|
|
643
|
+
* progress timestamps degrade to `null` when absent or unparseable — so a
|
|
644
|
+
* partial or older-API payload can advance the waiting UI at most, never
|
|
645
|
+
* make it believe a request was approved.
|
|
561
646
|
*
|
|
562
647
|
* @param sessionToken - The secret token from {@link startCommonsSignIn}.
|
|
563
648
|
*/
|
|
564
649
|
async pollCommonsSignIn(sessionToken) {
|
|
565
650
|
try {
|
|
566
|
-
|
|
651
|
+
const res = await this.makeRequest('GET', `/auth/session/status/${encodeURIComponent(sessionToken)}`, undefined,
|
|
567
652
|
// Public/pre-session (no bearer): a preflight here is wrong per se and,
|
|
568
653
|
// if ever reached while a refresh is pending, would re-enter
|
|
569
654
|
// refreshAccessToken and await the very promise it runs inside.
|
|
570
655
|
{ cache: false, retry: false, skipAuth: true });
|
|
656
|
+
if (res === null || typeof res !== 'object') {
|
|
657
|
+
throw new Error('auth/session/status returned an unexpected response shape');
|
|
658
|
+
}
|
|
659
|
+
const { authorized, sessionId, publicKey, status, purpose, pushSentAt, openedAt } = res;
|
|
660
|
+
return {
|
|
661
|
+
authorized: authorized === true,
|
|
662
|
+
...(typeof sessionId === 'string' && sessionId ? { sessionId } : {}),
|
|
663
|
+
...(typeof publicKey === 'string' && publicKey ? { publicKey } : {}),
|
|
664
|
+
...(typeof status === 'string' && status ? { status } : {}),
|
|
665
|
+
purpose: purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in',
|
|
666
|
+
pushSentAt: parseCommonsProgressTimestamp(pushSentAt),
|
|
667
|
+
openedAt: parseCommonsProgressTimestamp(openedAt),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
throw this.handleError(error);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* MECHANISM B (relying party) — ask Oxy to DELIVER a pending sign-in request
|
|
676
|
+
* to the identity's known Commons installations.
|
|
677
|
+
*
|
|
678
|
+
* This is the automatic half of "one intention, one primary action": rather
|
|
679
|
+
* than offering the user a menu of transports, the caller asks for delivery
|
|
680
|
+
* and lets the answer pick the route. Pass the returned `targets` to
|
|
681
|
+
* `selectCommonsDelivery` (`utils/commonsDelivery`) — `targets: 0` means no
|
|
682
|
+
* capable Commons installation is registered, which is a NORMAL outcome that
|
|
683
|
+
* resolves to the QR route, not an error to surface.
|
|
684
|
+
*
|
|
685
|
+
* **Requires a bearer.** Delivery is only allowed when Oxy already knows the
|
|
686
|
+
* intended identity from a trusted authenticated context — a request that
|
|
687
|
+
* merely carries a username or email typed into an unauthenticated browser
|
|
688
|
+
* must never be able to ring somebody's phone.
|
|
689
|
+
*
|
|
690
|
+
* The push it sends carries only `{ type, approvalUrl }` where the URL holds
|
|
691
|
+
* the public `authorizeCode` — no display data, no secrets. Commons resolves
|
|
692
|
+
* everything it shows from `getCommonsApprovalInfo`.
|
|
693
|
+
*
|
|
694
|
+
* @param authorizeCode - The public code from {@link startCommonsSignIn}.
|
|
695
|
+
*/
|
|
696
|
+
async deliverCommonsSignIn(authorizeCode) {
|
|
697
|
+
try {
|
|
698
|
+
const res = await this.makeRequest('POST', `/auth/session/deliver/${encodeURIComponent(authorizeCode)}`, undefined,
|
|
699
|
+
// Bearer REQUIRED (unlike its public siblings): the identity to
|
|
700
|
+
// deliver to comes from the authenticated caller, never the code.
|
|
701
|
+
{ cache: false });
|
|
702
|
+
if (res === null || typeof res !== 'object') {
|
|
703
|
+
throw new Error('auth/session/deliver returned an unexpected response shape');
|
|
704
|
+
}
|
|
705
|
+
const { delivered, targets } = res;
|
|
706
|
+
// Both fields drive the route choice, so a partial payload is rejected
|
|
707
|
+
// outright rather than defaulted into a route the server never chose.
|
|
708
|
+
if (typeof delivered !== 'boolean' ||
|
|
709
|
+
typeof targets !== 'number' ||
|
|
710
|
+
!Number.isInteger(targets) ||
|
|
711
|
+
targets < 0) {
|
|
712
|
+
throw new Error('auth/session/deliver returned an incomplete delivery result');
|
|
713
|
+
}
|
|
714
|
+
return { delivered, targets };
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
throw this.handleError(error);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* MECHANISM B (approver / Commons) — report that the approval route was
|
|
722
|
+
* OPENED, so the waiting relying party can show "Opened in Commons".
|
|
723
|
+
*
|
|
724
|
+
* Progress only. It is idempotent, applies to a `pending` request alone, and
|
|
725
|
+
* records a timestamp (`openedAt`) — it never approves, authorizes, or
|
|
726
|
+
* advances the authorization state machine. Public, like the other approver
|
|
727
|
+
* handles: the approver has only the public `authorizeCode` at this point
|
|
728
|
+
* and has not yet signed anything.
|
|
729
|
+
*
|
|
730
|
+
* Best-effort by nature — a failure here costs the user only a progress
|
|
731
|
+
* line, so callers are free to ignore a rejection and continue to the
|
|
732
|
+
* approval screen.
|
|
733
|
+
*
|
|
734
|
+
* @param authorizeCode - The public code scanned from the QR / deep-link / push.
|
|
735
|
+
*/
|
|
736
|
+
async markCommonsApprovalOpened(authorizeCode) {
|
|
737
|
+
try {
|
|
738
|
+
await this.makeRequest('POST', `/auth/session/opened/${encodeURIComponent(authorizeCode)}`, undefined,
|
|
739
|
+
// Public (no bearer) — skip the preflight, exactly like approve-info.
|
|
740
|
+
{ cache: false, skipAuth: true });
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
throw this.handleError(error);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* MECHANISM B (relying party) — finalize an APPROVED, OAuth-bound request
|
|
748
|
+
* into a single-use OAuth authorization code.
|
|
749
|
+
*
|
|
750
|
+
* The OAuth counterpart of `claimSessionByToken`: same secret credential,
|
|
751
|
+
* same single-use semantics, different output. Call it once the request the
|
|
752
|
+
* RP started with an `oauth` binding reports `authorized`; the server
|
|
753
|
+
* atomically mints exactly ONE `AuthCode` bound to the redirect URI, PKCE
|
|
754
|
+
* challenge, scopes, approving identity, and any delegated subject account
|
|
755
|
+
* the request was created with. A second call cannot mint another code.
|
|
756
|
+
*
|
|
757
|
+
* The result is an authorization CODE, never a token — the caller completes
|
|
758
|
+
* the flow with the ordinary PKCE exchange (`exchangeOAuthCode`) using the
|
|
759
|
+
* `codeVerifier` it never sent anywhere. Nothing here is exposed to the
|
|
760
|
+
* popup: the code travels back through the registered callback, and the
|
|
761
|
+
* main window owns the verifier.
|
|
762
|
+
*
|
|
763
|
+
* Like `claimSessionByToken`, this needs no Authorization header — the
|
|
764
|
+
* high-entropy SECRET `sessionToken` IS the credential. Never pass the
|
|
765
|
+
* public `authorizeCode` here; it is the approver's handle, not the
|
|
766
|
+
* initiator's. Every server-side failure (wrong/expired/already-finalized
|
|
767
|
+
* request, non-OAuth purpose, missing permission for the delegated account)
|
|
768
|
+
* surfaces as one generic error, so nothing about the request's state can be
|
|
769
|
+
* probed from outside.
|
|
770
|
+
*
|
|
771
|
+
* @param sessionToken - The secret token from {@link startCommonsSignIn}.
|
|
772
|
+
*/
|
|
773
|
+
async finalizeCommonsOAuth(sessionToken) {
|
|
774
|
+
try {
|
|
775
|
+
const res = await this.makeRequest('POST', `/auth/session/finalize/${encodeURIComponent(sessionToken)}`, undefined,
|
|
776
|
+
// Body-authenticated by the path's secret token (no bearer) — skip the
|
|
777
|
+
// preflight, exactly like the device-flow claim.
|
|
778
|
+
{ cache: false, skipAuth: true });
|
|
779
|
+
if (res === null || typeof res !== 'object') {
|
|
780
|
+
throw new Error('auth/session/finalize returned an unexpected response shape');
|
|
781
|
+
}
|
|
782
|
+
const { code, redirectUri, expiresIn } = res;
|
|
783
|
+
// All three fields are load-bearing for the exchange that follows, so a
|
|
784
|
+
// partial payload is rejected outright rather than returned half-parsed.
|
|
785
|
+
if (typeof code !== 'string' ||
|
|
786
|
+
!code ||
|
|
787
|
+
typeof redirectUri !== 'string' ||
|
|
788
|
+
!redirectUri ||
|
|
789
|
+
typeof expiresIn !== 'number' ||
|
|
790
|
+
!Number.isFinite(expiresIn)) {
|
|
791
|
+
throw new Error('auth/session/finalize returned an incomplete authorization code');
|
|
792
|
+
}
|
|
793
|
+
return { code, redirectUri, expiresIn };
|
|
571
794
|
}
|
|
572
795
|
catch (error) {
|
|
573
796
|
throw this.handleError(error);
|
|
@@ -577,9 +800,10 @@ function OxyServicesAuthMixin(Base) {
|
|
|
577
800
|
* MECHANISM B (approver / Commons) — resolve the TRUSTED identity of a
|
|
578
801
|
* sign-in request from its public `authorizeCode`.
|
|
579
802
|
*
|
|
580
|
-
* The returned `application`
|
|
581
|
-
*
|
|
582
|
-
* strings carried in the QR payload. Public (no auth
|
|
803
|
+
* The returned `application` and `subjectAccount` are resolved server-side
|
|
804
|
+
* and are the only safe things to display in the approval UI — NEVER trust
|
|
805
|
+
* the app/name/origin strings carried in the QR payload. Public (no auth
|
|
806
|
+
* required).
|
|
583
807
|
*
|
|
584
808
|
* @param authorizeCode - The public code scanned from the QR / deep-link.
|
|
585
809
|
*/
|
|
@@ -597,6 +821,17 @@ function OxyServicesAuthMixin(Base) {
|
|
|
597
821
|
// missing or non-boolean value (older server, malformed response)
|
|
598
822
|
// coerces to `false` so a stale server can never imply trust.
|
|
599
823
|
originVerified: raw.originVerified === true,
|
|
824
|
+
// Same discipline: a missing/blank/non-string label degrades to null
|
|
825
|
+
// (an older API, or a native requester with no browser to describe),
|
|
826
|
+
// and the approver simply omits the "where from" line.
|
|
827
|
+
requesterLabel: parseCommonsRequesterLabel(raw.requesterLabel),
|
|
828
|
+
// Same discipline: only the literal OAuth purpose opts into OAuth
|
|
829
|
+
// finalization. Anything else — including a server that predates this
|
|
830
|
+
// field — is the plain device sign-in it has always been.
|
|
831
|
+
purpose: raw.purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in',
|
|
832
|
+
// A missing/partial delegated account degrades to "no delegation"
|
|
833
|
+
// rather than a half-rendered "will act as" line.
|
|
834
|
+
subjectAccount: parseCommonsSubjectAccount(raw.subjectAccount),
|
|
600
835
|
expiresAt: raw.expiresAt,
|
|
601
836
|
status: raw.status,
|
|
602
837
|
};
|
|
@@ -648,10 +883,15 @@ function OxyServicesAuthMixin(Base) {
|
|
|
648
883
|
* device-flow session so the RP stops waiting.
|
|
649
884
|
*
|
|
650
885
|
* @param authorizeCode - The public code being denied.
|
|
886
|
+
* @param reason - Optional closed-set reason ({@link CommonsDenyReason}).
|
|
887
|
+
* Pass `'not_me'` ONLY when the user actually reported the request as one
|
|
888
|
+
* they did not start — the server records it as a suspicious denial rather
|
|
889
|
+
* than an ordinary cancel. Omitting it sends the exact body this endpoint
|
|
890
|
+
* has always received.
|
|
651
891
|
*/
|
|
652
|
-
async denyCommonsSignIn(authorizeCode) {
|
|
892
|
+
async denyCommonsSignIn(authorizeCode, reason) {
|
|
653
893
|
try {
|
|
654
|
-
return await this.makeRequest('POST', `/auth/session/deny/${encodeURIComponent(authorizeCode)}`, undefined,
|
|
894
|
+
return await this.makeRequest('POST', `/auth/session/deny/${encodeURIComponent(authorizeCode)}`, reason ? { reason } : undefined,
|
|
655
895
|
// Public (no auth required) — skip the bearer preflight.
|
|
656
896
|
{ cache: false, skipAuth: true });
|
|
657
897
|
}
|
|
@@ -914,7 +1154,7 @@ function OxyServicesAuthMixin(Base) {
|
|
|
914
1154
|
const deviceId = (record.deviceId ?? record.device_id);
|
|
915
1155
|
const deviceSecret = (record.deviceSecret ?? record.device_secret);
|
|
916
1156
|
const userRaw = record.user;
|
|
917
|
-
if (!sessionId || !deviceId || !userRaw || typeof userRaw !== 'object') {
|
|
1157
|
+
if (!sessionId || !deviceId || !deviceSecret || !userRaw || typeof userRaw !== 'object') {
|
|
918
1158
|
throw new Error('auth/oauth/token returned an incomplete session payload');
|
|
919
1159
|
}
|
|
920
1160
|
const userObj = userRaw;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AccountNotOnDeviceError = void 0;
|
|
3
4
|
exports.OxyServicesDeviceBootMixin = OxyServicesDeviceBootMixin;
|
|
4
5
|
/**
|
|
5
6
|
* Device-first token mint mixin.
|
|
@@ -17,6 +18,41 @@ exports.OxyServicesDeviceBootMixin = OxyServicesDeviceBootMixin;
|
|
|
17
18
|
* primitive can be reused from either without double-planting.
|
|
18
19
|
*/
|
|
19
20
|
const contracts_1 = require("@oxyhq/contracts");
|
|
21
|
+
/**
|
|
22
|
+
* The server's `401 account_not_on_device` for a PINNED mint: the requested
|
|
23
|
+
* `accountId` is not (or is no longer) a live account of this device session.
|
|
24
|
+
*
|
|
25
|
+
* Distinguished from every other mint 401 because the remedy is different: the
|
|
26
|
+
* device secret is FINE — it is the identity binding that went stale (the
|
|
27
|
+
* account was signed out on this device, or revoked). An identity-bound caller
|
|
28
|
+
* must re-establish its session from the local key rather than drop/clear the
|
|
29
|
+
* device credential.
|
|
30
|
+
*/
|
|
31
|
+
class AccountNotOnDeviceError extends Error {
|
|
32
|
+
constructor(accountId, cause) {
|
|
33
|
+
super(`account_not_on_device: ${accountId} is not a live account of this device session`);
|
|
34
|
+
this.accountId = accountId;
|
|
35
|
+
this.cause = cause;
|
|
36
|
+
this.name = 'AccountNotOnDeviceError';
|
|
37
|
+
/** HTTP status of the originating response; mirrors the ApiError shape. */
|
|
38
|
+
this.status = 401;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.AccountNotOnDeviceError = AccountNotOnDeviceError;
|
|
42
|
+
/**
|
|
43
|
+
* Structural (never `instanceof`) read of a normalized mint error: the thrown
|
|
44
|
+
* value may be a plain ApiError-shaped object or come from another realm.
|
|
45
|
+
*/
|
|
46
|
+
function isAccountNotOnDevice(error) {
|
|
47
|
+
if (!error || typeof error !== 'object') {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const { status, message } = error;
|
|
51
|
+
if (status !== 401) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return typeof message === 'string' && message.includes('account_not_on_device');
|
|
55
|
+
}
|
|
20
56
|
function OxyServicesDeviceBootMixin(Base) {
|
|
21
57
|
return class extends Base {
|
|
22
58
|
/**
|
|
@@ -40,11 +76,22 @@ function OxyServicesDeviceBootMixin(Base) {
|
|
|
40
76
|
* boot's worst-case time-to-route. A transient failure surfaces once and the
|
|
41
77
|
* scheduler/401 path retries it later.
|
|
42
78
|
*
|
|
79
|
+
* `options.accountId` PINS the mint to one account of the device instead of
|
|
80
|
+
* whichever account is currently active. It exists for identity-bound
|
|
81
|
+
* clients (Commons), whose authenticated user is fixed by a local
|
|
82
|
+
* cryptographic key and must never follow an account switch made by another
|
|
83
|
+
* app on the same device. The server never mutates `activeAccountId` for a
|
|
84
|
+
* pinned mint — the returned `state` still reports the device's true active
|
|
85
|
+
* account — and rejects a non-member/dead account with
|
|
86
|
+
* `401 account_not_on_device`, surfaced here as {@link AccountNotOnDeviceError}.
|
|
87
|
+
*
|
|
88
|
+
* @throws {AccountNotOnDeviceError} when a pinned mint's account is not on the device.
|
|
43
89
|
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
44
90
|
*/
|
|
45
|
-
async mintFromDeviceSecret(deviceId, deviceSecret) {
|
|
91
|
+
async mintFromDeviceSecret(deviceId, deviceSecret, options) {
|
|
92
|
+
const accountId = options?.accountId;
|
|
46
93
|
try {
|
|
47
|
-
const res = await this.makeRequest('POST', '/session/device/token', { deviceId, deviceSecret },
|
|
94
|
+
const res = await this.makeRequest('POST', '/session/device/token', { deviceId, deviceSecret, ...(accountId ? { accountId } : {}) },
|
|
48
95
|
// `bypassQueue`: this mint is the control-plane call the auth lane
|
|
49
96
|
// depends on — it must run even when every RequestQueue slot is parked
|
|
50
97
|
// awaiting it, or the whole client deadlocks. See RequestOptions.bypassQueue.
|
|
@@ -56,7 +103,11 @@ function OxyServicesDeviceBootMixin(Base) {
|
|
|
56
103
|
return parsed;
|
|
57
104
|
}
|
|
58
105
|
catch (error) {
|
|
59
|
-
|
|
106
|
+
const normalized = this.handleError(error);
|
|
107
|
+
if (accountId && isAccountNotOnDevice(normalized)) {
|
|
108
|
+
throw new AccountNotOnDeviceError(accountId, normalized);
|
|
109
|
+
}
|
|
110
|
+
throw normalized;
|
|
60
111
|
}
|
|
61
112
|
}
|
|
62
113
|
/** Mint a one-time hub sync ticket (bearer required). */
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OxyServicesNotificationsMixin = OxyServicesNotificationsMixin;
|
|
4
|
+
/**
|
|
5
|
+
* Shape of an Expo push token: `ExponentPushToken[…]` (what
|
|
6
|
+
* `getExpoPushTokenAsync()` returns today) or the equivalent `ExpoPushToken[…]`
|
|
7
|
+
* spelling. Deliberately plain ASCII — `@oxyhq/core` ships to Hermes, where
|
|
8
|
+
* Unicode property escapes throw at runtime.
|
|
9
|
+
*/
|
|
10
|
+
const EXPO_PUSH_TOKEN_PATTERN = /^Expo(nent)?PushToken\[[^[\]\s]+\]$/;
|
|
11
|
+
function OxyServicesNotificationsMixin(Base) {
|
|
12
|
+
return class extends Base {
|
|
13
|
+
/**
|
|
14
|
+
* Register this installation's **Expo** push token for the authenticated
|
|
15
|
+
* identity (`POST /notifications/push-token`, bearer required).
|
|
16
|
+
*
|
|
17
|
+
* Idempotent server-side: re-registering the same token for the same
|
|
18
|
+
* identity refreshes the existing row rather than creating a duplicate, so
|
|
19
|
+
* callers can safely re-register on every cold boot.
|
|
20
|
+
*
|
|
21
|
+
* @throws When `expoPushToken` is not an Expo push token — a raw APNs/FCM
|
|
22
|
+
* device token fails here rather than becoming a silently undeliverable
|
|
23
|
+
* registration.
|
|
24
|
+
*/
|
|
25
|
+
async registerPushToken(input) {
|
|
26
|
+
try {
|
|
27
|
+
if (!EXPO_PUSH_TOKEN_PATTERN.test(input.expoPushToken)) {
|
|
28
|
+
throw new Error('registerPushToken expects an Expo push token ("ExponentPushToken[...]", from getExpoPushTokenAsync). ' +
|
|
29
|
+
'A raw APNs/FCM device token from getDevicePushTokenAsync cannot be delivered to.');
|
|
30
|
+
}
|
|
31
|
+
await this.makeRequest('POST', '/notifications/push-token', {
|
|
32
|
+
token: input.expoPushToken,
|
|
33
|
+
platform: input.platform,
|
|
34
|
+
// Omitted entirely when absent so the body stays exactly what the
|
|
35
|
+
// endpoint has always received (the server reads presence).
|
|
36
|
+
...(input.deviceId ? { deviceId: input.deviceId } : {}),
|
|
37
|
+
...(input.clientId ? { clientId: input.clientId } : {}),
|
|
38
|
+
}, { cache: false });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw this.handleError(error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Retire an **Expo** push token for the authenticated identity
|
|
46
|
+
* (`DELETE /notifications/push-token`, bearer required).
|
|
47
|
+
*
|
|
48
|
+
* Call it when the user turns notifications off, when the vault identity is
|
|
49
|
+
* replaced, and on sign-out — otherwise the installation keeps receiving
|
|
50
|
+
* approval requests for an identity it no longer holds.
|
|
51
|
+
*
|
|
52
|
+
* @param expoPushToken - The exact token previously registered.
|
|
53
|
+
*/
|
|
54
|
+
async unregisterPushToken(expoPushToken) {
|
|
55
|
+
try {
|
|
56
|
+
await this.makeRequest('DELETE', '/notifications/push-token', { token: expoPushToken }, { cache: false });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw this.handleError(error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -152,6 +152,9 @@ function OxyServicesPrivacyMixin(Base) {
|
|
|
152
152
|
cache: false,
|
|
153
153
|
});
|
|
154
154
|
this.clearCacheEntry('GET:/privacy/restricted');
|
|
155
|
+
// The restriction changed the viewer's graph (`restrictedIds`) — bust the
|
|
156
|
+
// cached consolidated `GET /users/me/graph` so the next read reflects it.
|
|
157
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
155
158
|
return result;
|
|
156
159
|
}
|
|
157
160
|
catch (error) {
|
|
@@ -175,6 +178,9 @@ function OxyServicesPrivacyMixin(Base) {
|
|
|
175
178
|
cache: false,
|
|
176
179
|
});
|
|
177
180
|
this.clearCacheEntry('GET:/privacy/restricted');
|
|
181
|
+
// Symmetric to restrictUser: the unrestrict changed the viewer's
|
|
182
|
+
// `restrictedIds`, so bust the consolidated `GET /users/me/graph` cache.
|
|
183
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
178
184
|
return result;
|
|
179
185
|
}
|
|
180
186
|
catch (error) {
|
package/dist/cjs/mixins/index.js
CHANGED
|
@@ -28,6 +28,7 @@ const OxyServices_utility_1 = require("./OxyServices.utility");
|
|
|
28
28
|
const OxyServices_features_1 = require("./OxyServices.features");
|
|
29
29
|
const OxyServices_topics_1 = require("./OxyServices.topics");
|
|
30
30
|
const OxyServices_contacts_1 = require("./OxyServices.contacts");
|
|
31
|
+
const OxyServices_notifications_1 = require("./OxyServices.notifications");
|
|
31
32
|
const OxyServices_appData_1 = require("./OxyServices.appData");
|
|
32
33
|
const OxyServices_civic_1 = require("./OxyServices.civic");
|
|
33
34
|
const OxyServices_nodes_1 = require("./OxyServices.nodes");
|
|
@@ -75,6 +76,10 @@ const MIXIN_PIPELINE = [
|
|
|
75
76
|
OxyServices_features_1.OxyServicesFeaturesMixin,
|
|
76
77
|
OxyServices_topics_1.OxyServicesTopicsMixin,
|
|
77
78
|
OxyServices_contacts_1.OxyServicesContactsMixin,
|
|
79
|
+
// Push-token registration: the one SDK-owned register/unregister pair every
|
|
80
|
+
// Oxy app uses, and what lets a "Sign in with Oxy" request be delivered to a
|
|
81
|
+
// known Commons installation instead of falling back to a QR.
|
|
82
|
+
OxyServices_notifications_1.OxyServicesNotificationsMixin,
|
|
78
83
|
OxyServices_appData_1.OxyServicesAppDataMixin,
|
|
79
84
|
// Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
|
|
80
85
|
OxyServices_civic_1.OxyServicesCivicMixin,
|
|
@@ -33,6 +33,15 @@ class SessionClient {
|
|
|
33
33
|
getState() {
|
|
34
34
|
return this.state;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The account this client's bearer is pinned to, or `null` when it follows the
|
|
38
|
+
* device's active account (the default). Resolvers are expected to be a plain
|
|
39
|
+
* synchronous read of already-resolved state (see
|
|
40
|
+
* {@link SessionClientOptions.getPinnedAccountId}).
|
|
41
|
+
*/
|
|
42
|
+
pinnedAccountId() {
|
|
43
|
+
return this.options.getPinnedAccountId?.() ?? null;
|
|
44
|
+
}
|
|
36
45
|
subscribe(listener) {
|
|
37
46
|
this.listeners.add(listener);
|
|
38
47
|
return () => {
|
|
@@ -121,15 +130,26 @@ class SessionClient {
|
|
|
121
130
|
}
|
|
122
131
|
const previousState = this.state;
|
|
123
132
|
this.state = next;
|
|
133
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
124
134
|
// Plant the sync-supplied active token (it is for `next.activeAccountId`)
|
|
125
135
|
// now — before the notify below — so the bearer matches the new active
|
|
126
136
|
// account when subscribers observe it. Guarded on difference to avoid a
|
|
127
137
|
// redundant token-change notification on an unchanged token (bootstrap
|
|
128
|
-
// restate).
|
|
129
|
-
|
|
138
|
+
// restate). An identity-bound client only accepts it when the active account
|
|
139
|
+
// IS its pinned account — otherwise the token belongs to somebody else's
|
|
140
|
+
// switch and must never displace the pinned bearer.
|
|
141
|
+
if (activeToken &&
|
|
142
|
+
next.activeAccountId !== null &&
|
|
143
|
+
(pinnedAccountId === null || next.activeAccountId === pinnedAccountId) &&
|
|
144
|
+
activeToken !== this.host.getAccessToken()) {
|
|
130
145
|
this.host.setTokens(activeToken);
|
|
131
146
|
}
|
|
132
|
-
|
|
147
|
+
// The transport's entire job is converging the bearer on `activeAccountId`,
|
|
148
|
+
// which is precisely what an identity-bound client must not do: its token is
|
|
149
|
+
// minted for the pinned account by the cold boot / re-mint lane. Bypass it
|
|
150
|
+
// while pinned (also removing the mint-before-notify gate, which exists only
|
|
151
|
+
// to keep the bearer and the ACTIVE account in step).
|
|
152
|
+
const transport = pinnedAccountId === null ? this.options.transport : null;
|
|
133
153
|
const activeAccountId = next.activeAccountId;
|
|
134
154
|
// Mint before notifying when the bearer does not already belong to the new
|
|
135
155
|
// active account: no bearer at all, an opaque bearer, OR a bearer for a
|
|
@@ -201,12 +221,15 @@ class SessionClient {
|
|
|
201
221
|
// socket push): `applyState` no-ops without planting, but the token still
|
|
202
222
|
// needs planting. Guard on the sync's active account STILL being the current
|
|
203
223
|
// active account so a stale response cannot adopt a token for an account a
|
|
204
|
-
// newer state already switched away from
|
|
224
|
+
// newer state already switched away from — and, when pinned, on that account
|
|
225
|
+
// being the PINNED one (same rule as the applied path in `applyState`).
|
|
226
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
205
227
|
if (!applied &&
|
|
206
228
|
sync.activeToken &&
|
|
207
229
|
this.state &&
|
|
208
230
|
sync.state.activeAccountId !== null &&
|
|
209
231
|
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
232
|
+
(pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
|
|
210
233
|
sync.activeToken.accessToken !== this.host.getAccessToken()) {
|
|
211
234
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
212
235
|
}
|
|
@@ -240,12 +263,19 @@ class SessionClient {
|
|
|
240
263
|
* sign-in. This adds, then switches to the target so the UI lands on the
|
|
241
264
|
* account the user just authenticated.
|
|
242
265
|
*
|
|
266
|
+
* An identity-bound (pinned) client only ADDS: its own session is minted for
|
|
267
|
+
* the pinned account explicitly, so switching the device would gratuitously
|
|
268
|
+
* re-elect the active account under every OTHER app on this device — a
|
|
269
|
+
* mutation a pinned client must never make.
|
|
270
|
+
*
|
|
243
271
|
* @param accountId - The signed-in account id (e.g. `session.user.id`). When
|
|
244
272
|
* omitted, falls back to the host's current-account ref. If neither
|
|
245
273
|
* resolves, the add still applies and no switch is performed.
|
|
246
274
|
*/
|
|
247
275
|
async registerAndActivate(accountId) {
|
|
248
276
|
await this.addCurrentAccount();
|
|
277
|
+
if (this.pinnedAccountId() !== null)
|
|
278
|
+
return;
|
|
249
279
|
const target = accountId ?? this.host.getCurrentAccountId();
|
|
250
280
|
if (target && this.state?.activeAccountId !== target) {
|
|
251
281
|
await this.switchAccount(target);
|
|
@@ -349,6 +379,12 @@ class SessionClient {
|
|
|
349
379
|
const applied = this.applyState(payload, 'push');
|
|
350
380
|
if (!applied)
|
|
351
381
|
return;
|
|
382
|
+
// An identity-bound client tracks the pushed state but NEVER chases the
|
|
383
|
+
// device's active account: re-fetching here would only pull an
|
|
384
|
+
// `activeToken` for somebody else's switch, which the plant guards would
|
|
385
|
+
// then discard. Skip the round-trip entirely.
|
|
386
|
+
if (this.pinnedAccountId() !== null)
|
|
387
|
+
return;
|
|
352
388
|
// A push changed the active account on another device/tab — re-fetch state
|
|
353
389
|
// to plant the access token for the newly-active account. When this tab is
|
|
354
390
|
// still signed out, applyState mints via ensureActiveToken first; bootstrap
|