@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -18
- package/dist/cjs/OxyServices.base.js +0 -21
- package/dist/cjs/crypto/keyManager.js +7 -7
- package/dist/cjs/crypto/polyfill.js +6 -5
- package/dist/cjs/crypto/signatureService.js +44 -220
- package/dist/cjs/index.js +4 -8
- package/dist/cjs/mixins/OxyServices.accounts.js +90 -0
- package/dist/cjs/mixins/OxyServices.assets.js +68 -2
- package/dist/cjs/mixins/OxyServices.auth.js +3 -3
- package/dist/cjs/mixins/OxyServices.civic.js +3 -3
- package/dist/cjs/mixins/OxyServices.language.js +2 -2
- package/dist/cjs/mixins/OxyServices.utility.js +7 -95
- package/dist/cjs/utils/cacheKey.js +17 -19
- package/dist/cjs/utils/deviceManager.js +2 -2
- package/dist/cjs/utils/platform.js +0 -14
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -18
- package/dist/esm/OxyServices.base.js +0 -21
- package/dist/esm/crypto/keyManager.js +4 -4
- package/dist/esm/crypto/polyfill.js +5 -4
- package/dist/esm/crypto/signatureService.js +39 -214
- package/dist/esm/index.js +1 -2
- package/dist/esm/mixins/OxyServices.accounts.js +90 -0
- package/dist/esm/mixins/OxyServices.assets.js +67 -1
- package/dist/esm/mixins/OxyServices.auth.js +1 -1
- package/dist/esm/mixins/OxyServices.civic.js +3 -3
- package/dist/esm/mixins/OxyServices.language.js +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +6 -94
- package/dist/esm/utils/cacheKey.js +17 -19
- package/dist/esm/utils/deviceManager.js +1 -1
- package/dist/esm/utils/platform.js +0 -12
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -6
- package/dist/types/OxyServices.base.d.ts +0 -17
- package/dist/types/crypto/polyfill.d.ts +2 -2
- package/dist/types/crypto/signatureService.d.ts +18 -84
- package/dist/types/index.d.ts +4 -5
- package/dist/types/mixins/OxyServices.accounts.d.ts +66 -5
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +42 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +0 -2
- package/dist/types/mixins/OxyServices.civic.d.ts +3 -5
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +8 -5
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -44
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.redirect.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -2
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -32
- package/dist/types/models/interfaces.d.ts +18 -0
- package/dist/types/server/auth.d.ts +0 -6
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/utils/cacheKey.d.ts +6 -7
- package/dist/types/utils/platform.d.ts +0 -8
- package/package.json +4 -7
- package/src/HttpService.ts +6 -22
- package/src/OxyServices.base.ts +0 -23
- package/src/__tests__/httpServiceCache.test.ts +0 -19
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +2 -1
- package/src/crypto/__tests__/keyManager.test.ts +9 -7
- package/src/crypto/__tests__/signChallengeShared.test.ts +2 -1
- package/src/crypto/__tests__/signedRecord.test.ts +37 -150
- package/src/crypto/keyManager.ts +28 -17
- package/src/crypto/polyfill.ts +5 -4
- package/src/crypto/signatureService.ts +67 -255
- package/src/index.ts +4 -3
- package/src/mixins/OxyServices.accounts.ts +136 -3
- package/src/mixins/OxyServices.assets.ts +96 -2
- package/src/mixins/OxyServices.auth.ts +1 -1
- package/src/mixins/OxyServices.civic.ts +6 -17
- package/src/mixins/OxyServices.identity.ts +8 -2
- package/src/mixins/OxyServices.language.ts +1 -1
- package/src/mixins/OxyServices.nodes.ts +1 -12
- package/src/mixins/OxyServices.utility.ts +6 -119
- package/src/mixins/__tests__/OxyServices.civic.test.ts +2 -2
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +116 -0
- package/src/mixins/__tests__/accounts.test.ts +120 -0
- package/src/models/interfaces.ts +19 -0
- package/src/server/auth.ts +0 -7
- package/src/server/index.ts +0 -1
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/cacheKey.ts +16 -21
- package/src/utils/deviceManager.ts +1 -1
- package/src/utils/platform.ts +0 -14
- package/dist/cjs/crypto/canonicalJson.js +0 -107
- package/dist/cjs/utils/platformCrypto.js +0 -165
- package/dist/cjs/utils/platformCrypto.native.js +0 -123
- package/dist/esm/crypto/canonicalJson.js +0 -104
- package/dist/esm/utils/platformCrypto.js +0 -125
- package/dist/esm/utils/platformCrypto.native.js +0 -80
- package/dist/types/crypto/canonicalJson.d.ts +0 -44
- package/dist/types/utils/platformCrypto.d.ts +0 -87
- package/dist/types/utils/platformCrypto.native.d.ts +0 -54
- package/src/crypto/__tests__/canonicalJson.test.ts +0 -116
- package/src/crypto/canonicalJson.ts +0 -120
- package/src/utils/platformCrypto.native.ts +0 -101
- 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 '
|
|
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 '
|
|
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 '
|
|
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: `
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
47
|
-
|
|
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 '
|
|
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 '
|
|
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
|
-
//
|
|
494
|
-
// platform: the RN variant throws so Metro
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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
|
|
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
|
+
});
|