@oxyhq/core 12.7.0 → 12.9.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 (56) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +16 -3
  3. package/dist/cjs/crypto/identityMarker.js +255 -0
  4. package/dist/cjs/crypto/keyManager.js +844 -106
  5. package/dist/cjs/index.js +8 -4
  6. package/dist/cjs/mixins/OxyServices.auth.js +21 -6
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
  8. package/dist/cjs/mixins/OxyServices.utility.js +11 -1
  9. package/dist/cjs/server/auth.js +3 -0
  10. package/dist/cjs/server/index.js +2 -1
  11. package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/boot/sessionColdBoot.js +16 -3
  14. package/dist/esm/crypto/identityMarker.js +248 -0
  15. package/dist/esm/crypto/keyManager.js +843 -106
  16. package/dist/esm/index.js +2 -1
  17. package/dist/esm/mixins/OxyServices.auth.js +21 -6
  18. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  19. package/dist/esm/mixins/OxyServices.utility.js +11 -1
  20. package/dist/esm/server/auth.js +2 -0
  21. package/dist/esm/server/index.js +1 -1
  22. package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  25. package/dist/types/crypto/identityMarker.d.ts +94 -0
  26. package/dist/types/crypto/keyManager.d.ts +212 -3
  27. package/dist/types/index.d.ts +4 -2
  28. package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
  29. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  30. package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
  31. package/dist/types/server/auth.d.ts +4 -0
  32. package/dist/types/server/index.d.ts +2 -2
  33. package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
  34. package/package.json +1 -1
  35. package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
  36. package/src/boot/sessionColdBoot.ts +42 -3
  37. package/src/crypto/__tests__/identityMocks.ts +125 -0
  38. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  39. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  40. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  41. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  42. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  43. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  44. package/src/crypto/identityMarker.ts +291 -0
  45. package/src/crypto/keyManager.ts +1026 -105
  46. package/src/index.ts +7 -1
  47. package/src/mixins/OxyServices.auth.ts +31 -7
  48. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  49. package/src/mixins/OxyServices.utility.ts +19 -1
  50. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  51. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
  52. package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
  53. package/src/server/auth.ts +5 -0
  54. package/src/server/index.ts +2 -0
  55. package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
  56. package/src/utils/oxyServiceEnvironment.ts +17 -0
package/src/index.ts CHANGED
@@ -245,8 +245,14 @@ export {
245
245
  KeyManager,
246
246
  IdentityAlreadyExistsError,
247
247
  IdentityPersistError,
248
+ IdentityUnavailableError,
248
249
  } from './crypto/keyManager';
249
- export type { KeyPair } from './crypto/keyManager';
250
+ export type { KeyPair, IdentityStatus, IdentityRecoveryResult } from './crypto/keyManager';
251
+ export {
252
+ readIdentityMarker,
253
+ updateIdentityMarker,
254
+ } from './crypto/identityMarker';
255
+ export type { IdentityMarker } from './crypto/identityMarker';
250
256
  export { SignatureService } from './crypto/signatureService';
251
257
  export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
252
258
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';
@@ -497,14 +497,21 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
497
497
  /**
498
498
  * Request an authentication challenge
499
499
  * The client must sign this challenge with their private key
500
- *
500
+ *
501
501
  * @param publicKey - The user's public key
502
+ * @param requestOptions - Optional per-call transport overrides (`retry`,
503
+ * `timeout`). Interactive callers omit it (defaults keep retries); the
504
+ * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
505
+ * network cannot multiply boot latency via the inner retry loop.
502
506
  */
503
- async requestChallenge(publicKey: string): Promise<ChallengeResponse> {
507
+ async requestChallenge(
508
+ publicKey: string,
509
+ requestOptions?: { retry?: boolean; timeout?: number },
510
+ ): Promise<ChallengeResponse> {
504
511
  try {
505
512
  return await this.makeRequest<ChallengeResponse>('POST', '/auth/challenge', {
506
513
  publicKey,
507
- }, { cache: false });
514
+ }, { cache: false, ...requestOptions });
508
515
  } catch (error) {
509
516
  throw this.handleError(error);
510
517
  }
@@ -519,6 +526,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
519
526
  * @param timestamp - Timestamp when the signature was created
520
527
  * @param deviceName - Optional device name
521
528
  * @param deviceFingerprint - Optional device fingerprint
529
+ * @param requestOptions - Optional per-call transport overrides (`retry`,
530
+ * `timeout`). Interactive callers omit it (defaults keep retries); the
531
+ * cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
532
+ * network cannot multiply boot latency via the inner retry loop.
522
533
  */
523
534
  async verifyChallenge(
524
535
  publicKey: string,
@@ -526,7 +537,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
526
537
  signature: string,
527
538
  timestamp: number,
528
539
  deviceName?: string,
529
- deviceFingerprint?: string
540
+ deviceFingerprint?: string,
541
+ requestOptions?: { retry?: boolean; timeout?: number },
530
542
  ): Promise<SessionLoginResponse> {
531
543
  try {
532
544
  const res = await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
@@ -536,7 +548,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
536
548
  timestamp,
537
549
  deviceName,
538
550
  deviceFingerprint,
539
- }, { cache: false });
551
+ }, { cache: false, ...requestOptions });
540
552
 
541
553
  // Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
542
554
  // `/auth/verify` returns the first access token (and refresh token) in
@@ -718,9 +730,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
718
730
  *
719
731
  * The cold-boot wiring that CALLS this lives in `OxyContext`
720
732
  * (`@oxyhq/services`); this method just performs the exchange.
733
+ *
734
+ * @param opts.requestOptions - Optional per-call transport overrides
735
+ * (`retry`, `timeout`) forwarded to BOTH the `requestChallenge` and
736
+ * `verifyChallenge` round-trips. Interactive flows omit it (defaults keep
737
+ * retries); the cold-boot `shared-key-signin` step passes `{ retry: false }`
738
+ * so this network step cannot multiply boot latency via the inner retry
739
+ * loop. The token-refresh scheduler / 401 lane still retry later.
721
740
  */
722
741
  async signInWithSharedIdentity(
723
- opts: { deviceName?: string; deviceFingerprint?: string } = {}
742
+ opts: {
743
+ deviceName?: string;
744
+ deviceFingerprint?: string;
745
+ requestOptions?: { retry?: boolean; timeout?: number };
746
+ } = {}
724
747
  ): Promise<SessionLoginResponse | null> {
725
748
  try {
726
749
  // `hasSharedIdentity()` already returns false on web (the shared
@@ -734,7 +757,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
734
757
  return null;
735
758
  }
736
759
 
737
- const { challenge } = await this.requestChallenge(sharedPublicKey);
760
+ const { challenge } = await this.requestChallenge(sharedPublicKey, opts.requestOptions);
738
761
  const signed = await SignatureService.signChallengeWithSharedKey(challenge);
739
762
 
740
763
  // `signed.challenge` carries the SIGNATURE (mirrors `signChallenge`).
@@ -745,6 +768,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
745
768
  signed.timestamp,
746
769
  opts.deviceName,
747
770
  opts.deviceFingerprint,
771
+ opts.requestOptions,
748
772
  );
749
773
  } catch (error) {
750
774
  throw this.handleError(error);
@@ -39,6 +39,14 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
39
39
  * `no_active_session`) to decide whether to drop the secret and fall back or
40
40
  * resolve signed-out.
41
41
  *
42
+ * `retry: false`: the mint is a single logical attempt. The proactive
43
+ * token-refresh scheduler and the reactive 401 lane already own backoff and
44
+ * re-arm, so `HttpService`'s inner retry loop here would only multiply the
45
+ * mint's latency on a slow/black-hole network (3 retries × 5s timeout ≈ 20s
46
+ * per lane) with no correctness benefit — it is the dominant term in the cold
47
+ * boot's worst-case time-to-route. A transient failure surfaces once and the
48
+ * scheduler/401 path retries it later.
49
+ *
42
50
  * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
43
51
  */
44
52
  async mintFromDeviceSecret(
@@ -50,7 +58,7 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
50
58
  'POST',
51
59
  '/session/device/token',
52
60
  { deviceId, deviceSecret },
53
- { cache: false, skipAuth: true },
61
+ { cache: false, skipAuth: true, retry: false },
54
62
  );
55
63
  const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
56
64
  if (!parsed) {
@@ -12,6 +12,7 @@ import { loadNodeCrypto } from '@oxyhq/protocol';
12
12
  import { buildUrl } from '../utils/apiUtils';
13
13
  import { logger } from '../logger';
14
14
  import { CACHE_TIMES } from './mixinHelpers';
15
+ import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
15
16
 
16
17
  interface JwtPayload {
17
18
  exp?: number;
@@ -25,6 +26,7 @@ interface JwtPayload {
25
26
  scopes?: string[];
26
27
  aud?: string | string[];
27
28
  iss?: string;
29
+ environment?: string;
28
30
  [key: string]: unknown;
29
31
  }
30
32
 
@@ -57,6 +59,8 @@ export interface ServiceApp {
57
59
  scopes: string[];
58
60
  /** The credentialId of the specific service credential that minted this token. */
59
61
  credentialId: string;
62
+ /** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
63
+ environment: OxyServiceEnvironment;
60
64
  }
61
65
 
62
66
  /**
@@ -94,6 +98,13 @@ class ServiceTokenClaimError extends Error {
94
98
  }
95
99
  }
96
100
 
101
+ function isOxyServiceEnvironment(value: unknown): value is OxyServiceEnvironment {
102
+ return (
103
+ typeof value === 'string' &&
104
+ (OXY_SERVICE_ENVIRONMENTS as readonly string[]).includes(value)
105
+ );
106
+ }
107
+
97
108
  /**
98
109
  * Options for oxyClient.auth() middleware
99
110
  */
@@ -459,7 +470,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
459
470
  // Validate required service token fields
460
471
  const appId = decoded.appId;
461
472
  const credentialId = decoded.credentialId;
462
- if (!appId || typeof credentialId !== 'string' || credentialId.length === 0) {
473
+ const environment = decoded.environment;
474
+ if (
475
+ !appId ||
476
+ typeof credentialId !== 'string' ||
477
+ credentialId.length === 0 ||
478
+ !isOxyServiceEnvironment(environment)
479
+ ) {
463
480
  if (optional) {
464
481
  req.userId = null;
465
482
  req.user = null;
@@ -513,6 +530,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
513
530
  appName: decoded.appName || 'unknown',
514
531
  credentialId,
515
532
  scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
533
+ environment,
516
534
  };
517
535
 
518
536
  if (debug) {
@@ -31,15 +31,17 @@ describe('OxyServices.deviceBoot', () => {
31
31
  },
32
32
  };
33
33
 
34
- it('POSTs deviceId + deviceSecret with skipAuth (no bearer, no cache) and returns the validated mint', async () => {
34
+ it('POSTs deviceId + deviceSecret with skipAuth + retry:false (no bearer, no cache, single attempt) and returns the validated mint', async () => {
35
35
  makeRequest.mockResolvedValueOnce(MINT);
36
36
  const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
37
37
  expect(result).toEqual(MINT);
38
+ // `retry: false` — the scheduler/401 lane own backoff; HttpService's inner
39
+ // retry here would only multiply cold-boot latency on a slow network.
38
40
  expect(makeRequest).toHaveBeenCalledWith(
39
41
  'POST',
40
42
  '/session/device/token',
41
43
  { deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
42
- { cache: false, skipAuth: true },
44
+ { cache: false, skipAuth: true, retry: false },
43
45
  );
44
46
  });
45
47
 
@@ -242,6 +242,51 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
242
242
  });
243
243
  });
244
244
 
245
+ describe('requestChallenge / verifyChallenge — requestOptions spread into makeRequest', () => {
246
+ it('requestChallenge omits transport overrides by default (retries ON) and spreads them when given', async () => {
247
+ makeRequestSpy.mockResolvedValue(challengeFixture);
248
+
249
+ await oxy.requestChallenge('pub-x');
250
+ expect(makeRequestSpy).toHaveBeenLastCalledWith(
251
+ 'POST',
252
+ '/auth/challenge',
253
+ { publicKey: 'pub-x' },
254
+ { cache: false },
255
+ );
256
+
257
+ await oxy.requestChallenge('pub-x', { retry: false });
258
+ expect(makeRequestSpy).toHaveBeenLastCalledWith(
259
+ 'POST',
260
+ '/auth/challenge',
261
+ { publicKey: 'pub-x' },
262
+ { cache: false, retry: false },
263
+ );
264
+ });
265
+
266
+ it('verifyChallenge spreads requestOptions (retry + timeout) into makeRequest', async () => {
267
+ makeRequestSpy.mockResolvedValue(sessionFixture);
268
+
269
+ await oxy.verifyChallenge('pub-x', 'chal', 'sig', 123, 'dev', 'fp', {
270
+ retry: false,
271
+ timeout: 9000,
272
+ });
273
+
274
+ expect(makeRequestSpy).toHaveBeenLastCalledWith(
275
+ 'POST',
276
+ '/auth/verify',
277
+ {
278
+ publicKey: 'pub-x',
279
+ challenge: 'chal',
280
+ signature: 'sig',
281
+ timestamp: 123,
282
+ deviceName: 'dev',
283
+ deviceFingerprint: 'fp',
284
+ },
285
+ { cache: false, retry: false, timeout: 9000 },
286
+ );
287
+ });
288
+ });
289
+
245
290
  describe('signInWithSharedIdentity (Mechanism A — same-device SSO)', () => {
246
291
  it('mints a session from the shared key when one exists (native)', async () => {
247
292
  jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
@@ -263,7 +308,9 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
263
308
  deviceFingerprint: 'fp-1',
264
309
  });
265
310
 
266
- expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub');
311
+ // No requestOptions passed → both round-trips get `undefined` (defaults:
312
+ // retries ON), preserving interactive behaviour.
313
+ expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub', undefined);
267
314
  expect(verifyChallengeSpy).toHaveBeenCalledWith(
268
315
  'shared-pub',
269
316
  'chal-shared',
@@ -271,6 +318,42 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
271
318
  1700000000456,
272
319
  'iPad',
273
320
  'fp-1',
321
+ undefined,
322
+ );
323
+ expect(result).toEqual(sessionFixture);
324
+ });
325
+
326
+ it('threads requestOptions into BOTH the challenge and verify round-trips (cold-boot retry:false)', async () => {
327
+ jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
328
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue('shared-pub');
329
+ const requestChallengeSpy = jest
330
+ .spyOn(oxy, 'requestChallenge')
331
+ .mockResolvedValue({ challenge: 'chal-shared', expiresAt: '2026-06-26T00:05:00.000Z' });
332
+ jest.spyOn(SignatureService, 'signChallengeWithSharedKey').mockResolvedValue({
333
+ challenge: 'sig-shared',
334
+ publicKey: 'shared-pub',
335
+ timestamp: 1700000000456,
336
+ });
337
+ const verifyChallengeSpy = jest
338
+ .spyOn(oxy, 'verifyChallenge')
339
+ .mockResolvedValue(sessionFixture);
340
+
341
+ const result = await oxy.signInWithSharedIdentity({
342
+ requestOptions: { retry: false },
343
+ });
344
+
345
+ // The SAME requestOptions object is forwarded to both calls — this is how
346
+ // the cold-boot `shared-key-signin` step keeps its two round-trips as
347
+ // single attempts without changing interactive defaults.
348
+ expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub', { retry: false });
349
+ expect(verifyChallengeSpy).toHaveBeenCalledWith(
350
+ 'shared-pub',
351
+ 'chal-shared',
352
+ 'sig-shared',
353
+ 1700000000456,
354
+ undefined,
355
+ undefined,
356
+ { retry: false },
274
357
  );
275
358
  expect(result).toEqual(sessionFixture);
276
359
  });
@@ -30,6 +30,7 @@ interface ServiceTokenClaims {
30
30
  scopes?: string[];
31
31
  aud?: string | string[];
32
32
  iss?: string;
33
+ environment?: string;
33
34
  exp?: number;
34
35
  iat?: number;
35
36
  [key: string]: unknown;
@@ -49,6 +50,7 @@ const signServiceToken = (claims: ServiceTokenClaims, secret: string): string =>
49
50
  aud: 'oxy-api',
50
51
  iss: 'oxy-auth',
51
52
  credentialId: 'cred-1',
53
+ environment: 'production',
52
54
  ...claims,
53
55
  };
54
56
  const headerB64 = b64url(JSON.stringify(header));
@@ -183,6 +185,7 @@ describe('C3: service-token acting-as enforcement', () => {
183
185
  appName: 'trusted-service',
184
186
  credentialId: 'cred-1',
185
187
  scopes: ['user:read'],
188
+ environment: 'production',
186
189
  });
187
190
  });
188
191
 
@@ -762,3 +765,65 @@ describe('requireScope() middleware', () => {
762
765
  expect(() => oxy.requireScope(undefined as unknown as string)).toThrow('requireScope');
763
766
  });
764
767
  });
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // service-token environment claim (F2.0 task 1b) — test/live isolation.
771
+ // ---------------------------------------------------------------------------
772
+
773
+ describe('service-token environment claim (F2.0 task 1b)', () => {
774
+ let oxy: OxyServices;
775
+
776
+ beforeEach(() => {
777
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
778
+ });
779
+
780
+ it('populates req.serviceApp.environment from the token claim', async () => {
781
+ const token = signServiceToken(
782
+ { appId: 'app-1', appName: 'svc', environment: 'development' },
783
+ SERVICE_SECRET,
784
+ );
785
+ const req = makeReq({ headers: { authorization: `Bearer ${token}` } });
786
+ const res = makeRes();
787
+ const next = jest.fn();
788
+
789
+ const mw = oxy.auth({ jwtSecret: SERVICE_SECRET });
790
+ await mw(req as unknown as never, res as unknown as never, next as unknown as never);
791
+
792
+ expect(next).toHaveBeenCalledTimes(1);
793
+ expect(req.serviceApp).toMatchObject({ appId: 'app-1', environment: 'development' });
794
+ });
795
+
796
+ it('rejects a service token missing the environment claim (401)', async () => {
797
+ const token = signServiceToken(
798
+ { appId: 'app-1', appName: 'svc', environment: undefined },
799
+ SERVICE_SECRET,
800
+ );
801
+ const req = makeReq({ headers: { authorization: `Bearer ${token}` } });
802
+ const res = makeRes();
803
+ const next = jest.fn();
804
+
805
+ const mw = oxy.auth({ jwtSecret: SERVICE_SECRET });
806
+ await mw(req as unknown as never, res as unknown as never, next as unknown as never);
807
+
808
+ expect(next).not.toHaveBeenCalled();
809
+ expect(res.statusCode).toBe(401);
810
+ expect(res.body).toMatchObject({ code: 'INVALID_SERVICE_TOKEN' });
811
+ });
812
+
813
+ it('rejects a service token with an environment value outside the known set (401)', async () => {
814
+ const token = signServiceToken(
815
+ { appId: 'app-1', appName: 'svc', environment: 'bogus' },
816
+ SERVICE_SECRET,
817
+ );
818
+ const req = makeReq({ headers: { authorization: `Bearer ${token}` } });
819
+ const res = makeRes();
820
+ const next = jest.fn();
821
+
822
+ const mw = oxy.auth({ jwtSecret: SERVICE_SECRET });
823
+ await mw(req as unknown as never, res as unknown as never, next as unknown as never);
824
+
825
+ expect(next).not.toHaveBeenCalled();
826
+ expect(res.statusCode).toBe(401);
827
+ expect(res.body).toMatchObject({ code: 'INVALID_SERVICE_TOKEN' });
828
+ });
829
+ });
@@ -1,5 +1,9 @@
1
1
  import type { NextFunction, Request, RequestHandler, Response } from 'express';
2
2
  import type { OxyServices } from '../OxyServices';
3
+ import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
4
+
5
+ export { OXY_SERVICE_ENVIRONMENTS };
6
+ export type { OxyServiceEnvironment };
3
7
 
4
8
  export interface OxyRequestUser {
5
9
  id: string;
@@ -15,6 +19,7 @@ export interface OxyServiceAppContext {
15
19
  appName: string;
16
20
  scopes: string[];
17
21
  credentialId: string;
22
+ environment: OxyServiceEnvironment;
18
23
  }
19
24
 
20
25
  export interface OxyServiceActingAsContext {
@@ -22,6 +22,7 @@ export {
22
22
  getRequiredOxyUserId,
23
23
  isOxyAuthenticated,
24
24
  requireOxyAuth,
25
+ OXY_SERVICE_ENVIRONMENTS,
25
26
  } from './auth';
26
27
  export type {
27
28
  OxyAuthenticatedRequest,
@@ -30,6 +31,7 @@ export type {
30
31
  OxyRequestUser,
31
32
  OxyServiceActingAsContext,
32
33
  OxyServiceAppContext,
34
+ OxyServiceEnvironment,
33
35
  } from './auth';
34
36
  export { createOxyRateLimit } from './rateLimit';
35
37
  export type { OxyRateLimitOptions } from './rateLimit';
@@ -0,0 +1,7 @@
1
+ import { OXY_SERVICE_ENVIRONMENTS } from '../oxyServiceEnvironment';
2
+
3
+ describe('OXY_SERVICE_ENVIRONMENTS', () => {
4
+ it('lists exactly development, staging, production, in that order', () => {
5
+ expect(OXY_SERVICE_ENVIRONMENTS).toEqual(['development', 'staging', 'production']);
6
+ });
7
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Environment segregation for Oxy service-token JWTs (test/live isolation).
3
+ * Mirrors `ApplicationCredentialEnvironment` on the API's `ApplicationCredential`
4
+ * model (`packages/api/src/models/ApplicationCredential.ts`) as an INDEPENDENT
5
+ * literal union — `@oxyhq/core` has zero dependency on `@oxyhq/api`, so this is
6
+ * kept in sync by hand, not by import.
7
+ *
8
+ * Defined here (not in `server/auth.ts` or `mixins/OxyServices.utility.ts`
9
+ * directly) because BOTH of those files need it and neither may import from
10
+ * the other: `server/` types import `express` (Node-only, a peer dependency
11
+ * `mixins/` deliberately avoids so it stays safe to bundle into RN/browser
12
+ * consumers — see the "Local request/response/socket typing" comment in
13
+ * `OxyServices.utility.ts`). This file has zero imports, so both sides can
14
+ * depend on it without crossing that boundary.
15
+ */
16
+ export const OXY_SERVICE_ENVIRONMENTS = ['development', 'staging', 'production'] as const;
17
+ export type OxyServiceEnvironment = (typeof OXY_SERVICE_ENVIRONMENTS)[number];