@oxyhq/core 5.0.0 → 5.1.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/mixins/OxyServices.accounts.js +42 -6
- package/dist/cjs/mixins/OxyServices.assets.js +66 -0
- package/dist/cjs/mixins/OxyServices.user.js +30 -11
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.accounts.js +42 -6
- package/dist/esm/mixins/OxyServices.assets.js +66 -0
- package/dist/esm/mixins/OxyServices.user.js +30 -11
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +13 -4
- package/dist/types/mixins/OxyServices.assets.d.ts +42 -1
- package/dist/types/mixins/OxyServices.user.d.ts +33 -10
- package/dist/types/models/interfaces.d.ts +18 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.accounts.ts +51 -6
- package/src/mixins/OxyServices.assets.ts +95 -1
- package/src/mixins/OxyServices.user.ts +43 -11
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +116 -0
- package/src/mixins/__tests__/accounts.test.ts +66 -16
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
- package/src/models/interfaces.ts +19 -0
package/dist/types/index.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
|
62
62
|
export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
|
|
63
63
|
export { DeviceManager } from './utils/deviceManager';
|
|
64
64
|
export type { DeviceFingerprint, StoredDeviceInfo } from './utils/deviceManager';
|
|
65
|
-
export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceSession, DeviceSessionsResponse, DeviceSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
|
|
65
|
+
export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceSession, DeviceSessionsResponse, DeviceSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
|
|
66
66
|
export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
|
|
67
67
|
export { TopicType, TopicSource } from './models/Topic';
|
|
68
68
|
export type { TopicData, TopicTranslation } from './models/Topic';
|
|
@@ -437,10 +437,19 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
|
|
|
437
437
|
* Unlike the removed `X-Acting-As` delegation header, the returned session
|
|
438
438
|
* IS the new identity: this plants `accessToken` as the active token —
|
|
439
439
|
* exactly like `claimSessionByToken` / `verifyChallenge` — so every
|
|
440
|
-
* subsequent request authenticates as the target account.
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
440
|
+
* subsequent request authenticates as the target account.
|
|
441
|
+
*
|
|
442
|
+
* Joining the device multi-account set (so the switch survives a reload and
|
|
443
|
+
* propagates cross-domain via `/auth/refresh-all`) requires a SECOND call, to
|
|
444
|
+
* `POST /auth/session`, made here after the token is planted. The switch route
|
|
445
|
+
* lives at `/accounts/*`, OUTSIDE the `oxy_rt_<authuser>` cookie's `Path=/auth`
|
|
446
|
+
* scope, so the server never sees the device's existing slots from it and
|
|
447
|
+
* would clobber slot 0 (destroying the operator's own session). `/auth/session`
|
|
448
|
+
* runs where those cookies ARE visible, so the server allocates a NEW slot that
|
|
449
|
+
* coexists with the operator's and returns its `authuser`. This step is
|
|
450
|
+
* web-only (native multi-account uses stored sessions, not cookies) and
|
|
451
|
+
* best-effort — a failure leaves the in-session switch intact; the switched
|
|
452
|
+
* account simply won't survive a reload until the cookie is next established.
|
|
444
453
|
*
|
|
445
454
|
* After planting, the SDK's identity-scoped GET cache is fully cleared so
|
|
446
455
|
* every cached read re-fetches as the new account. (The consuming
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant } from '../models/interfaces';
|
|
1
|
+
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, ServiceAssetMetadata } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
3
|
export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
4
4
|
new (...args: any[]): {
|
|
5
|
+
/**
|
|
6
|
+
* Service-token request, implemented by the auth mixin earlier in the
|
|
7
|
+
* composition pipeline (see `mixins/index.ts`). The assets mixin is typed
|
|
8
|
+
* against `OxyServicesBase`, which does not carry the auth mixin's methods,
|
|
9
|
+
* so this `declare` surfaces the inherited runtime method to TypeScript
|
|
10
|
+
* without re-implementing it. Used by
|
|
11
|
+
* {@link getServiceAssetMetadataByIds} to authenticate the server-to-server
|
|
12
|
+
* `/assets/service/by-ids` bulk fetch with a bearer service token.
|
|
13
|
+
*/
|
|
14
|
+
makeServiceRequest: <R = unknown>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: unknown, userId?: string) => Promise<R>;
|
|
5
15
|
/**
|
|
6
16
|
* Delete file
|
|
7
17
|
*/
|
|
@@ -49,6 +59,37 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
|
|
|
49
59
|
* Get download URLs for multiple files efficiently
|
|
50
60
|
*/
|
|
51
61
|
getFileDownloadUrls(fileIds: string[], context?: string): Promise<Record<string, string>>;
|
|
62
|
+
/**
|
|
63
|
+
* Resolve many Oxy asset ids to their content-addressed metadata in one
|
|
64
|
+
* round-trip per chunk via `POST /assets/service/by-ids` (body `{ ids }`).
|
|
65
|
+
*
|
|
66
|
+
* Returns each asset's `sha256`, `mime`, byte `size`, and `status` — built
|
|
67
|
+
* for server-to-server callers (e.g. Mention's MTN Protocol blob-ref
|
|
68
|
+
* resolution) that need the content hash for an asset id. Ids are
|
|
69
|
+
* deduplicated and validated (empty/blank ids dropped) before being split
|
|
70
|
+
* into chunks of {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} (the server-side
|
|
71
|
+
* cap). The server omits unknown/deleted ids from each chunk's `data`, so
|
|
72
|
+
* the merged result may be shorter than the requested id list and the caller
|
|
73
|
+
* is expected to map by `id`.
|
|
74
|
+
*
|
|
75
|
+
* **Service-token auth (required).** `/assets/service/by-ids` is guarded by
|
|
76
|
+
* `serviceAuthMiddleware` + the `files:read` scope and is called via
|
|
77
|
+
* `makeServiceRequest`, which attaches `Authorization: Bearer <serviceToken>`
|
|
78
|
+
* (the same client that calls `POST /assets/service/cache`). The calling
|
|
79
|
+
* client MUST be service-configured (`configureServiceAuth(apiKey,
|
|
80
|
+
* apiSecret)`) before invoking this method; otherwise `getServiceToken()`
|
|
81
|
+
* throws because no credentials are available. A plain user-session request
|
|
82
|
+
* is rejected by the route's service-auth guard.
|
|
83
|
+
*
|
|
84
|
+
* Resilience: chunks are independent. A failed chunk is logged and skipped —
|
|
85
|
+
* the method returns every entry that resolved successfully rather than
|
|
86
|
+
* discarding the whole call on one chunk's failure. An empty/whitespace-only
|
|
87
|
+
* input resolves immediately with `[]` and performs no network call.
|
|
88
|
+
*
|
|
89
|
+
* Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
|
|
90
|
+
* rate), mirroring the sibling service/POST methods which never cache.
|
|
91
|
+
*/
|
|
92
|
+
getServiceAssetMetadataByIds(ids: string[]): Promise<ServiceAssetMetadata[]>;
|
|
52
93
|
/**
|
|
53
94
|
* Upload raw file data
|
|
54
95
|
*/
|
|
@@ -48,6 +48,17 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
48
48
|
* server-to-server `/users/by-ids` bulk fetch with a bearer service token.
|
|
49
49
|
*/
|
|
50
50
|
makeServiceRequest: <R = unknown>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: unknown, userId?: string) => Promise<R>;
|
|
51
|
+
/**
|
|
52
|
+
* Raw service credentials stored by `configureServiceAuth()` on the auth
|
|
53
|
+
* mixin (earlier in the pipeline). Surfaced here via `declare` — for the
|
|
54
|
+
* same typing reason as `makeServiceRequest` above — so `getUsersByIds` can
|
|
55
|
+
* detect whether this instance is service-configured (a backend) and pick
|
|
56
|
+
* the bearer-service path, or fall back to the user-session path (a browser/
|
|
57
|
+
* RN client). Both are `null` until `configureServiceAuth(apiKey, apiSecret)`
|
|
58
|
+
* is called.
|
|
59
|
+
*/
|
|
60
|
+
_serviceApiKey: string | null;
|
|
61
|
+
_serviceApiSecret: string | null;
|
|
51
62
|
/**
|
|
52
63
|
* Get profile by username
|
|
53
64
|
*/
|
|
@@ -138,16 +149,28 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
138
149
|
* by `id`); each is run through `normalizeUserIdentity`, matching
|
|
139
150
|
* `getUserById`.
|
|
140
151
|
*
|
|
141
|
-
* **
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
152
|
+
* **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
|
|
153
|
+
* oxy-api: it accepts a service token, a user session, or an anonymous
|
|
154
|
+
* caller, and returns the SAME public `{ data: PublicUserProfile[] }`
|
|
155
|
+
* payload (canonical `name.displayName` + `_count`) in every case — no
|
|
156
|
+
* viewer-specific fields. This method picks the path automatically:
|
|
157
|
+
* - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
|
|
158
|
+
* apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
|
|
159
|
+
* (attaches `Authorization: Bearer <serviceToken>`). This is the
|
|
160
|
+
* server-to-server feed/notification hydration path (e.g. Mention's
|
|
161
|
+
* `PostHydrationService`) and is unchanged.
|
|
162
|
+
* - **Plain client (browser / React Native with a user session):** when no
|
|
163
|
+
* service credentials are configured, the chunk is fetched via
|
|
164
|
+
* `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
|
|
165
|
+
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
166
|
+
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
167
|
+
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
168
|
+
* this method always used the service path, so every client-side caller
|
|
169
|
+
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
170
|
+
*
|
|
171
|
+
* Both paths run results through `normalizeUserIdentity` and unwrap the
|
|
172
|
+
* API's `{ data }` envelope identically (`makeServiceRequest` is literally
|
|
173
|
+
* `makeRequest` plus a bearer service header).
|
|
151
174
|
*
|
|
152
175
|
* Resilience: chunks are independent. A failed chunk is logged and skipped
|
|
153
176
|
* — the method returns every user that resolved successfully rather than
|
|
@@ -447,6 +447,24 @@ export interface AssetUpdateVisibilityResponse {
|
|
|
447
447
|
updatedAt: string;
|
|
448
448
|
};
|
|
449
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* Minimal, service-token-scoped asset metadata returned by
|
|
452
|
+
* `POST /assets/service/by-ids`.
|
|
453
|
+
*
|
|
454
|
+
* Resolves an Oxy asset `id` to its content-addressed identity (`sha256`),
|
|
455
|
+
* MIME type, byte `size`, and storage `status`. Used by server-to-server
|
|
456
|
+
* callers (e.g. Mention's MTN Protocol blob-ref resolution) that hold a
|
|
457
|
+
* `files:read`-scoped service token rather than a user session. Unknown or
|
|
458
|
+
* deleted ids are omitted from the response (never error the whole batch),
|
|
459
|
+
* so the result may be shorter than the requested id list.
|
|
460
|
+
*/
|
|
461
|
+
export interface ServiceAssetMetadata {
|
|
462
|
+
id: string;
|
|
463
|
+
sha256: string;
|
|
464
|
+
mime: string;
|
|
465
|
+
size: number;
|
|
466
|
+
status: 'active' | 'trash';
|
|
467
|
+
}
|
|
450
468
|
/**
|
|
451
469
|
* Account storage usage (server-side usage, not local AsyncStorage)
|
|
452
470
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.1",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
97
|
"@oxyhq/contracts": "^0.7.0",
|
|
98
|
-
"@oxyhq/protocol": "^0.1.
|
|
98
|
+
"@oxyhq/protocol": "^0.1.1",
|
|
99
99
|
"bip39": "^3.1.0",
|
|
100
100
|
"buffer": "^6.0.3",
|
|
101
101
|
"elliptic": "^6.6.1",
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ import type { User } from '../models/interfaces';
|
|
|
35
35
|
import type { SessionLoginResponse } from '../models/session';
|
|
36
36
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
37
37
|
import { normalizeUserIdentity } from '../utils/userIdentity';
|
|
38
|
+
import { isWeb } from '../utils/platform';
|
|
39
|
+
import { logger } from '../utils/loggerUtils';
|
|
38
40
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
39
41
|
|
|
40
42
|
// ---------------------------------------------------------------------------
|
|
@@ -520,10 +522,19 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
520
522
|
* Unlike the removed `X-Acting-As` delegation header, the returned session
|
|
521
523
|
* IS the new identity: this plants `accessToken` as the active token —
|
|
522
524
|
* exactly like `claimSessionByToken` / `verifyChallenge` — so every
|
|
523
|
-
* subsequent request authenticates as the target account.
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
525
|
+
* subsequent request authenticates as the target account.
|
|
526
|
+
*
|
|
527
|
+
* Joining the device multi-account set (so the switch survives a reload and
|
|
528
|
+
* propagates cross-domain via `/auth/refresh-all`) requires a SECOND call, to
|
|
529
|
+
* `POST /auth/session`, made here after the token is planted. The switch route
|
|
530
|
+
* lives at `/accounts/*`, OUTSIDE the `oxy_rt_<authuser>` cookie's `Path=/auth`
|
|
531
|
+
* scope, so the server never sees the device's existing slots from it and
|
|
532
|
+
* would clobber slot 0 (destroying the operator's own session). `/auth/session`
|
|
533
|
+
* runs where those cookies ARE visible, so the server allocates a NEW slot that
|
|
534
|
+
* coexists with the operator's and returns its `authuser`. This step is
|
|
535
|
+
* web-only (native multi-account uses stored sessions, not cookies) and
|
|
536
|
+
* best-effort — a failure leaves the in-session switch intact; the switched
|
|
537
|
+
* account simply won't survive a reload until the cookie is next established.
|
|
527
538
|
*
|
|
528
539
|
* After planting, the SDK's identity-scoped GET cache is fully cleared so
|
|
529
540
|
* every cached read re-fetches as the new account. (The consuming
|
|
@@ -546,12 +557,45 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
546
557
|
|
|
547
558
|
// Plant the freshly minted session as the ACTIVE session, mirroring
|
|
548
559
|
// `claimSessionByToken` / `verifyChallenge`: the response body carries
|
|
549
|
-
// the first access token
|
|
550
|
-
// cookie, so there is nothing else to store here.
|
|
560
|
+
// the first access token. The device refresh cookie is established below.
|
|
551
561
|
if (res?.accessToken) {
|
|
552
562
|
this.setTokens(res.accessToken);
|
|
553
563
|
}
|
|
554
564
|
|
|
565
|
+
// Register the switched session in the device's multi-account set by
|
|
566
|
+
// establishing its first-party refresh cookie. This MUST be a separate
|
|
567
|
+
// call to `POST /auth/session`: the switch route is at `/accounts/*`,
|
|
568
|
+
// outside the `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can
|
|
569
|
+
// never read the device's existing slots and would overwrite slot 0
|
|
570
|
+
// (destroying the operator's own session). `/auth/session` runs where the
|
|
571
|
+
// cookies ARE visible, so the server allocates a NEW slot that coexists
|
|
572
|
+
// with the operator's and returns its `authuser`. Web-only; best-effort.
|
|
573
|
+
let authuser = res.authuser;
|
|
574
|
+
if (isWeb()) {
|
|
575
|
+
try {
|
|
576
|
+
const established = await this.makeRequest<{ accessToken?: string; authuser?: number }>(
|
|
577
|
+
'POST',
|
|
578
|
+
'/auth/session',
|
|
579
|
+
undefined,
|
|
580
|
+
{ cache: false },
|
|
581
|
+
);
|
|
582
|
+
if (typeof established?.authuser === 'number') {
|
|
583
|
+
authuser = established.authuser;
|
|
584
|
+
}
|
|
585
|
+
// /auth/session mints a fresh access token off the same session;
|
|
586
|
+
// re-plant it so the active token matches the rotated cookie.
|
|
587
|
+
if (established?.accessToken) {
|
|
588
|
+
this.setTokens(established.accessToken);
|
|
589
|
+
}
|
|
590
|
+
} catch (error) {
|
|
591
|
+
logger.warn(
|
|
592
|
+
'[OxyServices] Failed to establish device refresh cookie after account switch; the switch is active in-session but may not survive a reload',
|
|
593
|
+
{ component: 'OxyServices', method: 'switchToAccount' },
|
|
594
|
+
error,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
555
599
|
// Identity changed → drop the entire GET response cache so no entry
|
|
556
600
|
// personalised for the previous identity is reused. Cache keys are
|
|
557
601
|
// identity-scoped, so a different identity could not READ the old
|
|
@@ -561,6 +605,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
561
605
|
|
|
562
606
|
return {
|
|
563
607
|
...res,
|
|
608
|
+
...(typeof authuser === 'number' ? { authuser } : {}),
|
|
564
609
|
user: normalizeUserIdentity(res.user),
|
|
565
610
|
};
|
|
566
611
|
} catch (error) {
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor } from '../models/interfaces';
|
|
1
|
+
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor, ServiceAssetMetadata } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
3
|
import { isReactNative } from '@oxyhq/protocol';
|
|
4
|
+
import { logger } from '../utils/loggerUtils';
|
|
5
|
+
import { extractErrorStatus } from '../utils/errorUtils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maximum number of ids sent per `POST /assets/service/by-ids` request. Matches
|
|
9
|
+
* the server-side batch cap (the route rejects empty or > 100 id arrays with a
|
|
10
|
+
* 400); larger inputs are split into multiple chunked calls and merged. Mirrors
|
|
11
|
+
* `getUsersByIds`'s `USERS_BY_IDS_CHUNK_SIZE`.
|
|
12
|
+
*/
|
|
13
|
+
const SERVICE_ASSET_METADATA_CHUNK_SIZE = 100;
|
|
4
14
|
|
|
5
15
|
export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
6
16
|
return class extends Base {
|
|
@@ -8,6 +18,22 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
8
18
|
super(...(args as [any]));
|
|
9
19
|
}
|
|
10
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Service-token request, implemented by the auth mixin earlier in the
|
|
23
|
+
* composition pipeline (see `mixins/index.ts`). The assets mixin is typed
|
|
24
|
+
* against `OxyServicesBase`, which does not carry the auth mixin's methods,
|
|
25
|
+
* so this `declare` surfaces the inherited runtime method to TypeScript
|
|
26
|
+
* without re-implementing it. Used by
|
|
27
|
+
* {@link getServiceAssetMetadataByIds} to authenticate the server-to-server
|
|
28
|
+
* `/assets/service/by-ids` bulk fetch with a bearer service token.
|
|
29
|
+
*/
|
|
30
|
+
declare makeServiceRequest: <R = unknown>(
|
|
31
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
32
|
+
url: string,
|
|
33
|
+
data?: unknown,
|
|
34
|
+
userId?: string,
|
|
35
|
+
) => Promise<R>;
|
|
36
|
+
|
|
11
37
|
/**
|
|
12
38
|
* Delete file
|
|
13
39
|
*/
|
|
@@ -167,6 +193,74 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
167
193
|
return urls;
|
|
168
194
|
}
|
|
169
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Resolve many Oxy asset ids to their content-addressed metadata in one
|
|
198
|
+
* round-trip per chunk via `POST /assets/service/by-ids` (body `{ ids }`).
|
|
199
|
+
*
|
|
200
|
+
* Returns each asset's `sha256`, `mime`, byte `size`, and `status` — built
|
|
201
|
+
* for server-to-server callers (e.g. Mention's MTN Protocol blob-ref
|
|
202
|
+
* resolution) that need the content hash for an asset id. Ids are
|
|
203
|
+
* deduplicated and validated (empty/blank ids dropped) before being split
|
|
204
|
+
* into chunks of {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} (the server-side
|
|
205
|
+
* cap). The server omits unknown/deleted ids from each chunk's `data`, so
|
|
206
|
+
* the merged result may be shorter than the requested id list and the caller
|
|
207
|
+
* is expected to map by `id`.
|
|
208
|
+
*
|
|
209
|
+
* **Service-token auth (required).** `/assets/service/by-ids` is guarded by
|
|
210
|
+
* `serviceAuthMiddleware` + the `files:read` scope and is called via
|
|
211
|
+
* `makeServiceRequest`, which attaches `Authorization: Bearer <serviceToken>`
|
|
212
|
+
* (the same client that calls `POST /assets/service/cache`). The calling
|
|
213
|
+
* client MUST be service-configured (`configureServiceAuth(apiKey,
|
|
214
|
+
* apiSecret)`) before invoking this method; otherwise `getServiceToken()`
|
|
215
|
+
* throws because no credentials are available. A plain user-session request
|
|
216
|
+
* is rejected by the route's service-auth guard.
|
|
217
|
+
*
|
|
218
|
+
* Resilience: chunks are independent. A failed chunk is logged and skipped —
|
|
219
|
+
* the method returns every entry that resolved successfully rather than
|
|
220
|
+
* discarding the whole call on one chunk's failure. An empty/whitespace-only
|
|
221
|
+
* input resolves immediately with `[]` and performs no network call.
|
|
222
|
+
*
|
|
223
|
+
* Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
|
|
224
|
+
* rate), mirroring the sibling service/POST methods which never cache.
|
|
225
|
+
*/
|
|
226
|
+
async getServiceAssetMetadataByIds(ids: string[]): Promise<ServiceAssetMetadata[]> {
|
|
227
|
+
const uniqueIds = Array.from(
|
|
228
|
+
new Set(ids.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)),
|
|
229
|
+
);
|
|
230
|
+
if (uniqueIds.length === 0) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const chunks: string[][] = [];
|
|
235
|
+
for (let i = 0; i < uniqueIds.length; i += SERVICE_ASSET_METADATA_CHUNK_SIZE) {
|
|
236
|
+
chunks.push(uniqueIds.slice(i, i + SERVICE_ASSET_METADATA_CHUNK_SIZE));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
240
|
+
const settled = await Promise.all(
|
|
241
|
+
chunks.map(async (chunk): Promise<ServiceAssetMetadata[]> => {
|
|
242
|
+
try {
|
|
243
|
+
const entries = await this.makeServiceRequest<ServiceAssetMetadata[]>(
|
|
244
|
+
'POST',
|
|
245
|
+
'/assets/service/by-ids',
|
|
246
|
+
{ ids: chunk },
|
|
247
|
+
);
|
|
248
|
+
return Array.isArray(entries) ? entries : [];
|
|
249
|
+
} catch (error: unknown) {
|
|
250
|
+
logger.warn('getServiceAssetMetadataByIds: chunk failed, continuing with remaining chunks', {
|
|
251
|
+
method: 'getServiceAssetMetadataByIds',
|
|
252
|
+
chunkSize: chunk.length,
|
|
253
|
+
status: extractErrorStatus(error),
|
|
254
|
+
error: error instanceof Error ? error.message : String(error),
|
|
255
|
+
});
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
return settled.flat();
|
|
262
|
+
}
|
|
263
|
+
|
|
170
264
|
/**
|
|
171
265
|
* Upload raw file data
|
|
172
266
|
*/
|
|
@@ -88,6 +88,18 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
88
88
|
userId?: string,
|
|
89
89
|
) => Promise<R>;
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Raw service credentials stored by `configureServiceAuth()` on the auth
|
|
93
|
+
* mixin (earlier in the pipeline). Surfaced here via `declare` — for the
|
|
94
|
+
* same typing reason as `makeServiceRequest` above — so `getUsersByIds` can
|
|
95
|
+
* detect whether this instance is service-configured (a backend) and pick
|
|
96
|
+
* the bearer-service path, or fall back to the user-session path (a browser/
|
|
97
|
+
* RN client). Both are `null` until `configureServiceAuth(apiKey, apiSecret)`
|
|
98
|
+
* is called.
|
|
99
|
+
*/
|
|
100
|
+
declare _serviceApiKey: string | null;
|
|
101
|
+
declare _serviceApiSecret: string | null;
|
|
102
|
+
|
|
91
103
|
/**
|
|
92
104
|
* Get profile by username
|
|
93
105
|
*/
|
|
@@ -349,16 +361,28 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
349
361
|
* by `id`); each is run through `normalizeUserIdentity`, matching
|
|
350
362
|
* `getUserById`.
|
|
351
363
|
*
|
|
352
|
-
* **
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
364
|
+
* **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
|
|
365
|
+
* oxy-api: it accepts a service token, a user session, or an anonymous
|
|
366
|
+
* caller, and returns the SAME public `{ data: PublicUserProfile[] }`
|
|
367
|
+
* payload (canonical `name.displayName` + `_count`) in every case — no
|
|
368
|
+
* viewer-specific fields. This method picks the path automatically:
|
|
369
|
+
* - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
|
|
370
|
+
* apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
|
|
371
|
+
* (attaches `Authorization: Bearer <serviceToken>`). This is the
|
|
372
|
+
* server-to-server feed/notification hydration path (e.g. Mention's
|
|
373
|
+
* `PostHydrationService`) and is unchanged.
|
|
374
|
+
* - **Plain client (browser / React Native with a user session):** when no
|
|
375
|
+
* service credentials are configured, the chunk is fetched via
|
|
376
|
+
* `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
|
|
377
|
+
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
378
|
+
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
379
|
+
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
380
|
+
* this method always used the service path, so every client-side caller
|
|
381
|
+
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
382
|
+
*
|
|
383
|
+
* Both paths run results through `normalizeUserIdentity` and unwrap the
|
|
384
|
+
* API's `{ data }` envelope identically (`makeServiceRequest` is literally
|
|
385
|
+
* `makeRequest` plus a bearer service header).
|
|
362
386
|
*
|
|
363
387
|
* Resilience: chunks are independent. A failed chunk is logged and skipped
|
|
364
388
|
* — the method returns every user that resolved successfully rather than
|
|
@@ -381,15 +405,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
381
405
|
chunks.push(uniqueIds.slice(i, i + USERS_BY_IDS_CHUNK_SIZE));
|
|
382
406
|
}
|
|
383
407
|
|
|
408
|
+
// A backend that called configureServiceAuth() uses the bearer-service
|
|
409
|
+
// path; any other caller (browser / RN with a user session) uses the
|
|
410
|
+
// user-bearer path. See the method doc for why the user path is CSRF-safe.
|
|
411
|
+
const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
412
|
+
|
|
384
413
|
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
385
414
|
const settled = await Promise.all(
|
|
386
415
|
chunks.map(async (chunk): Promise<User[]> => {
|
|
387
416
|
try {
|
|
388
|
-
const users =
|
|
417
|
+
const users = useServiceAuth
|
|
418
|
+
? await this.makeServiceRequest<User[]>('POST', '/users/by-ids', { ids: chunk })
|
|
419
|
+
: await this.makeRequest<User[]>('POST', '/users/by-ids', { ids: chunk }, { cache: false });
|
|
389
420
|
return Array.isArray(users) ? users.map((user) => normalizeUserIdentity(user)) : [];
|
|
390
421
|
} catch (error: unknown) {
|
|
391
422
|
logger.warn('getUsersByIds: chunk failed, continuing with remaining chunks', {
|
|
392
423
|
method: 'getUsersByIds',
|
|
424
|
+
mode: useServiceAuth ? 'service' : 'user',
|
|
393
425
|
chunkSize: chunk.length,
|
|
394
426
|
status: extractErrorStatus(error),
|
|
395
427
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `getServiceAssetMetadataByIds` mixin tests.
|
|
3
|
+
*
|
|
4
|
+
* Stubs `makeServiceRequest` (the service-token transport used by the route's
|
|
5
|
+
* `serviceAuthMiddleware` + `files:read` scope) so the tests run with no network
|
|
6
|
+
* and no `getServiceToken()` round-trip, then asserts:
|
|
7
|
+
* - empty / whitespace-only input no-ops to `[]` with no network call;
|
|
8
|
+
* - input is de-duplicated and a single chunk is sent for <= 100 unique ids,
|
|
9
|
+
* POSTed to `/assets/service/by-ids` as `{ ids }`;
|
|
10
|
+
* - the `{ data }` envelope is unwrapped to a bare `ServiceAssetMetadata[]`
|
|
11
|
+
* (mirroring how `makeServiceRequest<T[]>` returns the inner array);
|
|
12
|
+
* - inputs > 100 ids are chunked at 100/request and merged;
|
|
13
|
+
* - a failed chunk is logged and skipped — successful chunks still return.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ServiceAssetMetadata } from '../../models/interfaces';
|
|
17
|
+
import { OxyServices } from '../../OxyServices';
|
|
18
|
+
|
|
19
|
+
const sampleEntry: ServiceAssetMetadata = {
|
|
20
|
+
id: 'asset-1',
|
|
21
|
+
sha256: 'a'.repeat(64),
|
|
22
|
+
mime: 'image/jpeg',
|
|
23
|
+
size: 12345,
|
|
24
|
+
status: 'active',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('OxyServices.assets — getServiceAssetMetadataByIds', () => {
|
|
28
|
+
let oxy: OxyServices;
|
|
29
|
+
let makeServiceRequestSpy: jest.SpyInstance;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
33
|
+
makeServiceRequestSpy = jest.spyOn(oxy, 'makeServiceRequest');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
jest.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns [] and performs no network call for empty / whitespace input', async () => {
|
|
41
|
+
await expect(oxy.getServiceAssetMetadataByIds([])).resolves.toEqual([]);
|
|
42
|
+
await expect(oxy.getServiceAssetMetadataByIds(['', ' '])).resolves.toEqual([]);
|
|
43
|
+
expect(makeServiceRequestSpy).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('de-duplicates and sends a single chunk for <= 100 unique ids', async () => {
|
|
47
|
+
// makeServiceRequest unwraps the API's `{ data }` envelope, so the resolved
|
|
48
|
+
// value is the bare array (NOT `{ data: [...] }`) — mirror that real shape.
|
|
49
|
+
makeServiceRequestSpy.mockResolvedValueOnce([sampleEntry]);
|
|
50
|
+
|
|
51
|
+
const result = await oxy.getServiceAssetMetadataByIds([
|
|
52
|
+
'asset-1',
|
|
53
|
+
'asset-1', // duplicate
|
|
54
|
+
' ', // dropped
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
expect(result).toEqual([sampleEntry]);
|
|
58
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledWith(
|
|
60
|
+
'POST',
|
|
61
|
+
'/assets/service/by-ids',
|
|
62
|
+
{ ids: ['asset-1'] },
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('chunks at 100 ids per request and merges each chunk', async () => {
|
|
67
|
+
const ids = Array.from({ length: 250 }, (_, i) => `asset-${i}`);
|
|
68
|
+
|
|
69
|
+
makeServiceRequestSpy.mockImplementation(
|
|
70
|
+
async (
|
|
71
|
+
_method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
72
|
+
_url: string,
|
|
73
|
+
data?: { ids: string[] },
|
|
74
|
+
): Promise<ServiceAssetMetadata[]> =>
|
|
75
|
+
(data?.ids ?? []).map((id) => ({
|
|
76
|
+
id,
|
|
77
|
+
sha256: 'b'.repeat(64),
|
|
78
|
+
mime: 'application/octet-stream',
|
|
79
|
+
size: 1,
|
|
80
|
+
status: 'active' as const,
|
|
81
|
+
})),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const result = await oxy.getServiceAssetMetadataByIds(ids);
|
|
85
|
+
|
|
86
|
+
// 250 unique ids => 100 + 100 + 50 across three POSTs.
|
|
87
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(3);
|
|
88
|
+
const chunkSizes = makeServiceRequestSpy.mock.calls.map(
|
|
89
|
+
(call) => (call[2] as { ids: string[] }).ids.length,
|
|
90
|
+
);
|
|
91
|
+
expect(chunkSizes).toEqual([100, 100, 50]);
|
|
92
|
+
|
|
93
|
+
expect(result).toHaveLength(250);
|
|
94
|
+
expect(result[0]).toEqual({
|
|
95
|
+
id: 'asset-0',
|
|
96
|
+
sha256: 'b'.repeat(64),
|
|
97
|
+
mime: 'application/octet-stream',
|
|
98
|
+
size: 1,
|
|
99
|
+
status: 'active',
|
|
100
|
+
});
|
|
101
|
+
expect(result[249]?.id).toBe('asset-249');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('skips a failed chunk and returns the entries that resolved', async () => {
|
|
105
|
+
const ids = Array.from({ length: 150 }, (_, i) => `asset-${i}`);
|
|
106
|
+
|
|
107
|
+
makeServiceRequestSpy
|
|
108
|
+
.mockResolvedValueOnce([sampleEntry]) // first chunk (100 ids) succeeds
|
|
109
|
+
.mockRejectedValueOnce(new Error('chunk failed')); // second chunk (50 ids) fails
|
|
110
|
+
|
|
111
|
+
const result = await oxy.getServiceAssetMetadataByIds(ids);
|
|
112
|
+
|
|
113
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
|
|
114
|
+
expect(result).toEqual([sampleEntry]);
|
|
115
|
+
});
|
|
116
|
+
});
|