@oxyhq/core 13.2.0 → 15.0.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.
Files changed (36) 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 +58 -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 +59 -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 +43 -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 +67 -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__/followGraphPagination.test.ts +21 -0
  33. package/src/mixins/__tests__/reputation.test.ts +115 -1
  34. package/src/models/interfaces.ts +2 -0
  35. package/src/session/accountProjection.ts +5 -1
  36. package/src/types/bip39.d.ts +0 -32
@@ -12,334 +12,42 @@
12
12
  * their `active` transactions, augmented with a trust tier, capped influence
13
13
  * weights, and reliability signals.
14
14
  *
15
+ * A balance is served in TWO views (see `ReputationBalanceView`): the subject
16
+ * and platform staff get the whole thing, a third party gets the public trust
17
+ * signal only. The two are distinct types so a caller cannot read a field the
18
+ * server did not send them.
19
+ *
20
+ * EVERY type on this surface is owned by `@oxyhq/contracts`, which the API's
21
+ * serializers are annotated and validated against. This mixin declares none of
22
+ * them and re-exports none of them: consumers import the types straight from
23
+ * `@oxyhq/contracts`, so the wire shape has exactly one definition and a
24
+ * server-side change to a serializer cannot compile while the type still
25
+ * promises the old shape.
26
+ *
15
27
  * Reference users by their Mongo `_id` (or publicKey, which the API resolves),
16
28
  * transactions by their `id`, and disputes by their `id`.
17
29
  */
30
+ import type {
31
+ AwardReputationInput,
32
+ CreateReputationDisputeInput,
33
+ ReputationBalance,
34
+ ReputationBalanceView,
35
+ ReputationDispute,
36
+ ReputationInfluenceContext,
37
+ ReputationInfluenceResult,
38
+ ReputationLeaderboardEntry,
39
+ ReputationRule,
40
+ ReputationTransaction,
41
+ ResolveReputationDisputeInput,
42
+ ReverseReputationTransactionInput,
43
+ ReverseReputationTransactionResult,
44
+ UpsertReputationRuleInput,
45
+ } from '@oxyhq/contracts';
46
+ import { isFullReputationBalance } from '@oxyhq/contracts';
18
47
  import type { OxyServicesBase } from '../OxyServices.base';
19
- import type { User } from '../models/interfaces';
48
+ import { OxyAuthenticationError } from '../OxyServices.errors';
20
49
  import { CACHE_TIMES } from './mixinHelpers';
21
50
 
22
- // =============================================================================
23
- // UNION TYPES (mirror packages/api/src/utils/reputation.constants.ts)
24
- // =============================================================================
25
-
26
- /**
27
- * Category bucket a reputation transaction falls into. Drives the per-category
28
- * balance breakdown.
29
- */
30
- export type ReputationCategory =
31
- | 'content'
32
- | 'social'
33
- | 'trust'
34
- | 'moderation'
35
- | 'physical'
36
- | 'penalty'
37
- | 'other';
38
-
39
- /** Trust tiers, lowest → highest (plus the punitive `restricted`). */
40
- export type TrustTier = 'new' | 'trusted' | 'high_trust' | 'verified' | 'restricted';
41
-
42
- /**
43
- * Transaction lifecycle status. Only `active` transactions count toward the
44
- * balance; `disputed` still counts until the dispute resolves; `reversed` and
45
- * `voided` are excluded.
46
- */
47
- export type ReputationTransactionStatus = 'active' | 'disputed' | 'reversed' | 'voided';
48
-
49
- /** Kind of entity a transaction may target. */
50
- export type ReputationTargetEntityType =
51
- | 'post'
52
- | 'comment'
53
- | 'report'
54
- | 'purchase'
55
- | 'event'
56
- | 'check_in'
57
- | 'manual_review'
58
- | 'user'
59
- | 'other';
60
-
61
- /** Dispute lifecycle status. */
62
- export type ReputationDisputeStatus = 'open' | 'accepted' | 'rejected' | 'needs_review';
63
-
64
- /** Influence context selecting which capped weight axis to return. */
65
- export type ReputationInfluenceContext = 'default' | 'report' | 'moderation' | 'ranking';
66
-
67
- // =============================================================================
68
- // ENTITY SHAPES (mirror the server models; ids are strings, dates ISO strings)
69
- // =============================================================================
70
-
71
- /**
72
- * A single immutable entry in the reputation ledger. Ids are emitted as strings
73
- * and dates as ISO strings by the API.
74
- */
75
- export interface ReputationTransaction {
76
- /** The transaction's Mongo `_id` as a string. */
77
- id: string;
78
- /** Subject of the reputation change — the user whose balance moves. */
79
- userId: string;
80
- /** Signed point delta. Positive awards, negative penalties/reversals. */
81
- points: number;
82
- /** The rule/action key that produced this transaction (e.g. `post_created`). */
83
- actionType: string;
84
- /** Category bucket the points fall into. */
85
- category: ReputationCategory;
86
- /** Canonical source application that reported the action, if any. */
87
- applicationId?: string;
88
- /** The specific credential used by the source application, if any. */
89
- credentialId?: string;
90
- /** Opaque id of the originating action in the source system (idempotency key). */
91
- sourceActionId?: string;
92
- /** Source-system action type (e.g. `report_confirmed`, `event_check_in`). */
93
- sourceActionType?: string;
94
- /** Id of the entity the action targeted (post id, report id, etc.). */
95
- targetEntityId?: string;
96
- /** Kind of the targeted entity. */
97
- targetEntityType?: ReputationTargetEntityType;
98
- /** Lifecycle status — only `active` transactions count toward the balance. */
99
- status: ReputationTransactionStatus;
100
- /**
101
- * Set ONLY on a compensating reversal transaction; references the original
102
- * transaction it reverses. The original carries `status: 'reversed'`.
103
- */
104
- reversedTransactionId?: string;
105
- /** Human-readable reason / note. */
106
- reason?: string;
107
- /** Free-form structured metadata from the source system. */
108
- metadata?: Record<string, unknown>;
109
- /** The user who caused this change (the liker, the reporting user, staff). */
110
- createdByUserId?: string;
111
- /** Staff/service principal who reviewed (reversed/voided) this transaction. */
112
- reviewedByUserId?: string;
113
- /** ISO timestamp the transaction was reviewed at, if reviewed. */
114
- reviewedAt?: string;
115
- /** ISO creation timestamp. */
116
- createdAt: string;
117
- /** ISO last-update timestamp. */
118
- updatedAt: string;
119
- }
120
-
121
- /**
122
- * Per-category sums of a user's ACTIVE transactions. `penalties` is the
123
- * absolute sum of every negative-point transaction; the named buckets carry the
124
- * signed sum of transactions in that category.
125
- */
126
- export interface ReputationBalanceBreakdown {
127
- content: number;
128
- social: number;
129
- trust: number;
130
- moderation: number;
131
- physical: number;
132
- penalties: number;
133
- }
134
-
135
- /**
136
- * Capped influence weights (#219). Every weight is clamped to a configured
137
- * range; restricted users are floored on every axis. Downstream systems
138
- * (ranking, moderation, reporting) consume these to weight a user's
139
- * contributions without letting any single user dominate.
140
- */
141
- export interface ReputationInfluence {
142
- /** General-purpose trust weight derived from the lifetime total. */
143
- defaultWeight: number;
144
- /** Weight applied to this user's reports (scales with report accuracy). */
145
- reportWeight: number;
146
- /** Weight applied to this user's moderation actions (scales with tier). */
147
- moderationWeight: number;
148
- /** Damped weight applied to this user's ranking feedback. */
149
- rankingFeedbackWeight: number;
150
- }
151
-
152
- /**
153
- * Reliability signals (#219) derived from the user's moderation track record in
154
- * the ledger.
155
- */
156
- export interface ReputationReliability {
157
- /** Count of active transactions stamped `report_confirmed`. */
158
- accurateReports: number;
159
- /** Count of active transactions stamped `report_rejected`. */
160
- rejectedReports: number;
161
- /** accurate / (accurate + rejected), or the neutral 0.5 when no history. */
162
- reportAccuracyScore: number;
163
- /** Smoothed 0..1 abuse signal; high values force the `restricted` tier. */
164
- abuseScore: number;
165
- }
166
-
167
- /**
168
- * Cached, recomputable snapshot of a user's reputation. Shape mirrors the
169
- * `/reputation/:userId/balance` response (which omits internal `lastTransactionId`
170
- * and `createdAt`).
171
- */
172
- export interface ReputationBalance {
173
- userId: string;
174
- /** Net lifetime total across all active transactions. */
175
- total: number;
176
- /** Sum of positive points only. */
177
- positive: number;
178
- /** Sum of negative points only (a negative number). */
179
- negative: number;
180
- breakdown: ReputationBalanceBreakdown;
181
- trustTier: TrustTier;
182
- influence: ReputationInfluence;
183
- reliability: ReputationReliability;
184
- /** ISO timestamp the snapshot was last recomputed at. */
185
- recalculatedAt: string;
186
- /** ISO last-update timestamp. */
187
- updatedAt: string;
188
- }
189
-
190
- /**
191
- * A user-initiated dispute against a specific reputation transaction. Ids are
192
- * strings and dates ISO strings.
193
- */
194
- export interface ReputationDispute {
195
- /** The dispute's Mongo `_id` as a string. */
196
- id: string;
197
- /** The transaction being disputed. */
198
- transactionId: string;
199
- /** The user raising the dispute. */
200
- userId: string;
201
- /** Why the user believes the transaction is wrong. */
202
- reason: string;
203
- status: ReputationDisputeStatus;
204
- /** Optional supporting evidence (URLs / references). */
205
- evidence?: string[];
206
- /** ISO timestamp the dispute was resolved at, if resolved. */
207
- resolvedAt?: string;
208
- /** Staff principal who resolved the dispute, if resolved. */
209
- resolvedByUserId?: string;
210
- /** ISO creation timestamp. */
211
- createdAt: string;
212
- /** ISO last-update timestamp. */
213
- updatedAt: string;
214
- }
215
-
216
- /**
217
- * A configurable reputation award/penalty rule. The `/reputation/rules`
218
- * response shape: `id` is the rule's `_id`; no timestamps are emitted.
219
- */
220
- export interface ReputationRule {
221
- /** The rule's Mongo `_id` as a string. */
222
- id: string;
223
- /** Unique action key (e.g. `post_created`). */
224
- actionType: string;
225
- /** Signed points the rule awards (may be negative for penalties). */
226
- points: number;
227
- /** Category the resulting transaction is filed under. */
228
- category: ReputationCategory;
229
- description: string;
230
- /** Per (user, actionType) cooldown in minutes; 0 disables the cooldown. */
231
- cooldownInMinutes: number;
232
- isEnabled: boolean;
233
- }
234
-
235
- /**
236
- * A single leaderboard entry. `user` is the populated user document the API
237
- * returns alongside the lifetime total, derived trust tier, and 1-based rank.
238
- */
239
- export interface ReputationLeaderboardEntry {
240
- /** The populated user (id, username, name, avatar, publicKey). */
241
- user: Pick<User, 'id' | 'username' | 'name' | 'avatar' | 'publicKey'> & Partial<User>;
242
- /** Net lifetime total. */
243
- total: number;
244
- /** Derived trust tier. */
245
- trustTier: TrustTier;
246
- /** 1-based rank within the leaderboard (`offset + index + 1`). */
247
- rank: number;
248
- }
249
-
250
- /**
251
- * Result of `getReputationInfluence` — the requested context, the single capped
252
- * weight for that context, and the full influence block.
253
- */
254
- export interface ReputationInfluenceResult {
255
- context: ReputationInfluenceContext;
256
- weight: number;
257
- influence: ReputationInfluence;
258
- }
259
-
260
- /**
261
- * Result of `reverseReputationTransaction` — the now-`reversed` original plus
262
- * the compensating `active` reversal entry.
263
- */
264
- export interface ReverseReputationTransactionResult {
265
- original: ReputationTransaction;
266
- reversal: ReputationTransaction;
267
- }
268
-
269
- // =============================================================================
270
- // INPUT TYPES (mirror packages/api/src/schemas/reputation.schemas.ts)
271
- // =============================================================================
272
-
273
- /**
274
- * Input for `awardReputation`. Awarding is restricted to service tokens (the
275
- * canonical path) and platform staff; regular users may NOT award reputation.
276
- * When called with a service token, `applicationId` / `credentialId` are
277
- * resolved from the token and any client-supplied values are ignored.
278
- */
279
- export interface AwardReputationInput {
280
- /** The subject whose reputation changes (`_id` or publicKey). */
281
- userId: string;
282
- /** The enabled rule's action key (e.g. `post_created`). */
283
- actionType: string;
284
- /** Source application id (ignored for service tokens). */
285
- applicationId?: string;
286
- /** Source credential id (ignored for service tokens). */
287
- credentialId?: string;
288
- /** Opaque originating-action id used as the idempotency key. */
289
- sourceActionId?: string;
290
- /** Source-system action type. */
291
- sourceActionType?: string;
292
- /** Id of the targeted entity. */
293
- targetEntityId?: string;
294
- /** Kind of the targeted entity. */
295
- targetEntityType?: ReputationTargetEntityType;
296
- /** Optional human-readable reason (max 500 chars). */
297
- reason?: string;
298
- /** Free-form structured metadata from the source system. */
299
- metadata?: Record<string, unknown>;
300
- }
301
-
302
- /** Input for `createReputationDispute`. The disputer is the authenticated user. */
303
- export interface CreateReputationDisputeInput {
304
- /** The transaction being disputed. */
305
- transactionId: string;
306
- /** Why the transaction is believed to be wrong (1..1000 chars). */
307
- reason: string;
308
- /** Optional supporting evidence (URLs / references; max 20). */
309
- evidence?: string[];
310
- }
311
-
312
- /** Input for `resolveReputationDispute` (staff). */
313
- export interface ResolveReputationDisputeInput {
314
- /** Accepting reverses the disputed transaction; rejecting restores it. */
315
- status: 'accepted' | 'rejected';
316
- }
317
-
318
- /** Input for `upsertReputationRule` (staff). Keyed by `actionType`. */
319
- export interface UpsertReputationRuleInput {
320
- /** Unique action key (e.g. `post_created`). */
321
- actionType: string;
322
- /** Signed points the rule awards (may be negative). */
323
- points: number;
324
- /** Category the resulting transaction is filed under. */
325
- category: ReputationCategory;
326
- /** Human-readable description (1..500 chars). */
327
- description: string;
328
- /** Per (user, actionType) cooldown in minutes; defaults to 0. */
329
- cooldownInMinutes?: number;
330
- /** Whether the rule is active; defaults to true. */
331
- isEnabled?: boolean;
332
- }
333
-
334
- /**
335
- * Input for `reverseReputationTransaction` / `voidReputationTransaction`
336
- * (staff). The reviewing principal is the authenticated user.
337
- */
338
- export interface ReverseReputationTransactionInput {
339
- /** Optional human-readable reason (max 500 chars). */
340
- reason?: string;
341
- }
342
-
343
51
  /** Cache-key prefix for every cached `GET /reputation/...` response. */
344
52
  const REPUTATION_CACHE_PREFIX = 'GET:/reputation/';
345
53
 
@@ -350,13 +58,56 @@ export function OxyServicesReputationMixin<T extends typeof OxyServicesBase>(Bas
350
58
  }
351
59
 
352
60
  /**
353
- * Get a user's cached reputation balance derived totals, per-category
354
- * breakdown, trust tier, capped influence weights, and reliability signals.
61
+ * Get ANY user's reputation balance, in whichever view the server serves the
62
+ * caller.
63
+ *
64
+ * A third party gets `userId`, `total` and `trustTier` and nothing else, so
65
+ * the return type is a {@link ReputationBalanceView} union: narrow it with
66
+ * {@link isFullReputationBalance} before touching `breakdown`, `influence`
67
+ * or `reliability`. To read your OWN balance, call
68
+ * {@link getMyReputationBalance} instead — it returns the full shape with no
69
+ * narrowing.
70
+ *
355
71
  * @param userId - The subject user's `_id` or publicKey.
356
72
  */
357
- async getReputationBalance(userId: string): Promise<ReputationBalance> {
73
+ async getReputationBalance(userId: string): Promise<ReputationBalanceView> {
74
+ try {
75
+ return await this.makeRequest<ReputationBalanceView>(
76
+ 'GET',
77
+ `/reputation/${encodeURIComponent(userId)}/balance`,
78
+ undefined,
79
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
80
+ );
81
+ } catch (error) {
82
+ throw this.handleError(error);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get the SIGNED-IN user's own reputation balance, in full.
88
+ *
89
+ * The subject view is the only one carrying `breakdown`, `influence` and
90
+ * `reliability`, and the subject is the common caller, so this is the
91
+ * ergonomic path: no id to pass, no narrowing to do.
92
+ *
93
+ * Throws rather than returning a half-populated object when the request was
94
+ * not authenticated as the subject — with no signed-in user, and when the
95
+ * server answered `200` with the public view anyway (which it does for an
96
+ * absent or lapsed token, since the endpoint's auth is optional). Both mean
97
+ * the private blocks are simply absent, and a thrown error is the only
98
+ * honest report of that.
99
+ */
100
+ async getMyReputationBalance(): Promise<ReputationBalance> {
101
+ const userId = this.getCurrentUserId();
102
+ if (!userId) {
103
+ throw new OxyAuthenticationError(
104
+ 'Reading your own reputation balance requires a signed-in user',
105
+ );
106
+ }
107
+
108
+ let balance: ReputationBalanceView;
358
109
  try {
359
- return await this.makeRequest<ReputationBalance>(
110
+ balance = await this.makeRequest<ReputationBalanceView>(
360
111
  'GET',
361
112
  `/reputation/${encodeURIComponent(userId)}/balance`,
362
113
  undefined,
@@ -365,6 +116,13 @@ export function OxyServicesReputationMixin<T extends typeof OxyServicesBase>(Bas
365
116
  } catch (error) {
366
117
  throw this.handleError(error);
367
118
  }
119
+
120
+ if (!isFullReputationBalance(balance)) {
121
+ throw new OxyAuthenticationError(
122
+ 'The reputation balance came back as the public view — the request was not authenticated as its subject',
123
+ );
124
+ }
125
+ return balance;
368
126
  }
369
127
 
370
128
  /**
@@ -604,6 +362,10 @@ export function OxyServicesReputationMixin<T extends typeof OxyServicesBase>(Bas
604
362
  /**
605
363
  * Force a recompute of a user's balance snapshot from their active ledger
606
364
  * (staff only). Invalidates cached reputation reads.
365
+ *
366
+ * Staff-gated, so the response is always the full subject view — no
367
+ * narrowing needed.
368
+ *
607
369
  * @param userId - The subject user's `_id` or publicKey.
608
370
  */
609
371
  async recalculateReputation(userId: string): Promise<ReputationBalance> {
@@ -393,9 +393,13 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
393
393
  /**
394
394
  * Get profiles similar to a given user, based on co-follower overlap.
395
395
  */
396
- async getSimilarProfiles(userId: string, limit?: number): Promise<User[]> {
397
- const params: Record<string, string> = {};
398
- if (limit) params.limit = String(limit);
396
+ async getSimilarProfiles(
397
+ userId: string,
398
+ limitOrParams?: number | { limit?: number; offset?: number },
399
+ ): Promise<User[]> {
400
+ const pagination =
401
+ typeof limitOrParams === 'number' ? { limit: limitOrParams } : limitOrParams ?? {};
402
+ const params = buildQueryParams(pagination);
399
403
  const users = await this.makeRequest<User[]>('GET', `/profiles/${userId}/similar`, params, {
400
404
  cache: true,
401
405
  cacheTTL: 5 * 60 * 1000, // 5 min cache
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * Device-boot mixin tests. Stubs `makeRequest` so the tests run with no network
3
3
  * and asserts `mintFromDeviceSecret`'s route/shape, contract validation, and the
4
- * `skipAuth` flag on the bearer-less mint call.
4
+ * `skipAuth` flag on the bearer-less mint call, plus `provisionBackgroundCredential`'s
5
+ * route/options, contract validation and its 404-tolerant degrade.
5
6
  */
6
- import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
7
+ import type {
8
+ DeviceBackgroundCredentialResponse,
9
+ DeviceTokenMintResponse,
10
+ } from '@oxyhq/contracts';
7
11
  import { OxyServices } from '../../OxyServices';
8
12
 
9
13
  describe('OxyServices.deviceBoot', () => {
@@ -64,4 +68,57 @@ describe('OxyServices.deviceBoot', () => {
64
68
  await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('no_active_session');
65
69
  });
66
70
  });
71
+
72
+ describe('provisionBackgroundCredential', () => {
73
+ const CREDENTIAL: DeviceBackgroundCredentialResponse = {
74
+ deviceId: 'dev-1',
75
+ secret: 'bg-secret',
76
+ accountId: 'user-1',
77
+ expiresAt: '2030-01-01T00:00:00.000Z',
78
+ };
79
+
80
+ /** `HttpService` annotates its rejections with both `status` and `response.status`. */
81
+ const httpError = (status: number, message: string) =>
82
+ Object.assign(new Error(message), {
83
+ status,
84
+ response: { status, statusText: message },
85
+ });
86
+
87
+ it('POSTs to the background-credential route with NO body and no cache, and returns the validated credential', async () => {
88
+ makeRequest.mockResolvedValueOnce(CREDENTIAL);
89
+ const result = await oxy.provisionBackgroundCredential();
90
+ expect(result).toEqual(CREDENTIAL);
91
+ // No body: the server derives BOTH the deviceId and the account from the
92
+ // validated bearer. Normal authenticated path — no skipAuth (a 401 belongs
93
+ // in the ordinary re-mint lane), no bypassQueue (not control-plane).
94
+ expect(makeRequest).toHaveBeenCalledWith(
95
+ 'POST',
96
+ '/session/device/background-credential',
97
+ undefined,
98
+ { cache: false },
99
+ );
100
+ });
101
+
102
+ it('returns null on 404 (endpoint absent) instead of throwing, so an SDK ahead of the API degrades to "no background session"', async () => {
103
+ makeRequest.mockRejectedValueOnce(httpError(404, 'HTTP 404: Not Found'));
104
+ await expect(oxy.provisionBackgroundCredential()).resolves.toBeNull();
105
+ });
106
+
107
+ it('throws on an unexpected response shape (missing secret)', async () => {
108
+ makeRequest.mockResolvedValueOnce({
109
+ deviceId: 'dev-1',
110
+ accountId: 'user-1',
111
+ expiresAt: '2030-01-01T00:00:00.000Z',
112
+ });
113
+ await expect(oxy.provisionBackgroundCredential()).rejects.toThrow();
114
+ });
115
+
116
+ it('propagates a non-404 failure (401 / 500) — only an absent endpoint degrades quietly', async () => {
117
+ makeRequest.mockRejectedValueOnce(httpError(401, 'unauthorized'));
118
+ await expect(oxy.provisionBackgroundCredential()).rejects.toThrow('unauthorized');
119
+
120
+ makeRequest.mockRejectedValueOnce(httpError(500, 'server exploded'));
121
+ await expect(oxy.provisionBackgroundCredential()).rejects.toThrow('server exploded');
122
+ });
123
+ });
67
124
  });
@@ -247,4 +247,25 @@ describe('follow-graph pagination and ordering', () => {
247
247
  clearPrefixSpy.mockRestore();
248
248
  });
249
249
  });
250
+
251
+ describe('getSimilarProfiles forwards pagination params', () => {
252
+ it('sends limit and offset on getSimilarProfiles', async () => {
253
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
254
+ await oxy.getSimilarProfiles('target-1', { limit: 15, offset: 30 });
255
+
256
+ const url = new URL(requestedUrl(0));
257
+ expect(url.pathname).toBe('/profiles/target-1/similar');
258
+ expect(url.searchParams.get('limit')).toBe('15');
259
+ expect(url.searchParams.get('offset')).toBe('30');
260
+ });
261
+
262
+ it('still accepts a bare limit number for backward compatibility', async () => {
263
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
264
+ await oxy.getSimilarProfiles('target-1', 5);
265
+
266
+ const url = new URL(requestedUrl(0));
267
+ expect(url.searchParams.get('limit')).toBe('5');
268
+ expect(url.searchParams.has('offset')).toBe(false);
269
+ });
270
+ });
250
271
  });