@oxyhq/core 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) 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/i18n/locales/en-US.json +391 -17
  8. package/dist/cjs/i18n/locales/es-ES.json +391 -17
  9. package/dist/cjs/i18n/locales/locales/en-US.json +391 -17
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +391 -17
  11. package/dist/cjs/index.js +4 -8
  12. package/dist/cjs/mixins/OxyServices.accounts.js +54 -0
  13. package/dist/cjs/mixins/OxyServices.assets.js +2 -2
  14. package/dist/cjs/mixins/OxyServices.auth.js +3 -3
  15. package/dist/cjs/mixins/OxyServices.civic.js +3 -3
  16. package/dist/cjs/mixins/OxyServices.language.js +2 -2
  17. package/dist/cjs/mixins/OxyServices.utility.js +7 -95
  18. package/dist/cjs/utils/cacheKey.js +17 -19
  19. package/dist/cjs/utils/deviceManager.js +2 -2
  20. package/dist/cjs/utils/platform.js +0 -14
  21. package/dist/esm/.tsbuildinfo +1 -1
  22. package/dist/esm/HttpService.js +6 -18
  23. package/dist/esm/OxyServices.base.js +0 -21
  24. package/dist/esm/crypto/keyManager.js +4 -4
  25. package/dist/esm/crypto/polyfill.js +5 -4
  26. package/dist/esm/crypto/signatureService.js +39 -214
  27. package/dist/esm/i18n/locales/en-US.json +391 -17
  28. package/dist/esm/i18n/locales/es-ES.json +391 -17
  29. package/dist/esm/i18n/locales/locales/en-US.json +391 -17
  30. package/dist/esm/i18n/locales/locales/es-ES.json +391 -17
  31. package/dist/esm/index.js +1 -2
  32. package/dist/esm/mixins/OxyServices.accounts.js +54 -0
  33. package/dist/esm/mixins/OxyServices.assets.js +1 -1
  34. package/dist/esm/mixins/OxyServices.auth.js +1 -1
  35. package/dist/esm/mixins/OxyServices.civic.js +3 -3
  36. package/dist/esm/mixins/OxyServices.language.js +1 -1
  37. package/dist/esm/mixins/OxyServices.utility.js +6 -94
  38. package/dist/esm/utils/cacheKey.js +17 -19
  39. package/dist/esm/utils/deviceManager.js +1 -1
  40. package/dist/esm/utils/platform.js +0 -12
  41. package/dist/types/.tsbuildinfo +1 -1
  42. package/dist/types/HttpService.d.ts +3 -6
  43. package/dist/types/OxyServices.base.d.ts +0 -17
  44. package/dist/types/crypto/polyfill.d.ts +2 -2
  45. package/dist/types/crypto/signatureService.d.ts +18 -84
  46. package/dist/types/index.d.ts +3 -4
  47. package/dist/types/mixins/OxyServices.accounts.d.ts +57 -5
  48. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  49. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  50. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  51. package/dist/types/mixins/OxyServices.auth.d.ts +0 -2
  52. package/dist/types/mixins/OxyServices.civic.d.ts +3 -5
  53. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  54. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  55. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  56. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  57. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -2
  58. package/dist/types/mixins/OxyServices.identity.d.ts +8 -5
  59. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  60. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  61. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -44
  63. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  64. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.redirect.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.silent.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.sso.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.utility.d.ts +0 -32
  73. package/dist/types/server/auth.d.ts +0 -6
  74. package/dist/types/server/index.d.ts +1 -1
  75. package/dist/types/utils/cacheKey.d.ts +6 -7
  76. package/dist/types/utils/platform.d.ts +0 -8
  77. package/package.json +4 -7
  78. package/src/HttpService.ts +6 -22
  79. package/src/OxyServices.base.ts +0 -23
  80. package/src/__tests__/httpServiceCache.test.ts +0 -19
  81. package/src/crypto/__tests__/keyManager.atomicity.test.ts +2 -1
  82. package/src/crypto/__tests__/keyManager.test.ts +9 -7
  83. package/src/crypto/__tests__/signChallengeShared.test.ts +2 -1
  84. package/src/crypto/__tests__/signedRecord.test.ts +37 -150
  85. package/src/crypto/keyManager.ts +28 -17
  86. package/src/crypto/polyfill.ts +5 -4
  87. package/src/crypto/signatureService.ts +67 -255
  88. package/src/i18n/locales/en-US.json +391 -17
  89. package/src/i18n/locales/es-ES.json +391 -17
  90. package/src/index.ts +3 -3
  91. package/src/mixins/OxyServices.accounts.ts +91 -3
  92. package/src/mixins/OxyServices.assets.ts +1 -1
  93. package/src/mixins/OxyServices.auth.ts +1 -1
  94. package/src/mixins/OxyServices.civic.ts +6 -17
  95. package/src/mixins/OxyServices.identity.ts +8 -2
  96. package/src/mixins/OxyServices.language.ts +1 -1
  97. package/src/mixins/OxyServices.nodes.ts +1 -12
  98. package/src/mixins/OxyServices.utility.ts +6 -119
  99. package/src/mixins/__tests__/OxyServices.civic.test.ts +2 -2
  100. package/src/mixins/__tests__/accounts.test.ts +70 -0
  101. package/src/server/auth.ts +0 -7
  102. package/src/server/index.ts +0 -1
  103. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  104. package/src/utils/cacheKey.ts +16 -21
  105. package/src/utils/deviceManager.ts +1 -1
  106. package/src/utils/platform.ts +0 -14
  107. package/dist/cjs/crypto/canonicalJson.js +0 -107
  108. package/dist/cjs/utils/platformCrypto.js +0 -165
  109. package/dist/cjs/utils/platformCrypto.native.js +0 -123
  110. package/dist/esm/crypto/canonicalJson.js +0 -104
  111. package/dist/esm/utils/platformCrypto.js +0 -125
  112. package/dist/esm/utils/platformCrypto.native.js +0 -80
  113. package/dist/types/crypto/canonicalJson.d.ts +0 -44
  114. package/dist/types/utils/platformCrypto.d.ts +0 -87
  115. package/dist/types/utils/platformCrypto.native.d.ts +0 -54
  116. package/src/crypto/__tests__/canonicalJson.test.ts +0 -116
  117. package/src/crypto/canonicalJson.ts +0 -120
  118. package/src/utils/platformCrypto.native.ts +0 -101
  119. package/src/utils/platformCrypto.ts +0 -145
@@ -1,6 +1,6 @@
1
1
  import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
- import { isReactNative } from '../utils/platform';
3
+ import { isReactNative } from '@oxyhq/protocol';
4
4
 
5
5
  export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
6
6
  return class extends Base {
@@ -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',
@@ -25,6 +25,7 @@ import type {
25
25
  ApplicationCredentialWithSecret,
26
26
  RotateApplicationCredentialResult,
27
27
  ApplicationUsageStats,
28
+ SwitchAccountResult,
28
29
  } from '../OxyServices.accounts';
29
30
 
30
31
  const setAccessTokenForTest = (oxy: OxyServices): void => {
@@ -174,6 +175,75 @@ describe('OxyServices.accounts', () => {
174
175
  });
175
176
  });
176
177
 
178
+ describe('switchToAccount', () => {
179
+ const switchResponse: SwitchAccountResult = {
180
+ sessionId: 'sess_switch',
181
+ deviceId: 'dev_switch',
182
+ expiresAt: '2026-06-30T01:00:00.000Z',
183
+ accessToken: 'access_switch',
184
+ user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } },
185
+ authuser: 2,
186
+ };
187
+
188
+ it('posts to /:id/switch (no body, no cache), plants the token, sweeps the cache, and returns the session', async () => {
189
+ const setTokensSpy = jest.spyOn(oxy, 'setTokens');
190
+ const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
191
+ makeRequestSpy.mockResolvedValue(switchResponse);
192
+
193
+ const result = await oxy.switchToAccount('acc1');
194
+
195
+ // Request shape: POST, exact path, no body, cache disabled.
196
+ expect(makeRequestSpy).toHaveBeenCalledWith(
197
+ 'POST',
198
+ '/accounts/acc1/switch',
199
+ undefined,
200
+ expect.objectContaining({ cache: false }),
201
+ );
202
+
203
+ // Session planting: the access token from the body is installed as the
204
+ // active token (mirrors claimSessionByToken / verifyChallenge).
205
+ expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
206
+ expect(oxy.getAccessToken()).toBe('access_switch');
207
+ expect(oxy.hasValidToken()).toBe(true);
208
+
209
+ // Identity changed → the whole GET cache is swept so reads refetch as the
210
+ // new account, AND it happens AFTER the token is planted.
211
+ expect(clearCacheSpy).toHaveBeenCalledTimes(1);
212
+ expect(setTokensSpy.mock.invocationCallOrder[0]).toBeLessThan(
213
+ clearCacheSpy.mock.invocationCallOrder[0],
214
+ );
215
+
216
+ // The returned session carries the target account (id-normalised) + authuser.
217
+ expect(result).toEqual({ ...switchResponse, user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } } });
218
+ expect(result.authuser).toBe(2);
219
+
220
+ setTokensSpy.mockRestore();
221
+ clearCacheSpy.mockRestore();
222
+ });
223
+
224
+ it('URL-encodes the accountId path segment', async () => {
225
+ makeRequestSpy.mockResolvedValue(switchResponse);
226
+ await oxy.switchToAccount('a b/c');
227
+ expect(makeRequestSpy.mock.calls[0][1]).toBe('/accounts/a%20b%2Fc/switch');
228
+ });
229
+
230
+ it('does NOT plant or sweep when the operator is not authorized (403 surfaces via handleError)', async () => {
231
+ const setTokensSpy = jest.spyOn(oxy, 'setTokens');
232
+ const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
233
+ makeRequestSpy.mockRejectedValue(
234
+ Object.assign(new Error('forbidden'), { response: { status: 403 } }),
235
+ );
236
+
237
+ await expect(oxy.switchToAccount('acc1')).rejects.toThrow();
238
+ // A failed switch must NOT mutate session state.
239
+ expect(setTokensSpy).not.toHaveBeenCalled();
240
+ expect(clearCacheSpy).not.toHaveBeenCalled();
241
+
242
+ setTokensSpy.mockRestore();
243
+ clearCacheSpy.mockRestore();
244
+ });
245
+ });
246
+
177
247
  describe('createAccount', () => {
178
248
  it('posts the payload, unwraps `account`, and busts every list', async () => {
179
249
  makeRequestSpy.mockResolvedValue({ account: accountNodeFixture });
@@ -10,11 +10,6 @@ export interface OxyRequestUser {
10
10
  [key: string]: unknown;
11
11
  }
12
12
 
13
- export interface OxyActingAsContext {
14
- userId: string;
15
- role: 'owner' | 'admin' | 'editor';
16
- }
17
-
18
13
  export interface OxyServiceAppContext {
19
14
  appId: string;
20
15
  appName: string;
@@ -30,8 +25,6 @@ export interface OxyServiceActingAsContext {
30
25
  export interface OxyAuthRequest extends Request {
31
26
  userId?: string | null;
32
27
  user?: OxyRequestUser | null;
33
- originalUser?: OxyRequestUser | null;
34
- actingAs?: OxyActingAsContext;
35
28
  accessToken?: string;
36
29
  sessionId?: string | null;
37
30
  serviceApp?: OxyServiceAppContext;
@@ -24,7 +24,6 @@ export {
24
24
  requireOxyAuth,
25
25
  } from './auth';
26
26
  export type {
27
- OxyActingAsContext,
28
27
  OxyAuthenticatedRequest,
29
28
  OxyAuthMiddlewareOptions,
30
29
  OxyAuthRequest,
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Extracted from {@link HttpService} so the identity-tag derivation is a pure,
5
5
  * independently testable function with no dependency on instance/token state.
6
- * The HTTP service injects the live access token and acting-as id; everything
7
- * here is referentially transparent given those inputs.
6
+ * The HTTP service injects the live access token; everything here is
7
+ * referentially transparent given that input.
8
8
  */
9
9
 
10
10
  import { jwtDecode } from 'jwt-decode';
@@ -72,27 +72,22 @@ export function fnv1a32(str: string): string {
72
72
  *
73
73
  * We use the decoded user id rather than the raw JWT so the token never lands
74
74
  * in a cache key (no token leakage through any cache-key logging, no key bloat).
75
- * The acting-as id is folded in because managed-account responses differ per
76
- * acting identity and `X-Acting-As` already changes the server response for
77
- * the same bearer token.
75
+ * Switching into a managed account mints a REAL new session whose access token
76
+ * carries the target account's id, so the identity tag changes naturally on a
77
+ * switch there is no separate acting-as discriminator to fold in.
78
78
  *
79
79
  * @param accessToken The current bearer access token, or `null` when anonymous.
80
- * @param actingAsUserId The active managed-account id, or `null`.
81
80
  */
82
- export function computeIdentityTag(
83
- accessToken: string | null,
84
- actingAsUserId: string | null,
85
- ): string {
86
- let principal = ANON_IDENTITY;
87
- if (accessToken) {
88
- try {
89
- const decoded = jwtDecode<CacheIdentityJwtPayload>(accessToken);
90
- principal = decoded.userId || decoded.id || `t${fnv1a32(accessToken)}`;
91
- } catch {
92
- // Undecodable token — still partition it away from anon and from other
93
- // tokens via a hash. Never silently fall back to ANON_IDENTITY.
94
- principal = `t${fnv1a32(accessToken)}`;
95
- }
81
+ export function computeIdentityTag(accessToken: string | null): string {
82
+ if (!accessToken) {
83
+ return ANON_IDENTITY;
84
+ }
85
+ try {
86
+ const decoded = jwtDecode<CacheIdentityJwtPayload>(accessToken);
87
+ return decoded.userId || decoded.id || `t${fnv1a32(accessToken)}`;
88
+ } catch {
89
+ // Undecodable token still partition it away from anon and from other
90
+ // tokens via a hash. Never silently fall back to ANON_IDENTITY.
91
+ return `t${fnv1a32(accessToken)}`;
96
92
  }
97
- return actingAsUserId ? `${principal}~as${actingAsUserId}` : principal;
98
93
  }
@@ -1,4 +1,4 @@
1
- import { loadAsyncStorage } from './platformCrypto';
1
+ import { loadAsyncStorage } from '@oxyhq/protocol';
2
2
 
3
3
  export interface DeviceFingerprint {
4
4
  userAgent: string;
@@ -88,20 +88,6 @@ export function isAndroid(): boolean {
88
88
  return getPlatformOS() === 'android';
89
89
  }
90
90
 
91
- /**
92
- * Check if running in React Native
93
- */
94
- export function isReactNative(): boolean {
95
- return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
96
- }
97
-
98
- /**
99
- * Check if running in Node.js
100
- */
101
- export function isNodeJS(): boolean {
102
- return typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
103
- }
104
-
105
91
  /**
106
92
  * Set the platform OS explicitly
107
93
  * Called by React Native entry point to register the platform