@oxyhq/core 5.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.accounts.js +42 -6
- package/dist/cjs/mixins/OxyServices.assets.js +66 -0
- 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/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/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/__tests__/OxyServices.serviceAssetMetadata.test.ts +116 -0
- package/src/mixins/__tests__/accounts.test.ts +66 -16
- 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
|
*/
|
|
@@ -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.0",
|
|
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
|
*/
|
|
@@ -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
|
+
});
|
|
@@ -176,57 +176,100 @@ describe('OxyServices.accounts', () => {
|
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
describe('switchToAccount', () => {
|
|
179
|
+
// The switch route NO LONGER returns `authuser` or sets the device cookie —
|
|
180
|
+
// it can't (it's at /accounts/*, outside the Path=/auth cookie scope). The SDK
|
|
181
|
+
// establishes the cookie + resolves the slot via a follow-up POST /auth/session.
|
|
179
182
|
const switchResponse: SwitchAccountResult = {
|
|
180
183
|
sessionId: 'sess_switch',
|
|
181
184
|
deviceId: 'dev_switch',
|
|
182
185
|
expiresAt: '2026-06-30T01:00:00.000Z',
|
|
183
186
|
accessToken: 'access_switch',
|
|
184
187
|
user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } },
|
|
185
|
-
authuser: 2,
|
|
186
188
|
};
|
|
189
|
+
// POST /auth/session establishes the cookie in a correctly-allocated NEW slot
|
|
190
|
+
// and returns that slot + a fresh access token off the same session.
|
|
191
|
+
const sessionResponse = { accessToken: 'access_session', authuser: 1 };
|
|
192
|
+
|
|
193
|
+
// Route makeRequest by path: the switch call vs the /auth/session establishment.
|
|
194
|
+
const routeByPath = (response = switchResponse) =>
|
|
195
|
+
makeRequestSpy.mockImplementation((_method: string, path: string) =>
|
|
196
|
+
Promise.resolve(path === '/auth/session' ? sessionResponse : response),
|
|
197
|
+
);
|
|
187
198
|
|
|
188
|
-
it('posts to /:id/switch
|
|
199
|
+
it('posts to /:id/switch then establishes the cookie via POST /auth/session, planting both tokens and sweeping the cache', async () => {
|
|
189
200
|
const setTokensSpy = jest.spyOn(oxy, 'setTokens');
|
|
190
201
|
const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
|
|
191
|
-
|
|
202
|
+
routeByPath();
|
|
192
203
|
|
|
193
204
|
const result = await oxy.switchToAccount('acc1');
|
|
194
205
|
|
|
195
|
-
//
|
|
206
|
+
// Switch request shape: POST, exact path, no body, cache disabled.
|
|
196
207
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
197
208
|
'POST',
|
|
198
209
|
'/accounts/acc1/switch',
|
|
199
210
|
undefined,
|
|
200
211
|
expect.objectContaining({ cache: false }),
|
|
201
212
|
);
|
|
213
|
+
// Then the canonical refresh-cookie establishment under /auth (where the
|
|
214
|
+
// device's oxy_rt_* slots ARE visible, so a NEW slot is allocated).
|
|
215
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
216
|
+
'POST',
|
|
217
|
+
'/auth/session',
|
|
218
|
+
undefined,
|
|
219
|
+
expect.objectContaining({ cache: false }),
|
|
220
|
+
);
|
|
202
221
|
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
expect(setTokensSpy).
|
|
206
|
-
expect(oxy.getAccessToken()).toBe('
|
|
222
|
+
// The switch token is planted first; /auth/session's fresh token re-planted.
|
|
223
|
+
expect(setTokensSpy).toHaveBeenNthCalledWith(1, 'access_switch');
|
|
224
|
+
expect(setTokensSpy).toHaveBeenNthCalledWith(2, 'access_session');
|
|
225
|
+
expect(oxy.getAccessToken()).toBe('access_session');
|
|
207
226
|
expect(oxy.hasValidToken()).toBe(true);
|
|
208
227
|
|
|
209
|
-
//
|
|
210
|
-
// new account, AND it happens AFTER the token is planted.
|
|
228
|
+
// Cache swept once, AFTER both tokens are planted.
|
|
211
229
|
expect(clearCacheSpy).toHaveBeenCalledTimes(1);
|
|
212
|
-
expect(setTokensSpy.mock.invocationCallOrder[
|
|
230
|
+
expect(setTokensSpy.mock.invocationCallOrder[1]).toBeLessThan(
|
|
213
231
|
clearCacheSpy.mock.invocationCallOrder[0],
|
|
214
232
|
);
|
|
215
233
|
|
|
216
|
-
// The returned session carries the target account (id-normalised)
|
|
217
|
-
|
|
218
|
-
expect(result.
|
|
234
|
+
// The returned session carries the target account (id-normalised) and the
|
|
235
|
+
// slot resolved by /auth/session — NEVER the clobbering slot 0.
|
|
236
|
+
expect(result.sessionId).toBe('sess_switch');
|
|
237
|
+
expect(result.user).toEqual({ id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } });
|
|
238
|
+
expect(result.authuser).toBe(1);
|
|
219
239
|
|
|
220
240
|
setTokensSpy.mockRestore();
|
|
221
241
|
clearCacheSpy.mockRestore();
|
|
222
242
|
});
|
|
223
243
|
|
|
224
244
|
it('URL-encodes the accountId path segment', async () => {
|
|
225
|
-
|
|
245
|
+
routeByPath();
|
|
226
246
|
await oxy.switchToAccount('a b/c');
|
|
227
247
|
expect(makeRequestSpy.mock.calls[0][1]).toBe('/accounts/a%20b%2Fc/switch');
|
|
228
248
|
});
|
|
229
249
|
|
|
250
|
+
it('keeps the switch active in-session when /auth/session fails (best-effort cookie)', async () => {
|
|
251
|
+
const setTokensSpy = jest.spyOn(oxy, 'setTokens');
|
|
252
|
+
const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
|
|
253
|
+
makeRequestSpy.mockImplementation((_method: string, path: string) =>
|
|
254
|
+
path === '/auth/session'
|
|
255
|
+
? Promise.reject(Object.assign(new Error('origin'), { response: { status: 403 } }))
|
|
256
|
+
: Promise.resolve(switchResponse),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const result = await oxy.switchToAccount('acc1');
|
|
260
|
+
|
|
261
|
+
// The in-session switch survives: the switch token stays planted and the
|
|
262
|
+
// cache is still swept. The switched account just won't survive a reload
|
|
263
|
+
// until the cookie is next established (no authuser resolved).
|
|
264
|
+
expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
|
|
265
|
+
expect(oxy.getAccessToken()).toBe('access_switch');
|
|
266
|
+
expect(clearCacheSpy).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(result.authuser).toBeUndefined();
|
|
268
|
+
|
|
269
|
+
setTokensSpy.mockRestore();
|
|
270
|
+
clearCacheSpy.mockRestore();
|
|
271
|
+
});
|
|
272
|
+
|
|
230
273
|
it('does NOT plant or sweep when the operator is not authorized (403 surfaces via handleError)', async () => {
|
|
231
274
|
const setTokensSpy = jest.spyOn(oxy, 'setTokens');
|
|
232
275
|
const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
|
|
@@ -235,9 +278,16 @@ describe('OxyServices.accounts', () => {
|
|
|
235
278
|
);
|
|
236
279
|
|
|
237
280
|
await expect(oxy.switchToAccount('acc1')).rejects.toThrow();
|
|
238
|
-
// A failed switch must NOT mutate session state
|
|
281
|
+
// A failed switch must NOT mutate session state, and must NOT attempt the
|
|
282
|
+
// /auth/session establishment.
|
|
239
283
|
expect(setTokensSpy).not.toHaveBeenCalled();
|
|
240
284
|
expect(clearCacheSpy).not.toHaveBeenCalled();
|
|
285
|
+
expect(makeRequestSpy).not.toHaveBeenCalledWith(
|
|
286
|
+
'POST',
|
|
287
|
+
'/auth/session',
|
|
288
|
+
undefined,
|
|
289
|
+
expect.anything(),
|
|
290
|
+
);
|
|
241
291
|
|
|
242
292
|
setTokensSpy.mockRestore();
|
|
243
293
|
clearCacheSpy.mockRestore();
|
package/src/models/interfaces.ts
CHANGED
|
@@ -514,6 +514,25 @@ export interface AssetUpdateVisibilityResponse {
|
|
|
514
514
|
};
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Minimal, service-token-scoped asset metadata returned by
|
|
519
|
+
* `POST /assets/service/by-ids`.
|
|
520
|
+
*
|
|
521
|
+
* Resolves an Oxy asset `id` to its content-addressed identity (`sha256`),
|
|
522
|
+
* MIME type, byte `size`, and storage `status`. Used by server-to-server
|
|
523
|
+
* callers (e.g. Mention's MTN Protocol blob-ref resolution) that hold a
|
|
524
|
+
* `files:read`-scoped service token rather than a user session. Unknown or
|
|
525
|
+
* deleted ids are omitted from the response (never error the whole batch),
|
|
526
|
+
* so the result may be shorter than the requested id list.
|
|
527
|
+
*/
|
|
528
|
+
export interface ServiceAssetMetadata {
|
|
529
|
+
id: string;
|
|
530
|
+
sha256: string;
|
|
531
|
+
mime: string;
|
|
532
|
+
size: number;
|
|
533
|
+
status: 'active' | 'trash';
|
|
534
|
+
}
|
|
535
|
+
|
|
517
536
|
/**
|
|
518
537
|
* Account storage usage (server-side usage, not local AsyncStorage)
|
|
519
538
|
*/
|