@oxyhq/core 4.0.1 → 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.
Files changed (112) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -18
  3. package/dist/cjs/OxyServices.base.js +0 -21
  4. package/dist/cjs/crypto/keyManager.js +7 -7
  5. package/dist/cjs/crypto/polyfill.js +6 -5
  6. package/dist/cjs/crypto/signatureService.js +44 -220
  7. package/dist/cjs/index.js +4 -8
  8. package/dist/cjs/mixins/OxyServices.accounts.js +90 -0
  9. package/dist/cjs/mixins/OxyServices.assets.js +68 -2
  10. package/dist/cjs/mixins/OxyServices.auth.js +3 -3
  11. package/dist/cjs/mixins/OxyServices.civic.js +3 -3
  12. package/dist/cjs/mixins/OxyServices.language.js +2 -2
  13. package/dist/cjs/mixins/OxyServices.utility.js +7 -95
  14. package/dist/cjs/utils/cacheKey.js +17 -19
  15. package/dist/cjs/utils/deviceManager.js +2 -2
  16. package/dist/cjs/utils/platform.js +0 -14
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/HttpService.js +6 -18
  19. package/dist/esm/OxyServices.base.js +0 -21
  20. package/dist/esm/crypto/keyManager.js +4 -4
  21. package/dist/esm/crypto/polyfill.js +5 -4
  22. package/dist/esm/crypto/signatureService.js +39 -214
  23. package/dist/esm/index.js +1 -2
  24. package/dist/esm/mixins/OxyServices.accounts.js +90 -0
  25. package/dist/esm/mixins/OxyServices.assets.js +67 -1
  26. package/dist/esm/mixins/OxyServices.auth.js +1 -1
  27. package/dist/esm/mixins/OxyServices.civic.js +3 -3
  28. package/dist/esm/mixins/OxyServices.language.js +1 -1
  29. package/dist/esm/mixins/OxyServices.utility.js +6 -94
  30. package/dist/esm/utils/cacheKey.js +17 -19
  31. package/dist/esm/utils/deviceManager.js +1 -1
  32. package/dist/esm/utils/platform.js +0 -12
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/HttpService.d.ts +3 -6
  35. package/dist/types/OxyServices.base.d.ts +0 -17
  36. package/dist/types/crypto/polyfill.d.ts +2 -2
  37. package/dist/types/crypto/signatureService.d.ts +18 -84
  38. package/dist/types/index.d.ts +4 -5
  39. package/dist/types/mixins/OxyServices.accounts.d.ts +66 -5
  40. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  41. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  42. package/dist/types/mixins/OxyServices.assets.d.ts +42 -3
  43. package/dist/types/mixins/OxyServices.auth.d.ts +0 -2
  44. package/dist/types/mixins/OxyServices.civic.d.ts +3 -5
  45. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  46. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  47. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  48. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  49. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -2
  50. package/dist/types/mixins/OxyServices.identity.d.ts +8 -5
  51. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  52. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  53. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  54. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -44
  55. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  56. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  57. package/dist/types/mixins/OxyServices.redirect.d.ts +0 -2
  58. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  59. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  60. package/dist/types/mixins/OxyServices.silent.d.ts +0 -2
  61. package/dist/types/mixins/OxyServices.sso.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  64. package/dist/types/mixins/OxyServices.utility.d.ts +0 -32
  65. package/dist/types/models/interfaces.d.ts +18 -0
  66. package/dist/types/server/auth.d.ts +0 -6
  67. package/dist/types/server/index.d.ts +1 -1
  68. package/dist/types/utils/cacheKey.d.ts +6 -7
  69. package/dist/types/utils/platform.d.ts +0 -8
  70. package/package.json +4 -7
  71. package/src/HttpService.ts +6 -22
  72. package/src/OxyServices.base.ts +0 -23
  73. package/src/__tests__/httpServiceCache.test.ts +0 -19
  74. package/src/crypto/__tests__/keyManager.atomicity.test.ts +2 -1
  75. package/src/crypto/__tests__/keyManager.test.ts +9 -7
  76. package/src/crypto/__tests__/signChallengeShared.test.ts +2 -1
  77. package/src/crypto/__tests__/signedRecord.test.ts +37 -150
  78. package/src/crypto/keyManager.ts +28 -17
  79. package/src/crypto/polyfill.ts +5 -4
  80. package/src/crypto/signatureService.ts +67 -255
  81. package/src/index.ts +4 -3
  82. package/src/mixins/OxyServices.accounts.ts +136 -3
  83. package/src/mixins/OxyServices.assets.ts +96 -2
  84. package/src/mixins/OxyServices.auth.ts +1 -1
  85. package/src/mixins/OxyServices.civic.ts +6 -17
  86. package/src/mixins/OxyServices.identity.ts +8 -2
  87. package/src/mixins/OxyServices.language.ts +1 -1
  88. package/src/mixins/OxyServices.nodes.ts +1 -12
  89. package/src/mixins/OxyServices.utility.ts +6 -119
  90. package/src/mixins/__tests__/OxyServices.civic.test.ts +2 -2
  91. package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +116 -0
  92. package/src/mixins/__tests__/accounts.test.ts +120 -0
  93. package/src/models/interfaces.ts +19 -0
  94. package/src/server/auth.ts +0 -7
  95. package/src/server/index.ts +0 -1
  96. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  97. package/src/utils/cacheKey.ts +16 -21
  98. package/src/utils/deviceManager.ts +1 -1
  99. package/src/utils/platform.ts +0 -14
  100. package/dist/cjs/crypto/canonicalJson.js +0 -107
  101. package/dist/cjs/utils/platformCrypto.js +0 -165
  102. package/dist/cjs/utils/platformCrypto.native.js +0 -123
  103. package/dist/esm/crypto/canonicalJson.js +0 -104
  104. package/dist/esm/utils/platformCrypto.js +0 -125
  105. package/dist/esm/utils/platformCrypto.native.js +0 -80
  106. package/dist/types/crypto/canonicalJson.d.ts +0 -44
  107. package/dist/types/utils/platformCrypto.d.ts +0 -87
  108. package/dist/types/utils/platformCrypto.native.d.ts +0 -54
  109. package/src/crypto/__tests__/canonicalJson.test.ts +0 -116
  110. package/src/crypto/canonicalJson.ts +0 -120
  111. package/src/utils/platformCrypto.native.ts +0 -101
  112. package/src/utils/platformCrypto.ts +0 -145
@@ -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
- import { isReactNative } from '../utils/platform';
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
  */
@@ -16,7 +16,7 @@ import type { PublicApplication } from './OxyServices.connectedApps';
16
16
  import { OxyAuthenticationError } from '../OxyServices.errors';
17
17
  import { KeyManager } from '../crypto/keyManager';
18
18
  import { SignatureService } from '../crypto/signatureService';
19
- import { loadNodeCrypto } from '../utils/platformCrypto';
19
+ import { loadNodeCrypto } from '@oxyhq/protocol';
20
20
  import { logger } from '../utils/loggerUtils';
21
21
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
22
22
 
@@ -49,17 +49,18 @@
49
49
  * session.
50
50
  */
51
51
  import type {
52
+ ChainHeadResponse,
52
53
  CredentialIssueResult,
53
54
  CredentialListResult,
54
55
  CredentialStatus,
55
56
  CredentialVerifyResult,
56
57
  ExportAttestation,
58
+ OxySignedRecordType,
57
59
  PersonhoodStatusResult,
58
60
  PublicCard,
59
61
  RealLifeAttestationResult,
60
62
  SignedPublicCard,
61
63
  SignedRecordEnvelope,
62
- SignedRecordType,
63
64
  ValidationRequestSummary,
64
65
  ValidationVerdict,
65
66
  ValidationVoteResult,
@@ -67,7 +68,7 @@ import type {
67
68
  VouchResult,
68
69
  } from '@oxyhq/contracts';
69
70
  import type { OxyServicesBase } from '../OxyServices.base';
70
- import { canonicalize } from '../crypto/canonicalJson';
71
+ import { canonicalize, verifySignature } from '@oxyhq/protocol';
71
72
  import { SignatureService } from '../crypto/signatureService';
72
73
  import { buildUserDid } from './OxyServices.identity';
73
74
  import { CACHE_TIMES } from './mixinHelpers';
@@ -290,7 +291,7 @@ export function parseAttestPayload(raw: string): ParsedAttestPayload | null {
290
291
  * key, matching the server which omits absent keys entirely) and checks the
291
292
  * `ES256K-DER-SHA256` signature against `attestation.publicKey`.
292
293
  *
293
- * NEVER throws: `SignatureService.verify` already swallows malformed-input
294
+ * NEVER throws: `verifySignature` already swallows malformed-input
294
295
  * errors and returns `false`, and an absent attestation short-circuits to
295
296
  * `false`. A pure, reusable helper (Commons can call it on a cached card).
296
297
  *
@@ -308,7 +309,7 @@ export async function verifyPublicCardAttestation(
308
309
  if (!signature || !publicKey) {
309
310
  return false;
310
311
  }
311
- return SignatureService.verify(canonicalize(card), signature, publicKey);
312
+ return verifySignature(canonicalize(card), signature, publicKey);
312
313
  }
313
314
 
314
315
  /**
@@ -390,18 +391,6 @@ export interface RevokeCredentialResult {
390
391
  credential: VerifiableCredentialResponse;
391
392
  }
392
393
 
393
- /**
394
- * The current chain head as returned by `GET /identity/records/:userId/chain/head`.
395
- * `headRecordId` is `null` and `seq` is `-1` when the subject has no chain yet,
396
- * so the next record's coordinates are always `seq: head.seq + 1` (genesis = 0)
397
- * and `prev: head.headRecordId` (genesis = null).
398
- */
399
- interface ChainHeadResponse {
400
- headRecordId: string | null;
401
- seq: number;
402
- recordCount: number;
403
- }
404
-
405
394
  export function OxyServicesCivicMixin<T extends typeof OxyServicesBase>(Base: T) {
406
395
  return class extends Base {
407
396
  constructor(...args: any[]) {
@@ -929,7 +918,7 @@ export function OxyServicesCivicMixin<T extends typeof OxyServicesBase>(Base: T)
929
918
  * @param rkey - The AtProto-style record key within the collection.
930
919
  */
931
920
  async _signMyCivicRecordV2(
932
- type: SignedRecordType,
921
+ type: OxySignedRecordType,
933
922
  record: Record<string, unknown>,
934
923
  collection: string,
935
924
  rkey: string,
@@ -28,6 +28,7 @@ import type {
28
28
  DidDocument,
29
29
  DomainVerificationInstructions,
30
30
  ExportBundle,
31
+ OxySignedRecordType,
31
32
  SignedRecordEnvelope,
32
33
  VerifiedDomain,
33
34
  } from '@oxyhq/contracts';
@@ -43,8 +44,13 @@ import { CACHE_TIMES } from './mixinHelpers';
43
44
  */
44
45
  const OXY_IDENTITY_APEX = 'oxy.so';
45
46
 
46
- /** Record categories a client may sign and publish. */
47
- export type IdentityRecordType = SignedRecordEnvelope['type'];
47
+ /**
48
+ * Record categories a client may sign and publish to the Oxy store. The base
49
+ * envelope `type` is now an open string (any app may define its own records on
50
+ * the shared grammar); the Oxy identity store re-narrows it to the closed Oxy
51
+ * record set.
52
+ */
53
+ export type IdentityRecordType = OxySignedRecordType;
48
54
 
49
55
  /** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
50
56
  export type UnlinkableAuthMethodType = 'identity' | 'password' | 'google' | 'apple' | 'github';
@@ -4,7 +4,7 @@
4
4
  import { normalizeLanguageCode, getLanguageMetadata, getLanguageName, getNativeLanguageName } from '../utils/languageUtils';
5
5
  import type { LanguageMetadata } from '../utils/languageUtils';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
- import { loadAsyncStorage } from '../utils/platformCrypto';
7
+ import { loadAsyncStorage } from '@oxyhq/protocol';
8
8
  import { isDev } from '../shared/utils/debugUtils';
9
9
 
10
10
  export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base: T) {
@@ -40,6 +40,7 @@
40
40
  * server's `serializeNode` projection exactly. Dates cross the wire as ISO
41
41
  * strings.
42
42
  */
43
+ import type { ChainHeadResponse } from '@oxyhq/contracts';
43
44
  import type { OxyServicesBase } from '../OxyServices.base';
44
45
  import { SignatureService } from '../crypto/signatureService';
45
46
  import { buildUserDid } from './OxyServices.identity';
@@ -157,18 +158,6 @@ export interface RemoveNodeResult {
157
158
  revoked: boolean;
158
159
  }
159
160
 
160
- /**
161
- * The current chain head as returned by `GET /identity/records/:userId/chain/head`.
162
- * `headRecordId` is `null` and `seq` is `-1` when the subject has no chain yet,
163
- * so the next record's coordinates are always `seq: head.seq + 1` (genesis = 0)
164
- * and `prev: head.headRecordId` (genesis = null).
165
- */
166
- interface ChainHeadResponse {
167
- headRecordId: string | null;
168
- seq: number;
169
- recordCount: number;
170
- }
171
-
172
161
  export function OxyServicesNodesMixin<T extends typeof OxyServicesBase>(Base: T) {
173
162
  return class extends Base {
174
163
  constructor(...args: any[]) {
@@ -7,7 +7,7 @@
7
7
  import { jwtDecode } from 'jwt-decode';
8
8
  import type { ApiError, User } from '../models/interfaces';
9
9
  import type { OxyServicesBase } from '../OxyServices.base';
10
- import { loadNodeCrypto } from '../utils/platformCrypto';
10
+ import { loadNodeCrypto } from '@oxyhq/protocol';
11
11
  import { logger } from '../utils/loggerUtils';
12
12
  import { CACHE_TIMES } from './mixinHelpers';
13
13
 
@@ -26,17 +26,6 @@ interface JwtPayload {
26
26
  [key: string]: unknown;
27
27
  }
28
28
 
29
- /**
30
- * Result from the account acting-as verification endpoint
31
- * (`GET /accounts/verify-acting-as`). Indicates whether a user is authorized to
32
- * act as a given account. The `account:act_as` capability is granted only to the
33
- * `owner`, `admin`, and `editor` account roles.
34
- */
35
- interface ActingAsVerification {
36
- authorized: boolean;
37
- role: 'owner' | 'admin' | 'editor';
38
- }
39
-
40
29
  /**
41
30
  * Result from the service-acting-as verification endpoint.
42
31
  * Confirms that a given service app holds an active delegation grant for
@@ -141,9 +130,6 @@ interface AuthMiddlewareOptions {
141
130
 
142
131
  export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T) {
143
132
  return class extends Base {
144
- /** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
145
- _actingAsCache = new Map<string, { result: ActingAsVerification | null; expiresAt: number }>();
146
-
147
133
  /**
148
134
  * In-memory cache for service-acting-as verification.
149
135
  * Negative results are cached for 1min to avoid hammering the verify
@@ -161,55 +147,6 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
161
147
  super(...(args as [any]));
162
148
  }
163
149
 
164
- /**
165
- * Verify that a user is authorized to act as an account (direct membership
166
- * or inherited via an ancestor). Backed by `GET /accounts/verify-acting-as`.
167
- * Results are cached in-memory for 5 minutes to avoid repeated API calls.
168
- *
169
- * @internal Used by the auth() middleware — not part of the public API
170
- */
171
- async verifyActingAs(userId: string, accountId: string): Promise<ActingAsVerification | null> {
172
- const cacheKey = `${userId}:${accountId}`;
173
- const now = Date.now();
174
-
175
- // Check cache
176
- const cached = this._actingAsCache.get(cacheKey);
177
- if (cached && cached.expiresAt > now) {
178
- return cached.result;
179
- }
180
-
181
- // Query the API
182
- try {
183
- const result = await this.makeRequest<ActingAsVerification>(
184
- 'GET',
185
- '/accounts/verify-acting-as',
186
- { accountId, userId },
187
- { cache: false, retry: false, timeout: 5000 }
188
- );
189
-
190
- // Cache successful result for 5 minutes
191
- this._actingAsCache.set(cacheKey, {
192
- result: result && result.authorized ? result : null,
193
- expiresAt: now + 5 * 60 * 1000,
194
- });
195
-
196
- return result && result.authorized ? result : null;
197
- } catch (error) {
198
- logger.warn('[oxy.auth] verifyActingAs lookup failed — caching negative result', {
199
- component: 'auth',
200
- method: 'verifyActingAs',
201
- userId,
202
- accountId,
203
- }, error);
204
- // Cache negative result for 1 minute to avoid hammering on transient errors
205
- this._actingAsCache.set(cacheKey, {
206
- result: null,
207
- expiresAt: now + 1 * 60 * 1000,
208
- });
209
- return null;
210
- }
211
- }
212
-
213
150
  /**
214
151
  * Verify that a service app holds an active delegation grant authorising
215
152
  * it to act on behalf of `userId`. Returns the grant (with allowed scopes)
@@ -362,50 +299,6 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
362
299
 
363
300
  // Return an async middleware function
364
301
  return async (req: AuthReq, res: AuthRes, next: AuthNext) => {
365
- // Process X-Acting-As header for managed account identity delegation.
366
- // Called after successful authentication, before next(). If the header
367
- // is present, verifies authorization and swaps the request identity to
368
- // the managed account, preserving the original user for audit trails.
369
- const processActingAs = async (): Promise<boolean> => {
370
- const actingAsUserId = req.headers['x-acting-as'];
371
- if (!actingAsUserId || typeof actingAsUserId !== 'string') return true; // No header, proceed normally
372
- const currentUserId = req.userId;
373
- if (!currentUserId) return true; // No authenticated user yet — nothing to swap
374
-
375
- const verification = await oxyInstance.verifyActingAs(currentUserId, actingAsUserId);
376
- if (!verification) {
377
- const error = {
378
- error: 'ACTING_AS_UNAUTHORIZED',
379
- message: 'Not authorized to act as this account',
380
- code: 'ACTING_AS_UNAUTHORIZED',
381
- status: 403,
382
- };
383
- if (onError) {
384
- onError(error);
385
- } else {
386
- res.status(403).json(error);
387
- }
388
- return false;
389
- }
390
-
391
- // Preserve original user for audit trails
392
- req.originalUser = { id: currentUserId, ...(req.user ?? {}) };
393
- req.actingAs = { userId: actingAsUserId, role: verification.role };
394
-
395
- // Swap user identity to the managed account
396
- req.userId = actingAsUserId;
397
- req.user = { id: actingAsUserId, _id: actingAsUserId } as unknown as User;
398
-
399
- if (debug) {
400
- logger.debug(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${currentUserId}`, {
401
- component: 'auth',
402
- method: 'auth.processActingAs',
403
- });
404
- }
405
-
406
- return true;
407
- };
408
-
409
302
  try {
410
303
  // Extract token from Authorization header.
411
304
  // Node/Express normalizes `Authorization` to a string; we guard
@@ -490,9 +383,9 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
490
383
  // Signature verification uses a manual HMAC-SHA256 compare because
491
384
  // this file ships into RN/web bundles where `jsonwebtoken` is
492
385
  // unavailable. The middleware only ever runs on Node hosts (see
493
- // platformCrypto's doc-comment), and `loadNodeCrypto` is per-
494
- // platform: the RN variant throws so Metro never bundles a Node
495
- // built-in reference.
386
+ // `@oxyhq/protocol`'s `platform/crypto` doc-comment), and
387
+ // `loadNodeCrypto` is per-platform: the RN variant throws so Metro
388
+ // never bundles a Node built-in reference.
496
389
  try {
497
390
  await verifyServiceTokenSignature(token, jwtSecret);
498
391
  verifyServiceTokenClaims(decoded, {
@@ -712,9 +605,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
712
605
  });
713
606
  }
714
607
 
715
- // Process X-Acting-As header before proceeding
716
- if (await processActingAs()) return next();
717
- return;
608
+ return next();
718
609
  } catch (validationError) {
719
610
  if (debug) {
720
611
  logger.debug('[oxy.auth] Session validation failed', {
@@ -781,8 +672,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
781
672
  });
782
673
  }
783
674
 
784
- // Process X-Acting-As header before proceeding
785
- if (await processActingAs()) next();
675
+ next();
786
676
  } catch (error) {
787
677
  const handled = oxyInstance.handleError(error) as Error & {
788
678
  code?: string;
@@ -1128,8 +1018,6 @@ interface AuthReq {
1128
1018
  sessionId?: string | null;
1129
1019
  serviceApp?: ServiceApp;
1130
1020
  serviceActingAs?: { userId: string; scopes: string[] };
1131
- actingAs?: { userId: string; role: string };
1132
- originalUser?: { id: string } & Partial<User>;
1133
1021
  }
1134
1022
 
1135
1023
  interface AuthRes {
@@ -1146,7 +1034,6 @@ interface SocketLike {
1146
1034
  }
1147
1035
 
1148
1036
  interface OxyAuthInstance {
1149
- verifyActingAs(userId: string, accountId: string): Promise<ActingAsVerification | null>;
1150
1037
  verifyServiceActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null>;
1151
1038
  validateSession(
1152
1039
  sessionId: string,
@@ -37,7 +37,7 @@ import type {
37
37
  VerifiableCredentialResponse,
38
38
  } from '@oxyhq/contracts';
39
39
  import { OxyServices } from '../../OxyServices';
40
- import { canonicalize } from '../../crypto/canonicalJson';
40
+ import { canonicalize, signMessage } from '@oxyhq/protocol';
41
41
  import { SignatureService } from '../../crypto/signatureService';
42
42
  import { parseAttestPayload, parseIdPayload, verifyPublicCardAttestation } from '../OxyServices.civic';
43
43
 
@@ -61,7 +61,7 @@ async function signCard(card: PublicCard): Promise<{ attestation: ExportAttestat
61
61
  const keyPair = ec.genKeyPair();
62
62
  const privateKey = keyPair.getPrivate('hex');
63
63
  const publicKey = keyPair.getPublic('hex');
64
- const signature = await SignatureService.signWithKey(canonicalize(card), privateKey);
64
+ const signature = await signMessage(canonicalize(card), privateKey);
65
65
  return {
66
66
  attestation: {
67
67
  issuer: 'did:web:api.oxy.so',
@@ -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
+ });