@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/src/api/follows.ts
CHANGED
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
type FollowGraphParams,
|
|
29
29
|
} from '../utils/apiUtils';
|
|
30
30
|
|
|
31
|
+
const enc = encodeURIComponent;
|
|
32
|
+
|
|
31
33
|
const GRAPH_TTL = 2 * 60 * 1000;
|
|
32
34
|
const STATUS_TTL = 60 * 1000;
|
|
33
35
|
|
|
@@ -130,7 +132,7 @@ export class FollowsApi {
|
|
|
130
132
|
async follow(userId: string): Promise<FollowMutationResult> {
|
|
131
133
|
const result = await this.ctx.request<FollowMutationResult>(
|
|
132
134
|
'POST',
|
|
133
|
-
`/users/${userId}/follow`,
|
|
135
|
+
`/users/${enc(userId)}/follow`,
|
|
134
136
|
undefined,
|
|
135
137
|
{ cache: false },
|
|
136
138
|
);
|
|
@@ -155,7 +157,7 @@ export class FollowsApi {
|
|
|
155
157
|
async unfollow(userId: string): Promise<FollowMutationResult> {
|
|
156
158
|
const result = await this.ctx.request<FollowMutationResult>(
|
|
157
159
|
'DELETE',
|
|
158
|
-
`/users/${userId}/follow`,
|
|
160
|
+
`/users/${enc(userId)}/follow`,
|
|
159
161
|
undefined,
|
|
160
162
|
{ cache: false },
|
|
161
163
|
);
|
|
@@ -178,7 +180,7 @@ export class FollowsApi {
|
|
|
178
180
|
|
|
179
181
|
/** Whether the signed-in user follows `userId`. Cached 1 minute, busted by every write. */
|
|
180
182
|
async status(userId: string): Promise<{ isFollowing: boolean }> {
|
|
181
|
-
return this.ctx.request('GET', `/users/${userId}/follow-status`, undefined, {
|
|
183
|
+
return this.ctx.request('GET', `/users/${enc(userId)}/follow-status`, undefined, {
|
|
182
184
|
cache: true,
|
|
183
185
|
cacheTTL: STATUS_TTL,
|
|
184
186
|
});
|
|
@@ -221,13 +223,13 @@ export class FollowsApi {
|
|
|
221
223
|
* `limit`/`offset`/`sort` combination is its own cache entry (2 minutes).
|
|
222
224
|
*/
|
|
223
225
|
async followers(userId: string, params?: FollowGraphParams): Promise<UserPage<'followers'>> {
|
|
224
|
-
const { data, total, hasMore } = await this.userPage(`/users/${userId}/followers`, params);
|
|
226
|
+
const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/followers`, params);
|
|
225
227
|
return { followers: data, total, hasMore };
|
|
226
228
|
}
|
|
227
229
|
|
|
228
230
|
/** Who a user follows. `sort` as in `followers`. */
|
|
229
231
|
async following(userId: string, params?: FollowGraphParams): Promise<UserPage<'following'>> {
|
|
230
|
-
const { data, total, hasMore } = await this.userPage(`/users/${userId}/following`, params);
|
|
232
|
+
const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/following`, params);
|
|
231
233
|
return { following: data, total, hasMore };
|
|
232
234
|
}
|
|
233
235
|
|
|
@@ -236,7 +238,7 @@ export class FollowsApi {
|
|
|
236
238
|
* `userId`.
|
|
237
239
|
*/
|
|
238
240
|
async mutuals(userId: string, params?: FollowGraphParams): Promise<UserPage<'mutuals'>> {
|
|
239
|
-
const { data, total, hasMore } = await this.userPage(`/users/${userId}/mutuals`, params);
|
|
241
|
+
const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/mutuals`, params);
|
|
240
242
|
return { mutuals: data, total, hasMore };
|
|
241
243
|
}
|
|
242
244
|
|
|
@@ -443,15 +445,15 @@ export class FollowsApi {
|
|
|
443
445
|
const keys: string[] = ['GET:/users/me/graph'];
|
|
444
446
|
const prefixes: string[] = ['GET:/profiles/username/', 'GET:/profiles/resolve'];
|
|
445
447
|
for (const id of targetUserIds) {
|
|
446
|
-
keys.push(`GET:/users/${id}/follow-status`);
|
|
448
|
+
keys.push(`GET:/users/${enc(id)}/follow-status`);
|
|
447
449
|
// Profiles embed the viewer-relative `relationship`.
|
|
448
|
-
keys.push(`GET:/users/${id}`);
|
|
450
|
+
keys.push(`GET:/users/${enc(id)}`);
|
|
449
451
|
// The target gained or lost a follower, and "followers you know" with it.
|
|
450
|
-
prefixes.push(`GET:/users/${id}/followers`, `GET:/users/${id}/mutuals`);
|
|
452
|
+
prefixes.push(`GET:/users/${enc(id)}/followers`, `GET:/users/${enc(id)}/mutuals`);
|
|
451
453
|
}
|
|
452
454
|
// The viewer's own following list changed.
|
|
453
455
|
const viewerId = this.ctx.oxy.session.userId;
|
|
454
|
-
if (viewerId) prefixes.push(`GET:/users/${viewerId}/following`);
|
|
456
|
+
if (viewerId) prefixes.push(`GET:/users/${enc(viewerId)}/following`);
|
|
455
457
|
this.ctx.http.invalidateCache({ keys, prefixes });
|
|
456
458
|
}
|
|
457
459
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holder-context evidence: the server's point-in-time observation of which
|
|
3
|
+
* shared-device context a bearer is installed in, bound to ONE installation of
|
|
4
|
+
* that bearer in this client (ADR 0053).
|
|
5
|
+
*
|
|
6
|
+
* Evidence is never authorization. It lets a caller that already captured a
|
|
7
|
+
* viewer intent prove, before a write is dispatched, that the bearer the write
|
|
8
|
+
* will carry belongs to that viewer's account and actor, and then pin the write
|
|
9
|
+
* to the exact installation with `expectedCredentialGeneration`. Anything that
|
|
10
|
+
* cannot be proven is `unavailable`, and the caller keeps its existing checks.
|
|
11
|
+
*/
|
|
12
|
+
import type { HolderContextEvidenceV1Response } from '@oxy.so/contracts';
|
|
13
|
+
import type { CredentialGeneration } from '../HttpService';
|
|
14
|
+
|
|
15
|
+
/** The identifiers the server observed; none of them is a credential. */
|
|
16
|
+
export type HolderEvidenceIdentity = Readonly<
|
|
17
|
+
Pick<
|
|
18
|
+
HolderContextEvidenceV1Response,
|
|
19
|
+
'deviceId' | 'deviceSessionId' | 'contextId' | 'actorId' | 'accountId' | 'sessionId'
|
|
20
|
+
>
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
export interface HolderEvidence {
|
|
24
|
+
/** The installation of the bearer this evidence was observed for. */
|
|
25
|
+
readonly generation: CredentialGeneration;
|
|
26
|
+
readonly identity: HolderEvidenceIdentity;
|
|
27
|
+
/** The device-session revision the server observed. */
|
|
28
|
+
readonly deviceRevision: number;
|
|
29
|
+
/** Epoch milliseconds: the earlier of the bearer and session expiry. */
|
|
30
|
+
readonly expiresAtMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Why no evidence exists. Every reason means "keep the existing checks": none of
|
|
35
|
+
* them says the session is revoked, and none may clear or refresh credentials.
|
|
36
|
+
*
|
|
37
|
+
* - `no_credential`: no bearer is installed, or it comes from a pull-only provider.
|
|
38
|
+
* - `no_holder`: this client holds no device credential to prove the holder with.
|
|
39
|
+
* - `changed`: the bearer was replaced while the evidence was being acquired.
|
|
40
|
+
* - `unsupported`: a delegated, OAuth or legacy bearer, or the endpoint is not offered.
|
|
41
|
+
* - `binding_unavailable`: the server could not match holder, context and bearer.
|
|
42
|
+
* - `unavailable`: the endpoint is off, rate limited, unreachable or answered wrongly.
|
|
43
|
+
*/
|
|
44
|
+
export type HolderEvidenceUnavailableReason =
|
|
45
|
+
| 'no_credential'
|
|
46
|
+
| 'no_holder'
|
|
47
|
+
| 'changed'
|
|
48
|
+
| 'unsupported'
|
|
49
|
+
| 'binding_unavailable'
|
|
50
|
+
| 'unavailable';
|
|
51
|
+
|
|
52
|
+
export type HolderEvidenceResult =
|
|
53
|
+
| { readonly status: 'ok'; readonly evidence: HolderEvidence }
|
|
54
|
+
| { readonly status: 'unavailable'; readonly reason: HolderEvidenceUnavailableReason };
|
|
55
|
+
|
|
56
|
+
/** The one comparison that decides whether two observations name the same viewer context. */
|
|
57
|
+
export function sameHolderIdentity(a: HolderEvidenceIdentity, b: HolderEvidenceIdentity): boolean {
|
|
58
|
+
return (
|
|
59
|
+
a.deviceId === b.deviceId &&
|
|
60
|
+
a.deviceSessionId === b.deviceSessionId &&
|
|
61
|
+
a.contextId === b.contextId &&
|
|
62
|
+
a.actorId === b.actorId &&
|
|
63
|
+
a.accountId === b.accountId &&
|
|
64
|
+
a.sessionId === b.sessionId
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/api/privacy.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { BlockedUser, PrivacySettings, RestrictedUser } from '../models/int
|
|
|
5
5
|
import type { OxyContext } from '../client/context';
|
|
6
6
|
import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
|
|
7
7
|
|
|
8
|
+
const enc = encodeURIComponent;
|
|
9
|
+
|
|
8
10
|
const LIST_TTL = 60 * 1000;
|
|
9
11
|
const SETTINGS_TTL = 2 * 60 * 1000;
|
|
10
12
|
|
|
@@ -20,7 +22,7 @@ export class PrivacyApi {
|
|
|
20
22
|
/** A user's privacy settings (default: the signed-in user's). */
|
|
21
23
|
async settings(userId?: string): Promise<PrivacySettings> {
|
|
22
24
|
const id = await this.resolveUserId(userId);
|
|
23
|
-
return this.ctx.request<PrivacySettings>('GET', `/privacy/${id}/privacy`, undefined, {
|
|
25
|
+
return this.ctx.request<PrivacySettings>('GET', `/privacy/${enc(id)}/privacy`, undefined, {
|
|
24
26
|
cache: true,
|
|
25
27
|
cacheTTL: SETTINGS_TTL,
|
|
26
28
|
});
|
|
@@ -34,13 +36,13 @@ export class PrivacyApi {
|
|
|
34
36
|
const id = await this.resolveUserId(userId);
|
|
35
37
|
const res = await this.ctx.request<PrivacySettings>(
|
|
36
38
|
'PATCH',
|
|
37
|
-
`/privacy/${id}/privacy`,
|
|
39
|
+
`/privacy/${enc(id)}/privacy`,
|
|
38
40
|
settings,
|
|
39
41
|
{ cache: false },
|
|
40
42
|
);
|
|
41
43
|
// Privacy settings ride the user DTO, so every identity read goes stale too.
|
|
42
44
|
evictOxyIdentityCache(this.ctx.http, id);
|
|
43
|
-
this.ctx.oxy.cache.delete(`GET:/privacy/${id}/privacy`);
|
|
45
|
+
this.ctx.oxy.cache.delete(`GET:/privacy/${enc(id)}/privacy`);
|
|
44
46
|
return res;
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -59,7 +61,7 @@ export class PrivacyApi {
|
|
|
59
61
|
requireId(userId);
|
|
60
62
|
const res = await this.ctx.request<{ message: string }>(
|
|
61
63
|
'POST',
|
|
62
|
-
`/privacy/blocked/${userId}`,
|
|
64
|
+
`/privacy/blocked/${enc(userId)}`,
|
|
63
65
|
undefined,
|
|
64
66
|
{ cache: false },
|
|
65
67
|
);
|
|
@@ -72,7 +74,7 @@ export class PrivacyApi {
|
|
|
72
74
|
requireId(userId);
|
|
73
75
|
const res = await this.ctx.request<{ message: string }>(
|
|
74
76
|
'DELETE',
|
|
75
|
-
`/privacy/blocked/${userId}`,
|
|
77
|
+
`/privacy/blocked/${enc(userId)}`,
|
|
76
78
|
undefined,
|
|
77
79
|
{ cache: false },
|
|
78
80
|
);
|
|
@@ -105,7 +107,7 @@ export class PrivacyApi {
|
|
|
105
107
|
requireId(userId);
|
|
106
108
|
const res = await this.ctx.request<{ message: string }>(
|
|
107
109
|
'POST',
|
|
108
|
-
`/privacy/restricted/${userId}`,
|
|
110
|
+
`/privacy/restricted/${enc(userId)}`,
|
|
109
111
|
undefined,
|
|
110
112
|
{ cache: false },
|
|
111
113
|
);
|
|
@@ -118,7 +120,7 @@ export class PrivacyApi {
|
|
|
118
120
|
requireId(userId);
|
|
119
121
|
const res = await this.ctx.request<{ message: string }>(
|
|
120
122
|
'DELETE',
|
|
121
|
-
`/privacy/restricted/${userId}`,
|
|
123
|
+
`/privacy/restricted/${enc(userId)}`,
|
|
122
124
|
undefined,
|
|
123
125
|
{ cache: false },
|
|
124
126
|
);
|
package/src/api/session.ts
CHANGED
|
@@ -12,6 +12,22 @@ import type { ClientSession } from '../models/session';
|
|
|
12
12
|
import type { User } from '../models/interfaces';
|
|
13
13
|
import { normalizeUserIdentity } from '../utils/userIdentity';
|
|
14
14
|
import { OxyApiError } from '../OxyServices.errors';
|
|
15
|
+
import { checkContract, holderContextEvidenceV1ResponseCheck } from '@oxy.so/contracts/client';
|
|
16
|
+
import type { CredentialGeneration } from '../HttpService';
|
|
17
|
+
import type {
|
|
18
|
+
HolderEvidence,
|
|
19
|
+
HolderEvidenceResult,
|
|
20
|
+
HolderEvidenceUnavailableReason,
|
|
21
|
+
} from './holderEvidence';
|
|
22
|
+
|
|
23
|
+
const enc = encodeURIComponent;
|
|
24
|
+
|
|
25
|
+
/** How long a refusal (disabled, rate limited, unreachable) is remembered per installation. */
|
|
26
|
+
const EVIDENCE_REFUSAL_MS = 5_000;
|
|
27
|
+
const unavailable = (reason: HolderEvidenceUnavailableReason): HolderEvidenceResult => ({
|
|
28
|
+
status: 'unavailable',
|
|
29
|
+
reason,
|
|
30
|
+
});
|
|
15
31
|
|
|
16
32
|
/**
|
|
17
33
|
* Reads the device credential this client holds, if any — `{ deviceId,
|
|
@@ -42,12 +58,21 @@ interface AccessTokenClaims {
|
|
|
42
58
|
exp?: number;
|
|
43
59
|
userId?: string;
|
|
44
60
|
id?: string;
|
|
61
|
+
sessionId?: string;
|
|
62
|
+
deviceId?: string;
|
|
63
|
+
device_context_id?: string;
|
|
64
|
+
act?: { sub?: string };
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
export class SessionApi {
|
|
48
68
|
private deviceCredentialProvider: DeviceCredentialProvider | null = null;
|
|
49
69
|
private decodedFor: string | null = null;
|
|
50
70
|
private decoded: AccessTokenClaims | null = null;
|
|
71
|
+
/** One server round trip per installed bearer; the key is the opaque generation. */
|
|
72
|
+
private readonly evidence = new WeakMap<
|
|
73
|
+
CredentialGeneration,
|
|
74
|
+
{ result: Promise<HolderEvidenceResult>; settledAt?: number }
|
|
75
|
+
>();
|
|
51
76
|
|
|
52
77
|
constructor(protected readonly ctx: OxyContext) {}
|
|
53
78
|
|
|
@@ -163,6 +188,128 @@ export class SessionApi {
|
|
|
163
188
|
}
|
|
164
189
|
}
|
|
165
190
|
|
|
191
|
+
// ── Holder-context evidence (ADR 0053) ───────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Ask the server which shared-device context the INSTALLED bearer belongs to,
|
|
195
|
+
* and bind the answer to that exact installation.
|
|
196
|
+
*
|
|
197
|
+
* The result is honored only if the credential generation captured before
|
|
198
|
+
* the request is still current when the answer is committed, so a refresh,
|
|
199
|
+
* setter, account or actor switch, cross-tab change, restore or A to B to A
|
|
200
|
+
* during the request yields `changed`. The answer must also agree with the
|
|
201
|
+
* claims of the bearer that was captured; a disagreement is
|
|
202
|
+
* `binding_unavailable`. Nothing here refreshes, clears or mints a credential,
|
|
203
|
+
* and no outcome is a revocation verdict: callers keep their existing checks.
|
|
204
|
+
* Concurrent callers share one request per installation.
|
|
205
|
+
*/
|
|
206
|
+
observeHolderEvidence(): Promise<HolderEvidenceResult> {
|
|
207
|
+
const generation = this.ctx.http.captureCredentialGeneration();
|
|
208
|
+
const token = this.ctx.http.getAccessToken();
|
|
209
|
+
if (!generation || !token) return Promise.resolve(unavailable('no_credential'));
|
|
210
|
+
const known = this.evidence.get(generation);
|
|
211
|
+
if (known) {
|
|
212
|
+
const refused = known.settledAt !== undefined;
|
|
213
|
+
if (!refused || Date.now() - (known.settledAt as number) < EVIDENCE_REFUSAL_MS) {
|
|
214
|
+
return known.result.then((result) =>
|
|
215
|
+
result.status === 'ok' && result.evidence.expiresAtMs <= Date.now()
|
|
216
|
+
? this.refreshEvidence(generation, token)
|
|
217
|
+
: result,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return this.refreshEvidence(generation, token);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether evidence still names the installed bearer and has not expired. */
|
|
225
|
+
isHolderEvidenceCurrent(evidence: HolderEvidence): boolean {
|
|
226
|
+
return (
|
|
227
|
+
Date.now() < evidence.expiresAtMs &&
|
|
228
|
+
this.ctx.http.isCredentialGenerationCurrent(evidence.generation)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private refreshEvidence(
|
|
233
|
+
generation: CredentialGeneration,
|
|
234
|
+
token: string,
|
|
235
|
+
): Promise<HolderEvidenceResult> {
|
|
236
|
+
const entry: { result: Promise<HolderEvidenceResult>; settledAt?: number } = {
|
|
237
|
+
result: this.acquireEvidence(generation, token).then((result) => {
|
|
238
|
+
if (result.status !== 'ok') entry.settledAt = Date.now();
|
|
239
|
+
return result;
|
|
240
|
+
}),
|
|
241
|
+
};
|
|
242
|
+
this.evidence.set(generation, entry);
|
|
243
|
+
return entry.result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async acquireEvidence(
|
|
247
|
+
generation: CredentialGeneration,
|
|
248
|
+
token: string,
|
|
249
|
+
): Promise<HolderEvidenceResult> {
|
|
250
|
+
let claims: AccessTokenClaims;
|
|
251
|
+
try {
|
|
252
|
+
claims = jwtDecode<AccessTokenClaims>(token);
|
|
253
|
+
} catch {
|
|
254
|
+
return unavailable('unsupported');
|
|
255
|
+
}
|
|
256
|
+
const proof = await this.readDeviceProof();
|
|
257
|
+
if (!proof) return unavailable('no_holder');
|
|
258
|
+
let response: unknown;
|
|
259
|
+
try {
|
|
260
|
+
response = await this.ctx.request<unknown>(
|
|
261
|
+
'POST',
|
|
262
|
+
'/session/device/context-evidence-v1',
|
|
263
|
+
proof,
|
|
264
|
+
{
|
|
265
|
+
cache: false,
|
|
266
|
+
retry: false,
|
|
267
|
+
expectedCredentialGeneration: generation,
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if ((error as { code?: unknown } | null)?.code === 'AUTH_SESSION_CHANGED') {
|
|
272
|
+
return unavailable('changed');
|
|
273
|
+
}
|
|
274
|
+
const status = error instanceof OxyApiError ? error.status : 0;
|
|
275
|
+
if (status === 403) return unavailable('unsupported');
|
|
276
|
+
if (status === 409) return unavailable('binding_unavailable');
|
|
277
|
+
return unavailable('unavailable');
|
|
278
|
+
}
|
|
279
|
+
const parsed = checkContract(holderContextEvidenceV1ResponseCheck, response);
|
|
280
|
+
if (!parsed) return unavailable('unavailable');
|
|
281
|
+
// Commit synchronously: nothing may replace the bearer between this check
|
|
282
|
+
// and the evidence becoming visible to its caller.
|
|
283
|
+
if (!this.ctx.http.isCredentialGenerationCurrent(generation)) return unavailable('changed');
|
|
284
|
+
const expiresAtMs = Date.parse(parsed.expiresAt);
|
|
285
|
+
if (
|
|
286
|
+
parsed.deviceId !== proof.deviceId ||
|
|
287
|
+
parsed.accountId !== claims.userId ||
|
|
288
|
+
parsed.sessionId !== claims.sessionId ||
|
|
289
|
+
(claims.act?.sub !== undefined && parsed.actorId !== claims.act.sub) ||
|
|
290
|
+
(claims.device_context_id !== undefined && parsed.contextId !== claims.device_context_id)
|
|
291
|
+
) {
|
|
292
|
+
return unavailable('binding_unavailable');
|
|
293
|
+
}
|
|
294
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
|
295
|
+
return unavailable('unavailable');
|
|
296
|
+
}
|
|
297
|
+
const evidence: HolderEvidence = Object.freeze({
|
|
298
|
+
generation,
|
|
299
|
+
identity: Object.freeze({
|
|
300
|
+
deviceId: parsed.deviceId,
|
|
301
|
+
deviceSessionId: parsed.deviceSessionId,
|
|
302
|
+
contextId: parsed.contextId,
|
|
303
|
+
actorId: parsed.actorId,
|
|
304
|
+
accountId: parsed.accountId,
|
|
305
|
+
sessionId: parsed.sessionId,
|
|
306
|
+
}),
|
|
307
|
+
deviceRevision: parsed.revision,
|
|
308
|
+
expiresAtMs,
|
|
309
|
+
});
|
|
310
|
+
return { status: 'ok', evidence };
|
|
311
|
+
}
|
|
312
|
+
|
|
166
313
|
// ── The server session ───────────────────────────────────────────────────
|
|
167
314
|
|
|
168
315
|
/** Ask the server whether the current access token is valid. */
|
|
@@ -231,7 +378,7 @@ export class SessionApi {
|
|
|
231
378
|
)
|
|
232
379
|
: await this.ctx.request<SessionValidation>(
|
|
233
380
|
'GET',
|
|
234
|
-
`/session/validate/${sessionId}`,
|
|
381
|
+
`/session/validate/${enc(sessionId)}`,
|
|
235
382
|
{
|
|
236
383
|
...(options.deviceFingerprint
|
|
237
384
|
? { deviceFingerprint: options.deviceFingerprint }
|
|
@@ -262,7 +409,7 @@ export class SessionApi {
|
|
|
262
409
|
const invalid =
|
|
263
410
|
candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
|
|
264
411
|
const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
|
|
265
|
-
if (invalid) this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
|
|
412
|
+
if (invalid) this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
|
|
266
413
|
if (invalid || denied) throw error;
|
|
267
414
|
throw new OxyApiError('Live session authority is unavailable', {
|
|
268
415
|
status: 503,
|
|
@@ -271,29 +418,36 @@ export class SessionApi {
|
|
|
271
418
|
});
|
|
272
419
|
}
|
|
273
420
|
// The session is gone: drop any user cached for it (#196).
|
|
274
|
-
this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
|
|
421
|
+
this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
|
|
275
422
|
throw error;
|
|
276
423
|
}
|
|
277
424
|
}
|
|
278
425
|
|
|
279
426
|
/** The sessions that belong with `sessionId` (same account set). */
|
|
280
427
|
async list(sessionId: string): Promise<ClientSession[]> {
|
|
281
|
-
return this.ctx.request<ClientSession[]>(
|
|
282
|
-
|
|
283
|
-
|
|
428
|
+
return this.ctx.request<ClientSession[]>(
|
|
429
|
+
'GET',
|
|
430
|
+
`/session/sessions/${enc(sessionId)}`,
|
|
431
|
+
undefined,
|
|
432
|
+
{
|
|
433
|
+
cache: false,
|
|
434
|
+
},
|
|
435
|
+
);
|
|
284
436
|
}
|
|
285
437
|
|
|
286
438
|
/** Sign out `sessionId`, or — with `targetSessionId` — one of the sessions beside it. */
|
|
287
439
|
async logout(sessionId: string, targetSessionId?: string): Promise<void> {
|
|
288
440
|
const url = targetSessionId
|
|
289
|
-
? `/session/logout/${sessionId}/${targetSessionId}`
|
|
290
|
-
: `/session/logout/${sessionId}`;
|
|
441
|
+
? `/session/logout/${enc(sessionId)}/${enc(targetSessionId)}`
|
|
442
|
+
: `/session/logout/${enc(sessionId)}`;
|
|
291
443
|
await this.ctx.request('POST', url, undefined, { cache: false });
|
|
292
444
|
}
|
|
293
445
|
|
|
294
446
|
/** Sign out the other sessions for this account; preserve `sessionId`. */
|
|
295
447
|
async logoutAll(sessionId: string): Promise<void> {
|
|
296
|
-
await this.ctx.request('POST', `/session/logout-all/${sessionId}`, undefined, {
|
|
448
|
+
await this.ctx.request('POST', `/session/logout-all/${enc(sessionId)}`, undefined, {
|
|
449
|
+
cache: false,
|
|
450
|
+
});
|
|
297
451
|
}
|
|
298
452
|
|
|
299
453
|
/** Decoded claims of the current token, memoised per token string. */
|
package/src/api/topics.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import type { OxyContext } from '../client/context';
|
|
5
5
|
import type { TopicData, TopicListResult, TopicTranslation } from '../models/Topic';
|
|
6
6
|
|
|
7
|
+
const enc = encodeURIComponent;
|
|
8
|
+
|
|
7
9
|
const SHORT = 60 * 1000;
|
|
8
10
|
const LONG = 5 * 60 * 1000;
|
|
9
11
|
const EXTRA_LONG = 30 * 60 * 1000;
|
|
@@ -51,7 +53,7 @@ export class TopicsApi {
|
|
|
51
53
|
|
|
52
54
|
/** One topic by slug. */
|
|
53
55
|
async get(slug: string): Promise<TopicData> {
|
|
54
|
-
return this.ctx.request<TopicData>('GET', `/topics/${slug}`, undefined, {
|
|
56
|
+
return this.ctx.request<TopicData>('GET', `/topics/${enc(slug)}`, undefined, {
|
|
55
57
|
cache: true,
|
|
56
58
|
cacheTTL: LONG,
|
|
57
59
|
});
|
|
@@ -86,10 +88,10 @@ export class TopicsApi {
|
|
|
86
88
|
slug: string,
|
|
87
89
|
data: { description?: string; translations?: Record<string, TopicTranslation> },
|
|
88
90
|
): Promise<TopicData> {
|
|
89
|
-
const topic = await this.ctx.request<TopicData>('PATCH', `/topics/${slug}`, data, {
|
|
91
|
+
const topic = await this.ctx.request<TopicData>('PATCH', `/topics/${enc(slug)}`, data, {
|
|
90
92
|
cache: false,
|
|
91
93
|
});
|
|
92
|
-
this.ctx.oxy.cache.delete(`GET:/topics/${slug}`);
|
|
94
|
+
this.ctx.oxy.cache.delete(`GET:/topics/${enc(slug)}`);
|
|
93
95
|
return topic;
|
|
94
96
|
}
|
|
95
97
|
}
|
package/src/api/users.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { extractErrorStatus } from '../utils/errorUtils';
|
|
|
22
22
|
import { type BatchLookup, runBatchLookup } from '../utils/batchLookup';
|
|
23
23
|
import { logger } from '../logger';
|
|
24
24
|
|
|
25
|
+
const enc = encodeURIComponent;
|
|
26
|
+
|
|
25
27
|
const PROFILE_TTL = 5 * 60 * 1000;
|
|
26
28
|
const SESSION_USER_TTL = 2 * 60 * 1000;
|
|
27
29
|
const ME_TTL = 60 * 1000;
|
|
@@ -65,7 +67,7 @@ export class UsersApi {
|
|
|
65
67
|
* registry-fresh read that neither serves nor overwrites a cached entry.
|
|
66
68
|
*/
|
|
67
69
|
async get(userId: string, options?: { cache?: boolean }): Promise<User> {
|
|
68
|
-
const user = await this.ctx.request<User>('GET', `/users/${userId}`, undefined, {
|
|
70
|
+
const user = await this.ctx.request<User>('GET', `/users/${enc(userId)}`, undefined, {
|
|
69
71
|
cache: options?.cache ?? true,
|
|
70
72
|
cacheTTL: PROFILE_TTL,
|
|
71
73
|
});
|
|
@@ -166,7 +168,7 @@ export class UsersApi {
|
|
|
166
168
|
|
|
167
169
|
/** The user a session belongs to. */
|
|
168
170
|
async bySession(sessionId: string): Promise<User> {
|
|
169
|
-
const user = await this.ctx.request<User>('GET', `/session/user/${sessionId}`, undefined, {
|
|
171
|
+
const user = await this.ctx.request<User>('GET', `/session/user/${enc(sessionId)}`, undefined, {
|
|
170
172
|
cache: true,
|
|
171
173
|
cacheTTL: SESSION_USER_TTL,
|
|
172
174
|
});
|
|
@@ -271,7 +273,7 @@ export class UsersApi {
|
|
|
271
273
|
: (limitOrPagination ?? {});
|
|
272
274
|
const users = await this.ctx.request<User[]>(
|
|
273
275
|
'GET',
|
|
274
|
-
`/profiles/${userId}/similar`,
|
|
276
|
+
`/profiles/${enc(userId)}/similar`,
|
|
275
277
|
buildQueryParams(pagination),
|
|
276
278
|
{
|
|
277
279
|
cache: true,
|
|
@@ -289,7 +291,7 @@ export class UsersApi {
|
|
|
289
291
|
async moderators(communityId: string): Promise<User[]> {
|
|
290
292
|
const moderators = await this.ctx.request<User[]>(
|
|
291
293
|
'GET',
|
|
292
|
-
`/users/${communityId}/moderators`,
|
|
294
|
+
`/users/${enc(communityId)}/moderators`,
|
|
293
295
|
undefined,
|
|
294
296
|
{ cache: true, cacheTTL: PROFILE_TTL },
|
|
295
297
|
);
|