@oxy.so/core 7.11.1 → 7.13.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/apps.js +1 -1
- package/dist/cjs/api/auth.js +1 -1
- package/dist/cjs/api/billing.js +4 -3
- package/dist/cjs/api/devices.js +5 -4
- package/dist/cjs/api/follows.js +11 -10
- package/dist/cjs/api/holderEvidence.js +12 -0
- package/dist/cjs/api/privacy.js +8 -7
- package/dist/cjs/api/session.js +128 -7
- package/dist/cjs/api/topics.js +4 -3
- package/dist/cjs/api/users.js +5 -4
- package/dist/cjs/i18n/locales/compact/es-ES.generated.js +1 -1
- package/dist/cjs/i18n/locales/en-US.js +40 -15
- package/dist/cjs/i18n/locales/es-ES.js +40 -15
- 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/apps.js +1 -1
- package/dist/esm/api/auth.js +1 -1
- package/dist/esm/api/billing.js +4 -3
- package/dist/esm/api/devices.js +5 -4
- package/dist/esm/api/follows.js +11 -10
- package/dist/esm/api/holderEvidence.js +9 -0
- package/dist/esm/api/privacy.js +8 -7
- package/dist/esm/api/session.js +128 -7
- package/dist/esm/api/topics.js +4 -3
- package/dist/esm/api/users.js +5 -4
- package/dist/esm/i18n/locales/compact/es-ES.generated.js +1 -1
- package/dist/esm/i18n/locales/en-US.js +40 -15
- package/dist/esm/i18n/locales/es-ES.js +40 -15
- 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/i18n/locales/en-US.d.ts +37 -12
- package/dist/types/i18n/locales/es-ES.d.ts +37 -12
- 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/apps.ts +1 -1
- package/src/api/auth.ts +9 -4
- package/src/api/billing.ts +12 -5
- package/src/api/devices.ts +6 -4
- package/src/api/follows.ts +12 -10
- package/src/api/holderEvidence.ts +66 -0
- package/src/api/privacy.ts +9 -7
- package/src/api/session.ts +163 -9
- package/src/api/topics.ts +5 -3
- package/src/api/users.ts +6 -4
- package/src/i18n/locales/compact/es-ES.generated.ts +1 -1
- package/src/i18n/locales/en-US.ts +41 -15
- package/src/i18n/locales/es-ES.ts +42 -15
- 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) {
|
package/dist/cjs/api/apps.js
CHANGED
|
@@ -85,7 +85,7 @@ class ConnectedAppsApi {
|
|
|
85
85
|
* `ConnectedApp.applicationId`, NOT a credential/client id).
|
|
86
86
|
*/
|
|
87
87
|
async revoke(applicationId) {
|
|
88
|
-
await this.ctx.request('DELETE', `/auth/grants/${applicationId}`, undefined, { cache: false });
|
|
88
|
+
await this.ctx.request('DELETE', `/auth/grants/${enc(applicationId)}`, undefined, { cache: false });
|
|
89
89
|
this.ctx.oxy.cache.delete('GET:/auth/grants');
|
|
90
90
|
}
|
|
91
91
|
/** Resource-bound external MCP connections for the active Oxy account. */
|
package/dist/cjs/api/auth.js
CHANGED
|
@@ -974,7 +974,7 @@ class AuthApi {
|
|
|
974
974
|
*/
|
|
975
975
|
async checkUsername(username) {
|
|
976
976
|
// Public availability lookup (pre-session) — skip the bearer preflight.
|
|
977
|
-
return this.ctx.request('GET', `/auth/check-username/${username}`, undefined, {
|
|
977
|
+
return this.ctx.request('GET', `/auth/check-username/${encodeURIComponent(username)}`, undefined, {
|
|
978
978
|
cache: false,
|
|
979
979
|
skipAuth: true,
|
|
980
980
|
});
|
package/dist/cjs/api/billing.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.BillingApi = void 0;
|
|
15
15
|
const client_1 = require("@oxy.so/contracts/client");
|
|
16
|
+
const enc = encodeURIComponent;
|
|
16
17
|
const SUBSCRIPTION_TTL = 2 * 60 * 1000;
|
|
17
18
|
const WALLET_TTL = 60 * 1000;
|
|
18
19
|
class BillingApi {
|
|
@@ -76,7 +77,7 @@ class BillingApi {
|
|
|
76
77
|
/** A user's subscription (default: the signed-in user's). */
|
|
77
78
|
async subscription(userId) {
|
|
78
79
|
const id = this.resolveUserId(userId);
|
|
79
|
-
return this.ctx.request('GET', `/subscription/${id}`, undefined, {
|
|
80
|
+
return this.ctx.request('GET', `/subscription/${enc(id)}`, undefined, {
|
|
80
81
|
cache: true,
|
|
81
82
|
cacheTTL: SUBSCRIPTION_TTL,
|
|
82
83
|
});
|
|
@@ -84,7 +85,7 @@ class BillingApi {
|
|
|
84
85
|
/** A user's wallet (default: the signed-in user's). Cached briefly: the balance moves. */
|
|
85
86
|
async wallet(userId) {
|
|
86
87
|
const id = this.resolveUserId(userId);
|
|
87
|
-
return this.ctx.request('GET', `/wallet/${id}`, undefined, {
|
|
88
|
+
return this.ctx.request('GET', `/wallet/${enc(id)}`, undefined, {
|
|
88
89
|
cache: true,
|
|
89
90
|
cacheTTL: WALLET_TTL,
|
|
90
91
|
});
|
|
@@ -97,7 +98,7 @@ class BillingApi {
|
|
|
97
98
|
params.limit = options.limit;
|
|
98
99
|
if (options.offset)
|
|
99
100
|
params.offset = options.offset;
|
|
100
|
-
return this.ctx.request('GET', `/wallet/transactions/${id}`, params, {
|
|
101
|
+
return this.ctx.request('GET', `/wallet/transactions/${enc(id)}`, params, {
|
|
101
102
|
cache: false,
|
|
102
103
|
});
|
|
103
104
|
}
|
package/dist/cjs/api/devices.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.DevicesApi = exports.AccountNotOnDeviceError = void 0;
|
|
|
4
4
|
const client_1 = require("@oxy.so/contracts/client");
|
|
5
5
|
const logger_1 = require("../logger");
|
|
6
6
|
const errorUtils_1 = require("../utils/errorUtils");
|
|
7
|
+
const enc = encodeURIComponent;
|
|
7
8
|
/**
|
|
8
9
|
* The server's `401 account_not_on_device` for a PINNED mint: the requested
|
|
9
10
|
* `accountId` is not (or is no longer) a live account of this device session.
|
|
@@ -75,7 +76,7 @@ class DevicesApi {
|
|
|
75
76
|
* @param deviceId - The device ID to remove
|
|
76
77
|
*/
|
|
77
78
|
async remove(deviceId) {
|
|
78
|
-
await this.ctx.request('DELETE', `/devices/${deviceId}`, undefined, { cache: false });
|
|
79
|
+
await this.ctx.request('DELETE', `/devices/${enc(deviceId)}`, undefined, { cache: false });
|
|
79
80
|
}
|
|
80
81
|
/**
|
|
81
82
|
* Get device sessions for a given session ID
|
|
@@ -85,7 +86,7 @@ class DevicesApi {
|
|
|
85
86
|
*/
|
|
86
87
|
async sessions(sessionId) {
|
|
87
88
|
// Cache disabled by default to ensure fresh session data
|
|
88
|
-
return this.ctx.request('GET', `/session/device/sessions/${sessionId}`, undefined, {
|
|
89
|
+
return this.ctx.request('GET', `/session/device/sessions/${enc(sessionId)}`, undefined, {
|
|
89
90
|
cache: false, // Don't cache sessions - always get fresh data
|
|
90
91
|
deduplicate: true, // Deduplicate concurrent requests for same sessionId
|
|
91
92
|
});
|
|
@@ -103,7 +104,7 @@ class DevicesApi {
|
|
|
103
104
|
urlParams.deviceId = deviceId;
|
|
104
105
|
if (excludeCurrent)
|
|
105
106
|
urlParams.excludeCurrent = 'true';
|
|
106
|
-
return this.ctx.request('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
|
|
107
|
+
return this.ctx.request('POST', `/session/device/logout-all/${enc(sessionId)}`, urlParams, { cache: false });
|
|
107
108
|
}
|
|
108
109
|
/**
|
|
109
110
|
* Update device name
|
|
@@ -112,7 +113,7 @@ class DevicesApi {
|
|
|
112
113
|
* @returns Updated device object
|
|
113
114
|
*/
|
|
114
115
|
async rename(sessionId, deviceName) {
|
|
115
|
-
return this.ctx.request('PUT', `/session/device/name/${sessionId}`, { deviceName }, { cache: false });
|
|
116
|
+
return this.ctx.request('PUT', `/session/device/name/${enc(sessionId)}`, { deviceName }, { cache: false });
|
|
116
117
|
}
|
|
117
118
|
/**
|
|
118
119
|
* Get security information
|
package/dist/cjs/api/follows.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FollowsApi = void 0;
|
|
4
4
|
const apiUtils_1 = require("../utils/apiUtils");
|
|
5
|
+
const enc = encodeURIComponent;
|
|
5
6
|
const GRAPH_TTL = 2 * 60 * 1000;
|
|
6
7
|
const STATUS_TTL = 60 * 1000;
|
|
7
8
|
/**
|
|
@@ -16,7 +17,7 @@ class FollowsApi {
|
|
|
16
17
|
// ── People ───────────────────────────────────────────────────────────────
|
|
17
18
|
/** Follow a user. */
|
|
18
19
|
async follow(userId) {
|
|
19
|
-
const result = await this.ctx.request('POST', `/users/${userId}/follow`, undefined, { cache: false });
|
|
20
|
+
const result = await this.ctx.request('POST', `/users/${enc(userId)}/follow`, undefined, { cache: false });
|
|
20
21
|
this.invalidate([userId]);
|
|
21
22
|
return result;
|
|
22
23
|
}
|
|
@@ -30,7 +31,7 @@ class FollowsApi {
|
|
|
30
31
|
}
|
|
31
32
|
/** Unfollow a user. */
|
|
32
33
|
async unfollow(userId) {
|
|
33
|
-
const result = await this.ctx.request('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
|
|
34
|
+
const result = await this.ctx.request('DELETE', `/users/${enc(userId)}/follow`, undefined, { cache: false });
|
|
34
35
|
this.invalidate([userId]);
|
|
35
36
|
return result;
|
|
36
37
|
}
|
|
@@ -44,7 +45,7 @@ class FollowsApi {
|
|
|
44
45
|
}
|
|
45
46
|
/** Whether the signed-in user follows `userId`. Cached 1 minute, busted by every write. */
|
|
46
47
|
async status(userId) {
|
|
47
|
-
return this.ctx.request('GET', `/users/${userId}/follow-status`, undefined, {
|
|
48
|
+
return this.ctx.request('GET', `/users/${enc(userId)}/follow-status`, undefined, {
|
|
48
49
|
cache: true,
|
|
49
50
|
cacheTTL: STATUS_TTL,
|
|
50
51
|
});
|
|
@@ -74,12 +75,12 @@ class FollowsApi {
|
|
|
74
75
|
* `limit`/`offset`/`sort` combination is its own cache entry (2 minutes).
|
|
75
76
|
*/
|
|
76
77
|
async followers(userId, params) {
|
|
77
|
-
const { data, total, hasMore } = await this.userPage(`/users/${userId}/followers`, params);
|
|
78
|
+
const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/followers`, params);
|
|
78
79
|
return { followers: data, total, hasMore };
|
|
79
80
|
}
|
|
80
81
|
/** Who a user follows. `sort` as in `followers`. */
|
|
81
82
|
async following(userId, params) {
|
|
82
|
-
const { data, total, hasMore } = await this.userPage(`/users/${userId}/following`, params);
|
|
83
|
+
const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/following`, params);
|
|
83
84
|
return { following: data, total, hasMore };
|
|
84
85
|
}
|
|
85
86
|
/**
|
|
@@ -87,7 +88,7 @@ class FollowsApi {
|
|
|
87
88
|
* `userId`.
|
|
88
89
|
*/
|
|
89
90
|
async mutuals(userId, params) {
|
|
90
|
-
const { data, total, hasMore } = await this.userPage(`/users/${userId}/mutuals`, params);
|
|
91
|
+
const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/mutuals`, params);
|
|
91
92
|
return { mutuals: data, total, hasMore };
|
|
92
93
|
}
|
|
93
94
|
/**
|
|
@@ -222,16 +223,16 @@ class FollowsApi {
|
|
|
222
223
|
const keys = ['GET:/users/me/graph'];
|
|
223
224
|
const prefixes = ['GET:/profiles/username/', 'GET:/profiles/resolve'];
|
|
224
225
|
for (const id of targetUserIds) {
|
|
225
|
-
keys.push(`GET:/users/${id}/follow-status`);
|
|
226
|
+
keys.push(`GET:/users/${enc(id)}/follow-status`);
|
|
226
227
|
// Profiles embed the viewer-relative `relationship`.
|
|
227
|
-
keys.push(`GET:/users/${id}`);
|
|
228
|
+
keys.push(`GET:/users/${enc(id)}`);
|
|
228
229
|
// The target gained or lost a follower, and "followers you know" with it.
|
|
229
|
-
prefixes.push(`GET:/users/${id}/followers`, `GET:/users/${id}/mutuals`);
|
|
230
|
+
prefixes.push(`GET:/users/${enc(id)}/followers`, `GET:/users/${enc(id)}/mutuals`);
|
|
230
231
|
}
|
|
231
232
|
// The viewer's own following list changed.
|
|
232
233
|
const viewerId = this.ctx.oxy.session.userId;
|
|
233
234
|
if (viewerId)
|
|
234
|
-
prefixes.push(`GET:/users/${viewerId}/following`);
|
|
235
|
+
prefixes.push(`GET:/users/${enc(viewerId)}/following`);
|
|
235
236
|
this.ctx.http.invalidateCache({ keys, prefixes });
|
|
236
237
|
}
|
|
237
238
|
}
|
|
@@ -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/privacy.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.PrivacyApi = void 0;
|
|
4
4
|
const identityCacheSweep_1 = require("../utils/identityCacheSweep");
|
|
5
|
+
const enc = encodeURIComponent;
|
|
5
6
|
const LIST_TTL = 60 * 1000;
|
|
6
7
|
const SETTINGS_TTL = 2 * 60 * 1000;
|
|
7
8
|
const refId = (ref) => (typeof ref === 'string' ? ref : ref._id);
|
|
@@ -13,7 +14,7 @@ class PrivacyApi {
|
|
|
13
14
|
/** A user's privacy settings (default: the signed-in user's). */
|
|
14
15
|
async settings(userId) {
|
|
15
16
|
const id = await this.resolveUserId(userId);
|
|
16
|
-
return this.ctx.request('GET', `/privacy/${id}/privacy`, undefined, {
|
|
17
|
+
return this.ctx.request('GET', `/privacy/${enc(id)}/privacy`, undefined, {
|
|
17
18
|
cache: true,
|
|
18
19
|
cacheTTL: SETTINGS_TTL,
|
|
19
20
|
});
|
|
@@ -21,10 +22,10 @@ class PrivacyApi {
|
|
|
21
22
|
/** Update privacy settings (default: the signed-in user's). */
|
|
22
23
|
async updateSettings(settings, userId) {
|
|
23
24
|
const id = await this.resolveUserId(userId);
|
|
24
|
-
const res = await this.ctx.request('PATCH', `/privacy/${id}/privacy`, settings, { cache: false });
|
|
25
|
+
const res = await this.ctx.request('PATCH', `/privacy/${enc(id)}/privacy`, settings, { cache: false });
|
|
25
26
|
// Privacy settings ride the user DTO, so every identity read goes stale too.
|
|
26
27
|
(0, identityCacheSweep_1.evictOxyIdentityCache)(this.ctx.http, id);
|
|
27
|
-
this.ctx.oxy.cache.delete(`GET:/privacy/${id}/privacy`);
|
|
28
|
+
this.ctx.oxy.cache.delete(`GET:/privacy/${enc(id)}/privacy`);
|
|
28
29
|
return res;
|
|
29
30
|
}
|
|
30
31
|
// ── Blocked ──────────────────────────────────────────────────────────────
|
|
@@ -38,14 +39,14 @@ class PrivacyApi {
|
|
|
38
39
|
/** Block a user. */
|
|
39
40
|
async block(userId) {
|
|
40
41
|
requireId(userId);
|
|
41
|
-
const res = await this.ctx.request('POST', `/privacy/blocked/${userId}`, undefined, { cache: false });
|
|
42
|
+
const res = await this.ctx.request('POST', `/privacy/blocked/${enc(userId)}`, undefined, { cache: false });
|
|
42
43
|
this.invalidate('GET:/privacy/blocked');
|
|
43
44
|
return res;
|
|
44
45
|
}
|
|
45
46
|
/** Unblock a user. */
|
|
46
47
|
async unblock(userId) {
|
|
47
48
|
requireId(userId);
|
|
48
|
-
const res = await this.ctx.request('DELETE', `/privacy/blocked/${userId}`, undefined, { cache: false });
|
|
49
|
+
const res = await this.ctx.request('DELETE', `/privacy/blocked/${enc(userId)}`, undefined, { cache: false });
|
|
49
50
|
this.invalidate('GET:/privacy/blocked');
|
|
50
51
|
return res;
|
|
51
52
|
}
|
|
@@ -71,14 +72,14 @@ class PrivacyApi {
|
|
|
71
72
|
/** Restrict a user: limit their interactions without blocking them. */
|
|
72
73
|
async restrict(userId) {
|
|
73
74
|
requireId(userId);
|
|
74
|
-
const res = await this.ctx.request('POST', `/privacy/restricted/${userId}`, undefined, { cache: false });
|
|
75
|
+
const res = await this.ctx.request('POST', `/privacy/restricted/${enc(userId)}`, undefined, { cache: false });
|
|
75
76
|
this.invalidate('GET:/privacy/restricted');
|
|
76
77
|
return res;
|
|
77
78
|
}
|
|
78
79
|
/** Lift a restriction. */
|
|
79
80
|
async unrestrict(userId) {
|
|
80
81
|
requireId(userId);
|
|
81
|
-
const res = await this.ctx.request('DELETE', `/privacy/restricted/${userId}`, undefined, { cache: false });
|
|
82
|
+
const res = await this.ctx.request('DELETE', `/privacy/restricted/${enc(userId)}`, undefined, { cache: false });
|
|
82
83
|
this.invalidate('GET:/privacy/restricted');
|
|
83
84
|
return res;
|
|
84
85
|
}
|
package/dist/cjs/api/session.js
CHANGED
|
@@ -11,12 +11,22 @@ 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
|
+
const enc = encodeURIComponent;
|
|
16
|
+
/** How long a refusal (disabled, rate limited, unreachable) is remembered per installation. */
|
|
17
|
+
const EVIDENCE_REFUSAL_MS = 5000;
|
|
18
|
+
const unavailable = (reason) => ({
|
|
19
|
+
status: 'unavailable',
|
|
20
|
+
reason,
|
|
21
|
+
});
|
|
14
22
|
class SessionApi {
|
|
15
23
|
constructor(ctx) {
|
|
16
24
|
this.ctx = ctx;
|
|
17
25
|
this.deviceCredentialProvider = null;
|
|
18
26
|
this.decodedFor = null;
|
|
19
27
|
this.decoded = null;
|
|
28
|
+
/** One server round trip per installed bearer; the key is the opaque generation. */
|
|
29
|
+
this.evidence = new WeakMap();
|
|
20
30
|
}
|
|
21
31
|
// ── Local state ──────────────────────────────────────────────────────────
|
|
22
32
|
/** The current access token, or `null` when signed out. */
|
|
@@ -121,6 +131,115 @@ class SessionApi {
|
|
|
121
131
|
return null;
|
|
122
132
|
}
|
|
123
133
|
}
|
|
134
|
+
// ── Holder-context evidence (ADR 0053) ───────────────────────────────────
|
|
135
|
+
/**
|
|
136
|
+
* Ask the server which shared-device context the INSTALLED bearer belongs to,
|
|
137
|
+
* and bind the answer to that exact installation.
|
|
138
|
+
*
|
|
139
|
+
* The result is honored only if the credential generation captured before
|
|
140
|
+
* the request is still current when the answer is committed, so a refresh,
|
|
141
|
+
* setter, account or actor switch, cross-tab change, restore or A to B to A
|
|
142
|
+
* during the request yields `changed`. The answer must also agree with the
|
|
143
|
+
* claims of the bearer that was captured; a disagreement is
|
|
144
|
+
* `binding_unavailable`. Nothing here refreshes, clears or mints a credential,
|
|
145
|
+
* and no outcome is a revocation verdict: callers keep their existing checks.
|
|
146
|
+
* Concurrent callers share one request per installation.
|
|
147
|
+
*/
|
|
148
|
+
observeHolderEvidence() {
|
|
149
|
+
const generation = this.ctx.http.captureCredentialGeneration();
|
|
150
|
+
const token = this.ctx.http.getAccessToken();
|
|
151
|
+
if (!generation || !token)
|
|
152
|
+
return Promise.resolve(unavailable('no_credential'));
|
|
153
|
+
const known = this.evidence.get(generation);
|
|
154
|
+
if (known) {
|
|
155
|
+
const refused = known.settledAt !== undefined;
|
|
156
|
+
if (!refused || Date.now() - known.settledAt < EVIDENCE_REFUSAL_MS) {
|
|
157
|
+
return known.result.then((result) => result.status === 'ok' && result.evidence.expiresAtMs <= Date.now()
|
|
158
|
+
? this.refreshEvidence(generation, token)
|
|
159
|
+
: result);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return this.refreshEvidence(generation, token);
|
|
163
|
+
}
|
|
164
|
+
/** Whether evidence still names the installed bearer and has not expired. */
|
|
165
|
+
isHolderEvidenceCurrent(evidence) {
|
|
166
|
+
return (Date.now() < evidence.expiresAtMs &&
|
|
167
|
+
this.ctx.http.isCredentialGenerationCurrent(evidence.generation));
|
|
168
|
+
}
|
|
169
|
+
refreshEvidence(generation, token) {
|
|
170
|
+
const entry = {
|
|
171
|
+
result: this.acquireEvidence(generation, token).then((result) => {
|
|
172
|
+
if (result.status !== 'ok')
|
|
173
|
+
entry.settledAt = Date.now();
|
|
174
|
+
return result;
|
|
175
|
+
}),
|
|
176
|
+
};
|
|
177
|
+
this.evidence.set(generation, entry);
|
|
178
|
+
return entry.result;
|
|
179
|
+
}
|
|
180
|
+
async acquireEvidence(generation, token) {
|
|
181
|
+
let claims;
|
|
182
|
+
try {
|
|
183
|
+
claims = (0, jwt_decode_1.jwtDecode)(token);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return unavailable('unsupported');
|
|
187
|
+
}
|
|
188
|
+
const proof = await this.readDeviceProof();
|
|
189
|
+
if (!proof)
|
|
190
|
+
return unavailable('no_holder');
|
|
191
|
+
let response;
|
|
192
|
+
try {
|
|
193
|
+
response = await this.ctx.request('POST', '/session/device/context-evidence-v1', proof, {
|
|
194
|
+
cache: false,
|
|
195
|
+
retry: false,
|
|
196
|
+
expectedCredentialGeneration: generation,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error?.code === 'AUTH_SESSION_CHANGED') {
|
|
201
|
+
return unavailable('changed');
|
|
202
|
+
}
|
|
203
|
+
const status = error instanceof OxyServices_errors_1.OxyApiError ? error.status : 0;
|
|
204
|
+
if (status === 403)
|
|
205
|
+
return unavailable('unsupported');
|
|
206
|
+
if (status === 409)
|
|
207
|
+
return unavailable('binding_unavailable');
|
|
208
|
+
return unavailable('unavailable');
|
|
209
|
+
}
|
|
210
|
+
const parsed = (0, client_1.checkContract)(client_1.holderContextEvidenceV1ResponseCheck, response);
|
|
211
|
+
if (!parsed)
|
|
212
|
+
return unavailable('unavailable');
|
|
213
|
+
// Commit synchronously: nothing may replace the bearer between this check
|
|
214
|
+
// and the evidence becoming visible to its caller.
|
|
215
|
+
if (!this.ctx.http.isCredentialGenerationCurrent(generation))
|
|
216
|
+
return unavailable('changed');
|
|
217
|
+
const expiresAtMs = Date.parse(parsed.expiresAt);
|
|
218
|
+
if (parsed.deviceId !== proof.deviceId ||
|
|
219
|
+
parsed.accountId !== claims.userId ||
|
|
220
|
+
parsed.sessionId !== claims.sessionId ||
|
|
221
|
+
(claims.act?.sub !== undefined && parsed.actorId !== claims.act.sub) ||
|
|
222
|
+
(claims.device_context_id !== undefined && parsed.contextId !== claims.device_context_id)) {
|
|
223
|
+
return unavailable('binding_unavailable');
|
|
224
|
+
}
|
|
225
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
|
226
|
+
return unavailable('unavailable');
|
|
227
|
+
}
|
|
228
|
+
const evidence = Object.freeze({
|
|
229
|
+
generation,
|
|
230
|
+
identity: Object.freeze({
|
|
231
|
+
deviceId: parsed.deviceId,
|
|
232
|
+
deviceSessionId: parsed.deviceSessionId,
|
|
233
|
+
contextId: parsed.contextId,
|
|
234
|
+
actorId: parsed.actorId,
|
|
235
|
+
accountId: parsed.accountId,
|
|
236
|
+
sessionId: parsed.sessionId,
|
|
237
|
+
}),
|
|
238
|
+
deviceRevision: parsed.revision,
|
|
239
|
+
expiresAtMs,
|
|
240
|
+
});
|
|
241
|
+
return { status: 'ok', evidence };
|
|
242
|
+
}
|
|
124
243
|
// ── The server session ───────────────────────────────────────────────────
|
|
125
244
|
/** Ask the server whether the current access token is valid. */
|
|
126
245
|
async validateToken() {
|
|
@@ -175,7 +294,7 @@ class SessionApi {
|
|
|
175
294
|
: {}),
|
|
176
295
|
},
|
|
177
296
|
})
|
|
178
|
-
: await this.ctx.request('GET', `/session/validate/${sessionId}`, {
|
|
297
|
+
: await this.ctx.request('GET', `/session/validate/${enc(sessionId)}`, {
|
|
179
298
|
...(options.deviceFingerprint
|
|
180
299
|
? { deviceFingerprint: options.deviceFingerprint }
|
|
181
300
|
: {}),
|
|
@@ -202,7 +321,7 @@ class SessionApi {
|
|
|
202
321
|
const invalid = candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
|
|
203
322
|
const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
|
|
204
323
|
if (invalid)
|
|
205
|
-
this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
|
|
324
|
+
this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
|
|
206
325
|
if (invalid || denied)
|
|
207
326
|
throw error;
|
|
208
327
|
throw new OxyServices_errors_1.OxyApiError('Live session authority is unavailable', {
|
|
@@ -212,26 +331,28 @@ class SessionApi {
|
|
|
212
331
|
});
|
|
213
332
|
}
|
|
214
333
|
// The session is gone: drop any user cached for it (#196).
|
|
215
|
-
this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
|
|
334
|
+
this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
|
|
216
335
|
throw error;
|
|
217
336
|
}
|
|
218
337
|
}
|
|
219
338
|
/** The sessions that belong with `sessionId` (same account set). */
|
|
220
339
|
async list(sessionId) {
|
|
221
|
-
return this.ctx.request('GET', `/session/sessions/${sessionId}`, undefined, {
|
|
340
|
+
return this.ctx.request('GET', `/session/sessions/${enc(sessionId)}`, undefined, {
|
|
222
341
|
cache: false,
|
|
223
342
|
});
|
|
224
343
|
}
|
|
225
344
|
/** Sign out `sessionId`, or — with `targetSessionId` — one of the sessions beside it. */
|
|
226
345
|
async logout(sessionId, targetSessionId) {
|
|
227
346
|
const url = targetSessionId
|
|
228
|
-
? `/session/logout/${sessionId}/${targetSessionId}`
|
|
229
|
-
: `/session/logout/${sessionId}`;
|
|
347
|
+
? `/session/logout/${enc(sessionId)}/${enc(targetSessionId)}`
|
|
348
|
+
: `/session/logout/${enc(sessionId)}`;
|
|
230
349
|
await this.ctx.request('POST', url, undefined, { cache: false });
|
|
231
350
|
}
|
|
232
351
|
/** Sign out the other sessions for this account; preserve `sessionId`. */
|
|
233
352
|
async logoutAll(sessionId) {
|
|
234
|
-
await this.ctx.request('POST', `/session/logout-all/${sessionId}`, undefined, {
|
|
353
|
+
await this.ctx.request('POST', `/session/logout-all/${enc(sessionId)}`, undefined, {
|
|
354
|
+
cache: false,
|
|
355
|
+
});
|
|
235
356
|
}
|
|
236
357
|
/** Decoded claims of the current token, memoised per token string. */
|
|
237
358
|
claims() {
|
package/dist/cjs/api/topics.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.TopicsApi = void 0;
|
|
4
|
+
const enc = encodeURIComponent;
|
|
4
5
|
const SHORT = 60 * 1000;
|
|
5
6
|
const LONG = 5 * 60 * 1000;
|
|
6
7
|
const EXTRA_LONG = 30 * 60 * 1000;
|
|
@@ -44,7 +45,7 @@ class TopicsApi {
|
|
|
44
45
|
}
|
|
45
46
|
/** One topic by slug. */
|
|
46
47
|
async get(slug) {
|
|
47
|
-
return this.ctx.request('GET', `/topics/${slug}`, undefined, {
|
|
48
|
+
return this.ctx.request('GET', `/topics/${enc(slug)}`, undefined, {
|
|
48
49
|
cache: true,
|
|
49
50
|
cacheTTL: LONG,
|
|
50
51
|
});
|
|
@@ -63,10 +64,10 @@ class TopicsApi {
|
|
|
63
64
|
}
|
|
64
65
|
/** Update a topic's description and translations. Staff or service only. */
|
|
65
66
|
async update(slug, data) {
|
|
66
|
-
const topic = await this.ctx.request('PATCH', `/topics/${slug}`, data, {
|
|
67
|
+
const topic = await this.ctx.request('PATCH', `/topics/${enc(slug)}`, data, {
|
|
67
68
|
cache: false,
|
|
68
69
|
});
|
|
69
|
-
this.ctx.oxy.cache.delete(`GET:/topics/${slug}`);
|
|
70
|
+
this.ctx.oxy.cache.delete(`GET:/topics/${enc(slug)}`);
|
|
70
71
|
return topic;
|
|
71
72
|
}
|
|
72
73
|
}
|