@oxyhq/core 14.0.0 → 15.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/recoveryPhrase.js +32 -66
- package/dist/cjs/index.js +7 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +76 -0
- package/dist/cjs/mixins/OxyServices.reputation.js +47 -2
- package/dist/cjs/mixins/OxyServices.user.js +3 -4
- package/dist/cjs/session/accountProjection.js +4 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/recoveryPhrase.js +32 -33
- package/dist/esm/index.js +7 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +77 -1
- package/dist/esm/mixins/OxyServices.reputation.js +47 -2
- package/dist/esm/mixins/OxyServices.user.js +3 -4
- package/dist/esm/session/accountProjection.js +4 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/recoveryPhrase.d.ts +6 -0
- package/dist/types/index.d.ts +0 -1
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +61 -1
- package/dist/types/mixins/OxyServices.identityBackup.d.ts +1 -1
- package/dist/types/mixins/OxyServices.reputation.d.ts +43 -276
- package/dist/types/mixins/OxyServices.user.d.ts +4 -1
- package/dist/types/models/interfaces.d.ts +6 -0
- package/package.json +3 -3
- package/src/crypto/__tests__/keyManager.test.ts +3 -2
- package/src/crypto/recoveryPhrase.ts +33 -34
- package/src/index.ts +5 -24
- package/src/mixins/OxyServices.deviceBoot.ts +85 -0
- package/src/mixins/OxyServices.identityBackup.ts +1 -1
- package/src/mixins/OxyServices.reputation.ts +88 -326
- package/src/mixins/OxyServices.user.ts +7 -3
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +59 -2
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +135 -0
- package/src/mixins/__tests__/followGraphPagination.test.ts +21 -0
- package/src/mixins/__tests__/reputation.test.ts +115 -1
- package/src/models/interfaces.ts +2 -0
- package/src/session/accountProjection.ts +5 -1
- package/src/types/bip39.d.ts +0 -32
|
@@ -5,8 +5,15 @@
|
|
|
5
5
|
* for backing up and restoring user identities.
|
|
6
6
|
*
|
|
7
7
|
* Note: This module requires the polyfill to be loaded first (done via crypto/index.ts)
|
|
8
|
+
*
|
|
9
|
+
* Oxy recovery phrases are English-only, so the English wordlist is imported by
|
|
10
|
+
* its own subpath. Never reach for a package that exposes its wordlists through
|
|
11
|
+
* a barrel: `bip39`'s `_wordlists` hard-requires all ten languages, which put
|
|
12
|
+
* ~265 KB of unreachable wordlists into the initial chunk of every consuming
|
|
13
|
+
* app. Adding another language means one more subpath import, ideally lazy.
|
|
8
14
|
*/
|
|
9
|
-
import
|
|
15
|
+
import { generateMnemonic, mnemonicToSeed, validateMnemonic } from '@scure/bip39';
|
|
16
|
+
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
10
17
|
import { KeyManager } from './keyManager.js';
|
|
11
18
|
import { hkdfSha256 } from './kdf.js';
|
|
12
19
|
/**
|
|
@@ -48,13 +55,12 @@ export class RecoveryPhraseService {
|
|
|
48
55
|
*/
|
|
49
56
|
static async generateIdentityWithRecovery(options) {
|
|
50
57
|
// Generate 128-bit entropy for 12-word mnemonic
|
|
51
|
-
const mnemonic =
|
|
58
|
+
const mnemonic = generateMnemonic(wordlist, 128);
|
|
52
59
|
// Derive private key from mnemonic
|
|
53
60
|
// Using the seed directly as the private key (simplified approach)
|
|
54
|
-
const seed = await
|
|
61
|
+
const seed = await mnemonicToSeed(mnemonic);
|
|
55
62
|
// Use first 32 bytes of seed as private key
|
|
56
|
-
const
|
|
57
|
-
const privateKeyHex = toHex(seedSlice);
|
|
63
|
+
const privateKeyHex = toHex(seed.subarray(0, 32));
|
|
58
64
|
// Import the derived key pair. KeyManager.importKeyPair will refuse to
|
|
59
65
|
// clobber an existing identity unless overwrite is explicitly requested.
|
|
60
66
|
const publicKey = await KeyManager.importKeyPair(privateKeyHex, {
|
|
@@ -73,10 +79,9 @@ export class RecoveryPhraseService {
|
|
|
73
79
|
*/
|
|
74
80
|
static async generateIdentityWithRecovery24(options) {
|
|
75
81
|
// Generate 256-bit entropy for 24-word mnemonic
|
|
76
|
-
const mnemonic =
|
|
77
|
-
const seed = await
|
|
78
|
-
const
|
|
79
|
-
const privateKeyHex = toHex(seedSlice);
|
|
82
|
+
const mnemonic = generateMnemonic(wordlist, 256);
|
|
83
|
+
const seed = await mnemonicToSeed(mnemonic);
|
|
84
|
+
const privateKeyHex = toHex(seed.subarray(0, 32));
|
|
80
85
|
const publicKey = await KeyManager.importKeyPair(privateKeyHex, {
|
|
81
86
|
overwrite: options?.overwrite === true,
|
|
82
87
|
});
|
|
@@ -100,10 +105,9 @@ export class RecoveryPhraseService {
|
|
|
100
105
|
* committed anywhere — if it is lost the account becomes unrecoverable.
|
|
101
106
|
*/
|
|
102
107
|
static async derivePendingIdentity() {
|
|
103
|
-
const mnemonic =
|
|
104
|
-
const seed = await
|
|
105
|
-
const
|
|
106
|
-
const privateKey = toHex(seedSlice);
|
|
108
|
+
const mnemonic = generateMnemonic(wordlist, 128);
|
|
109
|
+
const seed = await mnemonicToSeed(mnemonic);
|
|
110
|
+
const privateKey = toHex(seed.subarray(0, 32));
|
|
107
111
|
const publicKey = KeyManager.derivePublicKey(privateKey);
|
|
108
112
|
return {
|
|
109
113
|
phrase: mnemonic,
|
|
@@ -122,12 +126,11 @@ export class RecoveryPhraseService {
|
|
|
122
126
|
*/
|
|
123
127
|
static async derivePrivateKeyFromPhrase(phrase) {
|
|
124
128
|
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
125
|
-
if (!
|
|
129
|
+
if (!validateMnemonic(normalizedPhrase, wordlist)) {
|
|
126
130
|
throw new Error('Invalid recovery phrase');
|
|
127
131
|
}
|
|
128
|
-
const seed = await
|
|
129
|
-
|
|
130
|
-
return toHex(seedSlice);
|
|
132
|
+
const seed = await mnemonicToSeed(normalizedPhrase);
|
|
133
|
+
return toHex(seed.subarray(0, 32));
|
|
131
134
|
}
|
|
132
135
|
/**
|
|
133
136
|
* Derive the encrypted-backup key material from a recovery phrase (b3 Feature
|
|
@@ -148,10 +151,10 @@ export class RecoveryPhraseService {
|
|
|
148
151
|
*/
|
|
149
152
|
static async deriveBackupMaterial(phrase) {
|
|
150
153
|
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
151
|
-
if (!
|
|
154
|
+
if (!validateMnemonic(normalizedPhrase, wordlist)) {
|
|
152
155
|
throw new Error('Invalid recovery phrase. Please check the words and try again.');
|
|
153
156
|
}
|
|
154
|
-
const seed = await
|
|
157
|
+
const seed = await mnemonicToSeed(normalizedPhrase);
|
|
155
158
|
const salt = utf8(BACKUP_KDF_SALT);
|
|
156
159
|
const backupKey = hkdfSha256(seed, salt, utf8(BACKUP_KDF_ENCRYPTION_INFO), BACKUP_MATERIAL_LENGTH);
|
|
157
160
|
const lookupId = toHex(hkdfSha256(seed, salt, utf8(BACKUP_KDF_LOOKUP_INFO), BACKUP_MATERIAL_LENGTH));
|
|
@@ -168,13 +171,12 @@ export class RecoveryPhraseService {
|
|
|
168
171
|
static async restoreFromPhrase(phrase, options) {
|
|
169
172
|
// Normalize and validate the phrase
|
|
170
173
|
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
171
|
-
if (!
|
|
174
|
+
if (!validateMnemonic(normalizedPhrase, wordlist)) {
|
|
172
175
|
throw new Error('Invalid recovery phrase. Please check the words and try again.');
|
|
173
176
|
}
|
|
174
177
|
// Derive the same private key from the mnemonic
|
|
175
|
-
const seed = await
|
|
176
|
-
const
|
|
177
|
-
const privateKeyHex = toHex(seedSlice);
|
|
178
|
+
const seed = await mnemonicToSeed(normalizedPhrase);
|
|
179
|
+
const privateKeyHex = toHex(seed.subarray(0, 32));
|
|
178
180
|
// Import and store the key pair
|
|
179
181
|
const publicKey = await KeyManager.importKeyPair(privateKeyHex, {
|
|
180
182
|
overwrite: options?.overwrite === true,
|
|
@@ -186,28 +188,26 @@ export class RecoveryPhraseService {
|
|
|
186
188
|
*/
|
|
187
189
|
static validatePhrase(phrase) {
|
|
188
190
|
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
189
|
-
return
|
|
191
|
+
return validateMnemonic(normalizedPhrase, wordlist);
|
|
190
192
|
}
|
|
191
193
|
/**
|
|
192
194
|
* Get the word list for autocomplete/validation
|
|
193
195
|
*/
|
|
194
196
|
static getWordList() {
|
|
195
|
-
return
|
|
197
|
+
return wordlist;
|
|
196
198
|
}
|
|
197
199
|
/**
|
|
198
200
|
* Check if a word is valid in the BIP39 word list
|
|
199
201
|
*/
|
|
200
202
|
static isValidWord(word) {
|
|
201
|
-
return
|
|
203
|
+
return wordlist.includes(word.toLowerCase());
|
|
202
204
|
}
|
|
203
205
|
/**
|
|
204
206
|
* Get suggestions for a partial word
|
|
205
207
|
*/
|
|
206
208
|
static getSuggestions(partial, limit = 5) {
|
|
207
209
|
const lowerPartial = partial.toLowerCase();
|
|
208
|
-
return
|
|
209
|
-
.filter((word) => word.startsWith(lowerPartial))
|
|
210
|
-
.slice(0, limit);
|
|
210
|
+
return wordlist.filter((word) => word.startsWith(lowerPartial)).slice(0, limit);
|
|
211
211
|
}
|
|
212
212
|
/**
|
|
213
213
|
* Derive the public key from a phrase without storing
|
|
@@ -215,12 +215,11 @@ export class RecoveryPhraseService {
|
|
|
215
215
|
*/
|
|
216
216
|
static async derivePublicKeyFromPhrase(phrase) {
|
|
217
217
|
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
218
|
-
if (!
|
|
218
|
+
if (!validateMnemonic(normalizedPhrase, wordlist)) {
|
|
219
219
|
throw new Error('Invalid recovery phrase');
|
|
220
220
|
}
|
|
221
|
-
const seed = await
|
|
222
|
-
const
|
|
223
|
-
const privateKeyHex = toHex(seedSlice);
|
|
221
|
+
const seed = await mnemonicToSeed(normalizedPhrase);
|
|
222
|
+
const privateKeyHex = toHex(seed.subarray(0, 32));
|
|
224
223
|
return KeyManager.derivePublicKey(privateKeyHex);
|
|
225
224
|
}
|
|
226
225
|
/**
|
package/dist/esm/index.js
CHANGED
|
@@ -40,6 +40,13 @@ export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHa
|
|
|
40
40
|
export { normalizeProfileLinks } from './utils/profileLinks.js';
|
|
41
41
|
export { ORGANIZATION_CATEGORIES } from './mixins/OxyServices.accounts.js';
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
43
|
+
// Reputation (Oxy Trust: ledger, balances, disputes, rules, influence).
|
|
44
|
+
// The whole type family — the closed value sets, the two balance views and the
|
|
45
|
+
// `isFullReputationBalance` narrowing guard, the ledger/dispute/rule/leaderboard
|
|
46
|
+
// shapes, and the write-endpoint inputs — is owned by `@oxyhq/contracts`, which
|
|
47
|
+
// the API's serializers are validated against. Import them from there.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
43
50
|
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
|
|
44
51
|
// verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
|
|
45
52
|
// AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
|
|
@@ -12,8 +12,14 @@
|
|
|
12
12
|
* This method carries NO persistence or token-planting side effects of its own;
|
|
13
13
|
* the cold boot / re-mint handler own persistence and `setTokens`, so the same
|
|
14
14
|
* primitive can be reused from either without double-planting.
|
|
15
|
+
*
|
|
16
|
+
* `provisionBackgroundCredential` is the mixin's second, adjacent call: it hands
|
|
17
|
+
* native background code (no JS runtime) its own non-rotating credential so that
|
|
18
|
+
* code never has to mint from — and therefore never rotates — the device secret
|
|
19
|
+
* JS depends on.
|
|
15
20
|
*/
|
|
16
|
-
import { deviceTokenMintResponseSchema, safeParseContract, } from '@oxyhq/contracts';
|
|
21
|
+
import { deviceBackgroundCredentialResponseSchema, deviceTokenMintResponseSchema, safeParseContract, } from '@oxyhq/contracts';
|
|
22
|
+
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
17
23
|
/**
|
|
18
24
|
* The server's `401 account_not_on_device` for a PINNED mint: the requested
|
|
19
25
|
* `accountId` is not (or is no longer) a live account of this device session.
|
|
@@ -105,5 +111,75 @@ export function OxyServicesDeviceBootMixin(Base) {
|
|
|
105
111
|
throw normalized;
|
|
106
112
|
}
|
|
107
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Provision a NON-rotating background credential for the caller's account on
|
|
116
|
+
* this device — the credential native background code (an Android widget
|
|
117
|
+
* worker, which runs with no JS runtime) presents to mint its own access
|
|
118
|
+
* tokens without any JS involvement.
|
|
119
|
+
*
|
|
120
|
+
* It exists precisely so background code never touches the device secret:
|
|
121
|
+
* `POST /session/device/token` ROTATES that secret on every mint (the
|
|
122
|
+
* presented one stays valid only for a short grace), so a worker minting
|
|
123
|
+
* from it would become a second writer of the value JS depends on and could
|
|
124
|
+
* silently sign the user out. The background credential is a separate,
|
|
125
|
+
* non-rotating value minted server-side, so the two lanes never contend.
|
|
126
|
+
*
|
|
127
|
+
* Bearer required and NO body: the server derives both the `deviceId` and
|
|
128
|
+
* the account from the validated bearer. This is the only way a background
|
|
129
|
+
* credential comes into existence, so background code can EXTEND a session
|
|
130
|
+
* the user established in-app but can never bootstrap one from nothing.
|
|
131
|
+
*
|
|
132
|
+
* Unlike the mint above this is NOT a control-plane call — it runs while a
|
|
133
|
+
* session is already live — so it takes the normal authenticated path: no
|
|
134
|
+
* `skipAuth` (a 401 should go through the ordinary re-mint lane) and no
|
|
135
|
+
* `bypassQueue` (nothing in the auth lane is parked awaiting it).
|
|
136
|
+
*
|
|
137
|
+
* There is deliberately no JS counterpart that MINTS from the returned
|
|
138
|
+
* credential: the native side owns that call, and a symmetric-looking JS
|
|
139
|
+
* method would be dead code plus a second implementation of the failure
|
|
140
|
+
* rules. The asymmetry is the design.
|
|
141
|
+
*
|
|
142
|
+
* **Call this from NATIVE only.** There is no background worker on web to
|
|
143
|
+
* consume the credential, and handing a browser origin a long-lived
|
|
144
|
+
* non-rotating secret to persist is strictly weaker than the rotating device
|
|
145
|
+
* secret it already holds. The 404 degrade below is also native-shaped: a
|
|
146
|
+
* browser attaches `Origin`, which a server predating this route answers
|
|
147
|
+
* `403 BAD_ORIGIN` from its router-wide same-site guard rather than 404, so
|
|
148
|
+
* the quiet degrade would not fire there. A native client sends no `Origin`
|
|
149
|
+
* and gets the 404. Gate the caller by platform; do not widen the degrade to
|
|
150
|
+
* 403, which would also swallow a genuine origin misconfiguration.
|
|
151
|
+
*
|
|
152
|
+
* That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
|
|
153
|
+
* oxy-api's router-wide origin guard (deliberately, so a native client with
|
|
154
|
+
* no `Origin` is not rejected), so as of this writing NOTHING server-side
|
|
155
|
+
* refuses a browser caller that presents a valid bearer. Until a server-side
|
|
156
|
+
* check lands, caller discipline is the only control — which is also why a
|
|
157
|
+
* doc note cannot be the whole answer to browser XSS minting a long-lived
|
|
158
|
+
* credential with the victim's bearer.
|
|
159
|
+
*
|
|
160
|
+
* @returns the provisioned credential, or `null` when the endpoint is absent
|
|
161
|
+
* (404). The API deploy leads the SDK release, so a client on a newer SDK
|
|
162
|
+
* than the server degrades to "no background session" quietly instead of
|
|
163
|
+
* surfacing an error. The status is read off the RAW rejection because
|
|
164
|
+
* `handleError` only preserves it for errors carrying `HttpService`'s
|
|
165
|
+
* annotations.
|
|
166
|
+
* @throws if the response does not match {@link deviceBackgroundCredentialResponseSchema}.
|
|
167
|
+
*/
|
|
168
|
+
async provisionBackgroundCredential() {
|
|
169
|
+
try {
|
|
170
|
+
const res = await this.makeRequest('POST', '/session/device/background-credential', undefined, { cache: false });
|
|
171
|
+
const parsed = safeParseContract(deviceBackgroundCredentialResponseSchema, res);
|
|
172
|
+
if (!parsed) {
|
|
173
|
+
throw new Error('session/device/background-credential returned an unexpected response shape');
|
|
174
|
+
}
|
|
175
|
+
return parsed;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (extractErrorStatus(error) === 404) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
throw this.handleError(error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
108
184
|
};
|
|
109
185
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isFullReputationBalance } from '@oxyhq/contracts';
|
|
2
|
+
import { OxyAuthenticationError } from '../OxyServices.errors.js';
|
|
1
3
|
import { CACHE_TIMES } from './mixinHelpers.js';
|
|
2
4
|
/** Cache-key prefix for every cached `GET /reputation/...` response. */
|
|
3
5
|
const REPUTATION_CACHE_PREFIX = 'GET:/reputation/';
|
|
@@ -7,8 +9,16 @@ export function OxyServicesReputationMixin(Base) {
|
|
|
7
9
|
super(...args);
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
|
-
* Get
|
|
11
|
-
*
|
|
12
|
+
* Get ANY user's reputation balance, in whichever view the server serves the
|
|
13
|
+
* caller.
|
|
14
|
+
*
|
|
15
|
+
* A third party gets `userId`, `total` and `trustTier` and nothing else, so
|
|
16
|
+
* the return type is a {@link ReputationBalanceView} union: narrow it with
|
|
17
|
+
* {@link isFullReputationBalance} before touching `breakdown`, `influence`
|
|
18
|
+
* or `reliability`. To read your OWN balance, call
|
|
19
|
+
* {@link getMyReputationBalance} instead — it returns the full shape with no
|
|
20
|
+
* narrowing.
|
|
21
|
+
*
|
|
12
22
|
* @param userId - The subject user's `_id` or publicKey.
|
|
13
23
|
*/
|
|
14
24
|
async getReputationBalance(userId) {
|
|
@@ -19,6 +29,37 @@ export function OxyServicesReputationMixin(Base) {
|
|
|
19
29
|
throw this.handleError(error);
|
|
20
30
|
}
|
|
21
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Get the SIGNED-IN user's own reputation balance, in full.
|
|
34
|
+
*
|
|
35
|
+
* The subject view is the only one carrying `breakdown`, `influence` and
|
|
36
|
+
* `reliability`, and the subject is the common caller, so this is the
|
|
37
|
+
* ergonomic path: no id to pass, no narrowing to do.
|
|
38
|
+
*
|
|
39
|
+
* Throws rather than returning a half-populated object when the request was
|
|
40
|
+
* not authenticated as the subject — with no signed-in user, and when the
|
|
41
|
+
* server answered `200` with the public view anyway (which it does for an
|
|
42
|
+
* absent or lapsed token, since the endpoint's auth is optional). Both mean
|
|
43
|
+
* the private blocks are simply absent, and a thrown error is the only
|
|
44
|
+
* honest report of that.
|
|
45
|
+
*/
|
|
46
|
+
async getMyReputationBalance() {
|
|
47
|
+
const userId = this.getCurrentUserId();
|
|
48
|
+
if (!userId) {
|
|
49
|
+
throw new OxyAuthenticationError('Reading your own reputation balance requires a signed-in user');
|
|
50
|
+
}
|
|
51
|
+
let balance;
|
|
52
|
+
try {
|
|
53
|
+
balance = await this.makeRequest('GET', `/reputation/${encodeURIComponent(userId)}/balance`, undefined, { cache: true, cacheTTL: CACHE_TIMES.MEDIUM });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw this.handleError(error);
|
|
57
|
+
}
|
|
58
|
+
if (!isFullReputationBalance(balance)) {
|
|
59
|
+
throw new OxyAuthenticationError('The reputation balance came back as the public view — the request was not authenticated as its subject');
|
|
60
|
+
}
|
|
61
|
+
return balance;
|
|
62
|
+
}
|
|
22
63
|
/**
|
|
23
64
|
* Get the reputation leaderboard, ordered by lifetime total descending.
|
|
24
65
|
* @param limit - Page size (server-capped).
|
|
@@ -189,6 +230,10 @@ export function OxyServicesReputationMixin(Base) {
|
|
|
189
230
|
/**
|
|
190
231
|
* Force a recompute of a user's balance snapshot from their active ledger
|
|
191
232
|
* (staff only). Invalidates cached reputation reads.
|
|
233
|
+
*
|
|
234
|
+
* Staff-gated, so the response is always the full subject view — no
|
|
235
|
+
* narrowing needed.
|
|
236
|
+
*
|
|
192
237
|
* @param userId - The subject user's `_id` or publicKey.
|
|
193
238
|
*/
|
|
194
239
|
async recalculateReputation(userId) {
|
|
@@ -217,10 +217,9 @@ export function OxyServicesUserMixin(Base) {
|
|
|
217
217
|
/**
|
|
218
218
|
* Get profiles similar to a given user, based on co-follower overlap.
|
|
219
219
|
*/
|
|
220
|
-
async getSimilarProfiles(userId,
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
params.limit = String(limit);
|
|
220
|
+
async getSimilarProfiles(userId, limitOrParams) {
|
|
221
|
+
const pagination = typeof limitOrParams === 'number' ? { limit: limitOrParams } : limitOrParams ?? {};
|
|
222
|
+
const params = buildQueryParams(pagination);
|
|
224
223
|
const users = await this.makeRequest('GET', `/profiles/${userId}/similar`, params, {
|
|
225
224
|
cache: true,
|
|
226
225
|
cacheTTL: 5 * 60 * 1000, // 5 min cache
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* atomic, so no cross-call current-row reconciliation is needed).
|
|
18
18
|
*/
|
|
19
19
|
import { getAccountDisplayName, getAccountFallbackHandle } from '../utils/accountUtils.js';
|
|
20
|
+
import { getNormalizedUserHandle } from '../utils/userHandle.js';
|
|
20
21
|
/**
|
|
21
22
|
* Pure union of device sign-ins and account-graph nodes into the flat
|
|
22
23
|
* {@link SwitchableAccount}[] every switcher renders.
|
|
@@ -43,7 +44,9 @@ export function projectSwitchableAccounts(input) {
|
|
|
43
44
|
kind: opts.kind,
|
|
44
45
|
parentAccountId: opts.parentAccountId,
|
|
45
46
|
callerMembership: opts.callerMembership,
|
|
46
|
-
displayName:
|
|
47
|
+
displayName: accountUser.name?.displayName ??
|
|
48
|
+
getNormalizedUserHandle(accountUser) ??
|
|
49
|
+
getAccountDisplayName(null, locale),
|
|
47
50
|
// Real email, or the `@handle` fallback (NEVER synthesized).
|
|
48
51
|
email: accountUser.email ?? secondaryHandle,
|
|
49
52
|
avatarUrl: resolveAvatarUrl(accountUser.avatar),
|