@oxy.so/core 1.0.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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +221 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/HttpService.js +1270 -0
- package/dist/cjs/OxyServices.base.js +390 -0
- package/dist/cjs/OxyServices.errors.js +95 -0
- package/dist/cjs/OxyServices.js +72 -0
- package/dist/cjs/boot/sessionColdBoot.js +395 -0
- package/dist/cjs/constants/version.js +16 -0
- package/dist/cjs/crypto/aead.js +79 -0
- package/dist/cjs/crypto/ecdh.js +35 -0
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/kdf.js +39 -0
- package/dist/cjs/crypto/keyManager.js +2234 -0
- package/dist/cjs/crypto/polyfill.js +131 -0
- package/dist/cjs/crypto/recoveryPhrase.js +242 -0
- package/dist/cjs/crypto/signatureService.js +273 -0
- package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
- package/dist/cjs/i18n/accountRoleLabels.js +27 -0
- package/dist/cjs/i18n/index.js +90 -0
- package/dist/cjs/i18n/locales/ar-SA.json +282 -0
- package/dist/cjs/i18n/locales/ca-ES.json +282 -0
- package/dist/cjs/i18n/locales/de-DE.json +282 -0
- package/dist/cjs/i18n/locales/en-US.json +2161 -0
- package/dist/cjs/i18n/locales/es-ES.json +2161 -0
- package/dist/cjs/i18n/locales/fr-FR.json +282 -0
- package/dist/cjs/i18n/locales/it-IT.json +282 -0
- package/dist/cjs/i18n/locales/ja-JP.json +282 -0
- package/dist/cjs/i18n/locales/ko-KR.json +282 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +282 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +282 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +282 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +2161 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +2161 -0
- package/dist/cjs/i18n/locales/locales/fr-FR.json +282 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +282 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +282 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +282 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +282 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +282 -0
- package/dist/cjs/i18n/locales/pt-PT.json +282 -0
- package/dist/cjs/i18n/locales/zh-CN.json +282 -0
- package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
- package/dist/cjs/i18n/trustTierLabels.js +19 -0
- package/dist/cjs/index.js +498 -0
- package/dist/cjs/inference/OxyInferenceClient.js +544 -0
- package/dist/cjs/logger/index.js +149 -0
- package/dist/cjs/mixins/OxyServices.accounts.js +555 -0
- package/dist/cjs/mixins/OxyServices.analytics.js +49 -0
- package/dist/cjs/mixins/OxyServices.appData.js +108 -0
- package/dist/cjs/mixins/OxyServices.assets.js +764 -0
- package/dist/cjs/mixins/OxyServices.auth.js +1254 -0
- package/dist/cjs/mixins/OxyServices.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.civic.js +611 -0
- package/dist/cjs/mixins/OxyServices.connectedApps.js +78 -0
- package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +191 -0
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +314 -0
- package/dist/cjs/mixins/OxyServices.devices.js +116 -0
- package/dist/cjs/mixins/OxyServices.features.js +335 -0
- package/dist/cjs/mixins/OxyServices.followGraph.js +221 -0
- package/dist/cjs/mixins/OxyServices.identity.js +461 -0
- package/dist/cjs/mixins/OxyServices.identityBackup.js +172 -0
- package/dist/cjs/mixins/OxyServices.inference.js +59 -0
- package/dist/cjs/mixins/OxyServices.language.js +125 -0
- package/dist/cjs/mixins/OxyServices.links.js +72 -0
- package/dist/cjs/mixins/OxyServices.location.js +43 -0
- package/dist/cjs/mixins/OxyServices.nodes.js +175 -0
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/OxyServices.payment.js +158 -0
- package/dist/cjs/mixins/OxyServices.privacy.js +199 -0
- package/dist/cjs/mixins/OxyServices.reputation.js +289 -0
- package/dist/cjs/mixins/OxyServices.security.js +89 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.topics.js +137 -0
- package/dist/cjs/mixins/OxyServices.user.js +902 -0
- package/dist/cjs/mixins/OxyServices.utility.js +1098 -0
- package/dist/cjs/mixins/index.js +135 -0
- package/dist/cjs/mixins/mixinHelpers.js +53 -0
- package/dist/cjs/models/Topic.js +16 -0
- package/dist/cjs/models/interfaces.js +20 -0
- package/dist/cjs/models/session.js +2 -0
- package/dist/cjs/server/auth.js +167 -0
- package/dist/cjs/server/capabilityTicket.js +192 -0
- package/dist/cjs/server/cors.js +225 -0
- package/dist/cjs/server/index.js +97 -0
- package/dist/cjs/server/rateLimit.js +207 -0
- package/dist/cjs/server/safeFetch.js +458 -0
- package/dist/cjs/server/securityHeaders.js +377 -0
- package/dist/cjs/server/userInvalidation.js +156 -0
- package/dist/cjs/server/verifySecret.js +50 -0
- package/dist/cjs/session/SessionClient.js +865 -0
- package/dist/cjs/session/accountDelegationAccess.js +48 -0
- package/dist/cjs/session/accountDialogController.js +1131 -0
- package/dist/cjs/session/accountSwitchTargets.js +81 -0
- package/dist/cjs/session/authStateStore.js +404 -0
- package/dist/cjs/session/createSessionClient.js +32 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +121 -0
- package/dist/cjs/session/refresh.js +398 -0
- package/dist/cjs/session/sessionClientHost.js +34 -0
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/cjs/session/socketLoader.js +55 -0
- package/dist/cjs/shared/utils/colorUtils.js +153 -0
- package/dist/cjs/shared/utils/errorUtils.js +183 -0
- package/dist/cjs/shared/utils/index.js +49 -0
- package/dist/cjs/shared/utils/networkUtils.js +183 -0
- package/dist/cjs/shared/utils/themeUtils.js +106 -0
- package/dist/cjs/utils/accountCacheSweep.js +80 -0
- package/dist/cjs/utils/accountUtils.js +140 -0
- package/dist/cjs/utils/activityId.js +6 -0
- package/dist/cjs/utils/apiUtils.js +91 -0
- package/dist/cjs/utils/asyncUtils.js +222 -0
- package/dist/cjs/utils/authHelpers.js +120 -0
- package/dist/cjs/utils/authWebUrl.js +19 -0
- package/dist/cjs/utils/avatarUtils.js +29 -0
- package/dist/cjs/utils/cache.js +235 -0
- package/dist/cjs/utils/cacheKey.js +85 -0
- package/dist/cjs/utils/coldBoot.js +122 -0
- package/dist/cjs/utils/commonsApproval.js +31 -0
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/deviceManager.js +174 -0
- package/dist/cjs/utils/displayNamePolicyRanges.generated.js +60 -0
- package/dist/cjs/utils/edgeRegion.js +6 -0
- package/dist/cjs/utils/errorUtils.js +243 -0
- package/dist/cjs/utils/identityCacheSweep.js +97 -0
- package/dist/cjs/utils/languageUtils.js +224 -0
- package/dist/cjs/utils/oauthPkce.js +311 -0
- package/dist/cjs/utils/officialOrigins.js +64 -0
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/cjs/utils/platform.js +119 -0
- package/dist/cjs/utils/profileLinks.js +63 -0
- package/dist/cjs/utils/redactUrl.js +29 -0
- package/dist/cjs/utils/registrableApex.js +45 -0
- package/dist/cjs/utils/requestUtils.js +209 -0
- package/dist/cjs/utils/sessionUtils.js +181 -0
- package/dist/cjs/utils/textNormalization.js +158 -0
- package/dist/cjs/utils/userHandle.js +37 -0
- package/dist/cjs/utils/userIdentity.js +50 -0
- package/dist/cjs/utils/validationUtils.js +370 -0
- package/dist/cjs/utils/webauthnOrigin.js +51 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/HttpService.js +1266 -0
- package/dist/esm/OxyServices.base.js +386 -0
- package/dist/esm/OxyServices.errors.js +88 -0
- package/dist/esm/OxyServices.js +66 -0
- package/dist/esm/boot/sessionColdBoot.js +392 -0
- package/dist/esm/constants/version.js +13 -0
- package/dist/esm/crypto/aead.js +74 -0
- package/dist/esm/crypto/ecdh.js +32 -0
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/kdf.js +36 -0
- package/dist/esm/crypto/keyManager.js +2227 -0
- package/dist/esm/crypto/polyfill.js +129 -0
- package/dist/esm/crypto/recoveryPhrase.js +238 -0
- package/dist/esm/crypto/signatureService.js +269 -0
- package/dist/esm/i18n/accountCategoryLabels.js +37 -0
- package/dist/esm/i18n/accountRoleLabels.js +20 -0
- package/dist/esm/i18n/index.js +83 -0
- package/dist/esm/i18n/locales/ar-SA.json +282 -0
- package/dist/esm/i18n/locales/ca-ES.json +282 -0
- package/dist/esm/i18n/locales/de-DE.json +282 -0
- package/dist/esm/i18n/locales/en-US.json +2161 -0
- package/dist/esm/i18n/locales/es-ES.json +2161 -0
- package/dist/esm/i18n/locales/fr-FR.json +282 -0
- package/dist/esm/i18n/locales/it-IT.json +282 -0
- package/dist/esm/i18n/locales/ja-JP.json +282 -0
- package/dist/esm/i18n/locales/ko-KR.json +282 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +282 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +282 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +282 -0
- package/dist/esm/i18n/locales/locales/en-US.json +2161 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +2161 -0
- package/dist/esm/i18n/locales/locales/fr-FR.json +282 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +282 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +282 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +282 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +282 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +282 -0
- package/dist/esm/i18n/locales/pt-PT.json +282 -0
- package/dist/esm/i18n/locales/zh-CN.json +282 -0
- package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
- package/dist/esm/i18n/trustTierLabels.js +12 -0
- package/dist/esm/index.js +254 -0
- package/dist/esm/inference/OxyInferenceClient.js +538 -0
- package/dist/esm/logger/index.js +140 -0
- package/dist/esm/mixins/OxyServices.accounts.js +543 -0
- package/dist/esm/mixins/OxyServices.analytics.js +46 -0
- package/dist/esm/mixins/OxyServices.appData.js +103 -0
- package/dist/esm/mixins/OxyServices.assets.js +761 -0
- package/dist/esm/mixins/OxyServices.auth.js +1247 -0
- package/dist/esm/mixins/OxyServices.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.civic.js +605 -0
- package/dist/esm/mixins/OxyServices.connectedApps.js +75 -0
- package/dist/esm/mixins/OxyServices.contacts.js +47 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +186 -0
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +311 -0
- package/dist/esm/mixins/OxyServices.devices.js +113 -0
- package/dist/esm/mixins/OxyServices.features.js +332 -0
- package/dist/esm/mixins/OxyServices.followGraph.js +218 -0
- package/dist/esm/mixins/OxyServices.identity.js +457 -0
- package/dist/esm/mixins/OxyServices.identityBackup.js +169 -0
- package/dist/esm/mixins/OxyServices.inference.js +56 -0
- package/dist/esm/mixins/OxyServices.language.js +122 -0
- package/dist/esm/mixins/OxyServices.links.js +69 -0
- package/dist/esm/mixins/OxyServices.location.js +40 -0
- package/dist/esm/mixins/OxyServices.nodes.js +172 -0
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/OxyServices.payment.js +155 -0
- package/dist/esm/mixins/OxyServices.privacy.js +196 -0
- package/dist/esm/mixins/OxyServices.reputation.js +286 -0
- package/dist/esm/mixins/OxyServices.security.js +86 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.topics.js +134 -0
- package/dist/esm/mixins/OxyServices.user.js +899 -0
- package/dist/esm/mixins/OxyServices.utility.js +1095 -0
- package/dist/esm/mixins/index.js +132 -0
- package/dist/esm/mixins/mixinHelpers.js +48 -0
- package/dist/esm/models/Topic.js +13 -0
- package/dist/esm/models/interfaces.js +17 -0
- package/dist/esm/models/session.js +1 -0
- package/dist/esm/server/auth.js +154 -0
- package/dist/esm/server/capabilityTicket.js +183 -0
- package/dist/esm/server/cors.js +220 -0
- package/dist/esm/server/index.js +47 -0
- package/dist/esm/server/rateLimit.js +201 -0
- package/dist/esm/server/safeFetch.js +447 -0
- package/dist/esm/server/securityHeaders.js +364 -0
- package/dist/esm/server/userInvalidation.js +152 -0
- package/dist/esm/server/verifySecret.js +47 -0
- package/dist/esm/session/SessionClient.js +861 -0
- package/dist/esm/session/accountDelegationAccess.js +45 -0
- package/dist/esm/session/accountDialogController.js +1126 -0
- package/dist/esm/session/accountSwitchTargets.js +77 -0
- package/dist/esm/session/authStateStore.js +398 -0
- package/dist/esm/session/createSessionClient.js +29 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +114 -0
- package/dist/esm/session/refresh.js +390 -0
- package/dist/esm/session/sessionClientHost.js +31 -0
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/esm/session/socketLoader.js +19 -0
- package/dist/esm/shared/utils/colorUtils.js +143 -0
- package/dist/esm/shared/utils/errorUtils.js +170 -0
- package/dist/esm/shared/utils/index.js +15 -0
- package/dist/esm/shared/utils/networkUtils.js +173 -0
- package/dist/esm/shared/utils/themeUtils.js +98 -0
- package/dist/esm/utils/accountCacheSweep.js +75 -0
- package/dist/esm/utils/accountUtils.js +131 -0
- package/dist/esm/utils/activityId.js +2 -0
- package/dist/esm/utils/apiUtils.js +84 -0
- package/dist/esm/utils/asyncUtils.js +207 -0
- package/dist/esm/utils/authHelpers.js +111 -0
- package/dist/esm/utils/authWebUrl.js +16 -0
- package/dist/esm/utils/avatarUtils.js +26 -0
- package/dist/esm/utils/cache.js +227 -0
- package/dist/esm/utils/cacheKey.js +80 -0
- package/dist/esm/utils/coldBoot.js +119 -0
- package/dist/esm/utils/commonsApproval.js +27 -0
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/deviceManager.js +170 -0
- package/dist/esm/utils/displayNamePolicyRanges.generated.js +57 -0
- package/dist/esm/utils/edgeRegion.js +2 -0
- package/dist/esm/utils/errorUtils.js +232 -0
- package/dist/esm/utils/identityCacheSweep.js +92 -0
- package/dist/esm/utils/languageUtils.js +212 -0
- package/dist/esm/utils/oauthPkce.js +297 -0
- package/dist/esm/utils/officialOrigins.js +59 -0
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/esm/utils/platform.js +110 -0
- package/dist/esm/utils/profileLinks.js +60 -0
- package/dist/esm/utils/redactUrl.js +26 -0
- package/dist/esm/utils/registrableApex.js +42 -0
- package/dist/esm/utils/requestUtils.js +203 -0
- package/dist/esm/utils/sessionUtils.js +171 -0
- package/dist/esm/utils/textNormalization.js +154 -0
- package/dist/esm/utils/userHandle.js +33 -0
- package/dist/esm/utils/userIdentity.js +45 -0
- package/dist/esm/utils/validationUtils.js +350 -0
- package/dist/esm/utils/webauthnOrigin.js +48 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/HttpService.d.ts +413 -0
- package/dist/types/OxyServices.base.d.ts +187 -0
- package/dist/types/OxyServices.d.ts +139 -0
- package/dist/types/OxyServices.errors.d.ts +84 -0
- package/dist/types/boot/sessionColdBoot.d.ts +140 -0
- package/dist/types/constants/version.d.ts +13 -0
- package/dist/types/crypto/aead.d.ts +56 -0
- package/dist/types/crypto/ecdh.d.ts +29 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/kdf.d.ts +25 -0
- package/dist/types/crypto/keyManager.d.ts +582 -0
- package/dist/types/crypto/polyfill.d.ts +25 -0
- package/dist/types/crypto/recoveryPhrase.d.ts +173 -0
- package/dist/types/crypto/signatureService.d.ts +136 -0
- package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
- package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
- package/dist/types/i18n/index.d.ts +3 -0
- package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
- package/dist/types/i18n/trustTierLabels.d.ts +9 -0
- package/dist/types/index.d.ts +145 -0
- package/dist/types/inference/OxyInferenceClient.d.ts +342 -0
- package/dist/types/logger/index.d.ts +104 -0
- package/dist/types/mixins/OxyServices.accounts.d.ts +868 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +72 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +108 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +329 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +908 -0
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +510 -0
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +169 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +100 -0
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +185 -0
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +103 -0
- package/dist/types/mixins/OxyServices.features.d.ts +250 -0
- package/dist/types/mixins/OxyServices.followGraph.d.ts +223 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +351 -0
- package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
- package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
- package/dist/types/mixins/OxyServices.language.d.ts +87 -0
- package/dist/types/mixins/OxyServices.links.d.ts +104 -0
- package/dist/types/mixins/OxyServices.location.d.ts +70 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +198 -0
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +117 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +146 -0
- package/dist/types/mixins/OxyServices.reputation.d.ts +202 -0
- package/dist/types/mixins/OxyServices.security.d.ts +84 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +110 -0
- package/dist/types/mixins/OxyServices.user.d.ts +558 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +393 -0
- package/dist/types/mixins/index.d.ts +87 -0
- package/dist/types/mixins/mixinHelpers.d.ts +31 -0
- package/dist/types/models/Topic.d.ts +42 -0
- package/dist/types/models/interfaces.d.ts +675 -0
- package/dist/types/models/session.d.ts +51 -0
- package/dist/types/server/auth.d.ts +130 -0
- package/dist/types/server/capabilityTicket.d.ts +41 -0
- package/dist/types/server/cors.d.ts +98 -0
- package/dist/types/server/index.d.ts +35 -0
- package/dist/types/server/rateLimit.d.ts +40 -0
- package/dist/types/server/safeFetch.d.ts +137 -0
- package/dist/types/server/securityHeaders.d.ts +221 -0
- package/dist/types/server/userInvalidation.d.ts +114 -0
- package/dist/types/server/verifySecret.d.ts +29 -0
- package/dist/types/session/SessionClient.d.ts +418 -0
- package/dist/types/session/accountDelegationAccess.d.ts +40 -0
- package/dist/types/session/accountDialogController.d.ts +648 -0
- package/dist/types/session/accountSwitchTargets.d.ts +70 -0
- package/dist/types/session/authStateStore.d.ts +153 -0
- package/dist/types/session/createSessionClient.d.ts +30 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- 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 +74 -0
- package/dist/types/session/refresh.d.ts +179 -0
- package/dist/types/session/sessionClientHost.d.ts +18 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/dist/types/session/socketLoader.d.ts +11 -0
- package/dist/types/shared/utils/colorUtils.d.ts +104 -0
- package/dist/types/shared/utils/errorUtils.d.ts +97 -0
- package/dist/types/shared/utils/index.d.ts +13 -0
- package/dist/types/shared/utils/networkUtils.d.ts +139 -0
- package/dist/types/shared/utils/themeUtils.d.ts +90 -0
- package/dist/types/utils/accountCacheSweep.d.ts +75 -0
- package/dist/types/utils/accountUtils.d.ts +100 -0
- package/dist/types/utils/activityId.d.ts +2 -0
- package/dist/types/utils/apiUtils.d.ts +95 -0
- package/dist/types/utils/asyncUtils.d.ts +62 -0
- package/dist/types/utils/authHelpers.d.ts +58 -0
- package/dist/types/utils/authWebUrl.d.ts +16 -0
- package/dist/types/utils/avatarUtils.d.ts +16 -0
- package/dist/types/utils/cache.d.ts +130 -0
- package/dist/types/utils/cacheKey.d.ts +66 -0
- package/dist/types/utils/coldBoot.d.ts +131 -0
- package/dist/types/utils/commonsApproval.d.ts +13 -0
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/deviceManager.d.ts +65 -0
- package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +57 -0
- package/dist/types/utils/edgeRegion.d.ts +2 -0
- package/dist/types/utils/errorUtils.d.ts +125 -0
- package/dist/types/utils/identityCacheSweep.d.ts +80 -0
- package/dist/types/utils/languageUtils.d.ts +100 -0
- package/dist/types/utils/oauthPkce.d.ts +157 -0
- package/dist/types/utils/officialOrigins.d.ts +13 -0
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/dist/types/utils/platform.d.ts +49 -0
- package/dist/types/utils/profileLinks.d.ts +44 -0
- package/dist/types/utils/redactUrl.d.ts +17 -0
- package/dist/types/utils/registrableApex.d.ts +27 -0
- package/dist/types/utils/requestUtils.d.ts +123 -0
- package/dist/types/utils/sessionUtils.d.ts +54 -0
- package/dist/types/utils/textNormalization.d.ts +103 -0
- package/dist/types/utils/userHandle.d.ts +26 -0
- package/dist/types/utils/userIdentity.d.ts +21 -0
- package/dist/types/utils/validationUtils.d.ts +268 -0
- package/dist/types/utils/webauthnOrigin.d.ts +32 -0
- package/package.json +168 -0
- package/src/HttpService.ts +1553 -0
- package/src/OxyServices.base.ts +478 -0
- package/src/OxyServices.errors.ts +122 -0
- package/src/OxyServices.ts +172 -0
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/__tests__/httpServiceActivityId.test.ts +49 -0
- package/src/__tests__/httpServiceAuthSelfAwait.test.ts +108 -0
- package/src/__tests__/httpServiceCache.test.ts +318 -0
- package/src/__tests__/httpServiceCsrf.test.ts +235 -0
- package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
- package/src/__tests__/httpServiceQueueDeadlock.test.ts +128 -0
- package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
- package/src/__tests__/inSessionRefresh.test.ts +260 -0
- package/src/__tests__/linkedClient.test.ts +370 -0
- package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
- package/src/__tests__/serverValueImportsDeclared.test.ts +120 -0
- package/src/__tests__/userIdentity.test.ts +143 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/__tests__/sessionColdBoot.test.ts +570 -0
- package/src/boot/sessionColdBoot.ts +529 -0
- package/src/constants/version.ts +15 -0
- package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
- package/src/crypto/__tests__/cryptoPrimitives.test.ts +226 -0
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +272 -0
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +329 -0
- package/src/crypto/__tests__/polyfill.test.ts +109 -0
- package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
- package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
- package/src/crypto/__tests__/signChallengeShared.test.ts +63 -0
- package/src/crypto/__tests__/signedRecord.test.ts +230 -0
- package/src/crypto/aead.ts +97 -0
- package/src/crypto/ecdh.ts +37 -0
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/kdf.ts +43 -0
- package/src/crypto/keyManager.ts +2607 -0
- package/src/crypto/polyfill.ts +143 -0
- package/src/crypto/recoveryPhrase.ts +337 -0
- package/src/crypto/signatureService.ts +350 -0
- package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
- package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
- package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
- package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
- package/src/i18n/accountCategoryLabels.ts +44 -0
- package/src/i18n/accountRoleLabels.ts +26 -0
- package/src/i18n/index.ts +88 -0
- package/src/i18n/locales/ar-SA.json +282 -0
- package/src/i18n/locales/ca-ES.json +282 -0
- package/src/i18n/locales/de-DE.json +282 -0
- package/src/i18n/locales/en-US.json +2161 -0
- package/src/i18n/locales/es-ES.json +2161 -0
- package/src/i18n/locales/fr-FR.json +282 -0
- package/src/i18n/locales/it-IT.json +282 -0
- package/src/i18n/locales/ja-JP.json +282 -0
- package/src/i18n/locales/ko-KR.json +282 -0
- package/src/i18n/locales/pt-PT.json +282 -0
- package/src/i18n/locales/zh-CN.json +282 -0
- package/src/i18n/reputationCategoryLabels.ts +20 -0
- package/src/i18n/trustTierLabels.ts +18 -0
- package/src/index.ts +881 -0
- package/src/inference/OxyInferenceClient.ts +888 -0
- package/src/inference/__tests__/OxyInferenceClient.test.ts +834 -0
- package/src/logger/__tests__/logger.test.ts +207 -0
- package/src/logger/index.ts +217 -0
- package/src/mixins/OxyServices.accounts.ts +1330 -0
- package/src/mixins/OxyServices.analytics.ts +53 -0
- package/src/mixins/OxyServices.appData.ts +158 -0
- package/src/mixins/OxyServices.assets.ts +861 -0
- package/src/mixins/OxyServices.auth.ts +1858 -0
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.civic.ts +945 -0
- package/src/mixins/OxyServices.connectedApps.ts +174 -0
- package/src/mixins/OxyServices.contacts.ts +73 -0
- package/src/mixins/OxyServices.deviceBoot.ts +210 -0
- package/src/mixins/OxyServices.deviceTransfer.ts +393 -0
- package/src/mixins/OxyServices.devices.ts +120 -0
- package/src/mixins/OxyServices.features.ts +454 -0
- package/src/mixins/OxyServices.followGraph.ts +293 -0
- package/src/mixins/OxyServices.identity.ts +705 -0
- package/src/mixins/OxyServices.identityBackup.ts +250 -0
- package/src/mixins/OxyServices.inference.ts +57 -0
- package/src/mixins/OxyServices.language.ts +144 -0
- package/src/mixins/OxyServices.links.ts +107 -0
- package/src/mixins/OxyServices.location.ts +46 -0
- package/src/mixins/OxyServices.nodes.ts +337 -0
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/OxyServices.payment.ts +163 -0
- package/src/mixins/OxyServices.privacy.ts +219 -0
- package/src/mixins/OxyServices.reputation.ts +436 -0
- package/src/mixins/OxyServices.security.ts +110 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.topics.ts +169 -0
- package/src/mixins/OxyServices.user.ts +1137 -0
- package/src/mixins/OxyServices.utility.ts +1370 -0
- package/src/mixins/__tests__/OxyServices.civic.test.ts +1095 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +124 -0
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +271 -0
- package/src/mixins/__tests__/OxyServices.identity.test.ts +376 -0
- package/src/mixins/__tests__/OxyServices.links.test.ts +154 -0
- package/src/mixins/__tests__/OxyServices.nodes.test.ts +341 -0
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +276 -0
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +192 -0
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
- package/src/mixins/__tests__/accounts.test.ts +727 -0
- package/src/mixins/__tests__/appData.test.ts +203 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +135 -0
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +1144 -0
- package/src/mixins/__tests__/connectedApps.test.ts +123 -0
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
- package/src/mixins/__tests__/followCacheInvalidation.test.ts +173 -0
- package/src/mixins/__tests__/followGraph.test.ts +147 -0
- package/src/mixins/__tests__/followGraphPagination.test.ts +271 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +346 -0
- package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
- package/src/mixins/__tests__/identityBackup.test.ts +315 -0
- package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +407 -0
- package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
- package/src/mixins/__tests__/onTokensChanged.test.ts +129 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +284 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +155 -0
- package/src/mixins/__tests__/reputation.test.ts +522 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +832 -0
- package/src/mixins/__tests__/serviceTokenJwks.test.ts +208 -0
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/topics.test.ts +156 -0
- package/src/mixins/__tests__/userReadCacheBypass.test.ts +129 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +130 -0
- package/src/mixins/__tests__/verifyServiceActingAs.test.ts +167 -0
- package/src/mixins/__tests__/webauthnAuth.test.ts +205 -0
- package/src/mixins/index.ts +201 -0
- package/src/mixins/mixinHelpers.ts +69 -0
- package/src/models/Topic.ts +46 -0
- package/src/models/interfaces.ts +786 -0
- package/src/models/session.ts +55 -0
- package/src/server/__tests__/auth.test.ts +78 -0
- package/src/server/__tests__/capabilityTicket.test.ts +111 -0
- package/src/server/__tests__/cors.socket.test.ts +225 -0
- package/src/server/__tests__/cors.test.ts +158 -0
- package/src/server/__tests__/rateLimit.test.ts +257 -0
- package/src/server/__tests__/safeFetch.test.ts +232 -0
- package/src/server/__tests__/securityHeaders.test.ts +348 -0
- package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
- package/src/server/__tests__/userInvalidation.test.ts +203 -0
- package/src/server/__tests__/verifySecret.test.ts +40 -0
- package/src/server/auth.ts +271 -0
- package/src/server/capabilityTicket.ts +258 -0
- package/src/server/cors.ts +277 -0
- package/src/server/index.ts +136 -0
- package/src/server/rateLimit.ts +313 -0
- package/src/server/safeFetch.ts +586 -0
- package/src/server/securityHeaders.ts +467 -0
- package/src/server/userInvalidation.ts +199 -0
- package/src/server/verifySecret.ts +52 -0
- package/src/session/SessionClient.ts +1001 -0
- package/src/session/__tests__/SessionClient.additive.test.ts +153 -0
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +114 -0
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +91 -0
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/SessionClient.httpIntegration.test.ts +65 -0
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +119 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +72 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +202 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +81 -0
- package/src/session/__tests__/SessionClient.state.test.ts +78 -0
- package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
- package/src/session/__tests__/accountDelegationAccess.test.ts +104 -0
- package/src/session/__tests__/accountDialogController.test.ts +1969 -0
- package/src/session/__tests__/accountDialogShape.test.ts +118 -0
- package/src/session/__tests__/accountSwitchTargets.test.ts +137 -0
- package/src/session/__tests__/authStateStore.test.ts +451 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- 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 +105 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +307 -0
- package/src/session/__tests__/refresh.test.ts +443 -0
- package/src/session/__tests__/sessionIntegration.test.ts +209 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDelegationAccess.ts +72 -0
- package/src/session/accountDialogController.ts +1494 -0
- package/src/session/accountSwitchTargets.ts +93 -0
- package/src/session/authStateStore.ts +502 -0
- package/src/session/createSessionClient.ts +39 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +137 -0
- package/src/session/refresh.ts +517 -0
- package/src/session/sessionClientHost.ts +39 -0
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/src/session/socketLoader.ts +30 -0
- package/src/shared/utils/colorUtils.ts +155 -0
- package/src/shared/utils/errorUtils.ts +181 -0
- package/src/shared/utils/index.ts +59 -0
- package/src/shared/utils/networkUtils.ts +248 -0
- package/src/shared/utils/themeUtils.ts +115 -0
- package/src/types/buffer.d.ts +97 -0
- package/src/types/expo-crypto.d.ts +16 -0
- package/src/types/expo-secure-store.d.ts +17 -0
- package/src/utils/__tests__/accountUtils.test.ts +151 -0
- package/src/utils/__tests__/activityId.test.ts +45 -0
- package/src/utils/__tests__/asyncUtils.test.ts +187 -0
- package/src/utils/__tests__/authWebUrl.test.ts +15 -0
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/__tests__/coldBoot.test.ts +496 -0
- package/src/utils/__tests__/commonsApproval.test.ts +60 -0
- package/src/utils/__tests__/identityCacheSweep.test.ts +151 -0
- package/src/utils/__tests__/languageUtils.test.ts +219 -0
- package/src/utils/__tests__/oauthPkce.test.ts +272 -0
- package/src/utils/__tests__/officialOrigins.test.ts +31 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/__tests__/profileLinks.test.ts +180 -0
- package/src/utils/__tests__/redactUrl.test.ts +33 -0
- package/src/utils/__tests__/registrableApex.test.ts +62 -0
- package/src/utils/__tests__/textNormalization.test.ts +205 -0
- package/src/utils/__tests__/validationUtils.test.ts +703 -0
- package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
- package/src/utils/accountCacheSweep.ts +93 -0
- package/src/utils/accountUtils.ts +194 -0
- package/src/utils/activityId.ts +2 -0
- package/src/utils/apiUtils.ts +137 -0
- package/src/utils/asyncUtils.ts +280 -0
- package/src/utils/authHelpers.ts +148 -0
- package/src/utils/authWebUrl.ts +17 -0
- package/src/utils/avatarUtils.ts +39 -0
- package/src/utils/cache.ts +273 -0
- package/src/utils/cacheKey.ts +93 -0
- package/src/utils/coldBoot.ts +218 -0
- package/src/utils/commonsApproval.ts +39 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/deviceManager.ts +203 -0
- package/src/utils/displayNamePolicyRanges.generated.ts +69 -0
- package/src/utils/edgeRegion.ts +2 -0
- package/src/utils/errorUtils.ts +352 -0
- package/src/utils/identityCacheSweep.ts +104 -0
- package/src/utils/languageUtils.ts +256 -0
- package/src/utils/oauthPkce.ts +392 -0
- package/src/utils/officialOrigins.ts +62 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
- package/src/utils/platform.ts +139 -0
- package/src/utils/profileLinks.ts +91 -0
- package/src/utils/redactUrl.ts +28 -0
- package/src/utils/registrableApex.ts +41 -0
- package/src/utils/requestUtils.ts +237 -0
- package/src/utils/sessionUtils.ts +206 -0
- package/src/utils/textNormalization.ts +162 -0
- package/src/utils/userHandle.ts +49 -0
- package/src/utils/userIdentity.ts +60 -0
- package/src/utils/validationUtils.ts +390 -0
- package/src/utils/webauthnOrigin.ts +52 -0
|
@@ -0,0 +1,1494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless controller for the unified Oxy account dialog.
|
|
3
|
+
*
|
|
4
|
+
* A framework-agnostic state machine + subscribe/getSnapshot store (the same
|
|
5
|
+
* pattern {@link SessionClient} uses — no React, no RN) that both
|
|
6
|
+
* every `OxyProvider` platform variant (Expo/RN and RN-Web)
|
|
7
|
+
* bind to via `useSyncExternalStore`, so the account chooser is ONE
|
|
8
|
+
* implementation across the ecosystem instead of the five drifting copies it
|
|
9
|
+
* replaces.
|
|
10
|
+
*
|
|
11
|
+
* The controller owns:
|
|
12
|
+
* - the device DIRECTORY (ADR 0002) — the server-authoritative read model of
|
|
13
|
+
* who is on this device and what each of them may act as, read through
|
|
14
|
+
* `SessionClient.refreshDirectory()`. It is not assembled here: the client
|
|
15
|
+
* holds one caller's account graph and cannot enumerate another principal's,
|
|
16
|
+
* so switchability is the server's answer and the controller only reads it;
|
|
17
|
+
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
|
|
18
|
+
* `signup`);
|
|
19
|
+
* - `activateContext` (the ADR 0002 switch, keyed on the `principal acting as
|
|
20
|
+
* account` pair) and the two removals an account id cannot name —
|
|
21
|
+
* `signOutContext` and `signOutPrincipal`;
|
|
22
|
+
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
23
|
+
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
24
|
+
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
25
|
+
* - AUTOMATIC delivery selection for that flow (issue #691): the user presses
|
|
26
|
+
* ONE primary action and the controller — not the user — picks how the
|
|
27
|
+
* request reaches their Commons identity, by gathering the facts
|
|
28
|
+
* (`platform`, `commonsAvailability`, and the `targets` a bearer-authorized
|
|
29
|
+
* `deliverCommonsSignIn` reached) and handing them to the pure
|
|
30
|
+
* `selectCommonsDelivery`. Exactly one route is primary; alternatives are
|
|
31
|
+
* never chained behind it, they are state (`signIn.route`,
|
|
32
|
+
* `signIn.routeFailed`) the UI reveals on its own terms;
|
|
33
|
+
* - honest, non-sensitive PROGRESS for that flow (`signIn.progress`), derived
|
|
34
|
+
* only from real signals — the chosen route, `pushSentAt`, `openedAt`,
|
|
35
|
+
* `authorized`, and the commit — never from an optimistic timeline;
|
|
36
|
+
* - `commonsAvailability` — whether Commons is installed on this device
|
|
37
|
+
* (native only, via the injected `canOpenApp` probe), so the QR view can
|
|
38
|
+
* offer a "Get Commons" fallback instead of a same-device dead end.
|
|
39
|
+
* - `startPasskeyHubSignIn` — on a non-Oxy web origin (where a WebAuthn
|
|
40
|
+
* ceremony can't run locally, the RP-ID is bound to oxy.so), open a popup
|
|
41
|
+
* at the auth.oxy.so passkey hub instead, scoped to the SAME device-flow
|
|
42
|
+
* session `showQr` creates, and complete it via the SAME poll/socket/claim
|
|
43
|
+
* engine. Falls back to `showQr`'s plain QR rendering if the popup is
|
|
44
|
+
* blocked.
|
|
45
|
+
*
|
|
46
|
+
* Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
|
|
47
|
+
* password, social login, and 2FA were removed ecosystem-wide. Account
|
|
48
|
+
* creation (`signup` view) is the same two identity backends: a passkey
|
|
49
|
+
* ceremony on web, or a Commons-created identity.
|
|
50
|
+
*
|
|
51
|
+
* The controller owns NO surface PRESENTATION: whether the account dialog is
|
|
52
|
+
* mounted, visible, or dismissed is the job of the shared surface stack
|
|
53
|
+
* (`@oxy.so/services` presents the `AccountDialog` surface into `@oxy.so/bloom`'s
|
|
54
|
+
* stack). This is a headless state machine only — it exposes `setView` /
|
|
55
|
+
* `add` / `startSignup` (the view axis) and `cancelSignIn` (device-flow
|
|
56
|
+
* teardown), never an `open` / `close` / `visible`.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import type { DeviceDirectory } from '@oxy.so/contracts';
|
|
60
|
+
import type { OxyServices } from '../OxyServices';
|
|
61
|
+
import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
62
|
+
import type { User } from '../models/interfaces';
|
|
63
|
+
import { logger } from '../logger';
|
|
64
|
+
import { extractErrorStatus } from '../utils/errorUtils';
|
|
65
|
+
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
|
|
66
|
+
import type { SessionClient } from './SessionClient';
|
|
67
|
+
import { getSocketIO, type MinimalSocket, type SocketIOFactory } from './socketLoader';
|
|
68
|
+
import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
|
|
69
|
+
import type { CommonsSignInHandle } from '../mixins/OxyServices.auth';
|
|
70
|
+
import {
|
|
71
|
+
pushTargetsFromDelivery,
|
|
72
|
+
selectCommonsDelivery,
|
|
73
|
+
type CommonsDeliveryPlatform,
|
|
74
|
+
type CommonsDeliveryRoute,
|
|
75
|
+
} from '../utils/commonsDelivery';
|
|
76
|
+
|
|
77
|
+
/** The dialog's top-level view. */
|
|
78
|
+
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether Commons is installed on this device, as resolved by the injected
|
|
82
|
+
* `canOpenApp` probe:
|
|
83
|
+
* - `'unknown'` — not yet probed, OR no probe was injected (web — there is
|
|
84
|
+
* no API to ask a browser whether a custom URL scheme is registered, so
|
|
85
|
+
* this stays `'unknown'` forever there and the QR view renders
|
|
86
|
+
* unconditionally, no gating).
|
|
87
|
+
* - `'checking'` — the probe is in flight.
|
|
88
|
+
* - `'available'` / `'unavailable'` — the probe's resolved terminal answer
|
|
89
|
+
* (native only). A probe error is treated as `'unavailable'` (fail-closed).
|
|
90
|
+
*/
|
|
91
|
+
export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavailable';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Lifecycle phase of the "Sign in with Oxy" device flow — the RESOURCE state
|
|
95
|
+
* (is there a live request, a claim in flight, a terminal outcome), as opposed
|
|
96
|
+
* to {@link SignInProgress}, which is what the user is told.
|
|
97
|
+
*
|
|
98
|
+
* `'completed'` is terminal-but-successful: the session was claimed and
|
|
99
|
+
* committed, nothing is in flight, and the surface may show "Identity
|
|
100
|
+
* confirmed" before it goes away. It is cleared back to `'idle'` the moment the
|
|
101
|
+
* dialog moves to another view (a new intention), so a later sign-in entry can
|
|
102
|
+
* never inherit the previous flow's terminal state.
|
|
103
|
+
*/
|
|
104
|
+
export type SignInFlowPhase =
|
|
105
|
+
| 'idle'
|
|
106
|
+
| 'starting'
|
|
107
|
+
| 'waiting'
|
|
108
|
+
| 'authorized'
|
|
109
|
+
| 'completed'
|
|
110
|
+
| 'error';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ordered, non-sensitive progress of the active request — the ONE thing the
|
|
114
|
+
* sign-in surface renders its status line from (issue #691, Phase 5).
|
|
115
|
+
*
|
|
116
|
+
* Every step is DERIVED from a real fact the controller observed; there is no
|
|
117
|
+
* optimistic sequence and no timer that advances it. The mapping the UI is
|
|
118
|
+
* expected to render (copy lives in the UI layer, never here):
|
|
119
|
+
*
|
|
120
|
+
* | value | shown as | advanced by |
|
|
121
|
+
* |------------------------|--------------------------------|-------------|
|
|
122
|
+
* | `idle` | (nothing) | no live request, or a terminal error |
|
|
123
|
+
* | `preparing` | "Preparing request" | the request is being created / its route is still being resolved |
|
|
124
|
+
* | `awaiting-approval` | route-specific waiting copy | the primary route is known (`qr` → the QR itself, `open-commons` → "Continue in Commons") |
|
|
125
|
+
* | `delivered-to-commons` | "Check Commons on your phone" | the server confirmed a push to ≥1 known Commons install (`await-push`, or `pushSentAt`) |
|
|
126
|
+
* | `opened-in-commons` | "Opened in Commons" | the approver reported `openedAt` |
|
|
127
|
+
* | `confirming-identity` | "Confirming identity" | the request reported `authorized`; the claim/commit is running |
|
|
128
|
+
* | `identity-confirmed` | "Identity confirmed" | the session was claimed and committed |
|
|
129
|
+
*
|
|
130
|
+
* `opened-in-commons` and `delivered-to-commons` are PROGRESS only — neither is
|
|
131
|
+
* evidence of an approval. Only `authorized` moves the flow forward.
|
|
132
|
+
*/
|
|
133
|
+
export type SignInProgress =
|
|
134
|
+
| 'idle'
|
|
135
|
+
| 'preparing'
|
|
136
|
+
| 'awaiting-approval'
|
|
137
|
+
| 'delivered-to-commons'
|
|
138
|
+
| 'opened-in-commons'
|
|
139
|
+
| 'confirming-identity'
|
|
140
|
+
| 'identity-confirmed';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Minimal structural handle over a popup `Window` — just enough for the
|
|
144
|
+
* cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
|
|
145
|
+
* navigate it once the device-flow session's `authorizeCode` is known, detect
|
|
146
|
+
* the user closing it early (`closed`), and close it programmatically on
|
|
147
|
+
* completion/failure. Satisfied directly by `window.open()`'s return value on
|
|
148
|
+
* web; native has no popup concept and never injects an opener.
|
|
149
|
+
*/
|
|
150
|
+
export interface PopupWindowHandle {
|
|
151
|
+
readonly closed: boolean;
|
|
152
|
+
close(): void;
|
|
153
|
+
location: { href: string };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* State of the "Sign in with Oxy" (shared-key / QR) device flow.
|
|
158
|
+
*
|
|
159
|
+
* Everything here is safe to render. The flow's SECRET credential (the
|
|
160
|
+
* device-flow `sessionToken`) is deliberately absent — it never leaves the
|
|
161
|
+
* controller's private field, so no surface, log, or serialized snapshot can
|
|
162
|
+
* leak it.
|
|
163
|
+
*/
|
|
164
|
+
export interface SignInFlowState {
|
|
165
|
+
phase: SignInFlowPhase;
|
|
166
|
+
/**
|
|
167
|
+
* The PUBLIC, single-use authorize code (safe to display), or `null`. NOT the
|
|
168
|
+
* secret `sessionToken` — the approver resolves the app identity from this.
|
|
169
|
+
*/
|
|
170
|
+
authorizeCode: string | null;
|
|
171
|
+
/**
|
|
172
|
+
* The structured deep-link / QR payload (`oxycommons://approve?...`) to render
|
|
173
|
+
* as a QR (cross-device) and open as a deep link (same-device), or `null`.
|
|
174
|
+
*/
|
|
175
|
+
qrPayload: string | null;
|
|
176
|
+
/** Server-authoritative expiry (epoch ms), or `null`. */
|
|
177
|
+
expiresAt: number | null;
|
|
178
|
+
/** Human-readable error for the retry UI, or `null`. */
|
|
179
|
+
error: string | null;
|
|
180
|
+
/**
|
|
181
|
+
* The ONE primary delivery route the controller chose for this request
|
|
182
|
+
* ({@link selectCommonsDelivery}), or `null` while it is still being resolved.
|
|
183
|
+
*
|
|
184
|
+
* There is no chain: exactly one route is primary, and the UI renders exactly
|
|
185
|
+
* one action for it. Alternatives stay behind a "Having trouble?" affordance
|
|
186
|
+
* that the UI reveals on its own terms — see {@link routeFailed}.
|
|
187
|
+
*/
|
|
188
|
+
route: CommonsDeliveryRoute | null;
|
|
189
|
+
/**
|
|
190
|
+
* `true` when the chosen primary route could NOT be carried out on this
|
|
191
|
+
* device — today only `'open-commons'` can fail this way (no URL opener was
|
|
192
|
+
* injected, or opening the verified Commons link threw). It is the signal the
|
|
193
|
+
* UI needs to reveal its alternatives; the controller never cascades to
|
|
194
|
+
* another route by itself.
|
|
195
|
+
*
|
|
196
|
+
* A push that reached zero installations is NOT a failure: no capable Commons
|
|
197
|
+
* install is a normal outcome that simply resolves the primary route to
|
|
198
|
+
* `'qr'`.
|
|
199
|
+
*/
|
|
200
|
+
routeFailed: boolean;
|
|
201
|
+
/**
|
|
202
|
+
* Server-reported ISO-8601 timestamp of when the request was pushed to a
|
|
203
|
+
* known Commons installation, or `null`. Progress only — never evidence of an
|
|
204
|
+
* approval. Monotone: once observed it is never cleared by a later, emptier
|
|
205
|
+
* poll response.
|
|
206
|
+
*/
|
|
207
|
+
pushSentAt: string | null;
|
|
208
|
+
/**
|
|
209
|
+
* Server-reported ISO-8601 timestamp of when the approval route was OPENED in
|
|
210
|
+
* Commons, or `null`. Progress only — never evidence of an approval. Monotone,
|
|
211
|
+
* like {@link pushSentAt}.
|
|
212
|
+
*/
|
|
213
|
+
openedAt: string | null;
|
|
214
|
+
/**
|
|
215
|
+
* The derived, ordered progress the surface renders. Always computed from the
|
|
216
|
+
* facts above by {@link deriveSignInProgress} — never assigned directly, so it
|
|
217
|
+
* cannot drift from them or run ahead of a real signal.
|
|
218
|
+
*/
|
|
219
|
+
progress: SignInProgress;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The observable FACTS of a device flow — {@link SignInFlowState} minus the
|
|
224
|
+
* value derived from them. Every mutation of the flow goes through this shape,
|
|
225
|
+
* which is what makes `progress` structurally impossible to set by hand.
|
|
226
|
+
*/
|
|
227
|
+
type SignInFlowFacts = Omit<SignInFlowState, 'progress'>;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Derive the surface-facing progress from the flow's real facts. Pure, total,
|
|
231
|
+
* and the single place the ladder is defined.
|
|
232
|
+
*
|
|
233
|
+
* Ordering within `'waiting'` is most-specific-first, so a late-arriving weaker
|
|
234
|
+
* signal can never pull the display backwards.
|
|
235
|
+
*/
|
|
236
|
+
function deriveSignInProgress(facts: SignInFlowFacts): SignInProgress {
|
|
237
|
+
switch (facts.phase) {
|
|
238
|
+
case 'idle':
|
|
239
|
+
case 'error':
|
|
240
|
+
return 'idle';
|
|
241
|
+
case 'starting':
|
|
242
|
+
return 'preparing';
|
|
243
|
+
case 'authorized':
|
|
244
|
+
return 'confirming-identity';
|
|
245
|
+
case 'completed':
|
|
246
|
+
return 'identity-confirmed';
|
|
247
|
+
case 'waiting':
|
|
248
|
+
if (facts.openedAt !== null) return 'opened-in-commons';
|
|
249
|
+
// `route === 'await-push'` is itself a server-confirmed dispatch (the
|
|
250
|
+
// route is only chosen when `deliverCommonsSignIn` reported ≥1 target),
|
|
251
|
+
// so it is a real signal — not an optimistic assumption that a push will
|
|
252
|
+
// arrive. `pushSentAt` is the same fact re-confirmed by the status poll.
|
|
253
|
+
if (facts.pushSentAt !== null || facts.route === 'await-push') return 'delivered-to-commons';
|
|
254
|
+
return facts.route === null ? 'preparing' : 'awaiting-approval';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Immutable snapshot consumed by `useSyncExternalStore`. */
|
|
259
|
+
export interface AccountDialogSnapshot {
|
|
260
|
+
/** The current view. */
|
|
261
|
+
view: AccountDialogView;
|
|
262
|
+
/**
|
|
263
|
+
* The server-authoritative device directory — principals and the contexts
|
|
264
|
+
* each may act as (ADR 0002) — or `null` before the first read.
|
|
265
|
+
*
|
|
266
|
+
* The ONE read model a switcher renders. The flat list this replaced was
|
|
267
|
+
* keyed by account id, so on a device holding two people it could show one
|
|
268
|
+
* route to a shared organization and never both.
|
|
269
|
+
*/
|
|
270
|
+
directory: DeviceDirectory | null;
|
|
271
|
+
/** The active `principal acting as account` pair, actor and subject apart. */
|
|
272
|
+
activeContext: DeviceContext | null;
|
|
273
|
+
/** `true` while the first directory read is in flight with nothing to show. */
|
|
274
|
+
loading: boolean;
|
|
275
|
+
/** A human-readable directory error, or `null`. */
|
|
276
|
+
error: string | null;
|
|
277
|
+
/** The `contextId` of an in-flight activation, or `null`. */
|
|
278
|
+
activatingContextId: string | null;
|
|
279
|
+
/** The `contextId` of an in-flight context removal, or `null`. */
|
|
280
|
+
removingContextId: string | null;
|
|
281
|
+
/** The `principalId` of an in-flight principal removal, or `null`. */
|
|
282
|
+
removingPrincipalId: string | null;
|
|
283
|
+
/** The "Sign in with Oxy" device-flow state. */
|
|
284
|
+
signIn: SignInFlowState;
|
|
285
|
+
/** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
|
|
286
|
+
commonsAvailability: CommonsAvailability;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Construction options for {@link AccountDialogController}. */
|
|
290
|
+
export interface AccountDialogControllerOptions {
|
|
291
|
+
/** The API client. Source of graph accounts, profiles, and the sign-in methods. */
|
|
292
|
+
oxyServices: OxyServices;
|
|
293
|
+
/** The device-first session authority. Source of device rows + the switch path. */
|
|
294
|
+
sessionClient: SessionClient;
|
|
295
|
+
/**
|
|
296
|
+
* The RP's registered OAuth client id (ApplicationCredential publicKey).
|
|
297
|
+
* Required for the QR handoff (`startCommonsSignIn`); when absent, `showQr`
|
|
298
|
+
* fails with a clear configuration error instead of creating a session the
|
|
299
|
+
* server would reject.
|
|
300
|
+
*/
|
|
301
|
+
clientId?: string | null;
|
|
302
|
+
/**
|
|
303
|
+
* Commit a freshly-authorized SIGN-IN session (device flow / shared identity)
|
|
304
|
+
* into the host's session set — device-first registration + durable persist +
|
|
305
|
+
* profile hydration. The consumer supplies its provider's commit path
|
|
306
|
+
* (`useOxy().handleWebSession` / the auth-sdk equivalent). Called AFTER the SDK
|
|
307
|
+
* has planted the access token. When omitted the controller falls back to
|
|
308
|
+
* `SessionClient.registerAndActivate` (registration + activation only — no
|
|
309
|
+
* provider-side durable persist/hydration).
|
|
310
|
+
*
|
|
311
|
+
* Sign-in is the only thing that commits a session here. An account SWITCH
|
|
312
|
+
* used to mint one too, on first entry into a graph account; activation mints
|
|
313
|
+
* the delegated session SERVER-side and hands back a bearer, so there is no
|
|
314
|
+
* second commit funnel to keep in step with this one.
|
|
315
|
+
*/
|
|
316
|
+
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
317
|
+
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
318
|
+
onSignedIn?: (user: MinimalUserData) => void;
|
|
319
|
+
/**
|
|
320
|
+
* QR device-flow FALLBACK poll interval in ms (default 12000). The primary
|
|
321
|
+
* approval signal is the `/auth-session` socket's `auth_update` event (instant);
|
|
322
|
+
* this slow poll is only the safety net for when the socket can't connect.
|
|
323
|
+
*/
|
|
324
|
+
pollIntervalMs?: number;
|
|
325
|
+
/**
|
|
326
|
+
* Optional injected `socket.io-client` factory, primarily for tests. Without
|
|
327
|
+
* one, the optional transport loads only when QR sign-in starts. The flow subscribes to the
|
|
328
|
+
* `/auth-session` namespace for an INSTANT `auth_update` wake instead of relying
|
|
329
|
+
* on the slow fallback poll. If the package is unavailable, the controller
|
|
330
|
+
* silently degrades to poll-only.
|
|
331
|
+
*/
|
|
332
|
+
socketFactory?: SocketIOFactory;
|
|
333
|
+
/**
|
|
334
|
+
* Optional URL opener. When provided, the controller invokes it to deep-link
|
|
335
|
+
* the Commons app for the QR handoff (web: `location.assign`; native:
|
|
336
|
+
* `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
|
|
337
|
+
*/
|
|
338
|
+
openUrl?: (url: string) => void;
|
|
339
|
+
/**
|
|
340
|
+
* Optional "can this app open this URL scheme?" probe, symmetric to
|
|
341
|
+
* {@link openUrl}. When provided, `showQr` uses it to detect an installed
|
|
342
|
+
* Commons (`oxycommons://`) and, if present, deep-links straight into its
|
|
343
|
+
* approve screen via {@link openUrl} — while KEEPING the QR/polling active as
|
|
344
|
+
* the fallback. Injected by the provider (native: `Linking.canOpenURL`; web:
|
|
345
|
+
* absent/false). Headless core never touches `Linking` itself; when absent
|
|
346
|
+
* `showQr` behaves exactly as before (render QR only).
|
|
347
|
+
*/
|
|
348
|
+
canOpenApp?: (url: string) => Promise<boolean>;
|
|
349
|
+
/**
|
|
350
|
+
* Open a new, empty popup window SYNCHRONOUSLY (before any `await`) so the
|
|
351
|
+
* browser attributes it to the click gesture that triggered it, returning a
|
|
352
|
+
* handle to navigate once the device-flow session is known — or `null` if
|
|
353
|
+
* the popup was blocked. Injected by the provider (web only:
|
|
354
|
+
* `window.open('', ...)`; absent on native, where the hub-popup flow does
|
|
355
|
+
* not apply). Used by {@link AccountDialogController.startPasskeyHubSignIn}.
|
|
356
|
+
*/
|
|
357
|
+
openPopup?: () => PopupWindowHandle | null;
|
|
358
|
+
/**
|
|
359
|
+
* Base origin of the auth.oxy.so passkey hub (defaults to
|
|
360
|
+
* `https://auth.${CENTRAL_IDP_APEX}`). Overridable for local/staging testing.
|
|
361
|
+
*/
|
|
362
|
+
hubBaseUrl?: string;
|
|
363
|
+
/**
|
|
364
|
+
* Which surface the sign-in is initiated from — a FACT supplied by the
|
|
365
|
+
* consumer, because only the consumer can classify its own environment
|
|
366
|
+
* (native → `'mobile'`; web → `'mobile'` for a mobile browser, `'desktop'`
|
|
367
|
+
* otherwise). Headless core never sniffs a user agent or a platform global.
|
|
368
|
+
*
|
|
369
|
+
* Feeds {@link selectCommonsDelivery} verbatim. Defaults to `'unknown'`,
|
|
370
|
+
* which is a first-class value there: an unclassified surface never opts into
|
|
371
|
+
* the deep-link route, because a custom-scheme navigation that does not
|
|
372
|
+
* resolve is a dead end with no automatic way back.
|
|
373
|
+
*/
|
|
374
|
+
platform?: CommonsDeliveryPlatform;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
|
|
379
|
+
* the approval instantly via `auth_update`; this poll only covers the case where
|
|
380
|
+
* the socket can't connect, so it is deliberately slow (was 3000 when polling was
|
|
381
|
+
* the sole mechanism).
|
|
382
|
+
*/
|
|
383
|
+
const DEFAULT_POLL_INTERVAL_MS = 12000;
|
|
384
|
+
|
|
385
|
+
/** Socket.IO namespace the API emits QR-flow approval (`auth_update`) events on. */
|
|
386
|
+
const AUTH_SESSION_NAMESPACE = '/auth-session';
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
|
|
390
|
+
* installed Commons on the same device; the `oxycommons://approve?...` deep link
|
|
391
|
+
* itself is the flow's `qrPayload`.
|
|
392
|
+
*/
|
|
393
|
+
const COMMONS_APP_SCHEME = 'oxycommons://';
|
|
394
|
+
|
|
395
|
+
const IDLE_SIGN_IN_FACTS: SignInFlowFacts = {
|
|
396
|
+
phase: 'idle',
|
|
397
|
+
authorizeCode: null,
|
|
398
|
+
qrPayload: null,
|
|
399
|
+
expiresAt: null,
|
|
400
|
+
error: null,
|
|
401
|
+
route: null,
|
|
402
|
+
routeFailed: false,
|
|
403
|
+
pushSentAt: null,
|
|
404
|
+
openedAt: null,
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const IDLE_SIGN_IN: SignInFlowState = {
|
|
408
|
+
...IDLE_SIGN_IN_FACTS,
|
|
409
|
+
progress: deriveSignInProgress(IDLE_SIGN_IN_FACTS),
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Terminal SUCCESS state: the session was claimed and committed. Holds no live
|
|
414
|
+
* resources and no request handles — only the terminal progress the surface
|
|
415
|
+
* shows ("Identity confirmed") before it closes.
|
|
416
|
+
*/
|
|
417
|
+
const COMPLETED_SIGN_IN: SignInFlowState = {
|
|
418
|
+
...IDLE_SIGN_IN_FACTS,
|
|
419
|
+
phase: 'completed',
|
|
420
|
+
progress: deriveSignInProgress({ ...IDLE_SIGN_IN_FACTS, phase: 'completed' }),
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
function errorMessage(error: unknown): string {
|
|
424
|
+
return error instanceof Error ? error.message : String(error);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
|
|
428
|
+
|
|
429
|
+
export class AccountDialogController {
|
|
430
|
+
private readonly oxyServices: OxyServices;
|
|
431
|
+
private readonly sessionClient: SessionClient;
|
|
432
|
+
private readonly clientId: string | null;
|
|
433
|
+
private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
434
|
+
private readonly onSignedIn?: (user: MinimalUserData) => void;
|
|
435
|
+
private readonly pollIntervalMs: number;
|
|
436
|
+
private readonly openUrl?: (url: string) => void;
|
|
437
|
+
private readonly canOpenApp?: (url: string) => Promise<boolean>;
|
|
438
|
+
private readonly socketFactory?: SocketIOFactory;
|
|
439
|
+
private readonly openPopup?: () => PopupWindowHandle | null;
|
|
440
|
+
private readonly hubBaseUrl: string;
|
|
441
|
+
private readonly platform: CommonsDeliveryPlatform;
|
|
442
|
+
|
|
443
|
+
private readonly listeners = new Set<SnapshotListener>();
|
|
444
|
+
|
|
445
|
+
// --- Internal (unprojected) state ---
|
|
446
|
+
private view: AccountDialogView = 'accounts';
|
|
447
|
+
private loading = false;
|
|
448
|
+
private error: string | null = null;
|
|
449
|
+
private activatingContextId: string | null = null;
|
|
450
|
+
private removingContextId: string | null = null;
|
|
451
|
+
private removingPrincipalId: string | null = null;
|
|
452
|
+
private signIn: SignInFlowState = IDLE_SIGN_IN;
|
|
453
|
+
private commonsAvailability: CommonsAvailability = 'unknown';
|
|
454
|
+
|
|
455
|
+
// --- Sign-in device-flow bookkeeping ---
|
|
456
|
+
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
457
|
+
private signInToken: string | null = null;
|
|
458
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
459
|
+
/**
|
|
460
|
+
* The `/auth-session` socket for the active QR flow, or null (poll-only). Its
|
|
461
|
+
* `auth_update` event wakes {@link pollOnce} instantly instead of waiting for the
|
|
462
|
+
* slow fallback timer.
|
|
463
|
+
*/
|
|
464
|
+
private authSessionSocket: MinimalSocket | null = null;
|
|
465
|
+
/**
|
|
466
|
+
* Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
|
|
467
|
+
* `auth_update` wake can fire together — without this both could claim the
|
|
468
|
+
* single-use token concurrently.
|
|
469
|
+
*/
|
|
470
|
+
private pollInFlight = false;
|
|
471
|
+
/** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
|
|
472
|
+
private activePopup: PopupWindowHandle | null = null;
|
|
473
|
+
/** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
|
|
474
|
+
private popupWatchTimer: ReturnType<typeof setInterval> | null = null;
|
|
475
|
+
|
|
476
|
+
// --- Store plumbing ---
|
|
477
|
+
private unsubscribeSession: (() => void) | null = null;
|
|
478
|
+
private unsubscribeTokens: (() => void) | null = null;
|
|
479
|
+
/** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
|
|
480
|
+
private authed = false;
|
|
481
|
+
private started = false;
|
|
482
|
+
private refreshSeq = 0;
|
|
483
|
+
private snapshot: AccountDialogSnapshot;
|
|
484
|
+
|
|
485
|
+
constructor(options: AccountDialogControllerOptions) {
|
|
486
|
+
this.oxyServices = options.oxyServices;
|
|
487
|
+
this.sessionClient = options.sessionClient;
|
|
488
|
+
this.clientId = options.clientId ?? null;
|
|
489
|
+
this.commitSession = options.commitSession;
|
|
490
|
+
this.onSignedIn = options.onSignedIn;
|
|
491
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
492
|
+
this.openUrl = options.openUrl;
|
|
493
|
+
this.canOpenApp = options.canOpenApp;
|
|
494
|
+
this.socketFactory = options.socketFactory;
|
|
495
|
+
this.openPopup = options.openPopup;
|
|
496
|
+
this.hubBaseUrl = options.hubBaseUrl ?? `https://auth.${CENTRAL_IDP_APEX}`;
|
|
497
|
+
this.platform = options.platform ?? 'unknown';
|
|
498
|
+
this.snapshot = this.computeSnapshot();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// =========================================================================
|
|
502
|
+
// Store surface (useSyncExternalStore)
|
|
503
|
+
// =========================================================================
|
|
504
|
+
|
|
505
|
+
/** Returns the current immutable snapshot (stable reference between changes). */
|
|
506
|
+
getSnapshot(): AccountDialogSnapshot {
|
|
507
|
+
return this.snapshot;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Subscribe to snapshot changes. Returns an unsubscribe function. */
|
|
511
|
+
subscribe(listener: SnapshotListener): () => void {
|
|
512
|
+
this.listeners.add(listener);
|
|
513
|
+
return () => {
|
|
514
|
+
this.listeners.delete(listener);
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// =========================================================================
|
|
519
|
+
// Lifecycle
|
|
520
|
+
// =========================================================================
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Begin driving the dialog: subscribe to `SessionClient` state and load the
|
|
524
|
+
* account list. Idempotent — a second `start()` is a no-op. Pair with
|
|
525
|
+
* {@link destroy}.
|
|
526
|
+
*/
|
|
527
|
+
start(): void {
|
|
528
|
+
if (this.started) return;
|
|
529
|
+
this.started = true;
|
|
530
|
+
this.authed = this.isAuthenticated();
|
|
531
|
+
this.unsubscribeSession = this.sessionClient.subscribe(() => {
|
|
532
|
+
// A device change (switch / sign-out / sibling sign-in) re-reads the
|
|
533
|
+
// directory inside `SessionClient` before it notifies, so the snapshot
|
|
534
|
+
// this rebuilds is already the new one — publish it and reconcile the
|
|
535
|
+
// auth-readiness edge. No profile fetch: the directory carries the display
|
|
536
|
+
// metadata a row needs.
|
|
537
|
+
this.emit();
|
|
538
|
+
this.reconcileAuth();
|
|
539
|
+
});
|
|
540
|
+
// The access token is planted AFTER `SessionClient.applyState` fires its
|
|
541
|
+
// subscription (`applySync` calls `setTokens` only once `applyState`/notify
|
|
542
|
+
// has returned; `ensureActiveToken` plants it async later), so the
|
|
543
|
+
// device-state subscription alone cannot observe the signed-out → signed-in
|
|
544
|
+
// edge. Observe the SDK-canonical readiness signal directly — a change to
|
|
545
|
+
// `oxyServices.getAccessToken()`, the `hasAccessToken` term of
|
|
546
|
+
// `OxyContext.canUsePrivateApi`.
|
|
547
|
+
this.unsubscribeTokens = this.oxyServices.onTokensChanged(() => {
|
|
548
|
+
this.reconcileAuth();
|
|
549
|
+
});
|
|
550
|
+
// Initial projection is device-only. `refresh()` fetches the graph IFF a
|
|
551
|
+
// bearer is already planted (warm start); when signed out (cold boot before
|
|
552
|
+
// restore) it re-projects from device state and makes NO private call.
|
|
553
|
+
void this.refresh();
|
|
554
|
+
// Eager, cached Commons-availability probe (native only — a no-op when no
|
|
555
|
+
// `canOpenApp` was injected). It's a cheap local OS check, so by the time a
|
|
556
|
+
// user actually opens the sign-in entry it has almost always resolved —
|
|
557
|
+
// `showQr`'s own lazy probe below is only the safety net for the rare race.
|
|
558
|
+
void this.resolveCommonsAvailability();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Stop driving the dialog: unsubscribe from `SessionClient` and tear down the
|
|
563
|
+
* active sign-in flow (timers). Idempotent.
|
|
564
|
+
*/
|
|
565
|
+
destroy(): void {
|
|
566
|
+
this.started = false;
|
|
567
|
+
if (this.unsubscribeSession) {
|
|
568
|
+
this.unsubscribeSession();
|
|
569
|
+
this.unsubscribeSession = null;
|
|
570
|
+
}
|
|
571
|
+
if (this.unsubscribeTokens) {
|
|
572
|
+
this.unsubscribeTokens();
|
|
573
|
+
this.unsubscribeTokens = null;
|
|
574
|
+
}
|
|
575
|
+
this.clearPollTimer();
|
|
576
|
+
this.closeAuthSessionSocket();
|
|
577
|
+
this.closeActivePopup();
|
|
578
|
+
this.signInToken = null;
|
|
579
|
+
this.listeners.clear();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// =========================================================================
|
|
583
|
+
// Auth readiness (SDK-canonical — mirrors OxyContext.canUsePrivateApi)
|
|
584
|
+
// =========================================================================
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Whether a PRIVATE endpoint may be called right now. Mirrors the
|
|
588
|
+
* `hasAccessToken` term of `OxyContext.canUsePrivateApi`
|
|
589
|
+
* (`authResolved && isAuthenticated && tokenReady && hasAccessToken`, where
|
|
590
|
+
* `hasAccessToken = Boolean(oxyServices.getAccessToken())`): a planted bearer
|
|
591
|
+
* is the only term that decides whether a request carries auth — the other
|
|
592
|
+
* three are provider render-lifecycle gates with no headless equivalent.
|
|
593
|
+
*
|
|
594
|
+
* `listAccounts()` (`GET /accounts`) and `getUsersByIds()`
|
|
595
|
+
* (`POST /users/by-ids`) are private; calling either before cold-boot restore
|
|
596
|
+
* plants the token 401s → `HttpService` clears the bearer + emits
|
|
597
|
+
* `onTokensChanged(null)` → the app signs out. Every graph/profile fetch gates
|
|
598
|
+
* on this.
|
|
599
|
+
*/
|
|
600
|
+
private isAuthenticated(): boolean {
|
|
601
|
+
return Boolean(this.oxyServices.getAccessToken());
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Reconcile the account graph against the current auth-readiness edge. On the
|
|
606
|
+
* signed-out → signed-in edge fetch the graph ONCE; on signed-in → signed-out
|
|
607
|
+
* drop it and re-project device-only. A no-op when readiness is unchanged, so
|
|
608
|
+
* a burst of token events / device pushes cannot restart the fetch — and a
|
|
609
|
+
* failed `listAccounts()` never flips the edge, so it cannot re-trigger itself
|
|
610
|
+
* (no retry storm).
|
|
611
|
+
*/
|
|
612
|
+
private reconcileAuth(): void {
|
|
613
|
+
const authed = this.isAuthenticated();
|
|
614
|
+
if (authed === this.authed) return;
|
|
615
|
+
this.authed = authed;
|
|
616
|
+
if (authed) {
|
|
617
|
+
void this.refresh();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Signed out. The directory is bearer-read and `SessionClient` drops the one
|
|
621
|
+
// it holds when the device empties, so there is nothing to clear here — only
|
|
622
|
+
// the in-flight bookkeeping, which no longer describes anything.
|
|
623
|
+
this.error = null;
|
|
624
|
+
this.loading = false;
|
|
625
|
+
this.emit();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// =========================================================================
|
|
629
|
+
// View actions
|
|
630
|
+
// =========================================================================
|
|
631
|
+
|
|
632
|
+
/** Set the dialog view directly. */
|
|
633
|
+
setView(view: AccountDialogView): void {
|
|
634
|
+
if (this.view === view) return;
|
|
635
|
+
this.view = view;
|
|
636
|
+
// A `'completed'` flow owns no timers, socket, popup, or token — it is only
|
|
637
|
+
// the terminal "Identity confirmed" the finished surface showed. Moving to
|
|
638
|
+
// another view is a NEW intention, so drop it; otherwise a later `add()`
|
|
639
|
+
// would open on the previous sign-in's terminal state.
|
|
640
|
+
if (this.signIn.phase === 'completed') {
|
|
641
|
+
this.signIn = IDLE_SIGN_IN;
|
|
642
|
+
}
|
|
643
|
+
this.emit();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
647
|
+
add(): void {
|
|
648
|
+
this.setView('add');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
652
|
+
startSignup(): void {
|
|
653
|
+
this.setView('signup');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// =========================================================================
|
|
657
|
+
// The directory
|
|
658
|
+
// =========================================================================
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Re-read `GET /session/device/directory`. Safe to call repeatedly;
|
|
662
|
+
* concurrent calls are reconciled by a sequence guard so a slow earlier read
|
|
663
|
+
* never overwrites a newer result.
|
|
664
|
+
*
|
|
665
|
+
* This is ONE request. It used to be three — the directory, plus
|
|
666
|
+
* `listAccounts()` and `getUsersByIds()` to rebuild the same tree client-side
|
|
667
|
+
* — and the reconstruction was not merely redundant: it enumerated the
|
|
668
|
+
* CALLER's account graph, which on a device holding two people is one
|
|
669
|
+
* person's answer presented as the device's.
|
|
670
|
+
*/
|
|
671
|
+
async refresh(): Promise<void> {
|
|
672
|
+
const seq = ++this.refreshSeq;
|
|
673
|
+
|
|
674
|
+
// Never read the directory while signed out: at cold boot the bearer is not
|
|
675
|
+
// planted yet, so the call 401s → `HttpService` clears the token and signs
|
|
676
|
+
// the user out.
|
|
677
|
+
if (!this.isAuthenticated()) {
|
|
678
|
+
this.loading = false;
|
|
679
|
+
this.error = null;
|
|
680
|
+
this.emit();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Nothing to show yet is the only state worth a spinner; a re-read behind an
|
|
685
|
+
// already-rendered directory refreshes in place.
|
|
686
|
+
this.loading = this.sessionClient.getDirectory() === null;
|
|
687
|
+
this.error = null;
|
|
688
|
+
this.emit();
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
await this.sessionClient.refreshDirectory();
|
|
692
|
+
} catch (error) {
|
|
693
|
+
// A 401 is the EXPECTED signed-out edge, not a failure: the bearer was
|
|
694
|
+
// stale/revoked, so `HttpService` already cleared it and emitted
|
|
695
|
+
// `onTokensChanged(null)`. Any OTHER error (network, 5xx, malformed) IS
|
|
696
|
+
// unexpected — surface it, and keep whatever directory is already held so
|
|
697
|
+
// an outage degrades rather than blanks the switcher.
|
|
698
|
+
if (extractErrorStatus(error) === 401) {
|
|
699
|
+
logger.debug('[AccountDialogController] directory unauthorized (signed out)', { component: 'AccountDialogController' }, error);
|
|
700
|
+
} else {
|
|
701
|
+
this.error = errorMessage(error);
|
|
702
|
+
logger.warn('[AccountDialogController] directory refresh failed', { component: 'AccountDialogController' }, error);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (seq !== this.refreshSeq) return; // superseded by a newer refresh
|
|
706
|
+
|
|
707
|
+
this.loading = false;
|
|
708
|
+
this.emit();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// =========================================================================
|
|
712
|
+
// Activation and the two removals (ADR 0002)
|
|
713
|
+
// =========================================================================
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Activate one `principal acting as account` context — the ADR 0002 switch,
|
|
717
|
+
* and the one that can express what an account id cannot: WHICH person's
|
|
718
|
+
* route to a shared organization to become.
|
|
719
|
+
*
|
|
720
|
+
* There is no on-device/graph fork. The directory has a row for a context the
|
|
721
|
+
* principal may act as but has never entered, and `POST /session/device/
|
|
722
|
+
* activate` reuses or mints the delegated session server-side, so one call
|
|
723
|
+
* covers both cases.
|
|
724
|
+
*
|
|
725
|
+
* A context id is not stable across a removal, so a stale one is an ordinary
|
|
726
|
+
* outcome rather than a bug: the server answers 404 or 403, heals the row, and
|
|
727
|
+
* the refresh below re-reads a directory that no longer offers it.
|
|
728
|
+
*/
|
|
729
|
+
async activateContext(contextId: string): Promise<boolean> {
|
|
730
|
+
if (this.activatingContextId) return false;
|
|
731
|
+
this.activatingContextId = contextId;
|
|
732
|
+
this.error = null;
|
|
733
|
+
this.emit();
|
|
734
|
+
try {
|
|
735
|
+
await this.sessionClient.activateContext(contextId);
|
|
736
|
+
await this.refresh();
|
|
737
|
+
return true;
|
|
738
|
+
} catch (error) {
|
|
739
|
+
this.error = errorMessage(error);
|
|
740
|
+
return false;
|
|
741
|
+
} finally {
|
|
742
|
+
this.activatingContextId = null;
|
|
743
|
+
this.emit();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Remove ONE `principal → account` pair, and only that pair.
|
|
749
|
+
*
|
|
750
|
+
* Not the account across the device: the same organization reached through a
|
|
751
|
+
* second person is a different session with a different audit actor, and it
|
|
752
|
+
* stays. Routing this through `signOut({accountId})` would revoke that second
|
|
753
|
+
* person's access as a side effect of one person tidying their own list.
|
|
754
|
+
*
|
|
755
|
+
* The removed pair is not gone for good while the membership lives — the
|
|
756
|
+
* server offers it again on the next read, under a NEW id and at an unchanged
|
|
757
|
+
* revision — so nothing may hold a context id across this call.
|
|
758
|
+
*/
|
|
759
|
+
async signOutContext(contextId: string): Promise<boolean> {
|
|
760
|
+
if (this.removingContextId || this.removingPrincipalId) return false;
|
|
761
|
+
this.removingContextId = contextId;
|
|
762
|
+
this.error = null;
|
|
763
|
+
this.emit();
|
|
764
|
+
try {
|
|
765
|
+
await this.sessionClient.signOutContext(contextId);
|
|
766
|
+
await this.refresh();
|
|
767
|
+
return true;
|
|
768
|
+
} catch (error) {
|
|
769
|
+
this.error = errorMessage(error);
|
|
770
|
+
return false;
|
|
771
|
+
} finally {
|
|
772
|
+
this.removingContextId = null;
|
|
773
|
+
this.emit();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Remove ONE PERSON and every context they reach — and nobody else's,
|
|
779
|
+
* including when another principal independently operates the same account.
|
|
780
|
+
*
|
|
781
|
+
* A separate call from {@link signOutContext} because it is a separate
|
|
782
|
+
* question, not a loop over the first one: the server removes the principal
|
|
783
|
+
* and elects a replacement active context in one transition.
|
|
784
|
+
*/
|
|
785
|
+
async signOutPrincipal(principalId: string): Promise<boolean> {
|
|
786
|
+
if (this.removingContextId || this.removingPrincipalId) return false;
|
|
787
|
+
this.removingPrincipalId = principalId;
|
|
788
|
+
this.error = null;
|
|
789
|
+
this.emit();
|
|
790
|
+
try {
|
|
791
|
+
await this.sessionClient.signOutPrincipal(principalId);
|
|
792
|
+
await this.refresh();
|
|
793
|
+
return true;
|
|
794
|
+
} catch (error) {
|
|
795
|
+
this.error = errorMessage(error);
|
|
796
|
+
return false;
|
|
797
|
+
} finally {
|
|
798
|
+
this.removingPrincipalId = null;
|
|
799
|
+
this.emit();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// =========================================================================
|
|
804
|
+
// Sign in with Oxy (device flow — shared keychain, else cross-device QR)
|
|
805
|
+
// =========================================================================
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Start "Sign in with Oxy". Native devices with a shared identity mint a
|
|
809
|
+
* session silently (`signInWithSharedIdentity`); everything else (web, or a
|
|
810
|
+
* native device without a shared identity) falls through to the cross-device
|
|
811
|
+
* QR handoff.
|
|
812
|
+
*/
|
|
813
|
+
async signInWithOxy(): Promise<void> {
|
|
814
|
+
this.setView('qr');
|
|
815
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
|
|
816
|
+
try {
|
|
817
|
+
const session = await this.oxyServices.signInWithSharedIdentity();
|
|
818
|
+
if (session) {
|
|
819
|
+
await this.completeSignIn(session, session.user);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
} catch (error) {
|
|
823
|
+
// Shared-key mint failed — log and fall through to the QR handoff rather
|
|
824
|
+
// than dead-ending the sign-in.
|
|
825
|
+
logger.warn('[AccountDialogController] signInWithSharedIdentity failed', { component: 'AccountDialogController' }, error);
|
|
826
|
+
}
|
|
827
|
+
await this.showQr();
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Begin (or restart) the cross-device QR handoff: create a device-flow
|
|
832
|
+
* session, surface its `authorizeCode` + `qrPayload`, and poll for approval.
|
|
833
|
+
* On approval the secret token is exchanged (`claimSessionByToken`) and the
|
|
834
|
+
* session committed. Requires `clientId`.
|
|
835
|
+
*/
|
|
836
|
+
async showQr(): Promise<void> {
|
|
837
|
+
this.cancelSignIn();
|
|
838
|
+
this.setView('qr');
|
|
839
|
+
await this.startDeviceFlowSession({ deliver: true });
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
|
|
844
|
+
* WebAuthn ceremony locally — a credential minted with `WEBAUTHN_RP_ID=oxy.so`
|
|
845
|
+
* can only be asserted from `oxy.so`/a subdomain/loopback, a browser-enforced
|
|
846
|
+
* boundary `isOxyRpOrigin()` (consumer-side) already gates on. Instead, open
|
|
847
|
+
* a popup at the auth.oxy.so passkey hub, scoped to the SAME device-flow
|
|
848
|
+
* session {@link showQr} would create (same `authorizeCode`/`sessionToken`
|
|
849
|
+
* pair), and let the SAME poll/socket/claim engine complete it once the hub
|
|
850
|
+
* authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
|
|
851
|
+
* bearer-authed, server-side — this method never sees or transmits the
|
|
852
|
+
* secret `sessionToken`).
|
|
853
|
+
*
|
|
854
|
+
* The popup MUST be opened synchronously, before any `await`, or the
|
|
855
|
+
* browser's popup blocker loses the click-gesture attribution and silently
|
|
856
|
+
* blocks it — so {@link openPopup} is invoked as the very first step, before
|
|
857
|
+
* the device-flow session (which needs an async call) even exists. If the
|
|
858
|
+
* popup is blocked (or no `openPopup` was injected), this falls back to
|
|
859
|
+
* `showQr`'s plain QR rendering — no dead end.
|
|
860
|
+
*/
|
|
861
|
+
async startPasskeyHubSignIn(): Promise<void> {
|
|
862
|
+
this.cancelSignIn();
|
|
863
|
+
this.setView('qr');
|
|
864
|
+
const popup = this.openPopup?.() ?? null;
|
|
865
|
+
if (!popup) {
|
|
866
|
+
await this.startDeviceFlowSession({ deliver: true });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
// The hub popup IS the primary surface here, chosen explicitly by the user —
|
|
870
|
+
// so this flow does NOT run automatic Commons delivery (ringing the user's
|
|
871
|
+
// phone because they asked for a passkey would be exactly the "menu of
|
|
872
|
+
// methods" the one-primary-action rule forbids). The underlying request is
|
|
873
|
+
// the same `AuthSession`, and its Commons route stays the QR the view
|
|
874
|
+
// renders beneath the popup.
|
|
875
|
+
const handle = await this.startDeviceFlowSession({ deliver: false });
|
|
876
|
+
if (!handle) {
|
|
877
|
+
popup.close();
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
// The user may have closed the popup during the async session creation
|
|
881
|
+
// above, before any watcher was attached to catch it — guard rather than
|
|
882
|
+
// navigate a dead window (browsers vary on whether that throws).
|
|
883
|
+
if (popup.closed) {
|
|
884
|
+
this.failSignIn('Sign-in was cancelled.');
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
this.activePopup = popup;
|
|
888
|
+
popup.location.href = `${this.hubBaseUrl}/hub-passkey?code=${encodeURIComponent(handle.authorizeCode)}`;
|
|
889
|
+
this.watchPopup(popup);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Shared device-flow session creation for both {@link showQr} (renders the
|
|
894
|
+
* QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
|
|
895
|
+
* same `startCommonsSignIn` → poll/socket wiring either way. Returns the
|
|
896
|
+
* handle on success (already reflected in `signIn`), or `null` on failure
|
|
897
|
+
* (already set as `signIn.error`).
|
|
898
|
+
*
|
|
899
|
+
* @param opts.deliver - Whether to run automatic Commons delivery selection
|
|
900
|
+
* ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
|
|
901
|
+
* entry; `false` when the caller already owns the primary surface (the
|
|
902
|
+
* passkey hub popup), where the request's Commons route is simply the QR.
|
|
903
|
+
*/
|
|
904
|
+
private async startDeviceFlowSession(opts: { deliver: boolean }): Promise<CommonsSignInHandle | null> {
|
|
905
|
+
if (!this.clientId) {
|
|
906
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
|
|
910
|
+
try {
|
|
911
|
+
const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
|
|
912
|
+
this.signInToken = handle.sessionToken;
|
|
913
|
+
this.setSignIn({
|
|
914
|
+
...IDLE_SIGN_IN_FACTS,
|
|
915
|
+
phase: 'waiting',
|
|
916
|
+
authorizeCode: handle.authorizeCode,
|
|
917
|
+
qrPayload: handle.qrPayload,
|
|
918
|
+
expiresAt: handle.expiresAt,
|
|
919
|
+
// No route yet: the surface shows "Preparing request" until the primary
|
|
920
|
+
// route is resolved below. It is never guessed in the meantime.
|
|
921
|
+
route: opts.deliver ? null : 'qr',
|
|
922
|
+
});
|
|
923
|
+
// Primary path: an instant `auth_update` wake over the `/auth-session`
|
|
924
|
+
// socket. The poll below is only the fallback for when the socket can't
|
|
925
|
+
// connect, so it now runs at the slow fallback cadence.
|
|
926
|
+
this.openAuthSessionSocket(handle.sessionToken);
|
|
927
|
+
this.scheduleNextPoll(handle.sessionToken);
|
|
928
|
+
if (opts.deliver) {
|
|
929
|
+
// Non-blocking on purpose: the QR/authorizeCode are already renderable
|
|
930
|
+
// and the popup caller can navigate immediately, while the route (a
|
|
931
|
+
// local probe plus at most one delivery round-trip) resolves behind it.
|
|
932
|
+
void this.resolveDeliveryRoute(handle);
|
|
933
|
+
}
|
|
934
|
+
return handle;
|
|
935
|
+
} catch (error) {
|
|
936
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: errorMessage(error) });
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Choose and carry out the ONE primary delivery route for the active request
|
|
943
|
+
* (issue #691, "Automatic delivery selection").
|
|
944
|
+
*
|
|
945
|
+
* The controller gathers the facts — is a verified Commons link openable on
|
|
946
|
+
* THIS device, and how many known Commons installations did Oxy actually push
|
|
947
|
+
* to — and hands them to the pure {@link selectCommonsDelivery}. The selector
|
|
948
|
+
* owns the decision; this method owns only the observations and the single
|
|
949
|
+
* action the chosen route implies. It never cascades: a route that yields
|
|
950
|
+
* nothing resolves to QR *before* a route is chosen, never after.
|
|
951
|
+
*/
|
|
952
|
+
private async resolveDeliveryRoute(handle: CommonsSignInHandle): Promise<void> {
|
|
953
|
+
if (!this.isAwaitingApproval(handle.sessionToken)) return;
|
|
954
|
+
|
|
955
|
+
// Fact 1 — a VERIFIED Commons link openable on this very device. Native
|
|
956
|
+
// only; on web `commonsAvailability` stays `'unknown'` (a browser cannot be
|
|
957
|
+
// asked whether a custom scheme is registered) and this is simply `false`.
|
|
958
|
+
if (
|
|
959
|
+
this.canOpenApp &&
|
|
960
|
+
(this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking')
|
|
961
|
+
) {
|
|
962
|
+
await this.resolveCommonsAvailability();
|
|
963
|
+
if (!this.isAwaitingApproval(handle.sessionToken)) return;
|
|
964
|
+
}
|
|
965
|
+
const commonsAvailable = this.commonsAvailability === 'available';
|
|
966
|
+
|
|
967
|
+
// Fact 2 — how many known Commons installations the server pushed to.
|
|
968
|
+
const pushTargets = await this.deliverToKnownCommons(handle.authorizeCode, commonsAvailable);
|
|
969
|
+
if (!this.isAwaitingApproval(handle.sessionToken)) return;
|
|
970
|
+
|
|
971
|
+
const route = selectCommonsDelivery({ platform: this.platform, commonsAvailable, pushTargets });
|
|
972
|
+
// The only route that has an action to perform on this device — and the only
|
|
973
|
+
// one that can fail here. `'await-push'` was already dispatched server-side;
|
|
974
|
+
// `'qr'` is rendered by the surface from `qrPayload`.
|
|
975
|
+
const routeFailed = route === 'open-commons' ? !this.openCommonsLink(handle.qrPayload) : false;
|
|
976
|
+
this.patchSignIn({ route, routeFailed });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Whether `sessionToken` is still THE request this surface is waiting on.
|
|
981
|
+
*
|
|
982
|
+
* Guards every step of the asynchronous route resolution: a cancelled,
|
|
983
|
+
* superseded, failed, or already-approved flow must neither open Commons nor
|
|
984
|
+
* mutate the surface — an approval that lands mid-resolution would otherwise
|
|
985
|
+
* be followed by a pointless app switch.
|
|
986
|
+
*/
|
|
987
|
+
private isAwaitingApproval(sessionToken: string): boolean {
|
|
988
|
+
return this.signInToken === sessionToken && this.signIn.phase === 'waiting';
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Ask Oxy to deliver the pending request to the identity's known Commons
|
|
993
|
+
* installations, returning how many it reached (`0` when delivery is not
|
|
994
|
+
* applicable, not permitted, or reached nobody).
|
|
995
|
+
*
|
|
996
|
+
* Two hard rules, both from the issue:
|
|
997
|
+
* - **Never push from an unauthenticated surface.** `deliverCommonsSignIn`
|
|
998
|
+
* is bearer-required precisely because a request that merely carries a
|
|
999
|
+
* typed-in username must never be able to ring somebody's phone. Without a
|
|
1000
|
+
* planted bearer the call is not made AT ALL — not made-and-failed.
|
|
1001
|
+
* - **Zero targets and a failed delivery are the same normal outcome.** Both
|
|
1002
|
+
* return `0`, which resolves the primary route to QR silently. Neither is
|
|
1003
|
+
* surfaced as an error: there is nothing the user did wrong and nothing for
|
|
1004
|
+
* them to fix.
|
|
1005
|
+
*/
|
|
1006
|
+
private async deliverToKnownCommons(authorizeCode: string, commonsAvailable: boolean): Promise<number> {
|
|
1007
|
+
// Route 1 (mobile with a verified local Commons link) reaches the identity
|
|
1008
|
+
// on this very device — pushing as well would notify a second surface for a
|
|
1009
|
+
// request the user is about to confirm here.
|
|
1010
|
+
if (this.platform === 'mobile' && commonsAvailable) return 0;
|
|
1011
|
+
if (!this.isAuthenticated()) return 0;
|
|
1012
|
+
try {
|
|
1013
|
+
const result = await this.oxyServices.deliverCommonsSignIn(authorizeCode);
|
|
1014
|
+
// `delivered: false` with `targets > 0` is a transport failure, not a
|
|
1015
|
+
// reachable install — the shared helper owns that rule so the surface
|
|
1016
|
+
// can never park a user on "check your phone" for a push that failed.
|
|
1017
|
+
return pushTargetsFromDelivery(result);
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
logger.debug(
|
|
1020
|
+
'[AccountDialogController] Commons delivery unavailable (QR route)',
|
|
1021
|
+
{ component: 'AccountDialogController' },
|
|
1022
|
+
error,
|
|
1023
|
+
);
|
|
1024
|
+
return 0;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Open the verified Commons link for the `'open-commons'` route. Returns
|
|
1030
|
+
* whether the navigation was actually handed off, so a missing opener or a
|
|
1031
|
+
* throwing one becomes an observable `routeFailed` instead of a silent dead
|
|
1032
|
+
* end the user stares at.
|
|
1033
|
+
*/
|
|
1034
|
+
private openCommonsLink(qrPayload: string): boolean {
|
|
1035
|
+
if (!this.openUrl) return false;
|
|
1036
|
+
try {
|
|
1037
|
+
this.openUrl(qrPayload);
|
|
1038
|
+
return true;
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
logger.debug(
|
|
1041
|
+
'[AccountDialogController] Commons deep link failed',
|
|
1042
|
+
{ component: 'AccountDialogController' },
|
|
1043
|
+
error,
|
|
1044
|
+
);
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
|
|
1051
|
+
* popup without completing sign-in gets prompt feedback (there is no DOM
|
|
1052
|
+
* event for a cross-origin popup closing — polling is the only mechanism).
|
|
1053
|
+
* Cleared by {@link closeActivePopup}, which every sign-in exit path already
|
|
1054
|
+
* calls.
|
|
1055
|
+
*/
|
|
1056
|
+
private watchPopup(popup: PopupWindowHandle): void {
|
|
1057
|
+
this.clearPopupWatchTimer();
|
|
1058
|
+
this.popupWatchTimer = setInterval(() => {
|
|
1059
|
+
if (!popup.closed) return;
|
|
1060
|
+
this.clearPopupWatchTimer();
|
|
1061
|
+
if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
|
|
1062
|
+
// Closing the surface cancels the REQUEST too, not just this listener.
|
|
1063
|
+
const pendingCode = this.signIn.authorizeCode;
|
|
1064
|
+
this.failSignIn('Sign-in was cancelled.');
|
|
1065
|
+
if (pendingCode) {
|
|
1066
|
+
void this.withdrawRequest(pendingCode);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}, 1000);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private clearPopupWatchTimer(): void {
|
|
1073
|
+
if (this.popupWatchTimer !== null) {
|
|
1074
|
+
clearInterval(this.popupWatchTimer);
|
|
1075
|
+
this.popupWatchTimer = null;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** Tear down the popup watch timer and close the popup, if any. Idempotent. */
|
|
1080
|
+
private closeActivePopup(): void {
|
|
1081
|
+
this.clearPopupWatchTimer();
|
|
1082
|
+
const popup = this.activePopup;
|
|
1083
|
+
this.activePopup = null;
|
|
1084
|
+
if (!popup || popup.closed) return;
|
|
1085
|
+
try {
|
|
1086
|
+
popup.close();
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
logger.debug('[AccountDialogController] popup close failed', { component: 'AccountDialogController' }, error);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Resolve whether Commons is installed on this device via the injected
|
|
1094
|
+
* `canOpenApp` probe, updating {@link commonsAvailability} as durable,
|
|
1095
|
+
* observable snapshot state. Native only — a no-op when `canOpenApp` was
|
|
1096
|
+
* not injected (web), where `commonsAvailability` stays `'unknown'` forever
|
|
1097
|
+
* and the QR view renders unconditionally (no gating).
|
|
1098
|
+
*
|
|
1099
|
+
* Replaces the old `maybeOpenCommons` fire-and-forget probe, whose outcome
|
|
1100
|
+
* was only ever reflected by whether Commons silently opened — a probe
|
|
1101
|
+
* failure or "not installed" answer was swallowed into a debug log with no
|
|
1102
|
+
* way for the UI to react. `commonsAvailability` fixes that.
|
|
1103
|
+
*/
|
|
1104
|
+
private async resolveCommonsAvailability(): Promise<void> {
|
|
1105
|
+
if (!this.canOpenApp) return;
|
|
1106
|
+
this.commonsAvailability = 'checking';
|
|
1107
|
+
this.emit();
|
|
1108
|
+
let available = false;
|
|
1109
|
+
try {
|
|
1110
|
+
available = await this.canOpenApp(COMMONS_APP_SCHEME);
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
logger.debug(
|
|
1113
|
+
'[AccountDialogController] Commons availability probe failed',
|
|
1114
|
+
{ component: 'AccountDialogController' },
|
|
1115
|
+
error,
|
|
1116
|
+
);
|
|
1117
|
+
available = false; // fail-closed — treat a probe error as "not installed"
|
|
1118
|
+
}
|
|
1119
|
+
this.commonsAvailability = available ? 'available' : 'unavailable';
|
|
1120
|
+
this.emit();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Tear down the active sign-in device flow (timers + socket + popup + token),
|
|
1125
|
+
* WITHDRAW the request server-side, and reset to idle.
|
|
1126
|
+
*
|
|
1127
|
+
* Cancellation has to converge in both directions: the surface closing must
|
|
1128
|
+
* cancel the request, not just stop listening to it. Without the withdrawal a
|
|
1129
|
+
* dismissed QR would stay approvable until it expired, so a later scan of a
|
|
1130
|
+
* stale code could authorize a session nobody is waiting for.
|
|
1131
|
+
*/
|
|
1132
|
+
cancelSignIn(): void {
|
|
1133
|
+
// Capture before the teardown clears it, and only for a request that can
|
|
1134
|
+
// still be approved — a completed/failed flow has nothing to withdraw.
|
|
1135
|
+
const pendingCode =
|
|
1136
|
+
this.signIn.phase === 'starting' || this.signIn.phase === 'waiting'
|
|
1137
|
+
? this.signIn.authorizeCode
|
|
1138
|
+
: null;
|
|
1139
|
+
this.clearPollTimer();
|
|
1140
|
+
this.closeAuthSessionSocket();
|
|
1141
|
+
this.closeActivePopup();
|
|
1142
|
+
this.signInToken = null;
|
|
1143
|
+
if (this.signIn !== IDLE_SIGN_IN) {
|
|
1144
|
+
this.setSignIn(IDLE_SIGN_IN_FACTS);
|
|
1145
|
+
}
|
|
1146
|
+
if (pendingCode) {
|
|
1147
|
+
void this.withdrawRequest(pendingCode);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Best-effort server-side withdrawal of a request this surface abandoned
|
|
1153
|
+
* (`POST /auth/session/deny/:authorizeCode`). Fire-and-forget by design: the
|
|
1154
|
+
* local teardown already happened, and a race with an approval that just
|
|
1155
|
+
* landed legitimately rejects here — neither outcome is worth surfacing.
|
|
1156
|
+
*/
|
|
1157
|
+
private async withdrawRequest(authorizeCode: string): Promise<void> {
|
|
1158
|
+
try {
|
|
1159
|
+
await this.oxyServices.denyCommonsSignIn(authorizeCode);
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
logger.debug(
|
|
1162
|
+
'[AccountDialogController] request withdrawal failed',
|
|
1163
|
+
{ component: 'AccountDialogController' },
|
|
1164
|
+
error,
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// =========================================================================
|
|
1170
|
+
// Internal sign-in helpers
|
|
1171
|
+
// =========================================================================
|
|
1172
|
+
|
|
1173
|
+
private scheduleNextPoll(sessionToken: string): void {
|
|
1174
|
+
this.clearPollTimer();
|
|
1175
|
+
this.pollTimer = setTimeout(() => {
|
|
1176
|
+
void this.pollOnce(sessionToken);
|
|
1177
|
+
}, this.pollIntervalMs);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Run one status check + (on approval) claim. Triggered by the fallback timer
|
|
1182
|
+
* AND by the `/auth-session` socket's `auth_update` wake, so it is guarded
|
|
1183
|
+
* against concurrent entry: whichever fires first claims the single-use token;
|
|
1184
|
+
* the other no-ops. The `auth_update` payload is never trusted — this always
|
|
1185
|
+
* re-checks the authoritative status via `pollCommonsSignIn`.
|
|
1186
|
+
*/
|
|
1187
|
+
private async pollOnce(sessionToken: string): Promise<void> {
|
|
1188
|
+
// A superseded / cancelled flow must not act; a poll already running owns the claim.
|
|
1189
|
+
if (this.signInToken !== sessionToken || this.pollInFlight) return;
|
|
1190
|
+
this.pollInFlight = true;
|
|
1191
|
+
try {
|
|
1192
|
+
const expiresAt = this.signIn.expiresAt;
|
|
1193
|
+
if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
|
|
1194
|
+
this.failSignIn('Session expired. Please try again.');
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
|
|
1199
|
+
if (this.signInToken !== sessionToken) return; // cancelled mid-request
|
|
1200
|
+
// Delivery PROGRESS first: it is reported alongside every status, and
|
|
1201
|
+
// recording it before the terminal branches means a poll that also
|
|
1202
|
+
// carries the approval still leaves an honest trail behind it.
|
|
1203
|
+
this.recordDeliveryProgress(status.pushSentAt, status.openedAt);
|
|
1204
|
+
const purpose = status.purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in';
|
|
1205
|
+
if (status.authorized && purpose === 'oauth_authorization') {
|
|
1206
|
+
// OAuth-bound sessions mint no sessionId on approval — they finalize
|
|
1207
|
+
// into an authorization code. The account dialog only starts device
|
|
1208
|
+
// sign-in today; stop rather than poll until expiry.
|
|
1209
|
+
this.failSignIn('This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (status.authorized && status.sessionId) {
|
|
1213
|
+
this.clearPollTimer();
|
|
1214
|
+
await this.claimAndComplete(status.sessionId, sessionToken);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
if (status.status === 'cancelled') {
|
|
1218
|
+
this.failSignIn('Authorization was denied.');
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (status.status === 'expired') {
|
|
1222
|
+
this.failSignIn('Session expired. Please try again.');
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
// Transient poll error — the next tick retries. Logged, never thrown.
|
|
1227
|
+
logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
|
|
1228
|
+
}
|
|
1229
|
+
if (this.signInToken === sessionToken) {
|
|
1230
|
+
this.scheduleNextPoll(sessionToken);
|
|
1231
|
+
}
|
|
1232
|
+
} finally {
|
|
1233
|
+
this.pollInFlight = false;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Record server-reported delivery progress on the active flow.
|
|
1239
|
+
*
|
|
1240
|
+
* Monotone and additive: a timestamp is only ever adopted, never replaced or
|
|
1241
|
+
* cleared, so an older API build (or a partial payload) that omits a field can
|
|
1242
|
+
* at most fail to advance the surface — it can never walk it backwards.
|
|
1243
|
+
* Emits only on a real change, so a steady poll does not churn the snapshot.
|
|
1244
|
+
*/
|
|
1245
|
+
private recordDeliveryProgress(pushSentAt: string | null, openedAt: string | null): void {
|
|
1246
|
+
const nextPushSentAt = this.signIn.pushSentAt ?? pushSentAt ?? null;
|
|
1247
|
+
const nextOpenedAt = this.signIn.openedAt ?? openedAt ?? null;
|
|
1248
|
+
if (nextPushSentAt === this.signIn.pushSentAt && nextOpenedAt === this.signIn.openedAt) return;
|
|
1249
|
+
this.patchSignIn({ pushSentAt: nextPushSentAt, openedAt: nextOpenedAt });
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
|
|
1253
|
+
this.patchSignIn({ phase: 'authorized' });
|
|
1254
|
+
let claimed: {
|
|
1255
|
+
accessToken: string;
|
|
1256
|
+
sessionId: string;
|
|
1257
|
+
deviceId: string;
|
|
1258
|
+
expiresAt: string;
|
|
1259
|
+
user: User;
|
|
1260
|
+
deviceSecret?: string;
|
|
1261
|
+
};
|
|
1262
|
+
try {
|
|
1263
|
+
claimed = await this.oxyServices.claimSessionByToken(sessionToken);
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
this.failSignIn(errorMessage(error));
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (!claimed?.accessToken || !claimed.user) {
|
|
1269
|
+
this.failSignIn('Authorization succeeded but the session could not be claimed. Please try again.');
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
// `SessionLoginResponse.user` is the minimal session-carried shape; the claim
|
|
1273
|
+
// returns the full `User` (avatar is `string | null | undefined`). Normalize
|
|
1274
|
+
// rather than widening the minimal shape to accept `null`.
|
|
1275
|
+
const minimalUser: MinimalUserData = {
|
|
1276
|
+
id: claimed.user.id,
|
|
1277
|
+
username: claimed.user.username,
|
|
1278
|
+
name: claimed.user.name,
|
|
1279
|
+
avatar: claimed.user.avatar ?? undefined,
|
|
1280
|
+
};
|
|
1281
|
+
try {
|
|
1282
|
+
await this.completeSignIn(
|
|
1283
|
+
{
|
|
1284
|
+
sessionId: claimed.sessionId || sessionId,
|
|
1285
|
+
deviceId: claimed.deviceId ?? '',
|
|
1286
|
+
expiresAt: claimed.expiresAt ?? '',
|
|
1287
|
+
user: minimalUser,
|
|
1288
|
+
accessToken: claimed.accessToken,
|
|
1289
|
+
...(claimed.deviceSecret ? { deviceSecret: claimed.deviceSecret } : {}),
|
|
1290
|
+
},
|
|
1291
|
+
minimalUser,
|
|
1292
|
+
);
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
this.failSignIn(errorMessage(error));
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Commit an authorized session, notify, and return to the account list. Shared
|
|
1300
|
+
* by the shared-key, QR, and mint-switch paths so they cannot drift.
|
|
1301
|
+
*/
|
|
1302
|
+
private async completeSignIn(
|
|
1303
|
+
session: SessionLoginResponse,
|
|
1304
|
+
user: MinimalUserData,
|
|
1305
|
+
): Promise<void> {
|
|
1306
|
+
await this.commitAuthorizedSession(session, user);
|
|
1307
|
+
this.signInToken = null;
|
|
1308
|
+
this.clearPollTimer();
|
|
1309
|
+
this.closeAuthSessionSocket();
|
|
1310
|
+
this.closeActivePopup();
|
|
1311
|
+
// Terminal SUCCESS, not idle: the surface gets one honest frame to show
|
|
1312
|
+
// "Identity confirmed" before it closes. Cleared on the next view change.
|
|
1313
|
+
this.signIn = COMPLETED_SIGN_IN;
|
|
1314
|
+
this.view = 'accounts';
|
|
1315
|
+
this.emit();
|
|
1316
|
+
this.onSignedIn?.(user);
|
|
1317
|
+
await this.refresh();
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Register a token-planted session into the device set. Prefers the
|
|
1322
|
+
* consumer's commit funnel (durable persist + hydration); falls back to
|
|
1323
|
+
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
1324
|
+
*/
|
|
1325
|
+
private async commitAuthorizedSession(
|
|
1326
|
+
session: SessionLoginResponse,
|
|
1327
|
+
user: MinimalUserData,
|
|
1328
|
+
): Promise<void> {
|
|
1329
|
+
if (this.commitSession) {
|
|
1330
|
+
await this.commitSession(session);
|
|
1331
|
+
} else {
|
|
1332
|
+
await this.sessionClient.registerAndActivate(user.id);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
private failSignIn(message: string): void {
|
|
1337
|
+
this.clearPollTimer();
|
|
1338
|
+
this.closeAuthSessionSocket();
|
|
1339
|
+
this.closeActivePopup();
|
|
1340
|
+
this.signInToken = null;
|
|
1341
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message });
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private clearPollTimer(): void {
|
|
1345
|
+
if (this.pollTimer !== null) {
|
|
1346
|
+
clearTimeout(this.pollTimer);
|
|
1347
|
+
this.pollTimer = null;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// =========================================================================
|
|
1352
|
+
// /auth-session socket (instant QR approval wake — replaces 3s polling)
|
|
1353
|
+
// =========================================================================
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Subscribe the active QR flow to the `/auth-session` namespace so the API's
|
|
1357
|
+
* `auth_update` event wakes {@link pollOnce} the instant the approval lands.
|
|
1358
|
+
*
|
|
1359
|
+
* The join is keyed by the secret `sessionToken` (the server's `auth:<token>`
|
|
1360
|
+
* room, joined by emitting `join`) and re-issued on every (re)connect so it
|
|
1361
|
+
* survives socket drops. `auth_update` is treated as a pure SIGNAL — the payload
|
|
1362
|
+
* is never trusted; `pollOnce` re-checks the authoritative status and claims.
|
|
1363
|
+
*
|
|
1364
|
+
* No-op (poll-only) when the optional socket transport is unavailable. The
|
|
1365
|
+
* namespace needs no auth.
|
|
1366
|
+
*/
|
|
1367
|
+
private async openAuthSessionSocket(sessionToken: string): Promise<void> {
|
|
1368
|
+
this.closeAuthSessionSocket();
|
|
1369
|
+
const socketFactory = this.socketFactory ?? (await getSocketIO());
|
|
1370
|
+
if (!socketFactory || this.signInToken !== sessionToken) return;
|
|
1371
|
+
let socket: MinimalSocket;
|
|
1372
|
+
try {
|
|
1373
|
+
socket = socketFactory(`${this.oxyServices.getBaseURL()}${AUTH_SESSION_NAMESPACE}`, {
|
|
1374
|
+
transports: ['websocket'],
|
|
1375
|
+
autoConnect: true,
|
|
1376
|
+
reconnection: true,
|
|
1377
|
+
reconnectionAttempts: Number.POSITIVE_INFINITY,
|
|
1378
|
+
reconnectionDelay: 1000,
|
|
1379
|
+
reconnectionDelayMax: 10000,
|
|
1380
|
+
});
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
// Socket unavailable — the fallback poll still completes the flow.
|
|
1383
|
+
logger.debug('[AccountDialogController] auth-session socket create failed (poll fallback)', { component: 'AccountDialogController' }, error);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const join = (): void => {
|
|
1387
|
+
if (this.signInToken !== sessionToken) return;
|
|
1388
|
+
try {
|
|
1389
|
+
socket.emit('join', sessionToken);
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
logger.debug('[AccountDialogController] auth-session join failed', { component: 'AccountDialogController' }, error);
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
socket.on('connect', join);
|
|
1395
|
+
if (socket.connected) join();
|
|
1396
|
+
socket.on('auth_update', () => {
|
|
1397
|
+
if (this.signInToken !== sessionToken) return;
|
|
1398
|
+
// Pure wake signal — re-check the authoritative status + claim the poll would have.
|
|
1399
|
+
void this.pollOnce(sessionToken);
|
|
1400
|
+
});
|
|
1401
|
+
this.authSessionSocket = socket;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** Tear down the `/auth-session` socket, if any. Idempotent. */
|
|
1405
|
+
private closeAuthSessionSocket(): void {
|
|
1406
|
+
const socket = this.authSessionSocket;
|
|
1407
|
+
if (!socket) return;
|
|
1408
|
+
this.authSessionSocket = null;
|
|
1409
|
+
try {
|
|
1410
|
+
socket.off('auth_update');
|
|
1411
|
+
socket.off('connect');
|
|
1412
|
+
socket.disconnect();
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
logger.debug('[AccountDialogController] auth-session socket close failed', { component: 'AccountDialogController' }, error);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// =========================================================================
|
|
1419
|
+
// Snapshot plumbing
|
|
1420
|
+
// =========================================================================
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Replace the device-flow state from its FACTS, re-deriving `progress`. The
|
|
1424
|
+
* only writer of `this.signIn` besides the two terminal constants — which is
|
|
1425
|
+
* what keeps `progress` impossible to set by hand, and therefore impossible
|
|
1426
|
+
* to advance without a fact behind it.
|
|
1427
|
+
*/
|
|
1428
|
+
private setSignIn(facts: SignInFlowFacts): void {
|
|
1429
|
+
this.signIn = { ...facts, progress: deriveSignInProgress(facts) };
|
|
1430
|
+
this.emit();
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/** Update a subset of the device-flow facts, re-deriving `progress`. */
|
|
1434
|
+
private patchSignIn(patch: Partial<SignInFlowFacts>): void {
|
|
1435
|
+
const {
|
|
1436
|
+
phase,
|
|
1437
|
+
authorizeCode,
|
|
1438
|
+
qrPayload,
|
|
1439
|
+
expiresAt,
|
|
1440
|
+
error,
|
|
1441
|
+
route,
|
|
1442
|
+
routeFailed,
|
|
1443
|
+
pushSentAt,
|
|
1444
|
+
openedAt,
|
|
1445
|
+
} = this.signIn;
|
|
1446
|
+
this.setSignIn({
|
|
1447
|
+
phase,
|
|
1448
|
+
authorizeCode,
|
|
1449
|
+
qrPayload,
|
|
1450
|
+
expiresAt,
|
|
1451
|
+
error,
|
|
1452
|
+
route,
|
|
1453
|
+
routeFailed,
|
|
1454
|
+
pushSentAt,
|
|
1455
|
+
openedAt,
|
|
1456
|
+
...patch,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
private computeSnapshot(): AccountDialogSnapshot {
|
|
1461
|
+
const directory = this.sessionClient.getDirectory();
|
|
1462
|
+
return {
|
|
1463
|
+
view: this.view,
|
|
1464
|
+
directory,
|
|
1465
|
+
activeContext: resolveActiveContext(directory),
|
|
1466
|
+
loading: this.loading,
|
|
1467
|
+
error: this.error,
|
|
1468
|
+
activatingContextId: this.activatingContextId,
|
|
1469
|
+
removingContextId: this.removingContextId,
|
|
1470
|
+
removingPrincipalId: this.removingPrincipalId,
|
|
1471
|
+
signIn: this.signIn,
|
|
1472
|
+
commonsAvailability: this.commonsAvailability,
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** Recompute the snapshot and notify subscribers. */
|
|
1477
|
+
private emit(): void {
|
|
1478
|
+
this.snapshot = this.computeSnapshot();
|
|
1479
|
+
for (const listener of this.listeners) {
|
|
1480
|
+
try {
|
|
1481
|
+
listener(this.snapshot);
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
logger.error('[AccountDialogController] subscriber threw', error);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
|
|
1490
|
+
export function createAccountDialogController(
|
|
1491
|
+
options: AccountDialogControllerOptions,
|
|
1492
|
+
): AccountDialogController {
|
|
1493
|
+
return new AccountDialogController(options);
|
|
1494
|
+
}
|