@oxyhq/core 4.0.1 → 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 (109) 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 +54 -0
  9. package/dist/cjs/mixins/OxyServices.assets.js +2 -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 +54 -0
  25. package/dist/esm/mixins/OxyServices.assets.js +1 -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 +3 -4
  39. package/dist/types/mixins/OxyServices.accounts.d.ts +57 -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 +0 -2
  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/server/auth.d.ts +0 -6
  66. package/dist/types/server/index.d.ts +1 -1
  67. package/dist/types/utils/cacheKey.d.ts +6 -7
  68. package/dist/types/utils/platform.d.ts +0 -8
  69. package/package.json +4 -7
  70. package/src/HttpService.ts +6 -22
  71. package/src/OxyServices.base.ts +0 -23
  72. package/src/__tests__/httpServiceCache.test.ts +0 -19
  73. package/src/crypto/__tests__/keyManager.atomicity.test.ts +2 -1
  74. package/src/crypto/__tests__/keyManager.test.ts +9 -7
  75. package/src/crypto/__tests__/signChallengeShared.test.ts +2 -1
  76. package/src/crypto/__tests__/signedRecord.test.ts +37 -150
  77. package/src/crypto/keyManager.ts +28 -17
  78. package/src/crypto/polyfill.ts +5 -4
  79. package/src/crypto/signatureService.ts +67 -255
  80. package/src/index.ts +3 -3
  81. package/src/mixins/OxyServices.accounts.ts +91 -3
  82. package/src/mixins/OxyServices.assets.ts +1 -1
  83. package/src/mixins/OxyServices.auth.ts +1 -1
  84. package/src/mixins/OxyServices.civic.ts +6 -17
  85. package/src/mixins/OxyServices.identity.ts +8 -2
  86. package/src/mixins/OxyServices.language.ts +1 -1
  87. package/src/mixins/OxyServices.nodes.ts +1 -12
  88. package/src/mixins/OxyServices.utility.ts +6 -119
  89. package/src/mixins/__tests__/OxyServices.civic.test.ts +2 -2
  90. package/src/mixins/__tests__/accounts.test.ts +70 -0
  91. package/src/server/auth.ts +0 -7
  92. package/src/server/index.ts +0 -1
  93. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  94. package/src/utils/cacheKey.ts +16 -21
  95. package/src/utils/deviceManager.ts +1 -1
  96. package/src/utils/platform.ts +0 -14
  97. package/dist/cjs/crypto/canonicalJson.js +0 -107
  98. package/dist/cjs/utils/platformCrypto.js +0 -165
  99. package/dist/cjs/utils/platformCrypto.native.js +0 -123
  100. package/dist/esm/crypto/canonicalJson.js +0 -104
  101. package/dist/esm/utils/platformCrypto.js +0 -125
  102. package/dist/esm/utils/platformCrypto.native.js +0 -80
  103. package/dist/types/crypto/canonicalJson.d.ts +0 -44
  104. package/dist/types/utils/platformCrypto.d.ts +0 -87
  105. package/dist/types/utils/platformCrypto.native.d.ts +0 -54
  106. package/src/crypto/__tests__/canonicalJson.test.ts +0 -116
  107. package/src/crypto/canonicalJson.ts +0 -120
  108. package/src/utils/platformCrypto.native.ts +0 -101
  109. package/src/utils/platformCrypto.ts +0 -145
@@ -1,108 +1,26 @@
1
1
  /**
2
- * Signature Service - ECDSA Digital Signatures
3
- *
4
- * Handles signing and verification of messages using ECDSA secp256k1.
5
- * Used for authenticating requests and proving identity ownership.
2
+ * Signature Service - ECDSA Digital Signatures (device-key bound)
3
+ *
4
+ * Handles signing and verification of messages with the user's DEVICE identity
5
+ * key (read from {@link KeyManager} / secure storage). All cryptography itself —
6
+ * canonical signing input, SHA-256, secp256k1 sign/verify, envelope assembly —
7
+ * is delegated to `@oxyhq/protocol`; this service only resolves the key from
8
+ * storage and orchestrates the protocol primitives.
6
9
  */
7
10
 
8
- import { ec as EC } from 'elliptic';
9
11
  import type { SignedRecordEnvelope } from '@oxyhq/contracts';
12
+ import {
13
+ signEnvelope,
14
+ signMessage,
15
+ verifySignature,
16
+ sha256,
17
+ loadExpoCrypto,
18
+ loadNodeCrypto,
19
+ isReactNative,
20
+ isNodeJS,
21
+ } from '@oxyhq/protocol';
10
22
  import { KeyManager } from './keyManager';
11
- import { canonicalize } from './canonicalJson';
12
- import { isReactNative, isNodeJS } from '../utils/platform';
13
- import { loadExpoCrypto, loadNodeCrypto } from '../utils/platformCrypto';
14
23
  import { logger } from '../utils/loggerUtils';
15
- import { isDev } from '../shared/utils/debugUtils';
16
-
17
- const ec = new EC('secp256k1');
18
-
19
- /**
20
- * The signing-input portion of a {@link SignedRecordEnvelope}: every field
21
- * EXCEPT the `publicKey` and `signature`. Both the client (when signing) and
22
- * the server (when verifying) canonicalize exactly these fields, so they agree
23
- * on the bytes that the signature covers.
24
- *
25
- * The v2 chain fields (`seq`/`prev`/`collection`/`rkey`) are optional: a v1
26
- * envelope omits them and is signed over only the base fields; a v2 envelope
27
- * carries them and includes them in the signed bytes.
28
- */
29
- export type SignedRecordSigningFields = Pick<
30
- SignedRecordEnvelope,
31
- 'version' | 'type' | 'subject' | 'issuer' | 'record' | 'issuedAt'
32
- > &
33
- Partial<Pick<SignedRecordEnvelope, 'seq' | 'prev' | 'collection' | 'rkey'>>;
34
-
35
- /**
36
- * Compute the canonical signing input for a signed-record envelope.
37
- *
38
- * This is the single definition of "what the signature covers". `@oxyhq/core`
39
- * (client signing) and `@oxyhq/api` (server verification) both call this, so a
40
- * record signed by a client and verified by the server cannot drift.
41
- *
42
- * - **v1**: the canonical JSON of `{version, type, subject, issuer, record,
43
- * issuedAt}` — BYTE-IDENTICAL to the original scheme, so every signature
44
- * already in production keeps verifying.
45
- * - **v2**: the canonical JSON additionally includes the hash-chain fields
46
- * `{seq, prev, collection, rkey}`. Because {@link canonicalize} sorts keys,
47
- * the on-the-wire field order is irrelevant; the resulting canonical key
48
- * order is `collection, issuedAt, issuer, prev, record, rkey, seq, subject,
49
- * type, version`. `prev` is `null` at genesis (serialized as `null`, not
50
- * omitted), so it is always part of the signed bytes.
51
- */
52
- export function signedRecordSigningInput(fields: SignedRecordSigningFields): string {
53
- const { version, type, subject, issuer, record, issuedAt } = fields;
54
- if (version === 2) {
55
- const { seq, prev, collection, rkey } = fields;
56
- return canonicalize({ version, type, subject, issuer, record, issuedAt, seq, prev, collection, rkey });
57
- }
58
- return canonicalize({ version, type, subject, issuer, record, issuedAt });
59
- }
60
-
61
- /**
62
- * Compute the `recordId` (content address) of a signed record: the SHA-256 hex
63
- * digest of its canonical {@link signedRecordSigningInput}.
64
- *
65
- * Deterministic and stable across runtimes (it reuses the same canonicalization
66
- * + SHA-256 the signature itself is built on). The recordId is what `prev`
67
- * references in the per-subject hash chain, so `@oxyhq/core` (client) and
68
- * `@oxyhq/api` (server) MUST compute it identically — both call this function.
69
- * It is taken over the SIGNING input (excluding `publicKey`/`signature`), so it
70
- * is a pure content address of the record's meaning, independent of who signed.
71
- */
72
- export async function computeRecordId(fields: SignedRecordSigningFields): Promise<string> {
73
- return sha256(signedRecordSigningInput(fields));
74
- }
75
-
76
- /**
77
- * Compute SHA-256 hash of a string
78
- */
79
- async function sha256(message: string): Promise<string> {
80
- // In React Native, use expo-crypto
81
- if (isReactNative()) {
82
- const Crypto = await loadExpoCrypto();
83
- return Crypto.digestStringAsync(
84
- Crypto.CryptoDigestAlgorithm.SHA256,
85
- message
86
- );
87
- }
88
-
89
- if (isNodeJS()) {
90
- try {
91
- const nodeCrypto = await loadNodeCrypto();
92
- return nodeCrypto.createHash('sha256').update(message).digest('hex');
93
- } catch (error) {
94
- // Node crypto failed to load — log and fall through to Web Crypto API
95
- logger.warn('[oxy.crypto] Node crypto unavailable, falling back to Web Crypto', { component: 'SignatureService' }, error);
96
- }
97
- }
98
-
99
- // Browser: use Web Crypto API
100
- const encoder = new TextEncoder();
101
- const data = encoder.encode(message);
102
- const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
103
- const hashArray = Array.from(new Uint8Array(hashBuffer));
104
- return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
105
- }
106
24
 
107
25
  export interface SignedMessage {
108
26
  message: string;
@@ -158,78 +76,15 @@ export class SignatureService {
158
76
  }
159
77
 
160
78
  /**
161
- * Sign a message using the stored private key
79
+ * Sign a message using the stored device private key
162
80
  * Returns the signature in DER format (hex encoded)
163
81
  */
164
82
  static async sign(message: string): Promise<string> {
165
- const keyPair = await KeyManager.getKeyPairObject();
166
- if (!keyPair) {
83
+ const privateKey = await KeyManager.getPrivateKey();
84
+ if (!privateKey) {
167
85
  throw new Error('No identity found. Please create or import an identity first.');
168
86
  }
169
-
170
- const messageHash = await sha256(message);
171
- const signature = keyPair.sign(messageHash);
172
- return signature.toDER('hex');
173
- }
174
-
175
- /**
176
- * Sign a message with an explicit private key (without storing)
177
- * Useful for one-time operations or testing
178
- */
179
- static async signWithKey(message: string, privateKey: string): Promise<string> {
180
- const keyPair = ec.keyFromPrivate(privateKey);
181
- const messageHash = await sha256(message);
182
- const signature = keyPair.sign(messageHash);
183
- return signature.toDER('hex');
184
- }
185
-
186
- /**
187
- * Verify a signature against a message and public key
188
- *
189
- * Returns false on any error (invalid signature, malformed input, etc.).
190
- * Errors are logged at debug level so they're available when troubleshooting
191
- * signature mismatches but don't surface to the caller.
192
- */
193
- static async verify(message: string, signature: string, publicKey: string): Promise<boolean> {
194
- try {
195
- const key = ec.keyFromPublic(publicKey, 'hex');
196
- const messageHash = await sha256(message);
197
- return key.verify(messageHash, signature);
198
- } catch (error) {
199
- if (isDev()) {
200
- logger.debug('[oxy.crypto] verify() returned false', { component: 'SignatureService' }, error);
201
- }
202
- return false;
203
- }
204
- }
205
-
206
- /**
207
- * Synchronous verification (for Node.js backend)
208
- * Uses crypto module directly for hashing
209
- * Note: This method should only be used in Node.js environments
210
- */
211
- static verifySync(message: string, signature: string, publicKey: string): boolean {
212
- try {
213
- if (!isNodeJS()) {
214
- // In React Native, use async verify instead
215
- throw new Error('verifySync should only be used in Node.js. Use verify() in React Native.');
216
- }
217
- // Intentionally using Function constructor here: this method is synchronous by design
218
- // (Node.js backend hot-path) so we cannot use `await import()`. The Function constructor
219
- // prevents Metro/bundlers from statically resolving the require. This is acceptable because
220
- // verifySync is gated by isNodeJS() and will never execute in browser/RN environments.
221
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
222
- const getCrypto = new Function('return require("crypto")');
223
- const crypto = getCrypto();
224
- const key = ec.keyFromPublic(publicKey, 'hex');
225
- const messageHash = crypto.createHash('sha256').update(message).digest('hex');
226
- return key.verify(messageHash, signature);
227
- } catch (error) {
228
- if (isDev()) {
229
- logger.debug('[oxy.crypto] verifySync() returned false', { component: 'SignatureService' }, error);
230
- }
231
- return false;
232
- }
87
+ return signMessage(message, privateKey);
233
88
  }
234
89
 
235
90
  /**
@@ -271,7 +126,7 @@ export class SignatureService {
271
126
 
272
127
  // Verify signature
273
128
  const messageWithTimestamp = `${message}:${timestamp}`;
274
- return SignatureService.verify(messageWithTimestamp, signature, publicKey);
129
+ return verifySignature(messageWithTimestamp, signature, publicKey);
275
130
  }
276
131
 
277
132
  /**
@@ -303,9 +158,10 @@ export class SignatureService {
303
158
  * Mirrors {@link signChallenge} exactly — same message format
304
159
  * (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
305
160
  * path is unchanged — but sources the shared public/private key from
306
- * `KeyManager` and signs with `signWithKey`. Used by "Sign in with Oxy"
307
- * same-device shared-keychain SSO (Mechanism A): a sibling native app proves
308
- * control of the shared identity to mint its own session.
161
+ * `KeyManager` and signs with the protocol's explicit-key {@link signMessage}.
162
+ * Used by "Sign in with Oxy" same-device shared-keychain SSO (Mechanism A): a
163
+ * sibling native app proves control of the shared identity to mint its own
164
+ * session.
309
165
  *
310
166
  * Throws if no shared identity exists (native-only; the shared keychain is
311
167
  * unavailable on web).
@@ -319,7 +175,7 @@ export class SignatureService {
319
175
 
320
176
  const timestamp = Date.now();
321
177
  const message = `auth:${publicKey}:${challenge}:${timestamp}`;
322
- const signature = await SignatureService.signWithKey(message, privateKey);
178
+ const signature = await signMessage(message, privateKey);
323
179
 
324
180
  return {
325
181
  challenge: signature,
@@ -345,7 +201,7 @@ export class SignatureService {
345
201
  }
346
202
 
347
203
  const message = `auth:${publicKey}:${originalChallenge}:${timestamp}`;
348
- return SignatureService.verify(message, signature, publicKey);
204
+ return verifySignature(message, signature, publicKey);
349
205
  }
350
206
 
351
207
  /**
@@ -385,12 +241,12 @@ export class SignatureService {
385
241
  }
386
242
 
387
243
  const timestamp = Date.now();
388
-
244
+
389
245
  // Create canonical string representation
390
246
  const sortedKeys = Object.keys(data).sort();
391
247
  const canonicalParts = sortedKeys.map(key => `${key}:${JSON.stringify(data[key])}`);
392
248
  const canonicalString = canonicalParts.join('|');
393
-
249
+
394
250
  const message = `request:${publicKey}:${timestamp}:${canonicalString}`;
395
251
  const signature = await SignatureService.sign(message);
396
252
 
@@ -406,9 +262,11 @@ export class SignatureService {
406
262
  *
407
263
  * The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
408
264
  * The signature covers the canonical JSON of every field EXCEPT `publicKey`
409
- * and `signature` (see {@link signedRecordSigningInput}); `alg` is
410
- * `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of the canonical bytes,
411
- * DER-encoded), the same scheme this service uses everywhere else.
265
+ * and `signature`; `alg` is `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of
266
+ * the canonical bytes, DER-encoded). The cryptography is delegated to the
267
+ * protocol's {@link signEnvelope}, which derives the (uncompressed-hex)
268
+ * `publicKey` from the stored device key — identical to the registered
269
+ * verification method.
412
270
  *
413
271
  * Requires a stored identity (native secure storage); throws if none exists.
414
272
  *
@@ -421,35 +279,22 @@ export class SignatureService {
421
279
  subject: string,
422
280
  record: Record<string, unknown>,
423
281
  ): Promise<SignedRecordEnvelope> {
424
- const publicKey = await KeyManager.getPublicKey();
425
- if (!publicKey) {
282
+ const privateKey = await KeyManager.getPrivateKey();
283
+ if (!privateKey) {
426
284
  throw new Error('No identity found. Please create or import an identity first.');
427
285
  }
428
286
 
429
- const version = 1 as const;
430
- const issuer = subject;
431
- const issuedAt = Date.now();
432
- const signingInput = signedRecordSigningInput({
433
- version,
434
- type,
435
- subject,
436
- issuer,
437
- record,
438
- issuedAt,
439
- });
440
- const signature = await SignatureService.sign(signingInput);
441
-
442
- return {
443
- version,
444
- type,
445
- subject,
446
- issuer,
447
- record,
448
- issuedAt,
449
- publicKey,
450
- alg: 'ES256K-DER-SHA256',
451
- signature,
452
- };
287
+ return signEnvelope(
288
+ {
289
+ version: 1,
290
+ type,
291
+ subject,
292
+ issuer: subject,
293
+ record,
294
+ issuedAt: Date.now(),
295
+ },
296
+ privateKey,
297
+ );
453
298
  }
454
299
 
455
300
  /**
@@ -457,8 +302,8 @@ export class SignatureService {
457
302
  * fields.
458
303
  *
459
304
  * Identical to {@link signRecord} (self-issued: `issuer === subject`; same
460
- * `ES256K-DER-SHA256` scheme over {@link signedRecordSigningInput}) but
461
- * `version` is `2` and the signed bytes additionally cover the chain fields:
305
+ * `ES256K-DER-SHA256` scheme) but `version` is `2` and the signed bytes
306
+ * additionally cover the chain fields:
462
307
  *
463
308
  * @param type - The record category.
464
309
  * @param subject - The subject DID the record is about (also the issuer).
@@ -478,60 +323,27 @@ export class SignatureService {
478
323
  record: Record<string, unknown>,
479
324
  chain: { seq: number; prev: string | null; collection: string; rkey: string },
480
325
  ): Promise<SignedRecordEnvelope> {
481
- const publicKey = await KeyManager.getPublicKey();
482
- if (!publicKey) {
326
+ const privateKey = await KeyManager.getPrivateKey();
327
+ if (!privateKey) {
483
328
  throw new Error('No identity found. Please create or import an identity first.');
484
329
  }
485
330
 
486
- const version = 2 as const;
487
- const issuer = subject;
488
- const issuedAt = Date.now();
489
331
  const { seq, prev, collection, rkey } = chain;
490
- const signingInput = signedRecordSigningInput({
491
- version,
492
- type,
493
- subject,
494
- issuer,
495
- record,
496
- issuedAt,
497
- seq,
498
- prev,
499
- collection,
500
- rkey,
501
- });
502
- const signature = await SignatureService.sign(signingInput);
503
-
504
- return {
505
- version,
506
- type,
507
- subject,
508
- issuer,
509
- record,
510
- issuedAt,
511
- seq,
512
- prev,
513
- collection,
514
- rkey,
515
- publicKey,
516
- alg: 'ES256K-DER-SHA256',
517
- signature,
518
- };
519
- }
520
-
521
- /**
522
- * Verify a signed-record envelope: recompute the canonical signing input from
523
- * the envelope's own fields and check the signature against the envelope's
524
- * `publicKey`.
525
- *
526
- * Note: this confirms the signature is internally consistent with the
527
- * embedded `publicKey`. It does NOT establish that `publicKey` is an
528
- * authorized verification method for `subject` — that authorization check is
529
- * the server's responsibility (it asserts the key is a current verification
530
- * method on the subject's DID).
531
- */
532
- static async verifyRecord(envelope: SignedRecordEnvelope): Promise<boolean> {
533
- const signingInput = signedRecordSigningInput(envelope);
534
- return SignatureService.verify(signingInput, envelope.signature, envelope.publicKey);
332
+ return signEnvelope(
333
+ {
334
+ version: 2,
335
+ type,
336
+ subject,
337
+ issuer: subject,
338
+ record,
339
+ issuedAt: Date.now(),
340
+ seq,
341
+ prev,
342
+ collection,
343
+ rkey,
344
+ },
345
+ privateKey,
346
+ );
535
347
  }
536
348
  }
537
349
 
package/src/index.ts CHANGED
@@ -125,6 +125,7 @@ export type {
125
125
  TransferAccountOwnershipInput,
126
126
  CreateAccountCredentialInput,
127
127
  AccountSuccessResult,
128
+ SwitchAccountResult,
128
129
  // Applications owned within the account graph (Application = OAuth client).
129
130
  Application,
130
131
  ApplicationType,
@@ -263,9 +264,8 @@ export {
263
264
  IdentityPersistError,
264
265
  } from './crypto/keyManager';
265
266
  export type { KeyPair } from './crypto/keyManager';
266
- export { SignatureService, signedRecordSigningInput, computeRecordId } from './crypto/signatureService';
267
- export type { SignedMessage, AuthChallenge, SignedRecordSigningFields } from './crypto/signatureService';
268
- export { canonicalize } from './crypto/canonicalJson';
267
+ export { SignatureService } from './crypto/signatureService';
268
+ export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
269
269
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';
270
270
  export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
271
271
 
@@ -25,12 +25,16 @@
25
25
  * member `_id`, and credentials by their `credentialId`. Never by name, slug, or
26
26
  * handle.
27
27
  *
28
- * NOTE: acting-as (delegated identity) is NOT part of this mixin — `setActingAs`
29
- * / `getActingAs` live on `OxyServices.base` and `verifyActingAs` on the utility
30
- * mixin (it verifies against `GET /accounts/verify-acting-as`).
28
+ * SWITCHING INTO AN ACCOUNT: `switchToAccount(accountId)` mints a REAL session
29
+ * for the target account and plants it as the active session — there is no
30
+ * per-request "acting-as" header. Identity is carried by the session/token, not
31
+ * a delegation header, so a switch propagates through reload and `refresh-all`
32
+ * exactly like a login.
31
33
  */
32
34
  import type { User } from '../models/interfaces';
35
+ import type { SessionLoginResponse } from '../models/session';
33
36
  import type { OxyServicesBase } from '../OxyServices.base';
37
+ import { normalizeUserIdentity } from '../utils/userIdentity';
34
38
  import { CACHE_TIMES } from './mixinHelpers';
35
39
 
36
40
  // ---------------------------------------------------------------------------
@@ -427,6 +431,29 @@ export interface AccountSuccessResult {
427
431
  success: boolean;
428
432
  }
429
433
 
434
+ /**
435
+ * Result of {@link OxyServicesAccountsMixin.switchToAccount} — the freshly
436
+ * minted session for the target account, in the SAME shape the canonical login
437
+ * / `claimSessionByToken` responses use (`SessionLoginResponse`), plus the
438
+ * device-local refresh-cookie slot index.
439
+ *
440
+ * `accessToken` is the first access token for the new session (already planted
441
+ * as the active token by `switchToAccount`). The refresh token is NOT in the
442
+ * body — the server sets it as the httpOnly `oxy_rt_<authuser>` cookie, which
443
+ * joins the device multi-account set so the switched session survives reload and
444
+ * propagates cross-domain via `/auth/refresh-all`. `user` is the target account.
445
+ */
446
+ export interface SwitchAccountResult extends SessionLoginResponse {
447
+ /**
448
+ * The device-local refresh-cookie slot index (`oxy_rt_<authuser>`) the server
449
+ * assigned to the minted session. Surfaced so the consumer can register the
450
+ * new session in its device multi-account set exactly like a login response.
451
+ * Absent only when the server could not set the cookie (best-effort — the
452
+ * switch itself still succeeds).
453
+ */
454
+ authuser?: number;
455
+ }
456
+
430
457
  export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base: T) {
431
458
  return class extends Base {
432
459
  constructor(...args: any[]) {
@@ -480,6 +507,67 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
480
507
  }
481
508
  }
482
509
 
510
+ /**
511
+ * Switch the active session INTO a managed account.
512
+ *
513
+ * Calls `POST /accounts/:id/switch` with the signed-in operator's bearer.
514
+ * The server authorises the operator (must hold `account:act_as` over the
515
+ * target, directly or inherited — else 403; 404 if missing/archived; 403 if
516
+ * the target is a personal account), then mints a REAL session for the
517
+ * target account and returns it in the canonical login / `claimSessionByToken`
518
+ * shape (`{ sessionId, deviceId, expiresAt, accessToken, user, authuser }`).
519
+ *
520
+ * Unlike the removed `X-Acting-As` delegation header, the returned session
521
+ * IS the new identity: this plants `accessToken` as the active token —
522
+ * exactly like `claimSessionByToken` / `verifyChallenge` — so every
523
+ * subsequent request authenticates as the target account. The refresh token
524
+ * is the server-set httpOnly `oxy_rt_<authuser>` cookie (never in the body),
525
+ * so it joins the device multi-account set and survives reload /
526
+ * `refresh-all` with no extra client work.
527
+ *
528
+ * After planting, the SDK's identity-scoped GET cache is fully cleared so
529
+ * every cached read re-fetches as the new account. (The consuming
530
+ * `OxyContext` additionally invalidates its React Query cache and updates
531
+ * session state from the returned `user`; this clears the SDK's own HTTP
532
+ * cache at the source — `setTokens` deliberately preserves the warm cache for
533
+ * same-user silent refreshes, so the sweep here is explicit.)
534
+ *
535
+ * @param accountId - The target account's Mongo `_id`.
536
+ * @returns The minted session (planted) plus the device `authuser` slot.
537
+ */
538
+ async switchToAccount(accountId: string): Promise<SwitchAccountResult> {
539
+ try {
540
+ const res = await this.makeRequest<SwitchAccountResult>(
541
+ 'POST',
542
+ `/accounts/${encodeURIComponent(accountId)}/switch`,
543
+ undefined,
544
+ { cache: false },
545
+ );
546
+
547
+ // Plant the freshly minted session as the ACTIVE session, mirroring
548
+ // `claimSessionByToken` / `verifyChallenge`: the response body carries
549
+ // the first access token; the refresh token is the server-set httpOnly
550
+ // cookie, so there is nothing else to store here.
551
+ if (res?.accessToken) {
552
+ this.setTokens(res.accessToken);
553
+ }
554
+
555
+ // Identity changed → drop the entire GET response cache so no entry
556
+ // personalised for the previous identity is reused. Cache keys are
557
+ // identity-scoped, so a different identity could not READ the old
558
+ // entries anyway, but clearing guarantees a clean refetch as the new
559
+ // account and frees the prior identity's resident data.
560
+ this.clearCache();
561
+
562
+ return {
563
+ ...res,
564
+ user: normalizeUserIdentity(res.user),
565
+ };
566
+ } catch (error) {
567
+ throw this.handleError(error);
568
+ }
569
+ }
570
+
483
571
  /**
484
572
  * Create a new (non-personal) account. The caller becomes its `owner`.
485
573
  * @param data - Account configuration: kind, optional parent, and profile.
@@ -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';