@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,1266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified HTTP Service
|
|
3
|
+
*
|
|
4
|
+
* Consolidates HttpClient + RequestManager into a single efficient class.
|
|
5
|
+
* Uses native fetch instead of axios for smaller bundle size.
|
|
6
|
+
*
|
|
7
|
+
* Handles:
|
|
8
|
+
* - Authentication (token management, auto-refresh)
|
|
9
|
+
* - Caching (TTL-based)
|
|
10
|
+
* - Deduplication (concurrent requests)
|
|
11
|
+
* - Retry logic
|
|
12
|
+
* - Error handling
|
|
13
|
+
* - Request queuing
|
|
14
|
+
*/
|
|
15
|
+
import { TTLCache, registerCacheForCleanup } from './utils/cache.js';
|
|
16
|
+
import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils.js';
|
|
17
|
+
import { retryAsync } from './utils/asyncUtils.js';
|
|
18
|
+
import { handleHttpError, parseHttpErrorBody } from './utils/errorUtils.js';
|
|
19
|
+
import { jwtDecode } from 'jwt-decode';
|
|
20
|
+
import { isNative, getPlatformOS } from './utils/platform.js';
|
|
21
|
+
import { isReactNative } from '@oxy.so/protocol';
|
|
22
|
+
import { computeIdentityTag, fnv1a32 } from './utils/cacheKey.js';
|
|
23
|
+
import { redactUrlQuery } from './utils/redactUrl.js';
|
|
24
|
+
import { OxyAuthenticationError } from './OxyServices.errors.js';
|
|
25
|
+
import { getBrowserEdgeRegionHeader } from './utils/edgeRegion.js';
|
|
26
|
+
import { getBrowserActivityIdHeader } from './utils/activityId.js';
|
|
27
|
+
/**
|
|
28
|
+
* Check if we're running in a native app environment (React Native, not web)
|
|
29
|
+
* This is used to determine CSRF handling mode
|
|
30
|
+
*/
|
|
31
|
+
const isNativeApp = isNative();
|
|
32
|
+
/**
|
|
33
|
+
* Default per-request timeout (ms) when neither the call site nor
|
|
34
|
+
* {@link OxyConfig.requestTimeout} overrides it. Kept tight so a stalled
|
|
35
|
+
* endpoint surfaces as an `AbortError` quickly rather than blocking the
|
|
36
|
+
* request queue.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
|
39
|
+
/**
|
|
40
|
+
* Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
|
|
41
|
+
* regular request timeout: this is a small, fast, unauthenticated call and
|
|
42
|
+
* should never inherit a longer per-request budget.
|
|
43
|
+
*/
|
|
44
|
+
const CSRF_FETCH_TIMEOUT_MS = 5000;
|
|
45
|
+
/**
|
|
46
|
+
* Number of attempts for fetching a CSRF token before giving up. The first
|
|
47
|
+
* failure is usually a cold edge/cookie race; a single retry recovers it
|
|
48
|
+
* without masking a genuinely broken `/csrf-token` route.
|
|
49
|
+
*/
|
|
50
|
+
const CSRF_FETCH_MAX_ATTEMPTS = 2;
|
|
51
|
+
/**
|
|
52
|
+
* Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
|
|
53
|
+
* fetch sits in the critical path of a state-changing request, so the retry
|
|
54
|
+
* must add minimal latency.
|
|
55
|
+
*/
|
|
56
|
+
const CSRF_FETCH_RETRY_DELAY_MS = 500;
|
|
57
|
+
/**
|
|
58
|
+
* Cooldown (ms) applied after a failed access-token refresh before another
|
|
59
|
+
* refresh is attempted while the CURRENT token is still valid (a proactive,
|
|
60
|
+
* near-expiry refresh). Prevents a refresh storm (and server hammering) when
|
|
61
|
+
* the auth refresh handler is failing — every in-flight request that
|
|
62
|
+
* hits a 401 would otherwise trigger its own refresh. A still-valid token can
|
|
63
|
+
* afford to wait this out; the request keeps carrying it in the meantime.
|
|
64
|
+
*/
|
|
65
|
+
const TOKEN_REFRESH_COOLDOWN_MS = 15000;
|
|
66
|
+
/**
|
|
67
|
+
* Cooldown (ms) applied after a failed refresh when the CURRENT access token is
|
|
68
|
+
* already past its `exp`. Much shorter than {@link TOKEN_REFRESH_COOLDOWN_MS}:
|
|
69
|
+
* an expired token is UNUSABLE, so the client must re-mint as soon as the mint
|
|
70
|
+
* endpoint is reachable again (e.g. a few seconds after an ECS rolling-deploy
|
|
71
|
+
* blip drains/restarts a task) instead of waiting out the full proactive
|
|
72
|
+
* cooldown while every request forwards or omits a stale bearer → server 401.
|
|
73
|
+
*
|
|
74
|
+
* Still NON-ZERO on purpose: it bounds the request-driven retry rate to at most
|
|
75
|
+
* one attempt per this interval so a PROLONGED outage cannot become a tight
|
|
76
|
+
* network storm. Combined with the process-wide single-flight below (concurrent
|
|
77
|
+
* requests coalesce to one in-flight mint) and the refresh handler's own
|
|
78
|
+
* terminal-state handling (a genuinely revoked session clears its device
|
|
79
|
+
* credential and stops issuing network mints), this recovers a transient blip
|
|
80
|
+
* ~15× faster without weakening the storm guard.
|
|
81
|
+
*/
|
|
82
|
+
const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
|
|
83
|
+
/**
|
|
84
|
+
* Lead time (seconds) before access-token expiry at which a preflight refresh
|
|
85
|
+
* is triggered. A token within this window of `exp` is treated as effectively
|
|
86
|
+
* expired so the request carries a fresh bearer rather than racing the clock.
|
|
87
|
+
*/
|
|
88
|
+
const TOKEN_REFRESH_LEAD_SECONDS = 60;
|
|
89
|
+
/**
|
|
90
|
+
* Soft ceiling on the number of live entries in the identity-scoped GET
|
|
91
|
+
* response cache. Crossing it does NOT evict anything (the {@link TTLCache}
|
|
92
|
+
* still expires by TTL and is swept on its cleanup interval) — it emits a
|
|
93
|
+
* single throttled telemetry warning via the logger so an unbounded-growth
|
|
94
|
+
* regression (e.g. an endpoint that mints a fresh identity tag per request, or
|
|
95
|
+
* a cache-key that accidentally folds in volatile data) is observable in the
|
|
96
|
+
* field instead of silently consuming memory. Tuned well above the working set
|
|
97
|
+
* a single authenticated user generates in normal use.
|
|
98
|
+
*/
|
|
99
|
+
const CACHE_SOFT_MAX_ENTRIES = 500;
|
|
100
|
+
/**
|
|
101
|
+
* Minimum interval (ms) between successive cache-size telemetry warnings, so a
|
|
102
|
+
* cache that sits above the soft limit logs at most once per window rather than
|
|
103
|
+
* on every cached write.
|
|
104
|
+
*/
|
|
105
|
+
const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
|
|
106
|
+
/**
|
|
107
|
+
* Token store for authentication (instance-based)
|
|
108
|
+
* Each HttpService gets its own TokenStore to prevent conflicts
|
|
109
|
+
* when multiple OxyServices instances coexist server-side.
|
|
110
|
+
*/
|
|
111
|
+
class TokenStore {
|
|
112
|
+
constructor() {
|
|
113
|
+
this.accessToken = null;
|
|
114
|
+
this.csrfToken = null;
|
|
115
|
+
this.csrfTokenFetchPromise = null;
|
|
116
|
+
}
|
|
117
|
+
setTokens(accessToken) {
|
|
118
|
+
this.accessToken = accessToken;
|
|
119
|
+
}
|
|
120
|
+
getAccessToken() {
|
|
121
|
+
return this.accessToken;
|
|
122
|
+
}
|
|
123
|
+
clearTokens() {
|
|
124
|
+
this.accessToken = null;
|
|
125
|
+
}
|
|
126
|
+
hasAccessToken() {
|
|
127
|
+
return !!this.accessToken;
|
|
128
|
+
}
|
|
129
|
+
setCsrfToken(token) {
|
|
130
|
+
this.csrfToken = token;
|
|
131
|
+
}
|
|
132
|
+
getCsrfToken() {
|
|
133
|
+
return this.csrfToken;
|
|
134
|
+
}
|
|
135
|
+
setCsrfTokenFetchPromise(promise) {
|
|
136
|
+
this.csrfTokenFetchPromise = promise;
|
|
137
|
+
}
|
|
138
|
+
getCsrfTokenFetchPromise() {
|
|
139
|
+
return this.csrfTokenFetchPromise;
|
|
140
|
+
}
|
|
141
|
+
clearCsrfToken() {
|
|
142
|
+
this.csrfToken = null;
|
|
143
|
+
this.csrfTokenFetchPromise = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Unified HTTP Service
|
|
148
|
+
*
|
|
149
|
+
* Consolidates HttpClient + RequestManager into a single efficient class.
|
|
150
|
+
* Uses native fetch instead of axios for smaller bundle size.
|
|
151
|
+
*/
|
|
152
|
+
export class HttpService {
|
|
153
|
+
constructor(config) {
|
|
154
|
+
this.tokenRefreshPromise = null;
|
|
155
|
+
/**
|
|
156
|
+
* Epoch ms of the last FAILED refresh (0 = none since the last success). The
|
|
157
|
+
* post-failure cooldown is measured from here; its length depends on whether
|
|
158
|
+
* the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or
|
|
159
|
+
* already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired
|
|
160
|
+
* token recovers promptly the instant it crosses `exp` — without storing a
|
|
161
|
+
* fixed deadline that could not shrink once the token expired mid-cooldown.
|
|
162
|
+
*/
|
|
163
|
+
this.lastRefreshFailureAt = 0;
|
|
164
|
+
this.authRefreshHandler = null;
|
|
165
|
+
this.accessTokenProvider = null;
|
|
166
|
+
this.deviceSecretMintInFlight = null;
|
|
167
|
+
/**
|
|
168
|
+
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
169
|
+
* re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
|
|
170
|
+
* one per {@link CACHE_SIZE_WARNING_THROTTLE_MS} window.
|
|
171
|
+
*/
|
|
172
|
+
this.cacheSizeWarningSilentUntil = 0;
|
|
173
|
+
/**
|
|
174
|
+
* Fan-out listeners notified on EVERY access-token change on this instance:
|
|
175
|
+
* explicit `setTokens`, `clearTokens`, a refresh-handler rotation, and the
|
|
176
|
+
* internal 401-driven clear. This is a Set so multiple independent observers
|
|
177
|
+
* can mirror token state without clobbering each other.
|
|
178
|
+
*
|
|
179
|
+
* Each listener receives the resulting access token, or `null` when cleared.
|
|
180
|
+
*/
|
|
181
|
+
this._tokenChangeListeners = new Set();
|
|
182
|
+
// Performance monitoring
|
|
183
|
+
this.requestMetrics = {
|
|
184
|
+
totalRequests: 0,
|
|
185
|
+
successfulRequests: 0,
|
|
186
|
+
failedRequests: 0,
|
|
187
|
+
cacheHits: 0,
|
|
188
|
+
cacheMisses: 0,
|
|
189
|
+
averageResponseTime: 0,
|
|
190
|
+
};
|
|
191
|
+
this.config = config;
|
|
192
|
+
this.baseURL = config.baseURL;
|
|
193
|
+
this.tokenStore = new TokenStore();
|
|
194
|
+
this.logger = new SimpleLogger(config.enableLogging || false, config.logLevel || 'error', 'HttpService');
|
|
195
|
+
// Initialize performance infrastructure. The per-instance GET response
|
|
196
|
+
// cache is disabled when the consumer explicitly opts out
|
|
197
|
+
// (`enableCache: false`) or asks for a non-positive TTL (`cacheTTL <= 0`).
|
|
198
|
+
// When disabled, nothing is ever stored, so there is no reason to register
|
|
199
|
+
// the cache for the global cleanup interval. Default (config unset) keeps
|
|
200
|
+
// caching ON with the 5-minute TTL — unchanged for existing consumers.
|
|
201
|
+
this.cacheDisabled =
|
|
202
|
+
config.enableCache === false ||
|
|
203
|
+
(typeof config.cacheTTL === 'number' && config.cacheTTL <= 0);
|
|
204
|
+
this.cache = new TTLCache(config.cacheTTL && config.cacheTTL > 0 ? config.cacheTTL : 5 * 60 * 1000);
|
|
205
|
+
if (!this.cacheDisabled) {
|
|
206
|
+
registerCacheForCleanup(this.cache);
|
|
207
|
+
}
|
|
208
|
+
this.deduplicator = new RequestDeduplicator();
|
|
209
|
+
this.requestQueue = new RequestQueue(config.maxConcurrentRequests || 10, config.requestQueueSize || 100);
|
|
210
|
+
}
|
|
211
|
+
syncAccessTokenFromProvider() {
|
|
212
|
+
if (!this.accessTokenProvider) {
|
|
213
|
+
return this.tokenStore.getAccessToken();
|
|
214
|
+
}
|
|
215
|
+
const providedToken = this.accessTokenProvider();
|
|
216
|
+
const currentToken = this.tokenStore.getAccessToken();
|
|
217
|
+
if (providedToken) {
|
|
218
|
+
if (providedToken !== currentToken) {
|
|
219
|
+
this.tokenStore.setTokens(providedToken);
|
|
220
|
+
this.notifyTokenChange();
|
|
221
|
+
}
|
|
222
|
+
return providedToken;
|
|
223
|
+
}
|
|
224
|
+
if (currentToken) {
|
|
225
|
+
this.clearTokens();
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Robust FormData detection that works in browser, React Native, and
|
|
231
|
+
* Node.js polyfill environments.
|
|
232
|
+
*
|
|
233
|
+
* Why we don't use `instanceof FormData` alone:
|
|
234
|
+
* - React Native's FormData is a separate class, not the browser one —
|
|
235
|
+
* `instanceof FormData` is true only inside the JS runtime that
|
|
236
|
+
* instantiated the value (browser-side polyfills also have their own).
|
|
237
|
+
* - The Node.js `form-data` polyfill ships its own constructor.
|
|
238
|
+
*
|
|
239
|
+
* Why we explicitly reject `URLSearchParams`:
|
|
240
|
+
* - `URLSearchParams` ALSO exposes `append` / `get` / `has`, so the
|
|
241
|
+
* duck-type fallback below would have misidentified it as FormData and
|
|
242
|
+
* sent an empty multipart body.
|
|
243
|
+
* - It has its own encoding path instead — see {@link isUrlSearchParams}.
|
|
244
|
+
*/
|
|
245
|
+
isFormData(data) {
|
|
246
|
+
if (!data || typeof data !== 'object') {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
// Reject URLSearchParams up front: it shares the duck-typed surface
|
|
250
|
+
// (append / get / has) but is a fundamentally different content type.
|
|
251
|
+
// The caller routes URLSearchParams through the regular body path.
|
|
252
|
+
if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
// Primary check: instanceof FormData. Works whenever the value was
|
|
256
|
+
// constructed by the same runtime/realm that exposes `FormData`.
|
|
257
|
+
if (typeof FormData !== 'undefined' && data instanceof FormData) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
// Fallback: detect Node / RN polyfills by constructor name. Limited to
|
|
261
|
+
// the small handful of known names so we don't accept arbitrary
|
|
262
|
+
// user-supplied objects with a coincidental `name`.
|
|
263
|
+
const constructorName = data.constructor?.name;
|
|
264
|
+
if (constructorName === 'FormData' || constructorName === 'FormDataImpl') {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
// Last-resort duck typing — require the full FormData write surface
|
|
268
|
+
// (`append`, `get`, `has`, `getAll`, `delete`) so plain objects with
|
|
269
|
+
// an `append` method don't accidentally match.
|
|
270
|
+
const candidate = data;
|
|
271
|
+
return (typeof candidate.append === 'function' &&
|
|
272
|
+
typeof candidate.get === 'function' &&
|
|
273
|
+
typeof candidate.has === 'function' &&
|
|
274
|
+
typeof candidate.getAll === 'function' &&
|
|
275
|
+
typeof candidate.delete === 'function');
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* True for an `application/x-www-form-urlencoded` payload.
|
|
279
|
+
*
|
|
280
|
+
* Needed because a handful of endpoints are defined by a standard that fixes
|
|
281
|
+
* their request encoding rather than by our own JSON conventions — today
|
|
282
|
+
* `POST /auth/oauth/token`, whose encoding RFC 6749 §4.1.3 mandates. Passing
|
|
283
|
+
* a `URLSearchParams` as `data` selects that encoding; everything else is
|
|
284
|
+
* still JSON.
|
|
285
|
+
*/
|
|
286
|
+
isUrlSearchParams(data) {
|
|
287
|
+
return typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Main request method - handles everything in one place
|
|
291
|
+
*/
|
|
292
|
+
async request(config) {
|
|
293
|
+
const { method, url, data, params, timeout = this.config.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, signal, cache: cacheRequested = method === 'GET', cacheTTL, deduplicate = true, retry = this.config.enableRetry !== false, maxRetries = this.config.maxRetries || 3, } = config;
|
|
294
|
+
// A per-instance disabled cache (`enableCache:false` / `cacheTTL<=0`)
|
|
295
|
+
// overrides any per-request `cache:true`: nothing is read from nor written
|
|
296
|
+
// to the response cache. Request deduplication below is unaffected.
|
|
297
|
+
const cache = cacheRequested && !this.cacheDisabled;
|
|
298
|
+
// Generate cache key (optimized for large objects)
|
|
299
|
+
const cacheKey = cache ? this.generateCacheKey(method, url, data || params) : null;
|
|
300
|
+
// Check cache first
|
|
301
|
+
if (cache && cacheKey) {
|
|
302
|
+
const cached = this.cache.get(cacheKey);
|
|
303
|
+
if (cached !== null) {
|
|
304
|
+
this.requestMetrics.cacheHits++;
|
|
305
|
+
// Redact the query string: an asset stream URL passed here carries a
|
|
306
|
+
// scoped `mt=` media token that must never reach a log sink.
|
|
307
|
+
this.logger.debug('Cache hit:', redactUrlQuery(url));
|
|
308
|
+
return cached;
|
|
309
|
+
}
|
|
310
|
+
this.requestMetrics.cacheMisses++;
|
|
311
|
+
}
|
|
312
|
+
// Resolve the auth preflight OUTSIDE the queue slot. A slot represents
|
|
313
|
+
// NETWORK OCCUPANCY only. `getAuthHeader` may await the single-flight token
|
|
314
|
+
// refresh/mint — itself a request — so if a slot-holding request awaited it
|
|
315
|
+
// here and every slot were held by requests all waiting on that same mint,
|
|
316
|
+
// the mint could never acquire a slot to run (systemic deadlock). Resolving
|
|
317
|
+
// it before enqueue means auth-blocked requests hold NO slot while the shared
|
|
318
|
+
// mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
|
|
319
|
+
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
320
|
+
// 401/CSRF retry re-enters request() with a fresh config, so it re-resolves
|
|
321
|
+
// these here with the refreshed token / cleared CSRF.
|
|
322
|
+
const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
|
323
|
+
const authHeader = config.skipAuth ? null : await this.getAuthHeader();
|
|
324
|
+
// CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
|
|
325
|
+
// clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
|
|
326
|
+
// should not need to implement a duplicate `/csrf-token` route.
|
|
327
|
+
const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
|
|
328
|
+
const edgeRegionHeader = await getBrowserEdgeRegionHeader();
|
|
329
|
+
const activityIdHeader = getBrowserActivityIdHeader();
|
|
330
|
+
// Request function
|
|
331
|
+
const requestFn = async () => {
|
|
332
|
+
const startTime = Date.now();
|
|
333
|
+
try {
|
|
334
|
+
// Build URL with params
|
|
335
|
+
const fullUrl = this.buildURL(url, params);
|
|
336
|
+
// Determine if data is FormData using robust detection
|
|
337
|
+
const isFormData = this.isFormData(data);
|
|
338
|
+
const isUrlEncoded = this.isUrlSearchParams(data);
|
|
339
|
+
// Make fetch request
|
|
340
|
+
const controller = new AbortController();
|
|
341
|
+
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
|
|
342
|
+
if (signal) {
|
|
343
|
+
signal.addEventListener('abort', () => controller.abort());
|
|
344
|
+
}
|
|
345
|
+
// Build headers - start with defaults
|
|
346
|
+
const headers = {
|
|
347
|
+
'Accept': 'application/json',
|
|
348
|
+
};
|
|
349
|
+
// Only set Content-Type for non-FormData requests (FormData sets it automatically with boundary)
|
|
350
|
+
if (isUrlEncoded) {
|
|
351
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
352
|
+
}
|
|
353
|
+
else if (!isFormData) {
|
|
354
|
+
headers['Content-Type'] = 'application/json';
|
|
355
|
+
}
|
|
356
|
+
// Add authorization header if available
|
|
357
|
+
if (authHeader) {
|
|
358
|
+
headers['Authorization'] = authHeader;
|
|
359
|
+
}
|
|
360
|
+
// Add CSRF token header for state-changing requests
|
|
361
|
+
if (csrfToken) {
|
|
362
|
+
headers['X-CSRF-Token'] = csrfToken;
|
|
363
|
+
}
|
|
364
|
+
// Add native app header for React Native (required for CSRF validation)
|
|
365
|
+
// Native apps can't persist cookies like browsers, so the server uses
|
|
366
|
+
// header-only CSRF validation when this header is present
|
|
367
|
+
if (isNativeApp && isStateChangingMethod) {
|
|
368
|
+
headers['X-Native-App'] = 'true';
|
|
369
|
+
}
|
|
370
|
+
// Debug logging for CSRF issues, routed through SimpleLogger so it only
|
|
371
|
+
// fires when consumers opt in via `enableLogging`.
|
|
372
|
+
if (isStateChangingMethod) {
|
|
373
|
+
this.logger.debug('CSRF Debug:', {
|
|
374
|
+
url,
|
|
375
|
+
method,
|
|
376
|
+
isNativeApp,
|
|
377
|
+
platformOS: getPlatformOS(),
|
|
378
|
+
hasCsrfToken: !!csrfToken,
|
|
379
|
+
csrfTokenLength: csrfToken?.length,
|
|
380
|
+
hasNativeAppHeader: headers['X-Native-App'] === 'true',
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// Merge custom headers if provided
|
|
384
|
+
if (config.headers) {
|
|
385
|
+
Object.entries(config.headers).forEach(([key, value]) => {
|
|
386
|
+
// For FormData, explicitly remove Content-Type if user tries to set it
|
|
387
|
+
// The browser/fetch API will set it automatically with the boundary
|
|
388
|
+
if (isFormData && key.toLowerCase() === 'content-type') {
|
|
389
|
+
this.logger.debug('Ignoring Content-Type header for FormData - will be set automatically');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
headers[key] = value;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
// This is Cloudflare's coarse serving PoP (for example `mad`), never an
|
|
396
|
+
// IP or coordinate. Set it after caller headers so request code cannot
|
|
397
|
+
// accidentally or deliberately substitute a different origin.
|
|
398
|
+
Object.assign(headers, edgeRegionHeader);
|
|
399
|
+
Object.assign(headers, activityIdHeader);
|
|
400
|
+
// `URLSearchParams` is serialised explicitly rather than handed to
|
|
401
|
+
// `fetch` as-is: RN's fetch does not consistently encode it, and doing
|
|
402
|
+
// it here keeps the body identical across every platform.
|
|
403
|
+
const bodyValue = method !== 'GET' && data
|
|
404
|
+
? (isFormData ? data : isUrlEncoded ? data.toString() : JSON.stringify(data))
|
|
405
|
+
: undefined;
|
|
406
|
+
// React Native FormData workaround:
|
|
407
|
+
// Expo SDK 56's "winter fetch" rejects RN file descriptors `{uri, type, name}`
|
|
408
|
+
// in FormDataPart conversion (`Unsupported FormDataPart implementation`).
|
|
409
|
+
// RN's native XMLHttpRequest handles those descriptors correctly, so we
|
|
410
|
+
// route multipart uploads through XHR on RN only. JSON, text, etc. still
|
|
411
|
+
// use fetch on every platform.
|
|
412
|
+
const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined';
|
|
413
|
+
const response = useXhrForUpload
|
|
414
|
+
? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout, this.shouldSendCredentials(fullUrl))
|
|
415
|
+
: await fetch(fullUrl, {
|
|
416
|
+
method,
|
|
417
|
+
headers,
|
|
418
|
+
body: bodyValue,
|
|
419
|
+
signal: controller.signal,
|
|
420
|
+
credentials: this.getCredentialsMode(fullUrl),
|
|
421
|
+
});
|
|
422
|
+
if (timeoutId)
|
|
423
|
+
clearTimeout(timeoutId);
|
|
424
|
+
// Handle response
|
|
425
|
+
if (!response.ok) {
|
|
426
|
+
// On 401, delegate to the installed auth refresh handler and retry
|
|
427
|
+
// once before giving up. HttpService deliberately does not know any
|
|
428
|
+
// session routes; the refresh handler owns session rotation.
|
|
429
|
+
if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
|
|
430
|
+
const refreshed = await this.refreshAccessToken('response-401');
|
|
431
|
+
if (refreshed) {
|
|
432
|
+
// `deduplicate: false` is REQUIRED on the retry (mirrors the 403
|
|
433
|
+
// CSRF retry below). This re-issue runs while the ORIGINAL request
|
|
434
|
+
// is still in-flight under its dedupe key; the refreshed token is
|
|
435
|
+
// for the SAME user, so the identity-scoped key is UNCHANGED — a
|
|
436
|
+
// deduplicated retry would resolve to the still-pending original
|
|
437
|
+
// and await itself (deadlock). Opting the retry out of dedupe makes
|
|
438
|
+
// it a fresh request.
|
|
439
|
+
return this.request({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
|
|
440
|
+
}
|
|
441
|
+
// Refresh failed or no token — clear tokens and stale CSRF
|
|
442
|
+
this.tokenStore.clearTokens();
|
|
443
|
+
this.tokenStore.clearCsrfToken();
|
|
444
|
+
this.notifyTokenChange();
|
|
445
|
+
}
|
|
446
|
+
// On 403 with CSRF error, clear cached token and retry once
|
|
447
|
+
if (response.status === 403 && !config._isCsrfRetry) {
|
|
448
|
+
try {
|
|
449
|
+
const clonedResponse = response.clone();
|
|
450
|
+
const errBody = await clonedResponse.json();
|
|
451
|
+
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
452
|
+
this.tokenStore.clearCsrfToken();
|
|
453
|
+
return this.request({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// Failed to parse error body — not a CSRF error
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
461
|
+
// Anything unreadable leaves `errorBody` undefined and degrades to the
|
|
462
|
+
// status-based message — an error path that throws its own error is
|
|
463
|
+
// worse than the error it was reporting.
|
|
464
|
+
let errorBody;
|
|
465
|
+
const errorContentType = response.headers.get('content-type');
|
|
466
|
+
if (errorContentType?.includes('application/json')) {
|
|
467
|
+
try {
|
|
468
|
+
errorBody = await response.json();
|
|
469
|
+
}
|
|
470
|
+
catch (parseError) {
|
|
471
|
+
// Malformed JSON or empty response - use status text
|
|
472
|
+
this.logger.warn('Failed to parse error response JSON:', parseError);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// `parseHttpErrorBody` handles every envelope in use, including the
|
|
476
|
+
// nested `{ error: { code, message } }` shape — assigning that nested
|
|
477
|
+
// OBJECT as the message is what produced `"[object Object]"`.
|
|
478
|
+
const parsed = parseHttpErrorBody(errorBody);
|
|
479
|
+
const error = new Error(parsed.message ?? `HTTP ${response.status}: ${response.statusText}`);
|
|
480
|
+
error.status = response.status;
|
|
481
|
+
error.response = { status: response.status, statusText: response.statusText, data: errorBody };
|
|
482
|
+
// Only set `code`/`details` when the server actually sent them.
|
|
483
|
+
// Assigning `undefined` would still create the property, which changes
|
|
484
|
+
// how `handleHttpError` classifies the error downstream.
|
|
485
|
+
if (parsed.code !== undefined) {
|
|
486
|
+
error.code = parsed.code;
|
|
487
|
+
}
|
|
488
|
+
if (parsed.details !== undefined) {
|
|
489
|
+
error.details = parsed.details;
|
|
490
|
+
}
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
// Handle different response types (optimized - read response once)
|
|
494
|
+
const contentType = response.headers.get('content-type');
|
|
495
|
+
let responseData;
|
|
496
|
+
if (config.responseType === 'blob') {
|
|
497
|
+
responseData = await response.blob();
|
|
498
|
+
}
|
|
499
|
+
else if (contentType && contentType.includes('application/json')) {
|
|
500
|
+
// Use response.json() directly for better performance
|
|
501
|
+
try {
|
|
502
|
+
responseData = await response.json();
|
|
503
|
+
// Handle null/undefined responses
|
|
504
|
+
if (responseData === null || responseData === undefined) {
|
|
505
|
+
responseData = null;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
// Unwrap standardized API response format for JSON
|
|
509
|
+
responseData = this.unwrapResponse(responseData);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (parseError) {
|
|
513
|
+
// Handle malformed JSON or empty responses gracefully
|
|
514
|
+
// Note: Once response.json() is called, the body is consumed and cannot be read again
|
|
515
|
+
// So we check the error type to determine if it's empty or malformed
|
|
516
|
+
if (parseError instanceof SyntaxError) {
|
|
517
|
+
this.logger.warn('Failed to parse JSON response (malformed or empty):', parseError);
|
|
518
|
+
// SyntaxError typically means empty or malformed JSON
|
|
519
|
+
// For empty responses, return null; for malformed JSON, throw descriptive error
|
|
520
|
+
responseData = null; // Treat as empty response for safety
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
this.logger.warn('Failed to read response:', parseError);
|
|
524
|
+
throw new Error('Failed to read response from server');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else if (contentType && (contentType.includes('application/octet-stream') || contentType.includes('image/') || contentType.includes('video/') || contentType.includes('audio/'))) {
|
|
529
|
+
// For binary responses (blobs), return the blob directly without unwrapping
|
|
530
|
+
responseData = await response.blob();
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
// For other responses, return as text
|
|
534
|
+
const text = await response.text();
|
|
535
|
+
responseData = text || null;
|
|
536
|
+
}
|
|
537
|
+
const duration = Date.now() - startTime;
|
|
538
|
+
this.updateMetrics(true, duration);
|
|
539
|
+
this.config.onRequestEnd?.(url, method, duration, true);
|
|
540
|
+
return responseData;
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
const duration = Date.now() - startTime;
|
|
544
|
+
this.updateMetrics(false, duration);
|
|
545
|
+
this.config.onRequestEnd?.(url, method, duration, false);
|
|
546
|
+
this.config.onRequestError?.(url, method, error instanceof Error ? error : new Error(String(error)));
|
|
547
|
+
// Handle AbortError specifically for better error messages
|
|
548
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
549
|
+
throw handleHttpError(error);
|
|
550
|
+
}
|
|
551
|
+
throw handleHttpError(error);
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
// Wrap with retry if enabled
|
|
555
|
+
const requestWithRetry = retry
|
|
556
|
+
? () => retryAsync(requestFn, maxRetries, this.config.retryDelay || 1000)
|
|
557
|
+
: requestFn;
|
|
558
|
+
// Wrap with deduplication if enabled (use optimized key generation)
|
|
559
|
+
const dedupeKey = deduplicate ? this.generateCacheKey(method, url, data || params) : null;
|
|
560
|
+
const finalRequest = dedupeKey
|
|
561
|
+
? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry)
|
|
562
|
+
: requestWithRetry;
|
|
563
|
+
// Execute the request. Control-plane calls the auth lane depends on
|
|
564
|
+
// (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
|
|
565
|
+
// could never acquire a slot when every slot is parked awaiting it.
|
|
566
|
+
const result = config.bypassQueue
|
|
567
|
+
? await finalRequest()
|
|
568
|
+
: await this.requestQueue.enqueue(finalRequest);
|
|
569
|
+
// Cache the result if caching is enabled
|
|
570
|
+
if (cache && cacheKey && result) {
|
|
571
|
+
this.cache.set(cacheKey, result, cacheTTL);
|
|
572
|
+
this.warnIfCacheOversized();
|
|
573
|
+
}
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Soft cache-size guard. Emits a single throttled telemetry warning when the
|
|
578
|
+
* identity-scoped response cache grows past {@link CACHE_SOFT_MAX_ENTRIES}.
|
|
579
|
+
*
|
|
580
|
+
* This intentionally does NOT evict: the {@link TTLCache} already bounds
|
|
581
|
+
* memory by TTL (and the global cleanup interval sweeps expired entries), so
|
|
582
|
+
* an LRU here would only risk thrashing a legitimately warm cache. The point
|
|
583
|
+
* is observability — if entry count climbs and stays high, an identity tag or
|
|
584
|
+
* cache key is folding in volatile data (a per-request nonce, an undecodable
|
|
585
|
+
* rotating token) and the cache is no longer doing its job. Surfacing that via
|
|
586
|
+
* the logger lets a consumer with `enableLogging` catch the regression in the
|
|
587
|
+
* field instead of debugging silent memory growth.
|
|
588
|
+
*/
|
|
589
|
+
warnIfCacheOversized() {
|
|
590
|
+
const size = this.cache.size();
|
|
591
|
+
if (size <= CACHE_SOFT_MAX_ENTRIES) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const now = Date.now();
|
|
595
|
+
if (now < this.cacheSizeWarningSilentUntil) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
this.cacheSizeWarningSilentUntil = now + CACHE_SIZE_WARNING_THROTTLE_MS;
|
|
599
|
+
this.logger.warn('Response cache exceeded soft entry limit — possible identity-tag or cache-key bloat', { size, softLimit: CACHE_SOFT_MAX_ENTRIES });
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Upload via XMLHttpRequest (React Native FormData workaround).
|
|
603
|
+
*
|
|
604
|
+
* Expo SDK 56's "winter fetch" cannot serialize RN file descriptors
|
|
605
|
+
* (`{uri, type, name}`) — `convertFormDataAsync` rejects them as
|
|
606
|
+
* `Unsupported FormDataPart implementation`. RN's native XHR streams
|
|
607
|
+
* the file from disk correctly, so multipart uploads go through XHR
|
|
608
|
+
* on RN only.
|
|
609
|
+
*
|
|
610
|
+
* Returns a standard `Response` so downstream parsing in `request()`
|
|
611
|
+
* (status checks, 401/403 retries, JSON/blob/text parsing) is identical
|
|
612
|
+
* to the fetch path.
|
|
613
|
+
*/
|
|
614
|
+
uploadViaXHR(url, method, headers, body, abortSignal, timeout, withCredentials) {
|
|
615
|
+
return new Promise((resolve, reject) => {
|
|
616
|
+
const xhr = new XMLHttpRequest();
|
|
617
|
+
xhr.open(method, url, true);
|
|
618
|
+
// Only send ambient cookies to the configured API origin. Absolute
|
|
619
|
+
// caller-supplied URLs can target arbitrary origins, so they must not
|
|
620
|
+
// receive credential-bearing requests by default.
|
|
621
|
+
xhr.withCredentials = withCredentials;
|
|
622
|
+
// Forward headers but skip Content-Type — XHR sets the multipart
|
|
623
|
+
// boundary automatically and overriding it breaks the upload.
|
|
624
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
625
|
+
if (key.toLowerCase() === 'content-type')
|
|
626
|
+
continue;
|
|
627
|
+
try {
|
|
628
|
+
xhr.setRequestHeader(key, value);
|
|
629
|
+
}
|
|
630
|
+
catch (headerError) {
|
|
631
|
+
// Some headers (e.g. forbidden header names) cannot be set —
|
|
632
|
+
// log and continue rather than failing the whole upload.
|
|
633
|
+
this.logger.warn('XHR setRequestHeader failed:', key, headerError);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
xhr.responseType = 'text';
|
|
637
|
+
if (timeout > 0) {
|
|
638
|
+
xhr.timeout = timeout;
|
|
639
|
+
}
|
|
640
|
+
const onAbort = () => {
|
|
641
|
+
try {
|
|
642
|
+
xhr.abort();
|
|
643
|
+
}
|
|
644
|
+
catch { /* xhr already finished */ }
|
|
645
|
+
};
|
|
646
|
+
if (abortSignal.aborted) {
|
|
647
|
+
reject(new DOMException('The user aborted a request.', 'AbortError'));
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
abortSignal.addEventListener('abort', onAbort);
|
|
651
|
+
const cleanup = () => {
|
|
652
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
653
|
+
};
|
|
654
|
+
xhr.onload = () => {
|
|
655
|
+
cleanup();
|
|
656
|
+
const responseHeaders = HttpService.parseXHRHeaders(xhr.getAllResponseHeaders());
|
|
657
|
+
resolve(new Response(xhr.responseText, {
|
|
658
|
+
status: xhr.status,
|
|
659
|
+
statusText: xhr.statusText,
|
|
660
|
+
headers: responseHeaders,
|
|
661
|
+
}));
|
|
662
|
+
};
|
|
663
|
+
xhr.onerror = () => {
|
|
664
|
+
cleanup();
|
|
665
|
+
reject(new TypeError('Network request failed'));
|
|
666
|
+
};
|
|
667
|
+
xhr.ontimeout = () => {
|
|
668
|
+
cleanup();
|
|
669
|
+
reject(new DOMException('The request timed out.', 'TimeoutError'));
|
|
670
|
+
};
|
|
671
|
+
xhr.onabort = () => {
|
|
672
|
+
cleanup();
|
|
673
|
+
reject(new DOMException('The user aborted a request.', 'AbortError'));
|
|
674
|
+
};
|
|
675
|
+
xhr.send(body);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Parse raw header string from `XMLHttpRequest.getAllResponseHeaders()`
|
|
680
|
+
* into a `Headers`-compatible object.
|
|
681
|
+
*/
|
|
682
|
+
static parseXHRHeaders(rawHeaders) {
|
|
683
|
+
const headers = new Headers();
|
|
684
|
+
if (!rawHeaders)
|
|
685
|
+
return headers;
|
|
686
|
+
// RFC 7230 line terminator is CRLF; some XHR implementations use LF only.
|
|
687
|
+
const lines = rawHeaders.trim().split(/\r?\n/);
|
|
688
|
+
for (const line of lines) {
|
|
689
|
+
const colonIndex = line.indexOf(':');
|
|
690
|
+
if (colonIndex <= 0)
|
|
691
|
+
continue;
|
|
692
|
+
const key = line.slice(0, colonIndex).trim();
|
|
693
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
694
|
+
if (key) {
|
|
695
|
+
try {
|
|
696
|
+
headers.append(key, value);
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
// Invalid header name/value — skip.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return headers;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Derive a stable, non-sensitive identity discriminator for cache scoping.
|
|
707
|
+
*
|
|
708
|
+
* Thin instance wrapper over the pure {@link computeIdentityTag} helper —
|
|
709
|
+
* binds it to this instance's live access token. See that function's docs for
|
|
710
|
+
* the full resolution contract (anon fallback, decoded `userId || id`,
|
|
711
|
+
* token-hash fallback for undecodable tokens).
|
|
712
|
+
*/
|
|
713
|
+
computeIdentityTag() {
|
|
714
|
+
return computeIdentityTag(this.tokenStore.getAccessToken());
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Generate cache key efficiently
|
|
718
|
+
* Uses a content-addressed hash for large payloads so two requests with
|
|
719
|
+
* the same shape but different values never collide on the same key
|
|
720
|
+
* (which would silently serve stale data — e.g. paginated search results,
|
|
721
|
+
* large object updates).
|
|
722
|
+
*
|
|
723
|
+
* The key is identity-scoped: the logical `method:url[:data]` portion is
|
|
724
|
+
* suffixed with ` id=<identityTag>` so two callers with different
|
|
725
|
+
* identities (anon vs authed, or two different users) never share an entry.
|
|
726
|
+
* The identity tag is placed at the END so the key still STARTS with
|
|
727
|
+
* `method:url`, preserving the prefix-based invalidation in
|
|
728
|
+
* `clearCacheByPrefix` (e.g. `GET:/session/user/`) and the base-key matching
|
|
729
|
+
* in `clearCacheEntry`.
|
|
730
|
+
*/
|
|
731
|
+
generateCacheKey(method, url, data) {
|
|
732
|
+
return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()}`;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Build the identity-agnostic portion of a cache key (`method:url[:data]`).
|
|
736
|
+
* Kept separate so identity scoping is applied in exactly one place
|
|
737
|
+
* (`generateCacheKey`) and cannot drift between the cache and dedupe paths.
|
|
738
|
+
*/
|
|
739
|
+
generateBaseCacheKey(method, url, data) {
|
|
740
|
+
if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
|
|
741
|
+
return `${method}:${url}`;
|
|
742
|
+
}
|
|
743
|
+
// For small objects, the full serialization IS the key — fastest and
|
|
744
|
+
// guaranteed to be content-addressed.
|
|
745
|
+
const dataStr = JSON.stringify(data);
|
|
746
|
+
if (dataStr.length < 1000) {
|
|
747
|
+
return `${method}:${url}:${dataStr}`;
|
|
748
|
+
}
|
|
749
|
+
// For large payloads, hash the full serialized string so the key remains
|
|
750
|
+
// content-addressed (any byte change yields a different hash). Previous
|
|
751
|
+
// implementation hashed `keys + length` which collided for any two
|
|
752
|
+
// payloads with the same top-level keys and serialized length.
|
|
753
|
+
return `${method}:${url}:${fnv1a32(dataStr)}`;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Build full URL with query params
|
|
757
|
+
*/
|
|
758
|
+
buildURL(url, params) {
|
|
759
|
+
const base = /^https?:\/\//i.test(url)
|
|
760
|
+
? url
|
|
761
|
+
: `${this.baseURL.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`;
|
|
762
|
+
if (!params || Object.keys(params).length === 0) {
|
|
763
|
+
return base;
|
|
764
|
+
}
|
|
765
|
+
const searchParams = new URLSearchParams();
|
|
766
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
767
|
+
if (value !== undefined && value !== null) {
|
|
768
|
+
searchParams.append(key, String(value));
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
const queryString = searchParams.toString();
|
|
772
|
+
return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base;
|
|
773
|
+
}
|
|
774
|
+
getCredentialsMode(url) {
|
|
775
|
+
return this.shouldSendCredentials(url) ? 'include' : 'omit';
|
|
776
|
+
}
|
|
777
|
+
shouldSendCredentials(url) {
|
|
778
|
+
try {
|
|
779
|
+
return new URL(url).origin === new URL(this.baseURL).origin;
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Fetch CSRF token from server (with deduplication)
|
|
787
|
+
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
788
|
+
*/
|
|
789
|
+
async fetchCsrfToken() {
|
|
790
|
+
// Return cached token if available
|
|
791
|
+
const cachedToken = this.tokenStore.getCsrfToken();
|
|
792
|
+
if (cachedToken) {
|
|
793
|
+
this.logger.debug('Using cached CSRF token');
|
|
794
|
+
return cachedToken;
|
|
795
|
+
}
|
|
796
|
+
// Deduplicate concurrent CSRF token fetches
|
|
797
|
+
const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
|
|
798
|
+
if (existingPromise) {
|
|
799
|
+
this.logger.debug('Waiting for existing CSRF fetch');
|
|
800
|
+
return existingPromise;
|
|
801
|
+
}
|
|
802
|
+
const fetchPromise = (async () => {
|
|
803
|
+
for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
|
|
804
|
+
try {
|
|
805
|
+
this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
|
|
806
|
+
// Use AbortController for timeout (more compatible than AbortSignal.timeout)
|
|
807
|
+
const controller = new AbortController();
|
|
808
|
+
const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
|
|
809
|
+
const response = await fetch(`${this.baseURL}/csrf-token`, {
|
|
810
|
+
method: 'GET',
|
|
811
|
+
headers: { 'Accept': 'application/json' },
|
|
812
|
+
credentials: 'include', // Required to receive and send cookies
|
|
813
|
+
signal: controller.signal,
|
|
814
|
+
});
|
|
815
|
+
clearTimeout(timeoutId);
|
|
816
|
+
this.logger.debug('CSRF fetch response:', response.status, response.ok);
|
|
817
|
+
if (response.ok) {
|
|
818
|
+
const data = await response.json();
|
|
819
|
+
const token = data.csrfToken || null;
|
|
820
|
+
this.logger.debug('CSRF response data:', {
|
|
821
|
+
hasCsrfToken: typeof token === 'string' && token.length > 0,
|
|
822
|
+
csrfTokenLength: token?.length,
|
|
823
|
+
});
|
|
824
|
+
this.tokenStore.setCsrfToken(token);
|
|
825
|
+
this.logger.debug('CSRF token fetched');
|
|
826
|
+
return token;
|
|
827
|
+
}
|
|
828
|
+
// Also check response header for CSRF token
|
|
829
|
+
const headerToken = response.headers.get('X-CSRF-Token');
|
|
830
|
+
if (headerToken) {
|
|
831
|
+
this.tokenStore.setCsrfToken(headerToken);
|
|
832
|
+
this.logger.debug('CSRF token from header');
|
|
833
|
+
return headerToken;
|
|
834
|
+
}
|
|
835
|
+
this.logger.debug('CSRF fetch failed with status:', response.status);
|
|
836
|
+
this.logger.warn('Failed to fetch CSRF token:', response.status);
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
this.logger.debug('CSRF fetch error:', error);
|
|
840
|
+
this.logger.warn('CSRF token fetch error:', error);
|
|
841
|
+
}
|
|
842
|
+
// Brief backoff before the next attempt.
|
|
843
|
+
if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
|
|
844
|
+
await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return null;
|
|
848
|
+
})().finally(() => {
|
|
849
|
+
this.tokenStore.setCsrfTokenFetchPromise(null);
|
|
850
|
+
});
|
|
851
|
+
this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
|
|
852
|
+
return fetchPromise;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Get auth header with automatic token refresh
|
|
856
|
+
*/
|
|
857
|
+
async getAuthHeader() {
|
|
858
|
+
const accessToken = this.syncAccessTokenFromProvider();
|
|
859
|
+
if (!accessToken) {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
const decoded = jwtDecode(accessToken);
|
|
864
|
+
const currentTime = Math.floor(Date.now() / 1000);
|
|
865
|
+
// If the token expires within the refresh lead window, refresh it.
|
|
866
|
+
if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
|
|
867
|
+
const refreshed = await this.refreshAccessToken('preflight');
|
|
868
|
+
if (refreshed)
|
|
869
|
+
return `Bearer ${refreshed}`;
|
|
870
|
+
if (decoded.exp > currentTime) {
|
|
871
|
+
return `Bearer ${accessToken}`;
|
|
872
|
+
}
|
|
873
|
+
// Refresh failed — don't use an expired token (would cause 401 loop)
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
return `Bearer ${accessToken}`;
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
this.logger.error('Error processing token:', error);
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
async refreshAccessToken(reason) {
|
|
884
|
+
if (!this.authRefreshHandler) {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
// Post-failure cooldown. A genuinely EXPIRED current token uses a much
|
|
888
|
+
// shorter cooldown than a still-valid (proactive, near-expiry) one: an
|
|
889
|
+
// expired token is unusable, so re-mint as soon as the endpoint is reachable
|
|
890
|
+
// again rather than waiting out the full window while requests carry a stale
|
|
891
|
+
// bearer. Both cooldowns are measured from the last failure, so the moment a
|
|
892
|
+
// still-valid token crosses `exp` mid-cooldown the shorter window applies.
|
|
893
|
+
const cooldownMs = this.isAccessTokenExpired()
|
|
894
|
+
? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
|
|
895
|
+
: TOKEN_REFRESH_COOLDOWN_MS;
|
|
896
|
+
if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
if (!this.tokenRefreshPromise) {
|
|
900
|
+
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
901
|
+
.then((newToken) => {
|
|
902
|
+
if (!newToken) {
|
|
903
|
+
this.lastRefreshFailureAt = Date.now();
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
907
|
+
this.tokenStore.setTokens(newToken);
|
|
908
|
+
this.notifyTokenChange();
|
|
909
|
+
}
|
|
910
|
+
// A success clears the failure timestamp so the next refresh is never
|
|
911
|
+
// throttled by a stale cooldown.
|
|
912
|
+
this.lastRefreshFailureAt = 0;
|
|
913
|
+
this.logger.debug('Token refreshed via the auth refresh handler');
|
|
914
|
+
return newToken;
|
|
915
|
+
})
|
|
916
|
+
.catch((error) => {
|
|
917
|
+
this.logger.warn('Token refresh failed:', error);
|
|
918
|
+
this.lastRefreshFailureAt = Date.now();
|
|
919
|
+
return null;
|
|
920
|
+
})
|
|
921
|
+
.finally(() => {
|
|
922
|
+
this.tokenRefreshPromise = null;
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
return this.tokenRefreshPromise;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
929
|
+
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
930
|
+
* a still-valid (near-expiry) token can wait out the full cooldown, but an
|
|
931
|
+
* expired one must re-mint promptly. Returns `false` for an absent or
|
|
932
|
+
* opaque/no-`exp` token — no proof it is expired, so keep the conservative
|
|
933
|
+
* (longer) cooldown and avoid an unnecessary retry loop.
|
|
934
|
+
*/
|
|
935
|
+
isAccessTokenExpired() {
|
|
936
|
+
const token = this.tokenStore.getAccessToken();
|
|
937
|
+
if (!token) {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
const decoded = jwtDecode(token);
|
|
942
|
+
return typeof decoded.exp === 'number' && decoded.exp <= Math.floor(Date.now() / 1000);
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* PROCESS-WIDE single-flight for the rotating device-secret mint
|
|
950
|
+
* (`POST /session/device/token`).
|
|
951
|
+
*
|
|
952
|
+
* The server rotates the presented `deviceSecret` on every successful mint, so
|
|
953
|
+
* two concurrent mints would double-rotate and the durable store could end up
|
|
954
|
+
* holding a superseded secret → a later cold-boot mint 401s → the user is
|
|
955
|
+
* signed out. Every mint lane (the re-mint handler behind `refreshAccessToken`,
|
|
956
|
+
* the device-first cold boot, the socket token transport, the tab-focus
|
|
957
|
+
* reconcile) funnels its `refreshDeviceSecretArm` call through here, so
|
|
958
|
+
* concurrent callers await the SAME in-flight mint and all receive its result —
|
|
959
|
+
* exactly one server rotation.
|
|
960
|
+
*
|
|
961
|
+
* Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint
|
|
962
|
+
* handler incl. the native shared-key arm + the failure cooldown): this inner
|
|
963
|
+
* guard serializes the rotation itself across BOTH the handler and the
|
|
964
|
+
* handler-independent cold boot, which never runs through `refreshAccessToken`.
|
|
965
|
+
*/
|
|
966
|
+
runSingleFlightDeviceSecretMint(mint) {
|
|
967
|
+
if (!this.deviceSecretMintInFlight) {
|
|
968
|
+
this.deviceSecretMintInFlight = mint().finally(() => {
|
|
969
|
+
this.deviceSecretMintInFlight = null;
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
return this.deviceSecretMintInFlight;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Unwrap the standardized API response envelope — EXCEPT when the envelope is
|
|
976
|
+
* a page, in which case it travels whole.
|
|
977
|
+
*
|
|
978
|
+
* `{ data: <payload> }` is the house success envelope (`sendSuccess`), and
|
|
979
|
+
* reducing it to `<payload>` is what every call site in the SDK expects. But
|
|
980
|
+
* the reduction DISCARDS every sibling key, silently, and a page's siblings
|
|
981
|
+
* are the only thing that says where the next page starts. That is how
|
|
982
|
+
* `GET /accounts/:id/audit` lost its `nextCursor`: the caller received a bare
|
|
983
|
+
* array, `getNextPageParam` read `undefined`, and pagination was dead past the
|
|
984
|
+
* first page with nothing to show that it was.
|
|
985
|
+
*
|
|
986
|
+
* ## Why the rule is narrow, and not "any sibling key survives"
|
|
987
|
+
*
|
|
988
|
+
* "An object carrying `data` plus anything else is not an envelope" is the
|
|
989
|
+
* tempting general rule, and it is wrong here: this API already answers
|
|
990
|
+
* `{ data, count }` on ~15 routes, plus `{ data, source }`, `{ data, reason }`
|
|
991
|
+
* and `{ data, credentialRevoked }`, and a dozen measured Console call sites
|
|
992
|
+
* type those as the bare payload (`Array<ProviderConnection>`,
|
|
993
|
+
* `AccountBillingState | null`, …). Preserving those envelopes would hand every
|
|
994
|
+
* one of them an object where it expects its payload — at runtime only, since
|
|
995
|
+
* the response type is a call-site assertion. So the rule names PAGINATION
|
|
996
|
+
* specifically: `data` beside {@link PAGE_ENVELOPE_KEYS} is a page.
|
|
997
|
+
*
|
|
998
|
+
* A route whose sibling key genuinely matters to its caller belongs in that
|
|
999
|
+
* list, or should not be a sibling of `data` at all — the cursor-paginated
|
|
1000
|
+
* surfaces already in the SDK (`{ follows, nextCursor }`,
|
|
1001
|
+
* `{ records, nextCursor }`) sidestep this by never using `data`.
|
|
1002
|
+
*/
|
|
1003
|
+
unwrapResponse(responseData) {
|
|
1004
|
+
if (!responseData || typeof responseData !== 'object' || !('data' in responseData)) {
|
|
1005
|
+
// Not the success envelope (or not an object at all) — as-is.
|
|
1006
|
+
return responseData;
|
|
1007
|
+
}
|
|
1008
|
+
// A page travels whole: its cursor/pagination sibling is unrecoverable
|
|
1009
|
+
// information, not decoration.
|
|
1010
|
+
if (HttpService.PAGE_ENVELOPE_KEYS.some((key) => key in responseData)) {
|
|
1011
|
+
return responseData;
|
|
1012
|
+
}
|
|
1013
|
+
// Regular success envelope: `{ data: ... }` -> the payload.
|
|
1014
|
+
return Array.isArray(responseData) ? responseData : responseData.data;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Update request metrics
|
|
1018
|
+
*/
|
|
1019
|
+
updateMetrics(success, duration) {
|
|
1020
|
+
this.requestMetrics.totalRequests++;
|
|
1021
|
+
if (success) {
|
|
1022
|
+
this.requestMetrics.successfulRequests++;
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
this.requestMetrics.failedRequests++;
|
|
1026
|
+
}
|
|
1027
|
+
const alpha = 0.1;
|
|
1028
|
+
this.requestMetrics.averageResponseTime =
|
|
1029
|
+
this.requestMetrics.averageResponseTime * (1 - alpha) + duration * alpha;
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Send an authenticated request without consuming its response body.
|
|
1033
|
+
*
|
|
1034
|
+
* Regular {@link request} calls parse the body before returning. Streaming
|
|
1035
|
+
* consumers instead need the original `Response`, while retaining the same
|
|
1036
|
+
* token ownership and one-time 401 refresh behaviour as every SDK request.
|
|
1037
|
+
* No SDK timeout is installed after the connection opens: the caller-owned
|
|
1038
|
+
* abort signal defines the lifetime of a healthy stream.
|
|
1039
|
+
*/
|
|
1040
|
+
async requestAuthenticatedResponse(config) {
|
|
1041
|
+
const authHeader = await this.getAuthHeader();
|
|
1042
|
+
if (!authHeader) {
|
|
1043
|
+
throw new OxyAuthenticationError('An active Oxy session is required for this request', 'AUTH_REQUIRED');
|
|
1044
|
+
}
|
|
1045
|
+
return this.requestAuthenticatedResponseAttempt(config, authHeader, false);
|
|
1046
|
+
}
|
|
1047
|
+
async requestAuthenticatedResponseAttempt(config, authHeader, isAuthRetry) {
|
|
1048
|
+
const startTime = Date.now();
|
|
1049
|
+
const headers = new Headers(config.headers);
|
|
1050
|
+
if (!headers.has('Accept')) {
|
|
1051
|
+
headers.set('Accept', 'application/json');
|
|
1052
|
+
}
|
|
1053
|
+
// Authentication is owned by this SDK instance. A caller cannot replace
|
|
1054
|
+
// the bearer with a different session or leak one across linked apps.
|
|
1055
|
+
headers.set('Authorization', authHeader);
|
|
1056
|
+
const edgeRegionHeader = await getBrowserEdgeRegionHeader();
|
|
1057
|
+
for (const [name, value] of Object.entries(edgeRegionHeader))
|
|
1058
|
+
headers.set(name, value);
|
|
1059
|
+
const activityIdHeader = getBrowserActivityIdHeader();
|
|
1060
|
+
for (const [name, value] of Object.entries(activityIdHeader))
|
|
1061
|
+
headers.set(name, value);
|
|
1062
|
+
try {
|
|
1063
|
+
const fullUrl = this.buildURL(config.url);
|
|
1064
|
+
const response = await fetch(fullUrl, {
|
|
1065
|
+
method: config.method,
|
|
1066
|
+
headers,
|
|
1067
|
+
body: config.method === 'GET' ? undefined : config.body,
|
|
1068
|
+
signal: config.signal,
|
|
1069
|
+
credentials: this.getCredentialsMode(fullUrl),
|
|
1070
|
+
});
|
|
1071
|
+
if (response.status === 401 && !isAuthRetry) {
|
|
1072
|
+
const refreshed = await this.refreshAccessToken('response-401');
|
|
1073
|
+
if (refreshed) {
|
|
1074
|
+
await response.body?.cancel();
|
|
1075
|
+
return this.requestAuthenticatedResponseAttempt(config, `Bearer ${refreshed}`, true);
|
|
1076
|
+
}
|
|
1077
|
+
this.tokenStore.clearTokens();
|
|
1078
|
+
this.tokenStore.clearCsrfToken();
|
|
1079
|
+
this.notifyTokenChange();
|
|
1080
|
+
}
|
|
1081
|
+
const duration = Date.now() - startTime;
|
|
1082
|
+
this.updateMetrics(response.ok, duration);
|
|
1083
|
+
this.config.onRequestEnd?.(config.url, config.method, duration, response.ok);
|
|
1084
|
+
return response;
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
const duration = Date.now() - startTime;
|
|
1088
|
+
this.updateMetrics(false, duration);
|
|
1089
|
+
this.config.onRequestEnd?.(config.url, config.method, duration, false);
|
|
1090
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
1091
|
+
this.config.onRequestError?.(config.url, config.method, normalizedError);
|
|
1092
|
+
throw handleHttpError(normalizedError);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
// Convenience methods
|
|
1096
|
+
async get(url, config) {
|
|
1097
|
+
return this.request({ method: 'GET', url, ...config });
|
|
1098
|
+
}
|
|
1099
|
+
async post(url, data, config) {
|
|
1100
|
+
return this.request({ method: 'POST', url, data, ...config });
|
|
1101
|
+
}
|
|
1102
|
+
async put(url, data, config) {
|
|
1103
|
+
return this.request({ method: 'PUT', url, data, ...config });
|
|
1104
|
+
}
|
|
1105
|
+
async patch(url, data, config) {
|
|
1106
|
+
return this.request({ method: 'PATCH', url, data, ...config });
|
|
1107
|
+
}
|
|
1108
|
+
async delete(url, config) {
|
|
1109
|
+
return this.request({ method: 'DELETE', url, ...config });
|
|
1110
|
+
}
|
|
1111
|
+
// Token management
|
|
1112
|
+
setTokens(accessToken) {
|
|
1113
|
+
this.tokenStore.setTokens(accessToken);
|
|
1114
|
+
this.notifyTokenChange();
|
|
1115
|
+
}
|
|
1116
|
+
setAuthRefreshHandler(handler) {
|
|
1117
|
+
this.authRefreshHandler = handler;
|
|
1118
|
+
}
|
|
1119
|
+
setAccessTokenProvider(provider) {
|
|
1120
|
+
this.accessTokenProvider = provider;
|
|
1121
|
+
}
|
|
1122
|
+
clearTokens() {
|
|
1123
|
+
this.tokenStore.clearTokens();
|
|
1124
|
+
this.tokenStore.clearCsrfToken();
|
|
1125
|
+
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1126
|
+
// different user could never read these entries, but a logged-out client
|
|
1127
|
+
// must not keep the previous session's personalized data resident in
|
|
1128
|
+
// memory (privacy + correct logout semantics). We do NOT clear on
|
|
1129
|
+
// `setTokens` because a silent token refresh re-issues a token for the
|
|
1130
|
+
// SAME user — the identity tag is unchanged and the warm cache is still
|
|
1131
|
+
// valid; clearing there would defeat caching as refreshes fire near
|
|
1132
|
+
// every token expiry.
|
|
1133
|
+
this.cache.clear();
|
|
1134
|
+
this.notifyTokenChange();
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Subscribe to access-token changes on this instance.
|
|
1138
|
+
*
|
|
1139
|
+
* Fires on every mutation of the access token — `setTokens`, `clearTokens`,
|
|
1140
|
+
* a successful silent refresh, and the internal 401-driven clear — passing
|
|
1141
|
+
* the resulting token (or `null` when cleared). Returns an unsubscribe
|
|
1142
|
+
* function; call it on teardown to avoid leaks.
|
|
1143
|
+
*
|
|
1144
|
+
* This is the single hook downstream code (e.g. @oxy.so/services' OxyProvider)
|
|
1145
|
+
* uses to keep an external token sink — such as the shared `oxyClient`
|
|
1146
|
+
* singleton — in lockstep with the active session, regardless of which code
|
|
1147
|
+
* path mutated the token.
|
|
1148
|
+
*/
|
|
1149
|
+
addTokenChangeListener(listener) {
|
|
1150
|
+
this._tokenChangeListeners.add(listener);
|
|
1151
|
+
return () => {
|
|
1152
|
+
this._tokenChangeListeners.delete(listener);
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Notify all token-change listeners with the current access token.
|
|
1157
|
+
* Listener exceptions are isolated so one bad subscriber cannot break token
|
|
1158
|
+
* propagation to the others or to the calling auth flow.
|
|
1159
|
+
* @internal
|
|
1160
|
+
*/
|
|
1161
|
+
notifyTokenChange() {
|
|
1162
|
+
if (this._tokenChangeListeners.size === 0)
|
|
1163
|
+
return;
|
|
1164
|
+
const accessToken = this.tokenStore.getAccessToken();
|
|
1165
|
+
for (const listener of this._tokenChangeListeners) {
|
|
1166
|
+
try {
|
|
1167
|
+
listener(accessToken);
|
|
1168
|
+
}
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
this.logger.error('Token change listener threw:', error);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
getAccessToken() {
|
|
1175
|
+
return this.tokenStore.getAccessToken();
|
|
1176
|
+
}
|
|
1177
|
+
hasAccessToken() {
|
|
1178
|
+
return this.tokenStore.hasAccessToken();
|
|
1179
|
+
}
|
|
1180
|
+
getBaseURL() {
|
|
1181
|
+
return this.baseURL;
|
|
1182
|
+
}
|
|
1183
|
+
// Cache management
|
|
1184
|
+
clearCache() {
|
|
1185
|
+
this.cache.clear();
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Delete a cache entry by its LOGICAL key (`method:url[:data]`).
|
|
1189
|
+
*
|
|
1190
|
+
* Because the response cache is identity-scoped — stored keys carry an
|
|
1191
|
+
* ` id=<identityTag>` suffix — a caller passing the logical key
|
|
1192
|
+
* `GET:/users/<id>` must invalidate that resource for EVERY identity that
|
|
1193
|
+
* cached it (e.g. `updateProfile` busting a user representation that may be
|
|
1194
|
+
* cached under both the owner's id and a viewer's id). We therefore delete
|
|
1195
|
+
* the exact key (for any pre-existing un-suffixed entries) AND every
|
|
1196
|
+
* identity-scoped variant `<key> id=*`.
|
|
1197
|
+
*/
|
|
1198
|
+
clearCacheEntry(key) {
|
|
1199
|
+
this.cache.delete(key);
|
|
1200
|
+
const identityVariantPrefix = `${key}${HttpService.CACHE_IDENTITY_DELIM}`;
|
|
1201
|
+
for (const existing of this.cache.keys()) {
|
|
1202
|
+
if (existing.startsWith(identityVariantPrefix)) {
|
|
1203
|
+
this.cache.delete(existing);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Delete every cache entry whose key starts with `prefix`.
|
|
1209
|
+
*
|
|
1210
|
+
* Used by mutations that don't know the exact downstream cache keys —
|
|
1211
|
+
* e.g. `updateProfile` invalidating all `GET:/session/user/*` entries
|
|
1212
|
+
* without having to track every active session ID. Returns the number of
|
|
1213
|
+
* deleted entries (for observability in tests).
|
|
1214
|
+
*/
|
|
1215
|
+
clearCacheByPrefix(prefix) {
|
|
1216
|
+
let removed = 0;
|
|
1217
|
+
for (const key of this.cache.keys()) {
|
|
1218
|
+
if (key.startsWith(prefix)) {
|
|
1219
|
+
this.cache.delete(key);
|
|
1220
|
+
removed++;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return removed;
|
|
1224
|
+
}
|
|
1225
|
+
getCacheStats() {
|
|
1226
|
+
const cacheStats = this.cache.getStats();
|
|
1227
|
+
const total = this.requestMetrics.cacheHits + this.requestMetrics.cacheMisses;
|
|
1228
|
+
return {
|
|
1229
|
+
size: cacheStats.size,
|
|
1230
|
+
hits: this.requestMetrics.cacheHits,
|
|
1231
|
+
misses: this.requestMetrics.cacheMisses,
|
|
1232
|
+
hitRate: total > 0 ? this.requestMetrics.cacheHits / total : 0,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
getMetrics() {
|
|
1236
|
+
return { ...this.requestMetrics };
|
|
1237
|
+
}
|
|
1238
|
+
// Test-only utility — clears tokens on this instance
|
|
1239
|
+
__resetTokensForTests() {
|
|
1240
|
+
this.tokenStore.clearTokens();
|
|
1241
|
+
this.tokenStore.clearCsrfToken();
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Delimiter that separates the logical `method:url[:data]` portion of a
|
|
1246
|
+
* cache key from its identity suffix. Always APPENDED, never used to parse
|
|
1247
|
+
* a key apart, so the `method:url` prefix stays intact for
|
|
1248
|
+
* `clearCacheByPrefix` sweeps and `clearCacheEntry` base-key matching.
|
|
1249
|
+
* The `clearCacheEntry` callsites all pass fixed, dataless logical keys
|
|
1250
|
+
* (`GET:/users/<id>`, `GET:/session/user/<sessionId>`,
|
|
1251
|
+
* `GET:/auth/grants`), so this readable suffix can never be
|
|
1252
|
+
* ambiguous with a serialized request body.
|
|
1253
|
+
*/
|
|
1254
|
+
HttpService.CACHE_IDENTITY_DELIM = ' id=';
|
|
1255
|
+
/**
|
|
1256
|
+
* The keys whose presence beside `data` makes a body a PAGE rather than a
|
|
1257
|
+
* payload — see {@link unwrapResponse} for why this list is narrow.
|
|
1258
|
+
*
|
|
1259
|
+
* - `pagination` — the offset-paginated house envelope (`sendPaginated`).
|
|
1260
|
+
* - `nextCursor` — the keyset-paginated one (the account audit trails).
|
|
1261
|
+
*
|
|
1262
|
+
* Membership is decided by key PRESENCE, never by value: the last page sends
|
|
1263
|
+
* `nextCursor: null`, and an envelope that collapsed into a bare payload
|
|
1264
|
+
* exactly when the stream ended would be a worse bug than the one this fixes.
|
|
1265
|
+
*/
|
|
1266
|
+
HttpService.PAGE_ENVELOPE_KEYS = ['pagination', 'nextCursor'];
|