@oxy.so/core 7.11.1 → 7.12.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/HttpService.js +70 -1
- package/dist/cjs/OxyServices.js +1 -1
- package/dist/cjs/api/holderEvidence.js +12 -0
- package/dist/cjs/api/session.js +118 -0
- package/dist/cjs/index.js +6 -4
- package/dist/esm/HttpService.js +70 -1
- package/dist/esm/OxyServices.js +1 -1
- package/dist/esm/api/holderEvidence.js +9 -0
- package/dist/esm/api/session.js +118 -0
- package/dist/esm/index.js +1 -0
- package/dist/types/HttpService.d.ts +25 -1
- package/dist/types/api/holderEvidence.d.ts +45 -0
- package/dist/types/api/session.d.ts +21 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/server/safety.d.ts +9 -9
- package/package.json +2 -2
- package/src/HttpService.ts +108 -1
- package/src/OxyServices.ts +4 -1
- package/src/api/holderEvidence.ts +66 -0
- package/src/api/session.ts +145 -0
- package/src/index.ts +8 -0
package/dist/cjs/HttpService.js
CHANGED
|
@@ -39,6 +39,9 @@ const REQUESTER_PROOF_HEADERS = new Set([
|
|
|
39
39
|
'x-oxy-presented-requester-context',
|
|
40
40
|
'x-oxy-presented-service-token',
|
|
41
41
|
]);
|
|
42
|
+
function newCredentialGeneration() {
|
|
43
|
+
return Object.freeze({});
|
|
44
|
+
}
|
|
42
45
|
/** Comparison only fences local async work; it never validates token authority. */
|
|
43
46
|
function sameTokenSession(left, right) {
|
|
44
47
|
if (left === right)
|
|
@@ -249,14 +252,25 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
|
|
|
249
252
|
class TokenStore {
|
|
250
253
|
constructor() {
|
|
251
254
|
this.accessToken = null;
|
|
255
|
+
this.generation = newCredentialGeneration();
|
|
252
256
|
}
|
|
253
257
|
setTokens(accessToken) {
|
|
258
|
+
this.generation = newCredentialGeneration();
|
|
254
259
|
this.accessToken = accessToken;
|
|
255
260
|
}
|
|
261
|
+
/** The identity of the bearer's current installation, never of its bytes. */
|
|
262
|
+
getGeneration() {
|
|
263
|
+
return this.generation;
|
|
264
|
+
}
|
|
265
|
+
/** Retire the current generation without touching the bearer (provider swaps). */
|
|
266
|
+
invalidateGeneration() {
|
|
267
|
+
this.generation = newCredentialGeneration();
|
|
268
|
+
}
|
|
256
269
|
getAccessToken() {
|
|
257
270
|
return this.accessToken;
|
|
258
271
|
}
|
|
259
272
|
clearTokens() {
|
|
273
|
+
this.generation = newCredentialGeneration();
|
|
260
274
|
this.accessToken = null;
|
|
261
275
|
}
|
|
262
276
|
hasAccessToken() {
|
|
@@ -307,7 +321,7 @@ function linkAbort(parent, child) {
|
|
|
307
321
|
* Uses native fetch instead of axios for smaller bundle size.
|
|
308
322
|
*/
|
|
309
323
|
class HttpService {
|
|
310
|
-
constructor(config) {
|
|
324
|
+
constructor(config, credentialSource = null) {
|
|
311
325
|
// Only active calls retain a lease. Invalidation detaches matching leases,
|
|
312
326
|
// so late results cannot warm the cache or attract new deduplicated callers.
|
|
313
327
|
this.readLeases = new Map();
|
|
@@ -404,6 +418,7 @@ class HttpService {
|
|
|
404
418
|
averageResponseTime: 0,
|
|
405
419
|
};
|
|
406
420
|
this.config = config;
|
|
421
|
+
this.credentialSource = credentialSource;
|
|
407
422
|
this.baseURL = config.baseURL;
|
|
408
423
|
this.tokenStore = new TokenStore();
|
|
409
424
|
this.logger = new requestUtils_1.SimpleLogger(config.enableLogging || false, config.logLevel || 'error', 'HttpService');
|
|
@@ -520,6 +535,17 @@ class HttpService {
|
|
|
520
535
|
async request(config) {
|
|
521
536
|
if (config.signal?.aborted)
|
|
522
537
|
throw (0, errorUtils_1.createCancelledError)('Request cancelled before it was sent');
|
|
538
|
+
if (config.expectedCredentialGeneration) {
|
|
539
|
+
// The prototype is deliberately limited to writes; it does not
|
|
540
|
+
// yet define leases for cached/deduplicated reads or raw response paths.
|
|
541
|
+
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(config.method) ||
|
|
542
|
+
config.skipAuth ||
|
|
543
|
+
Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization')) {
|
|
544
|
+
throw new Error('A credential generation requires an implicitly authenticated write');
|
|
545
|
+
}
|
|
546
|
+
(this.credentialSource ?? this).assertCredentialGeneration(config.expectedCredentialGeneration);
|
|
547
|
+
config = { ...config, cache: false, deduplicate: false, retry: false };
|
|
548
|
+
}
|
|
523
549
|
// The provider can change independently of the last request. Synchronize
|
|
524
550
|
// before deriving the cache and deduplication identity, including hits.
|
|
525
551
|
const explicitAuthorization = Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
|
|
@@ -609,6 +635,12 @@ class HttpService {
|
|
|
609
635
|
const authHeader = usesSessionAuth
|
|
610
636
|
? await (0, requestSignal_1.awaitRequestSignal)(this.getAuthHeader(), signal)
|
|
611
637
|
: null;
|
|
638
|
+
const assertExpectedCredential = () => {
|
|
639
|
+
if (config.expectedCredentialGeneration) {
|
|
640
|
+
(this.credentialSource ?? this).assertCredentialGeneration(config.expectedCredentialGeneration, authHeader);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
assertExpectedCredential();
|
|
612
644
|
const isOriginalSession = () => {
|
|
613
645
|
if (this.sessionEpoch !== contextEpoch)
|
|
614
646
|
return false;
|
|
@@ -658,6 +690,7 @@ class HttpService {
|
|
|
658
690
|
if (usesSessionAuth && !isOriginalSession()) {
|
|
659
691
|
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
660
692
|
}
|
|
693
|
+
assertExpectedCredential();
|
|
661
694
|
/**
|
|
662
695
|
* Whether OUR timer fired, so a timeout can be told apart from a
|
|
663
696
|
* cancellation. Both reach us from `fetch` as an indistinguishable
|
|
@@ -754,6 +787,9 @@ class HttpService {
|
|
|
754
787
|
Object.keys(headers).some((key) => REQUESTER_PROOF_HEADERS.has(key.toLowerCase()))) {
|
|
755
788
|
throw new Error('Requester proofs require a transport that rejects redirects');
|
|
756
789
|
}
|
|
790
|
+
// Header and source generation are checked together in this synchronous
|
|
791
|
+
// dispatch segment, after the preflight and any queue wait.
|
|
792
|
+
assertExpectedCredential();
|
|
757
793
|
const response = useXhrForUpload
|
|
758
794
|
? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout, false)
|
|
759
795
|
: await fetch(fullUrl, {
|
|
@@ -768,6 +804,13 @@ class HttpService {
|
|
|
768
804
|
// it, including on every throw between here and there.
|
|
769
805
|
if (timeoutId)
|
|
770
806
|
clearTimeout(timeoutId);
|
|
807
|
+
try {
|
|
808
|
+
assertExpectedCredential();
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
void discardResponseBody(response);
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
771
814
|
if (usesSessionAuth && !isOriginalSession()) {
|
|
772
815
|
void discardResponseBody(response);
|
|
773
816
|
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
@@ -778,6 +821,7 @@ class HttpService {
|
|
|
778
821
|
// once before giving up. HttpService deliberately does not know any
|
|
779
822
|
// session routes; the refresh handler owns session rotation.
|
|
780
823
|
if (response.status === 401 &&
|
|
824
|
+
!config.expectedCredentialGeneration &&
|
|
781
825
|
!config._isAuthRetry &&
|
|
782
826
|
usesSessionAuth &&
|
|
783
827
|
isOriginalSession()) {
|
|
@@ -913,12 +957,14 @@ class HttpService {
|
|
|
913
957
|
if (usesSessionAuth && !isOriginalSession()) {
|
|
914
958
|
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
915
959
|
}
|
|
960
|
+
assertExpectedCredential();
|
|
916
961
|
const duration = Date.now() - startTime;
|
|
917
962
|
this.updateMetrics(true, duration);
|
|
918
963
|
this.config.onRequestEnd?.(url, method, duration, true);
|
|
919
964
|
return responseData;
|
|
920
965
|
}
|
|
921
966
|
catch (error) {
|
|
967
|
+
assertExpectedCredential();
|
|
922
968
|
const duration = Date.now() - startTime;
|
|
923
969
|
this.updateMetrics(false, duration);
|
|
924
970
|
// A body reader can reject or finish parsing an error after the viewer
|
|
@@ -1030,6 +1076,7 @@ class HttpService {
|
|
|
1030
1076
|
if (usesSessionAuth && !isOriginalSession()) {
|
|
1031
1077
|
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1032
1078
|
}
|
|
1079
|
+
assertExpectedCredential();
|
|
1033
1080
|
// Cache the result if caching is enabled
|
|
1034
1081
|
if (cache && cacheKey && lease.valid && result) {
|
|
1035
1082
|
this.cache.set(cacheKey, result, cacheTTL);
|
|
@@ -1755,6 +1802,27 @@ class HttpService {
|
|
|
1755
1802
|
getSessionEpoch() {
|
|
1756
1803
|
return this.sessionEpoch;
|
|
1757
1804
|
}
|
|
1805
|
+
/** Capture only the local generation; the caller must separately bind authority. */
|
|
1806
|
+
captureCredentialGeneration() {
|
|
1807
|
+
// A pull-only provider can change A→B→A between reads without notifying us.
|
|
1808
|
+
if (this.accessTokenProvider)
|
|
1809
|
+
return null;
|
|
1810
|
+
return this.tokenStore.getAccessToken() ? this.tokenStore.getGeneration() : null;
|
|
1811
|
+
}
|
|
1812
|
+
isCredentialGenerationCurrent(generation) {
|
|
1813
|
+
return (!this.accessTokenProvider &&
|
|
1814
|
+
this.tokenStore.getAccessToken() !== null &&
|
|
1815
|
+
generation === this.tokenStore.getGeneration());
|
|
1816
|
+
}
|
|
1817
|
+
assertCredentialGeneration(generation, selectedHeader) {
|
|
1818
|
+
const token = this.tokenStore.getAccessToken();
|
|
1819
|
+
if (this.accessTokenProvider ||
|
|
1820
|
+
!token ||
|
|
1821
|
+
generation !== this.tokenStore.getGeneration() ||
|
|
1822
|
+
(selectedHeader !== undefined && selectedHeader !== `Bearer ${token}`)) {
|
|
1823
|
+
throw new OxyServices_errors_1.OxyAuthenticationError('The request credential changed', 'AUTH_SESSION_CHANGED');
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1758
1826
|
/** Whether {@link endSession} ran and no token has been planted since. */
|
|
1759
1827
|
hasSessionEnded() {
|
|
1760
1828
|
return this.sessionEnded;
|
|
@@ -1763,6 +1831,7 @@ class HttpService {
|
|
|
1763
1831
|
this.authRefreshHandler = handler;
|
|
1764
1832
|
}
|
|
1765
1833
|
setAccessTokenProvider(provider) {
|
|
1834
|
+
this.tokenStore.invalidateGeneration();
|
|
1766
1835
|
this.accessTokenProvider = provider;
|
|
1767
1836
|
}
|
|
1768
1837
|
/**
|
package/dist/cjs/OxyServices.js
CHANGED
|
@@ -117,7 +117,7 @@ class OxyServices {
|
|
|
117
117
|
* Query). Pass `enableCache: true` to opt in.
|
|
118
118
|
*/
|
|
119
119
|
createLinkedClient(config) {
|
|
120
|
-
const client = new HttpService_1.HttpService({ ...config, enableCache: config.enableCache ?? false });
|
|
120
|
+
const client = new HttpService_1.HttpService({ ...config, enableCache: config.enableCache ?? false }, this.http);
|
|
121
121
|
const syncToken = (accessToken) => {
|
|
122
122
|
const current = client.getAccessToken();
|
|
123
123
|
if (accessToken) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sameHolderIdentity = sameHolderIdentity;
|
|
4
|
+
/** The one comparison that decides whether two observations name the same viewer context. */
|
|
5
|
+
function sameHolderIdentity(a, b) {
|
|
6
|
+
return (a.deviceId === b.deviceId &&
|
|
7
|
+
a.deviceSessionId === b.deviceSessionId &&
|
|
8
|
+
a.contextId === b.contextId &&
|
|
9
|
+
a.actorId === b.actorId &&
|
|
10
|
+
a.accountId === b.accountId &&
|
|
11
|
+
a.sessionId === b.sessionId);
|
|
12
|
+
}
|
package/dist/cjs/api/session.js
CHANGED
|
@@ -11,12 +11,21 @@ exports.SessionApi = void 0;
|
|
|
11
11
|
const jwt_decode_1 = require("jwt-decode");
|
|
12
12
|
const userIdentity_1 = require("../utils/userIdentity");
|
|
13
13
|
const OxyServices_errors_1 = require("../OxyServices.errors");
|
|
14
|
+
const client_1 = require("@oxy.so/contracts/client");
|
|
15
|
+
/** How long a refusal (disabled, rate limited, unreachable) is remembered per installation. */
|
|
16
|
+
const EVIDENCE_REFUSAL_MS = 5000;
|
|
17
|
+
const unavailable = (reason) => ({
|
|
18
|
+
status: 'unavailable',
|
|
19
|
+
reason,
|
|
20
|
+
});
|
|
14
21
|
class SessionApi {
|
|
15
22
|
constructor(ctx) {
|
|
16
23
|
this.ctx = ctx;
|
|
17
24
|
this.deviceCredentialProvider = null;
|
|
18
25
|
this.decodedFor = null;
|
|
19
26
|
this.decoded = null;
|
|
27
|
+
/** One server round trip per installed bearer; the key is the opaque generation. */
|
|
28
|
+
this.evidence = new WeakMap();
|
|
20
29
|
}
|
|
21
30
|
// ── Local state ──────────────────────────────────────────────────────────
|
|
22
31
|
/** The current access token, or `null` when signed out. */
|
|
@@ -121,6 +130,115 @@ class SessionApi {
|
|
|
121
130
|
return null;
|
|
122
131
|
}
|
|
123
132
|
}
|
|
133
|
+
// ── Holder-context evidence (ADR 0053) ───────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Ask the server which shared-device context the INSTALLED bearer belongs to,
|
|
136
|
+
* and bind the answer to that exact installation.
|
|
137
|
+
*
|
|
138
|
+
* The result is honored only if the credential generation captured before
|
|
139
|
+
* the request is still current when the answer is committed, so a refresh,
|
|
140
|
+
* setter, account or actor switch, cross-tab change, restore or A to B to A
|
|
141
|
+
* during the request yields `changed`. The answer must also agree with the
|
|
142
|
+
* claims of the bearer that was captured; a disagreement is
|
|
143
|
+
* `binding_unavailable`. Nothing here refreshes, clears or mints a credential,
|
|
144
|
+
* and no outcome is a revocation verdict: callers keep their existing checks.
|
|
145
|
+
* Concurrent callers share one request per installation.
|
|
146
|
+
*/
|
|
147
|
+
observeHolderEvidence() {
|
|
148
|
+
const generation = this.ctx.http.captureCredentialGeneration();
|
|
149
|
+
const token = this.ctx.http.getAccessToken();
|
|
150
|
+
if (!generation || !token)
|
|
151
|
+
return Promise.resolve(unavailable('no_credential'));
|
|
152
|
+
const known = this.evidence.get(generation);
|
|
153
|
+
if (known) {
|
|
154
|
+
const refused = known.settledAt !== undefined;
|
|
155
|
+
if (!refused || Date.now() - known.settledAt < EVIDENCE_REFUSAL_MS) {
|
|
156
|
+
return known.result.then((result) => result.status === 'ok' && result.evidence.expiresAtMs <= Date.now()
|
|
157
|
+
? this.refreshEvidence(generation, token)
|
|
158
|
+
: result);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return this.refreshEvidence(generation, token);
|
|
162
|
+
}
|
|
163
|
+
/** Whether evidence still names the installed bearer and has not expired. */
|
|
164
|
+
isHolderEvidenceCurrent(evidence) {
|
|
165
|
+
return (Date.now() < evidence.expiresAtMs &&
|
|
166
|
+
this.ctx.http.isCredentialGenerationCurrent(evidence.generation));
|
|
167
|
+
}
|
|
168
|
+
refreshEvidence(generation, token) {
|
|
169
|
+
const entry = {
|
|
170
|
+
result: this.acquireEvidence(generation, token).then((result) => {
|
|
171
|
+
if (result.status !== 'ok')
|
|
172
|
+
entry.settledAt = Date.now();
|
|
173
|
+
return result;
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
this.evidence.set(generation, entry);
|
|
177
|
+
return entry.result;
|
|
178
|
+
}
|
|
179
|
+
async acquireEvidence(generation, token) {
|
|
180
|
+
let claims;
|
|
181
|
+
try {
|
|
182
|
+
claims = (0, jwt_decode_1.jwtDecode)(token);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return unavailable('unsupported');
|
|
186
|
+
}
|
|
187
|
+
const proof = await this.readDeviceProof();
|
|
188
|
+
if (!proof)
|
|
189
|
+
return unavailable('no_holder');
|
|
190
|
+
let response;
|
|
191
|
+
try {
|
|
192
|
+
response = await this.ctx.request('POST', '/session/device/context-evidence-v1', proof, {
|
|
193
|
+
cache: false,
|
|
194
|
+
retry: false,
|
|
195
|
+
expectedCredentialGeneration: generation,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error?.code === 'AUTH_SESSION_CHANGED') {
|
|
200
|
+
return unavailable('changed');
|
|
201
|
+
}
|
|
202
|
+
const status = error instanceof OxyServices_errors_1.OxyApiError ? error.status : 0;
|
|
203
|
+
if (status === 403)
|
|
204
|
+
return unavailable('unsupported');
|
|
205
|
+
if (status === 409)
|
|
206
|
+
return unavailable('binding_unavailable');
|
|
207
|
+
return unavailable('unavailable');
|
|
208
|
+
}
|
|
209
|
+
const parsed = (0, client_1.checkContract)(client_1.holderContextEvidenceV1ResponseCheck, response);
|
|
210
|
+
if (!parsed)
|
|
211
|
+
return unavailable('unavailable');
|
|
212
|
+
// Commit synchronously: nothing may replace the bearer between this check
|
|
213
|
+
// and the evidence becoming visible to its caller.
|
|
214
|
+
if (!this.ctx.http.isCredentialGenerationCurrent(generation))
|
|
215
|
+
return unavailable('changed');
|
|
216
|
+
const expiresAtMs = Date.parse(parsed.expiresAt);
|
|
217
|
+
if (parsed.deviceId !== proof.deviceId ||
|
|
218
|
+
parsed.accountId !== claims.userId ||
|
|
219
|
+
parsed.sessionId !== claims.sessionId ||
|
|
220
|
+
(claims.act?.sub !== undefined && parsed.actorId !== claims.act.sub) ||
|
|
221
|
+
(claims.device_context_id !== undefined && parsed.contextId !== claims.device_context_id)) {
|
|
222
|
+
return unavailable('binding_unavailable');
|
|
223
|
+
}
|
|
224
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
|
225
|
+
return unavailable('unavailable');
|
|
226
|
+
}
|
|
227
|
+
const evidence = Object.freeze({
|
|
228
|
+
generation,
|
|
229
|
+
identity: Object.freeze({
|
|
230
|
+
deviceId: parsed.deviceId,
|
|
231
|
+
deviceSessionId: parsed.deviceSessionId,
|
|
232
|
+
contextId: parsed.contextId,
|
|
233
|
+
actorId: parsed.actorId,
|
|
234
|
+
accountId: parsed.accountId,
|
|
235
|
+
sessionId: parsed.sessionId,
|
|
236
|
+
}),
|
|
237
|
+
deviceRevision: parsed.revision,
|
|
238
|
+
expiresAtMs,
|
|
239
|
+
});
|
|
240
|
+
return { status: 'ok', evidence };
|
|
241
|
+
}
|
|
124
242
|
// ── The server session ───────────────────────────────────────────────────
|
|
125
243
|
/** Ask the server whether the current access token is valid. */
|
|
126
244
|
async validateToken() {
|
package/dist/cjs/index.js
CHANGED
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
* If a symbol does not appear here, it is NOT part of the public API.
|
|
22
22
|
*/
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
-
exports.
|
|
25
|
-
exports.
|
|
26
|
-
exports.
|
|
27
|
-
exports.packageInfo = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.CENTRAL_IDP_APEX = exports.AUTH_WEB_ORIGIN = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = void 0;
|
|
24
|
+
exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.OxyAppDataIdentifierError = exports.AccountNotOnDeviceError = exports.REPUTATION_CACHE_PREFIX = exports.StoreInstallerError = exports.setStoreInstaller = exports.getStoreInstaller = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.buildUserDid = exports.DirectUploadInterruptedError = exports.MAX_UPLOAD_BYTES = exports.assetUrlCacheTTL = exports.ASSET_MULTIPART_UPLOAD_MAX_BYTES = exports.sameHolderIdentity = exports.ServiceAssetMetadataError = exports.AssetUrlResolutionError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyApiError = exports.OXY_CLOUD_URL = exports.OXY_API_URL = exports.OxyServices = void 0;
|
|
25
|
+
exports.loadLocale = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.coerceToSupportedLocale = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = void 0;
|
|
26
|
+
exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.accountNameInvalidKey = exports.accountNameInvalidMessage = exports.accountNameMaxLength = exports.isValidAccountName = exports.isValidDisplayName = exports.isValidPassword = exports.isValidEmail = exports.ACCOUNT_TITLE_INVALID_MESSAGE = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.parseHttpErrorBody = exports.isHttpRequestError = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.trustTierLabel = exports.reputationCategoryLabel = exports.accountRoleLabel = exports.accountCategoryLabel = exports.getLocalesVersion = exports.subscribeLocales = exports.isLocaleLoaded = void 0;
|
|
27
|
+
exports.packageInfo = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.CENTRAL_IDP_APEX = exports.AUTH_WEB_ORIGIN = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = void 0;
|
|
28
28
|
// The crypto shim MUST stay the first import. The root itself ships no crypto
|
|
29
29
|
// library (`__tests__/rootEntryGraph.test.ts`), but `@oxy.so/protocol`'s root
|
|
30
30
|
// entry (reached by the OAuth/PKCE and device helpers) does, and Metro does not
|
|
@@ -44,6 +44,8 @@ Object.defineProperty(exports, "OxyAuthenticationError", { enumerable: true, get
|
|
|
44
44
|
Object.defineProperty(exports, "OxyAuthenticationTimeoutError", { enumerable: true, get: function () { return OxyServices_errors_1.OxyAuthenticationTimeoutError; } });
|
|
45
45
|
Object.defineProperty(exports, "AssetUrlResolutionError", { enumerable: true, get: function () { return OxyServices_errors_1.AssetUrlResolutionError; } });
|
|
46
46
|
Object.defineProperty(exports, "ServiceAssetMetadataError", { enumerable: true, get: function () { return OxyServices_errors_1.ServiceAssetMetadataError; } });
|
|
47
|
+
var holderEvidence_1 = require("./api/holderEvidence");
|
|
48
|
+
Object.defineProperty(exports, "sameHolderIdentity", { enumerable: true, get: function () { return holderEvidence_1.sameHolderIdentity; } });
|
|
47
49
|
var assets_1 = require("./api/assets");
|
|
48
50
|
Object.defineProperty(exports, "ASSET_MULTIPART_UPLOAD_MAX_BYTES", { enumerable: true, get: function () { return assets_1.ASSET_MULTIPART_UPLOAD_MAX_BYTES; } });
|
|
49
51
|
Object.defineProperty(exports, "assetUrlCacheTTL", { enumerable: true, get: function () { return assets_1.assetUrlCacheTTL; } });
|
package/dist/esm/HttpService.js
CHANGED
|
@@ -36,6 +36,9 @@ const REQUESTER_PROOF_HEADERS = new Set([
|
|
|
36
36
|
'x-oxy-presented-requester-context',
|
|
37
37
|
'x-oxy-presented-service-token',
|
|
38
38
|
]);
|
|
39
|
+
function newCredentialGeneration() {
|
|
40
|
+
return Object.freeze({});
|
|
41
|
+
}
|
|
39
42
|
/** Comparison only fences local async work; it never validates token authority. */
|
|
40
43
|
function sameTokenSession(left, right) {
|
|
41
44
|
if (left === right)
|
|
@@ -246,14 +249,25 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
|
|
|
246
249
|
class TokenStore {
|
|
247
250
|
constructor() {
|
|
248
251
|
this.accessToken = null;
|
|
252
|
+
this.generation = newCredentialGeneration();
|
|
249
253
|
}
|
|
250
254
|
setTokens(accessToken) {
|
|
255
|
+
this.generation = newCredentialGeneration();
|
|
251
256
|
this.accessToken = accessToken;
|
|
252
257
|
}
|
|
258
|
+
/** The identity of the bearer's current installation, never of its bytes. */
|
|
259
|
+
getGeneration() {
|
|
260
|
+
return this.generation;
|
|
261
|
+
}
|
|
262
|
+
/** Retire the current generation without touching the bearer (provider swaps). */
|
|
263
|
+
invalidateGeneration() {
|
|
264
|
+
this.generation = newCredentialGeneration();
|
|
265
|
+
}
|
|
253
266
|
getAccessToken() {
|
|
254
267
|
return this.accessToken;
|
|
255
268
|
}
|
|
256
269
|
clearTokens() {
|
|
270
|
+
this.generation = newCredentialGeneration();
|
|
257
271
|
this.accessToken = null;
|
|
258
272
|
}
|
|
259
273
|
hasAccessToken() {
|
|
@@ -304,7 +318,7 @@ function linkAbort(parent, child) {
|
|
|
304
318
|
* Uses native fetch instead of axios for smaller bundle size.
|
|
305
319
|
*/
|
|
306
320
|
export class HttpService {
|
|
307
|
-
constructor(config) {
|
|
321
|
+
constructor(config, credentialSource = null) {
|
|
308
322
|
// Only active calls retain a lease. Invalidation detaches matching leases,
|
|
309
323
|
// so late results cannot warm the cache or attract new deduplicated callers.
|
|
310
324
|
this.readLeases = new Map();
|
|
@@ -401,6 +415,7 @@ export class HttpService {
|
|
|
401
415
|
averageResponseTime: 0,
|
|
402
416
|
};
|
|
403
417
|
this.config = config;
|
|
418
|
+
this.credentialSource = credentialSource;
|
|
404
419
|
this.baseURL = config.baseURL;
|
|
405
420
|
this.tokenStore = new TokenStore();
|
|
406
421
|
this.logger = new SimpleLogger(config.enableLogging || false, config.logLevel || 'error', 'HttpService');
|
|
@@ -517,6 +532,17 @@ export class HttpService {
|
|
|
517
532
|
async request(config) {
|
|
518
533
|
if (config.signal?.aborted)
|
|
519
534
|
throw createCancelledError('Request cancelled before it was sent');
|
|
535
|
+
if (config.expectedCredentialGeneration) {
|
|
536
|
+
// The prototype is deliberately limited to writes; it does not
|
|
537
|
+
// yet define leases for cached/deduplicated reads or raw response paths.
|
|
538
|
+
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(config.method) ||
|
|
539
|
+
config.skipAuth ||
|
|
540
|
+
Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization')) {
|
|
541
|
+
throw new Error('A credential generation requires an implicitly authenticated write');
|
|
542
|
+
}
|
|
543
|
+
(this.credentialSource ?? this).assertCredentialGeneration(config.expectedCredentialGeneration);
|
|
544
|
+
config = { ...config, cache: false, deduplicate: false, retry: false };
|
|
545
|
+
}
|
|
520
546
|
// The provider can change independently of the last request. Synchronize
|
|
521
547
|
// before deriving the cache and deduplication identity, including hits.
|
|
522
548
|
const explicitAuthorization = Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
|
|
@@ -606,6 +632,12 @@ export class HttpService {
|
|
|
606
632
|
const authHeader = usesSessionAuth
|
|
607
633
|
? await awaitRequestSignal(this.getAuthHeader(), signal)
|
|
608
634
|
: null;
|
|
635
|
+
const assertExpectedCredential = () => {
|
|
636
|
+
if (config.expectedCredentialGeneration) {
|
|
637
|
+
(this.credentialSource ?? this).assertCredentialGeneration(config.expectedCredentialGeneration, authHeader);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
assertExpectedCredential();
|
|
609
641
|
const isOriginalSession = () => {
|
|
610
642
|
if (this.sessionEpoch !== contextEpoch)
|
|
611
643
|
return false;
|
|
@@ -655,6 +687,7 @@ export class HttpService {
|
|
|
655
687
|
if (usesSessionAuth && !isOriginalSession()) {
|
|
656
688
|
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
657
689
|
}
|
|
690
|
+
assertExpectedCredential();
|
|
658
691
|
/**
|
|
659
692
|
* Whether OUR timer fired, so a timeout can be told apart from a
|
|
660
693
|
* cancellation. Both reach us from `fetch` as an indistinguishable
|
|
@@ -751,6 +784,9 @@ export class HttpService {
|
|
|
751
784
|
Object.keys(headers).some((key) => REQUESTER_PROOF_HEADERS.has(key.toLowerCase()))) {
|
|
752
785
|
throw new Error('Requester proofs require a transport that rejects redirects');
|
|
753
786
|
}
|
|
787
|
+
// Header and source generation are checked together in this synchronous
|
|
788
|
+
// dispatch segment, after the preflight and any queue wait.
|
|
789
|
+
assertExpectedCredential();
|
|
754
790
|
const response = useXhrForUpload
|
|
755
791
|
? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout, false)
|
|
756
792
|
: await fetch(fullUrl, {
|
|
@@ -765,6 +801,13 @@ export class HttpService {
|
|
|
765
801
|
// it, including on every throw between here and there.
|
|
766
802
|
if (timeoutId)
|
|
767
803
|
clearTimeout(timeoutId);
|
|
804
|
+
try {
|
|
805
|
+
assertExpectedCredential();
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
void discardResponseBody(response);
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
768
811
|
if (usesSessionAuth && !isOriginalSession()) {
|
|
769
812
|
void discardResponseBody(response);
|
|
770
813
|
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
@@ -775,6 +818,7 @@ export class HttpService {
|
|
|
775
818
|
// once before giving up. HttpService deliberately does not know any
|
|
776
819
|
// session routes; the refresh handler owns session rotation.
|
|
777
820
|
if (response.status === 401 &&
|
|
821
|
+
!config.expectedCredentialGeneration &&
|
|
778
822
|
!config._isAuthRetry &&
|
|
779
823
|
usesSessionAuth &&
|
|
780
824
|
isOriginalSession()) {
|
|
@@ -910,12 +954,14 @@ export class HttpService {
|
|
|
910
954
|
if (usesSessionAuth && !isOriginalSession()) {
|
|
911
955
|
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
912
956
|
}
|
|
957
|
+
assertExpectedCredential();
|
|
913
958
|
const duration = Date.now() - startTime;
|
|
914
959
|
this.updateMetrics(true, duration);
|
|
915
960
|
this.config.onRequestEnd?.(url, method, duration, true);
|
|
916
961
|
return responseData;
|
|
917
962
|
}
|
|
918
963
|
catch (error) {
|
|
964
|
+
assertExpectedCredential();
|
|
919
965
|
const duration = Date.now() - startTime;
|
|
920
966
|
this.updateMetrics(false, duration);
|
|
921
967
|
// A body reader can reject or finish parsing an error after the viewer
|
|
@@ -1027,6 +1073,7 @@ export class HttpService {
|
|
|
1027
1073
|
if (usesSessionAuth && !isOriginalSession()) {
|
|
1028
1074
|
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1029
1075
|
}
|
|
1076
|
+
assertExpectedCredential();
|
|
1030
1077
|
// Cache the result if caching is enabled
|
|
1031
1078
|
if (cache && cacheKey && lease.valid && result) {
|
|
1032
1079
|
this.cache.set(cacheKey, result, cacheTTL);
|
|
@@ -1752,6 +1799,27 @@ export class HttpService {
|
|
|
1752
1799
|
getSessionEpoch() {
|
|
1753
1800
|
return this.sessionEpoch;
|
|
1754
1801
|
}
|
|
1802
|
+
/** Capture only the local generation; the caller must separately bind authority. */
|
|
1803
|
+
captureCredentialGeneration() {
|
|
1804
|
+
// A pull-only provider can change A→B→A between reads without notifying us.
|
|
1805
|
+
if (this.accessTokenProvider)
|
|
1806
|
+
return null;
|
|
1807
|
+
return this.tokenStore.getAccessToken() ? this.tokenStore.getGeneration() : null;
|
|
1808
|
+
}
|
|
1809
|
+
isCredentialGenerationCurrent(generation) {
|
|
1810
|
+
return (!this.accessTokenProvider &&
|
|
1811
|
+
this.tokenStore.getAccessToken() !== null &&
|
|
1812
|
+
generation === this.tokenStore.getGeneration());
|
|
1813
|
+
}
|
|
1814
|
+
assertCredentialGeneration(generation, selectedHeader) {
|
|
1815
|
+
const token = this.tokenStore.getAccessToken();
|
|
1816
|
+
if (this.accessTokenProvider ||
|
|
1817
|
+
!token ||
|
|
1818
|
+
generation !== this.tokenStore.getGeneration() ||
|
|
1819
|
+
(selectedHeader !== undefined && selectedHeader !== `Bearer ${token}`)) {
|
|
1820
|
+
throw new OxyAuthenticationError('The request credential changed', 'AUTH_SESSION_CHANGED');
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1755
1823
|
/** Whether {@link endSession} ran and no token has been planted since. */
|
|
1756
1824
|
hasSessionEnded() {
|
|
1757
1825
|
return this.sessionEnded;
|
|
@@ -1760,6 +1828,7 @@ export class HttpService {
|
|
|
1760
1828
|
this.authRefreshHandler = handler;
|
|
1761
1829
|
}
|
|
1762
1830
|
setAccessTokenProvider(provider) {
|
|
1831
|
+
this.tokenStore.invalidateGeneration();
|
|
1763
1832
|
this.accessTokenProvider = provider;
|
|
1764
1833
|
}
|
|
1765
1834
|
/**
|
package/dist/esm/OxyServices.js
CHANGED
|
@@ -114,7 +114,7 @@ export class OxyServices {
|
|
|
114
114
|
* Query). Pass `enableCache: true` to opt in.
|
|
115
115
|
*/
|
|
116
116
|
createLinkedClient(config) {
|
|
117
|
-
const client = new HttpService({ ...config, enableCache: config.enableCache ?? false });
|
|
117
|
+
const client = new HttpService({ ...config, enableCache: config.enableCache ?? false }, this.http);
|
|
118
118
|
const syncToken = (accessToken) => {
|
|
119
119
|
const current = client.getAccessToken();
|
|
120
120
|
if (accessToken) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The one comparison that decides whether two observations name the same viewer context. */
|
|
2
|
+
export function sameHolderIdentity(a, b) {
|
|
3
|
+
return (a.deviceId === b.deviceId &&
|
|
4
|
+
a.deviceSessionId === b.deviceSessionId &&
|
|
5
|
+
a.contextId === b.contextId &&
|
|
6
|
+
a.actorId === b.actorId &&
|
|
7
|
+
a.accountId === b.accountId &&
|
|
8
|
+
a.sessionId === b.sessionId);
|
|
9
|
+
}
|