@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.
Files changed (37) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/recoveryPhrase.js +32 -66
  3. package/dist/cjs/index.js +7 -0
  4. package/dist/cjs/mixins/OxyServices.deviceBoot.js +76 -0
  5. package/dist/cjs/mixins/OxyServices.reputation.js +47 -2
  6. package/dist/cjs/mixins/OxyServices.user.js +3 -4
  7. package/dist/cjs/session/accountProjection.js +4 -1
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/crypto/recoveryPhrase.js +32 -33
  10. package/dist/esm/index.js +7 -0
  11. package/dist/esm/mixins/OxyServices.deviceBoot.js +77 -1
  12. package/dist/esm/mixins/OxyServices.reputation.js +47 -2
  13. package/dist/esm/mixins/OxyServices.user.js +3 -4
  14. package/dist/esm/session/accountProjection.js +4 -1
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/crypto/recoveryPhrase.d.ts +6 -0
  17. package/dist/types/index.d.ts +0 -1
  18. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +61 -1
  19. package/dist/types/mixins/OxyServices.identityBackup.d.ts +1 -1
  20. package/dist/types/mixins/OxyServices.reputation.d.ts +43 -276
  21. package/dist/types/mixins/OxyServices.user.d.ts +4 -1
  22. package/dist/types/models/interfaces.d.ts +6 -0
  23. package/package.json +3 -3
  24. package/src/crypto/__tests__/keyManager.test.ts +3 -2
  25. package/src/crypto/recoveryPhrase.ts +33 -34
  26. package/src/index.ts +5 -24
  27. package/src/mixins/OxyServices.deviceBoot.ts +85 -0
  28. package/src/mixins/OxyServices.identityBackup.ts +1 -1
  29. package/src/mixins/OxyServices.reputation.ts +88 -326
  30. package/src/mixins/OxyServices.user.ts +7 -3
  31. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +59 -2
  32. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +135 -0
  33. package/src/mixins/__tests__/followGraphPagination.test.ts +21 -0
  34. package/src/mixins/__tests__/reputation.test.ts +115 -1
  35. package/src/models/interfaces.ts +2 -0
  36. package/src/session/accountProjection.ts +5 -1
  37. 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 * as bip39 from 'bip39';
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 = bip39.generateMnemonic(128);
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 bip39.mnemonicToSeed(mnemonic);
61
+ const seed = await mnemonicToSeed(mnemonic);
55
62
  // Use first 32 bytes of seed as private key
56
- const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
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 = bip39.generateMnemonic(256);
77
- const seed = await bip39.mnemonicToSeed(mnemonic);
78
- const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
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 = bip39.generateMnemonic(128);
104
- const seed = await bip39.mnemonicToSeed(mnemonic);
105
- const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
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 (!bip39.validateMnemonic(normalizedPhrase)) {
129
+ if (!validateMnemonic(normalizedPhrase, wordlist)) {
126
130
  throw new Error('Invalid recovery phrase');
127
131
  }
128
- const seed = await bip39.mnemonicToSeed(normalizedPhrase);
129
- const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
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 (!bip39.validateMnemonic(normalizedPhrase)) {
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 bip39.mnemonicToSeed(normalizedPhrase);
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 (!bip39.validateMnemonic(normalizedPhrase)) {
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 bip39.mnemonicToSeed(normalizedPhrase);
176
- const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
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 bip39.validateMnemonic(normalizedPhrase);
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 bip39.wordlists.english;
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 bip39.wordlists.english.includes(word.toLowerCase());
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 bip39.wordlists.english
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 (!bip39.validateMnemonic(normalizedPhrase)) {
218
+ if (!validateMnemonic(normalizedPhrase, wordlist)) {
219
219
  throw new Error('Invalid recovery phrase');
220
220
  }
221
- const seed = await bip39.mnemonicToSeed(normalizedPhrase);
222
- const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
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 a user's cached reputation balance derived totals, per-category
11
- * breakdown, trust tier, capped influence weights, and reliability signals.
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, limit) {
221
- const params = {};
222
- if (limit)
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: getAccountDisplayName(accountUser, locale),
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),