@oxyhq/core 5.1.1 → 5.2.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +27 -5
- package/dist/cjs/OxyServices.base.js +71 -0
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/mixins/OxyServices.accounts.js +10 -25
- package/dist/cjs/mixins/OxyServices.assets.js +76 -0
- package/dist/cjs/utils/ssoBounce.js +59 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +27 -5
- package/dist/esm/OxyServices.base.js +71 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.accounts.js +10 -25
- package/dist/esm/mixins/OxyServices.assets.js +76 -0
- package/dist/esm/utils/ssoBounce.js +57 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.base.d.ts +38 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +36 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +1 -0
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +1 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +1 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.links.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +1 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +23 -0
- package/dist/types/utils/ssoBounce.d.ts +47 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +29 -5
- package/src/OxyServices.base.ts +79 -0
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +221 -0
- package/src/index.ts +3 -0
- package/src/mixins/OxyServices.accounts.ts +10 -33
- package/src/mixins/OxyServices.assets.ts +92 -1
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
- package/src/models/interfaces.ts +24 -0
- package/src/utils/__tests__/ssoBounce.test.ts +41 -0
- package/src/utils/ssoBounce.ts +61 -0
|
@@ -142,6 +142,44 @@ export declare class OxyServicesBase {
|
|
|
142
142
|
* Get the raw access token (for constructing anchor URLs when needed)
|
|
143
143
|
*/
|
|
144
144
|
getAccessToken(): string | null;
|
|
145
|
+
/**
|
|
146
|
+
* Register the CURRENTLY-ACTIVE session in the device's first-party
|
|
147
|
+
* multi-account refresh-cookie set by calling `POST /auth/session`.
|
|
148
|
+
*
|
|
149
|
+
* This is the single, shared primitive every web primary-session commit and the
|
|
150
|
+
* account switch use to plant their `oxy_rt_<authuser>` slot. It MUST be a
|
|
151
|
+
* dedicated call to `/auth/session` rather than relying on whichever endpoint
|
|
152
|
+
* established the session: that endpoint is frequently OUTSIDE the cookie's
|
|
153
|
+
* `Path=/auth` scope (`/accounts/:id/switch`) or is a cross-origin/credential-
|
|
154
|
+
* less restore (`/sso/exchange`, the IdP `/auth/silent` postMessage) that cannot
|
|
155
|
+
* set an `api.oxy.so` cookie at all. `/auth/session` runs where the device's
|
|
156
|
+
* existing slots ARE visible, so the server resolves this user's slot (reusing an
|
|
157
|
+
* existing one or allocating a new one) without clobbering a sibling account,
|
|
158
|
+
* mints a fresh access token bound to the same session, and returns the resolved
|
|
159
|
+
* `authuser`.
|
|
160
|
+
*
|
|
161
|
+
* Behaviour:
|
|
162
|
+
* - Requires a planted bearer (the caller must have already installed the
|
|
163
|
+
* session's access token); `/auth/session` derives the session from it.
|
|
164
|
+
* - On success re-plants the rotated access token (so the active token matches
|
|
165
|
+
* the freshly-rotated cookie) and returns the device `authuser` slot.
|
|
166
|
+
* - WEB-ONLY: on native there are no first-party refresh cookies → returns
|
|
167
|
+
* `null`.
|
|
168
|
+
* - FIRST-PARTY-ONLY: the cookie is host-only on the API host with
|
|
169
|
+
* `SameSite=Lax`, so it only sticks when the page is SAME-SITE (same
|
|
170
|
+
* registrable apex) as the API. On a cross-apex RP (`mention.earth` calling
|
|
171
|
+
* `api.oxy.so`) the browser rejects the `Set-Cookie` as a third-party cookie,
|
|
172
|
+
* so a returned slot would be a phantom never enumerated by `refresh-all`.
|
|
173
|
+
* Those RPs durably restore via the per-apex `/auth/silent` iframe + `/sso`
|
|
174
|
+
* bounce, NOT this device set → returns `null` without calling the API.
|
|
175
|
+
* - BEST-EFFORT: a failure (e.g. transient network) never throws — the session
|
|
176
|
+
* stays active in-memory; only its reload durability via the device set is at
|
|
177
|
+
* risk. The caller treats `null` as "not registered in the device set".
|
|
178
|
+
*
|
|
179
|
+
* @returns The resolved device `authuser` slot, or `null` on native / cross-apex
|
|
180
|
+
* / failure.
|
|
181
|
+
*/
|
|
182
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
145
183
|
/**
|
|
146
184
|
* Decode the current access token and return its `exp` claim in SECONDS since
|
|
147
185
|
* the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
|
package/dist/types/index.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
|
62
62
|
export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
|
|
63
63
|
export { DeviceManager } from './utils/deviceManager';
|
|
64
64
|
export type { DeviceFingerprint, StoredDeviceInfo } from './utils/deviceManager';
|
|
65
|
-
export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceSession, DeviceSessionsResponse, DeviceSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
|
|
65
|
+
export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, ServiceAssetMetadataBySha, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceSession, DeviceSessionsResponse, DeviceSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
|
|
66
66
|
export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
|
|
67
67
|
export { TopicType, TopicSource } from './models/Topic';
|
|
68
68
|
export type { TopicData, TopicTranslation } from './models/Topic';
|
|
@@ -93,7 +93,7 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
|
|
|
93
93
|
export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
|
|
94
94
|
export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
|
|
95
95
|
export { generateSsoState } from './mixins/OxyServices.sso';
|
|
96
|
-
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, allowSsoBounce, } from './utils/ssoBounce';
|
|
96
|
+
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoSignedOutKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, silentRestoreSuppressed, allowSsoBounce, } from './utils/ssoBounce';
|
|
97
97
|
export type { SsoBounceGate } from './utils/ssoBounce';
|
|
98
98
|
export { runColdBoot } from './utils/coldBoot';
|
|
99
99
|
export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
|
|
@@ -684,6 +684,7 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
|
|
|
684
684
|
getCurrentUserId(): string | null;
|
|
685
685
|
hasValidToken(): boolean;
|
|
686
686
|
getAccessToken(): string | null;
|
|
687
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
687
688
|
getAccessTokenExpiry(): number | null;
|
|
688
689
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
689
690
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -54,6 +54,7 @@ export declare function OxyServicesAnalyticsMixin<T extends typeof OxyServicesBa
|
|
|
54
54
|
getCurrentUserId(): string | null;
|
|
55
55
|
hasValidToken(): boolean;
|
|
56
56
|
getAccessToken(): string | null;
|
|
57
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
57
58
|
getAccessTokenExpiry(): number | null;
|
|
58
59
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
59
60
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -90,6 +90,7 @@ export declare function OxyServicesAppDataMixin<T extends typeof OxyServicesBase
|
|
|
90
90
|
getCurrentUserId(): string | null;
|
|
91
91
|
hasValidToken(): boolean;
|
|
92
92
|
getAccessToken(): string | null;
|
|
93
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
93
94
|
getAccessTokenExpiry(): number | null;
|
|
94
95
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
95
96
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, ServiceAssetMetadata } from '../models/interfaces';
|
|
1
|
+
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
3
|
export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
4
4
|
new (...args: any[]): {
|
|
@@ -90,6 +90,40 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
|
|
|
90
90
|
* rate), mirroring the sibling service/POST methods which never cache.
|
|
91
91
|
*/
|
|
92
92
|
getServiceAssetMetadataByIds(ids: string[]): Promise<ServiceAssetMetadata[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Reverse content-address lookup: resolve many content `sha256` digests to
|
|
95
|
+
* the servable Oxy asset holding each, in one round-trip per chunk via
|
|
96
|
+
* `POST /assets/service/by-sha256` (body `{ sha256s }`).
|
|
97
|
+
*
|
|
98
|
+
* This is the INVERSE of {@link getServiceAssetMetadataByIds}: given a
|
|
99
|
+
* record's `blob.sha256`, it returns the asset's `id`, `mime`, byte `size`,
|
|
100
|
+
* `status`, and — for active, public, CDN-reachable assets only — a public
|
|
101
|
+
* `url` (`cloud.oxy.so`). Built for server-to-server callers (e.g. Mention's
|
|
102
|
+
* MTN materializer / node-blob sync) that hold a content hash and need to
|
|
103
|
+
* map it back to a servable asset. Hashes are lowercased, validated against
|
|
104
|
+
* a 64-char hex pattern (malformed entries dropped client-side), and
|
|
105
|
+
* deduplicated before being split into chunks of
|
|
106
|
+
* {@link SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE} (the server-side cap). The
|
|
107
|
+
* server omits unknown/deleted hashes from each chunk's `data`, so the
|
|
108
|
+
* merged result may be shorter than the requested list and the caller is
|
|
109
|
+
* expected to map by `sha256`.
|
|
110
|
+
*
|
|
111
|
+
* **Service-token auth (required).** `/assets/service/by-sha256` is guarded
|
|
112
|
+
* by `serviceAuthMiddleware` + the `files:read` scope and is called via
|
|
113
|
+
* `makeServiceRequest` (`Authorization: Bearer <serviceToken>`, `cache:false`).
|
|
114
|
+
* The calling client MUST be service-configured (`configureServiceAuth`)
|
|
115
|
+
* before invoking; a plain user-session request is rejected by the route's
|
|
116
|
+
* service-auth guard.
|
|
117
|
+
*
|
|
118
|
+
* Resilience: chunks are independent. A failed chunk is logged and skipped —
|
|
119
|
+
* every entry that resolved is still returned. An empty input (or one whose
|
|
120
|
+
* every value is malformed) resolves immediately with `[]` and performs no
|
|
121
|
+
* network call.
|
|
122
|
+
*
|
|
123
|
+
* Not cached at the SDK layer: it's a POST keyed on a multi-hash body (low
|
|
124
|
+
* hit rate), mirroring the sibling service/POST methods which never cache.
|
|
125
|
+
*/
|
|
126
|
+
getServiceAssetMetadataBySha256(sha256s: string[]): Promise<ServiceAssetMetadataBySha[]>;
|
|
93
127
|
/**
|
|
94
128
|
* Upload raw file data
|
|
95
129
|
*/
|
|
@@ -175,6 +209,7 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
|
|
|
175
209
|
getCurrentUserId(): string | null;
|
|
176
210
|
hasValidToken(): boolean;
|
|
177
211
|
getAccessToken(): string | null;
|
|
212
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
178
213
|
getAccessTokenExpiry(): number | null;
|
|
179
214
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
180
215
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -569,6 +569,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
569
569
|
getCurrentUserId(): string | null;
|
|
570
570
|
hasValidToken(): boolean;
|
|
571
571
|
getAccessToken(): string | null;
|
|
572
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
572
573
|
getAccessTokenExpiry(): number | null;
|
|
573
574
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
574
575
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -492,6 +492,7 @@ export declare function OxyServicesCivicMixin<T extends typeof OxyServicesBase>(
|
|
|
492
492
|
getCurrentUserId(): string | null;
|
|
493
493
|
hasValidToken(): boolean;
|
|
494
494
|
getAccessToken(): string | null;
|
|
495
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
495
496
|
getAccessTokenExpiry(): number | null;
|
|
496
497
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
497
498
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -147,6 +147,7 @@ export declare function OxyServicesConnectedAppsMixin<T extends typeof OxyServic
|
|
|
147
147
|
getCurrentUserId(): string | null;
|
|
148
148
|
hasValidToken(): boolean;
|
|
149
149
|
getAccessToken(): string | null;
|
|
150
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
150
151
|
getAccessTokenExpiry(): number | null;
|
|
151
152
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
152
153
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -82,6 +82,7 @@ export declare function OxyServicesContactsMixin<T extends typeof OxyServicesBas
|
|
|
82
82
|
getCurrentUserId(): string | null;
|
|
83
83
|
hasValidToken(): boolean;
|
|
84
84
|
getAccessToken(): string | null;
|
|
85
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
85
86
|
getAccessTokenExpiry(): number | null;
|
|
86
87
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
87
88
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -84,6 +84,7 @@ export declare function OxyServicesDevicesMixin<T extends typeof OxyServicesBase
|
|
|
84
84
|
getCurrentUserId(): string | null;
|
|
85
85
|
hasValidToken(): boolean;
|
|
86
86
|
getAccessToken(): string | null;
|
|
87
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
87
88
|
getAccessTokenExpiry(): number | null;
|
|
88
89
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
89
90
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -232,6 +232,7 @@ export declare function OxyServicesFeaturesMixin<T extends typeof OxyServicesBas
|
|
|
232
232
|
getCurrentUserId(): string | null;
|
|
233
233
|
hasValidToken(): boolean;
|
|
234
234
|
getAccessToken(): string | null;
|
|
235
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
235
236
|
getAccessTokenExpiry(): number | null;
|
|
236
237
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
237
238
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -286,6 +286,7 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
|
|
|
286
286
|
getCurrentUserId(): string | null;
|
|
287
287
|
hasValidToken(): boolean;
|
|
288
288
|
getAccessToken(): string | null;
|
|
289
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
289
290
|
getAccessTokenExpiry(): number | null;
|
|
290
291
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
291
292
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -234,6 +234,7 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
|
|
|
234
234
|
getCurrentUserId(): string | null;
|
|
235
235
|
hasValidToken(): boolean;
|
|
236
236
|
getAccessToken(): string | null;
|
|
237
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
237
238
|
getAccessTokenExpiry(): number | null;
|
|
238
239
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
239
240
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -69,6 +69,7 @@ export declare function OxyServicesLanguageMixin<T extends typeof OxyServicesBas
|
|
|
69
69
|
getCurrentUserId(): string | null;
|
|
70
70
|
hasValidToken(): boolean;
|
|
71
71
|
getAccessToken(): string | null;
|
|
72
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
72
73
|
getAccessTokenExpiry(): number | null;
|
|
73
74
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
74
75
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -86,6 +86,7 @@ export declare function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(
|
|
|
86
86
|
getCurrentUserId(): string | null;
|
|
87
87
|
hasValidToken(): boolean;
|
|
88
88
|
getAccessToken(): string | null;
|
|
89
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
89
90
|
getAccessTokenExpiry(): number | null;
|
|
90
91
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
91
92
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -52,6 +52,7 @@ export declare function OxyServicesLocationMixin<T extends typeof OxyServicesBas
|
|
|
52
52
|
getCurrentUserId(): string | null;
|
|
53
53
|
hasValidToken(): boolean;
|
|
54
54
|
getAccessToken(): string | null;
|
|
55
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
55
56
|
getAccessTokenExpiry(): number | null;
|
|
56
57
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
57
58
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -180,6 +180,7 @@ export declare function OxyServicesNodesMixin<T extends typeof OxyServicesBase>(
|
|
|
180
180
|
getCurrentUserId(): string | null;
|
|
181
181
|
hasValidToken(): boolean;
|
|
182
182
|
getAccessToken(): string | null;
|
|
183
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
183
184
|
getAccessTokenExpiry(): number | null;
|
|
184
185
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
185
186
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -99,6 +99,7 @@ export declare function OxyServicesPaymentMixin<T extends typeof OxyServicesBase
|
|
|
99
99
|
getCurrentUserId(): string | null;
|
|
100
100
|
hasValidToken(): boolean;
|
|
101
101
|
getAccessToken(): string | null;
|
|
102
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
102
103
|
getAccessTokenExpiry(): number | null;
|
|
103
104
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
104
105
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -128,6 +128,7 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
|
|
|
128
128
|
getCurrentUserId(): string | null;
|
|
129
129
|
hasValidToken(): boolean;
|
|
130
130
|
getAccessToken(): string | null;
|
|
131
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
131
132
|
getAccessTokenExpiry(): number | null;
|
|
132
133
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
133
134
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -71,6 +71,7 @@ export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyService
|
|
|
71
71
|
getCurrentUserId(): string | null;
|
|
72
72
|
hasValidToken(): boolean;
|
|
73
73
|
getAccessToken(): string | null;
|
|
74
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
74
75
|
getAccessTokenExpiry(): number | null;
|
|
75
76
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
76
77
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -417,6 +417,7 @@ export declare function OxyServicesReputationMixin<T extends typeof OxyServicesB
|
|
|
417
417
|
getCurrentUserId(): string | null;
|
|
418
418
|
hasValidToken(): boolean;
|
|
419
419
|
getAccessToken(): string | null;
|
|
420
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
420
421
|
getAccessTokenExpiry(): number | null;
|
|
421
422
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
422
423
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -66,6 +66,7 @@ export declare function OxyServicesSecurityMixin<T extends typeof OxyServicesBas
|
|
|
66
66
|
getCurrentUserId(): string | null;
|
|
67
67
|
hasValidToken(): boolean;
|
|
68
68
|
getAccessToken(): string | null;
|
|
69
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
69
70
|
getAccessTokenExpiry(): number | null;
|
|
70
71
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
71
72
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -109,6 +109,7 @@ export declare function OxyServicesSilentAuthMixin<T extends typeof OxyServicesB
|
|
|
109
109
|
getCurrentUserId(): string | null;
|
|
110
110
|
hasValidToken(): boolean;
|
|
111
111
|
getAccessToken(): string | null;
|
|
112
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
112
113
|
getAccessTokenExpiry(): number | null;
|
|
113
114
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
114
115
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -94,6 +94,7 @@ export declare function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Ba
|
|
|
94
94
|
getCurrentUserId(): string | null;
|
|
95
95
|
hasValidToken(): boolean;
|
|
96
96
|
getAccessToken(): string | null;
|
|
97
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
97
98
|
getAccessTokenExpiry(): number | null;
|
|
98
99
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
99
100
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -92,6 +92,7 @@ export declare function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>
|
|
|
92
92
|
getCurrentUserId(): string | null;
|
|
93
93
|
hasValidToken(): boolean;
|
|
94
94
|
getAccessToken(): string | null;
|
|
95
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
95
96
|
getAccessTokenExpiry(): number | null;
|
|
96
97
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
97
98
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -386,6 +386,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
386
386
|
getCurrentUserId(): string | null;
|
|
387
387
|
hasValidToken(): boolean;
|
|
388
388
|
getAccessToken(): string | null;
|
|
389
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
389
390
|
getAccessTokenExpiry(): number | null;
|
|
390
391
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
391
392
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -257,6 +257,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
257
257
|
getCurrentUserId(): string | null;
|
|
258
258
|
hasValidToken(): boolean;
|
|
259
259
|
getAccessToken(): string | null;
|
|
260
|
+
establishDeviceRefreshSlot(): Promise<number | null>;
|
|
260
261
|
getAccessTokenExpiry(): number | null;
|
|
261
262
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
262
263
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
@@ -465,6 +465,29 @@ export interface ServiceAssetMetadata {
|
|
|
465
465
|
size: number;
|
|
466
466
|
status: 'active' | 'trash';
|
|
467
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Reverse-lookup asset metadata returned by `POST /assets/service/by-sha256`.
|
|
470
|
+
*
|
|
471
|
+
* Resolves a content-addressed `sha256` digest back to the live Oxy asset that
|
|
472
|
+
* holds those bytes: its file `id`, MIME type, byte `size`, storage `status`,
|
|
473
|
+
* and — for active, public, CDN-reachable assets only — a public `url`
|
|
474
|
+
* (`cloud.oxy.so`). This is the inverse of {@link ServiceAssetMetadata}: it lets
|
|
475
|
+
* a `files:read`-scoped service-to-server caller (e.g. Mention's MTN materializer
|
|
476
|
+
* / node-blob sync) turn a record's `blob.sha256` into a servable asset.
|
|
477
|
+
*
|
|
478
|
+
* `url` is omitted for private/unlisted assets (and for public assets whose
|
|
479
|
+
* bytes are not yet CDN-reachable) — those must be streamed through the origin.
|
|
480
|
+
* Unknown or deleted hashes are omitted from the response (never error the whole
|
|
481
|
+
* batch), so the result may be shorter than the requested hash list.
|
|
482
|
+
*/
|
|
483
|
+
export interface ServiceAssetMetadataBySha {
|
|
484
|
+
sha256: string;
|
|
485
|
+
id: string;
|
|
486
|
+
mime: string;
|
|
487
|
+
size: number;
|
|
488
|
+
status: 'active' | 'trash';
|
|
489
|
+
url?: string;
|
|
490
|
+
}
|
|
468
491
|
/**
|
|
469
492
|
* Account storage usage (server-side usage, not local AsyncStorage)
|
|
470
493
|
*/
|
|
@@ -94,6 +94,34 @@ export declare function ssoAttemptedKey(origin: string): string;
|
|
|
94
94
|
* IdP, while a truly first-time anonymous visitor is never force-bounced.
|
|
95
95
|
*/
|
|
96
96
|
export declare function ssoPriorSessionKey(origin: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Per-origin DURABLE "the user DELIBERATELY signed out on this device/origin"
|
|
99
|
+
* flag.
|
|
100
|
+
*
|
|
101
|
+
* Like {@link ssoPriorSessionKey} this lives in DURABLE storage (web
|
|
102
|
+
* `localStorage`; services uses its own `storageKeyPrefix`-scoped key), NOT the
|
|
103
|
+
* per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
|
|
104
|
+
*
|
|
105
|
+
* It exists purely to suppress AUTOMATIC silent restore after a deliberate
|
|
106
|
+
* sign-out: a still-live IdP session (the central `fedcm_session` / the FedCM
|
|
107
|
+
* credential association) would otherwise let `fedcm-silent` / the per-apex
|
|
108
|
+
* `/auth/silent` iframe re-mint a session on the very next cold boot, so a user
|
|
109
|
+
* who pressed "Sign out" gets silently signed back in on reload. With this flag
|
|
110
|
+
* set, those silent cold-boot steps are skipped while the Gmail-style
|
|
111
|
+
* returning-account fast-path is otherwise preserved.
|
|
112
|
+
*
|
|
113
|
+
* Lifecycle (mirrors the existing gate machinery — set on a definitive event,
|
|
114
|
+
* cleared on its inverse):
|
|
115
|
+
* - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
|
|
116
|
+
* and the SSO bounce state).
|
|
117
|
+
* - CLEARED on ANY deliberate sign-in (password, FedCM, account switch, device
|
|
118
|
+
* claim) so a real sign-in fully re-enables silent restore — there is no
|
|
119
|
+
* "stuck signed out" state.
|
|
120
|
+
*
|
|
121
|
+
* NOTE: this gates only AUTOMATIC/silent restore. An INTERACTIVE sign-in always
|
|
122
|
+
* clears it first, so the user can always sign back in.
|
|
123
|
+
*/
|
|
124
|
+
export declare function ssoSignedOutKey(origin: string): string;
|
|
97
125
|
/**
|
|
98
126
|
* Per-origin marker written by the pre-hydration callback bootstrap.
|
|
99
127
|
*
|
|
@@ -174,6 +202,25 @@ export declare function isCentralIdPOrigin(origin: string): boolean;
|
|
|
174
202
|
* `Date.now()`.
|
|
175
203
|
*/
|
|
176
204
|
export declare function guardActive(storage: Pick<Storage, 'getItem'>, origin: string, now?: number): boolean;
|
|
205
|
+
/**
|
|
206
|
+
* Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
|
|
207
|
+
* user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
|
|
208
|
+
*
|
|
209
|
+
* When `true`, the silent cold-boot steps that can re-mint a session from a
|
|
210
|
+
* still-live IdP session WITHOUT user intent — `fedcm-silent` and the per-apex
|
|
211
|
+
* `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
|
|
212
|
+
* not silently signed back in on the next reload. Interactive sign-in clears the
|
|
213
|
+
* flag, so this never blocks a deliberate re-sign-in.
|
|
214
|
+
*
|
|
215
|
+
* Defensive: a `getItem` that throws (locked/disabled storage) is treated as NOT
|
|
216
|
+
* suppressed, so the gate fails toward the normal restore behaviour rather than
|
|
217
|
+
* wedging the user out.
|
|
218
|
+
*
|
|
219
|
+
* @param storage - The DURABLE storage to read (web `localStorage`; injected for
|
|
220
|
+
* testability).
|
|
221
|
+
* @param origin - The page origin whose signed-out flag to evaluate.
|
|
222
|
+
*/
|
|
223
|
+
export declare function silentRestoreSuppressed(storage: Pick<Storage, 'getItem'>, origin: string): boolean;
|
|
177
224
|
/**
|
|
178
225
|
* Inputs to the smart {@link allowSsoBounce} gate.
|
|
179
226
|
*/
|
package/package.json
CHANGED
package/src/AuthManager.ts
CHANGED
|
@@ -509,11 +509,35 @@ export class AuthManager {
|
|
|
509
509
|
|
|
510
510
|
this.currentAuthMethod = method;
|
|
511
511
|
|
|
512
|
-
|
|
513
|
-
|
|
512
|
+
// Register this primary session in the device's first-party multi-account
|
|
513
|
+
// refresh-cookie set (web only). Every web primary commit funnels through
|
|
514
|
+
// here (FedCM, central `/sso` return, credentials), but only a same-apex
|
|
515
|
+
// `/fedcm/exchange` plants an `oxy_rt_<authuser>` slot as a side effect — the
|
|
516
|
+
// cross-origin/credential-less restores (`/sso/exchange`, the IdP
|
|
517
|
+
// `/auth/silent` postMessage) cannot set an `api.oxy.so` cookie. WITHOUT this
|
|
518
|
+
// call those primaries never join the device set, so `refresh-all` returns no
|
|
519
|
+
// accounts and account-switch persistence has no foundation. The shared
|
|
520
|
+
// `POST /auth/session` primitive plants/reuses this user's slot where the
|
|
521
|
+
// cookies ARE visible, re-plants the rotated access token, and returns the
|
|
522
|
+
// AUTHORITATIVE `authuser` slot (the one actually written) — preferred over the
|
|
523
|
+
// JWT-decoded guess, which may not correspond to a real cookie. No-op on native
|
|
524
|
+
// (returns `null`); best-effort on transient failure.
|
|
525
|
+
const establishedAuthuser = await this.oxyServices.establishDeviceRefreshSlot();
|
|
526
|
+
// The slot mint rotated the access token; pick it up so the registry and the
|
|
527
|
+
// refresh authority track the cookie just written. Falls back to the original
|
|
528
|
+
// session token when the slot was a no-op (native) or failed.
|
|
529
|
+
const rotatedToken =
|
|
530
|
+
(establishedAuthuser !== null ? this.oxyServices.getAccessToken() : null) ??
|
|
531
|
+
session.accessToken;
|
|
532
|
+
if (rotatedToken) {
|
|
533
|
+
this._lastKnownAccessToken = rotatedToken;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const decodedAuthuser = rotatedToken
|
|
537
|
+
? AuthManager.decodeAuthuserFromAccessToken(rotatedToken)
|
|
514
538
|
: null;
|
|
515
|
-
const authuser = decodedAuthuser ?? 0;
|
|
516
|
-
if (
|
|
539
|
+
const authuser = establishedAuthuser ?? decodedAuthuser ?? 0;
|
|
540
|
+
if (rotatedToken && session.sessionId) {
|
|
517
541
|
this.accounts.set(authuser, {
|
|
518
542
|
authuser,
|
|
519
543
|
sessionId: session.sessionId,
|
|
@@ -523,7 +547,7 @@ export class AuthManager {
|
|
|
523
547
|
name: session.user.name,
|
|
524
548
|
avatar: session.user.avatar ?? null,
|
|
525
549
|
},
|
|
526
|
-
accessToken:
|
|
550
|
+
accessToken: rotatedToken,
|
|
527
551
|
expiresAt: session.expiresAt,
|
|
528
552
|
});
|
|
529
553
|
this.activeAuthuser = authuser;
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { handleHttpError } from './utils/errorUtils';
|
|
|
9
9
|
import { HttpService, type AuthRefreshReason, type RequestOptions } from './HttpService';
|
|
10
10
|
import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
|
|
11
11
|
import { resolveCentralAuthUrl } from './utils/authWebUrl';
|
|
12
|
+
import { isWeb } from './utils/platform';
|
|
13
|
+
import { registrableApex } from './utils/fapiAutoDetect';
|
|
14
|
+
import { logger } from './utils/loggerUtils';
|
|
12
15
|
|
|
13
16
|
export interface OxyConfig extends OxyConfigBase {
|
|
14
17
|
cloudURL?: string;
|
|
@@ -310,6 +313,82 @@ export class OxyServicesBase {
|
|
|
310
313
|
return this.httpService.getAccessToken();
|
|
311
314
|
}
|
|
312
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Register the CURRENTLY-ACTIVE session in the device's first-party
|
|
318
|
+
* multi-account refresh-cookie set by calling `POST /auth/session`.
|
|
319
|
+
*
|
|
320
|
+
* This is the single, shared primitive every web primary-session commit and the
|
|
321
|
+
* account switch use to plant their `oxy_rt_<authuser>` slot. It MUST be a
|
|
322
|
+
* dedicated call to `/auth/session` rather than relying on whichever endpoint
|
|
323
|
+
* established the session: that endpoint is frequently OUTSIDE the cookie's
|
|
324
|
+
* `Path=/auth` scope (`/accounts/:id/switch`) or is a cross-origin/credential-
|
|
325
|
+
* less restore (`/sso/exchange`, the IdP `/auth/silent` postMessage) that cannot
|
|
326
|
+
* set an `api.oxy.so` cookie at all. `/auth/session` runs where the device's
|
|
327
|
+
* existing slots ARE visible, so the server resolves this user's slot (reusing an
|
|
328
|
+
* existing one or allocating a new one) without clobbering a sibling account,
|
|
329
|
+
* mints a fresh access token bound to the same session, and returns the resolved
|
|
330
|
+
* `authuser`.
|
|
331
|
+
*
|
|
332
|
+
* Behaviour:
|
|
333
|
+
* - Requires a planted bearer (the caller must have already installed the
|
|
334
|
+
* session's access token); `/auth/session` derives the session from it.
|
|
335
|
+
* - On success re-plants the rotated access token (so the active token matches
|
|
336
|
+
* the freshly-rotated cookie) and returns the device `authuser` slot.
|
|
337
|
+
* - WEB-ONLY: on native there are no first-party refresh cookies → returns
|
|
338
|
+
* `null`.
|
|
339
|
+
* - FIRST-PARTY-ONLY: the cookie is host-only on the API host with
|
|
340
|
+
* `SameSite=Lax`, so it only sticks when the page is SAME-SITE (same
|
|
341
|
+
* registrable apex) as the API. On a cross-apex RP (`mention.earth` calling
|
|
342
|
+
* `api.oxy.so`) the browser rejects the `Set-Cookie` as a third-party cookie,
|
|
343
|
+
* so a returned slot would be a phantom never enumerated by `refresh-all`.
|
|
344
|
+
* Those RPs durably restore via the per-apex `/auth/silent` iframe + `/sso`
|
|
345
|
+
* bounce, NOT this device set → returns `null` without calling the API.
|
|
346
|
+
* - BEST-EFFORT: a failure (e.g. transient network) never throws — the session
|
|
347
|
+
* stays active in-memory; only its reload durability via the device set is at
|
|
348
|
+
* risk. The caller treats `null` as "not registered in the device set".
|
|
349
|
+
*
|
|
350
|
+
* @returns The resolved device `authuser` slot, or `null` on native / cross-apex
|
|
351
|
+
* / failure.
|
|
352
|
+
*/
|
|
353
|
+
public async establishDeviceRefreshSlot(): Promise<number | null> {
|
|
354
|
+
if (!isWeb()) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
if (typeof window !== 'undefined' && window.location?.hostname) {
|
|
358
|
+
const pageApex = registrableApex(window.location.hostname);
|
|
359
|
+
let apiApex: string | null = null;
|
|
360
|
+
try {
|
|
361
|
+
apiApex = registrableApex(new URL(this.getBaseURL()).hostname);
|
|
362
|
+
} catch {
|
|
363
|
+
apiApex = null;
|
|
364
|
+
}
|
|
365
|
+
if (!pageApex || !apiApex || pageApex !== apiApex) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
const established = await this.makeRequest<{ accessToken?: string; authuser?: number }>(
|
|
371
|
+
'POST',
|
|
372
|
+
'/auth/session',
|
|
373
|
+
undefined,
|
|
374
|
+
{ cache: false },
|
|
375
|
+
);
|
|
376
|
+
// `/auth/session` mints a fresh access token off the same session; re-plant
|
|
377
|
+
// it so the active token matches the rotated cookie.
|
|
378
|
+
if (established?.accessToken) {
|
|
379
|
+
this.setTokens(established.accessToken);
|
|
380
|
+
}
|
|
381
|
+
return typeof established?.authuser === 'number' ? established.authuser : null;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
logger.warn(
|
|
384
|
+
'[OxyServices] Failed to establish device refresh cookie via POST /auth/session; the session is active in-session but may not survive a reload as part of the device account set',
|
|
385
|
+
{ component: 'OxyServices', method: 'establishDeviceRefreshSlot' },
|
|
386
|
+
error,
|
|
387
|
+
);
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
313
392
|
/**
|
|
314
393
|
* Decode the current access token and return its `exp` claim in SECONDS since
|
|
315
394
|
* the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
|