@oxyhq/core 12.6.0 → 12.8.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 (48) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/keyManager.js +50 -0
  3. package/dist/cjs/i18n/locales/en-US.json +7 -0
  4. package/dist/cjs/i18n/locales/es-ES.json +7 -0
  5. package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
  7. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
  8. package/dist/cjs/mixins/OxyServices.utility.js +11 -1
  9. package/dist/cjs/mixins/index.js +4 -0
  10. package/dist/cjs/server/auth.js +3 -0
  11. package/dist/cjs/server/index.js +2 -1
  12. package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/crypto/keyManager.js +50 -0
  15. package/dist/esm/i18n/locales/en-US.json +7 -0
  16. package/dist/esm/i18n/locales/es-ES.json +7 -0
  17. package/dist/esm/i18n/locales/locales/en-US.json +7 -0
  18. package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
  19. package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
  20. package/dist/esm/mixins/OxyServices.utility.js +11 -1
  21. package/dist/esm/mixins/index.js +4 -0
  22. package/dist/esm/server/auth.js +2 -0
  23. package/dist/esm/server/index.js +1 -1
  24. package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/crypto/keyManager.d.ts +20 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
  29. package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
  30. package/dist/types/mixins/index.d.ts +2 -1
  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/crypto/__tests__/scopedSeed.test.ts +126 -0
  36. package/src/crypto/keyManager.ts +55 -0
  37. package/src/i18n/locales/en-US.json +7 -0
  38. package/src/i18n/locales/es-ES.json +7 -0
  39. package/src/index.ts +4 -0
  40. package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
  41. package/src/mixins/OxyServices.utility.ts +19 -1
  42. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
  43. package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
  44. package/src/mixins/index.ts +6 -0
  45. package/src/server/auth.ts +5 -0
  46. package/src/server/index.ts +2 -0
  47. package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
  48. package/src/utils/oxyServiceEnvironment.ts +17 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Device-to-device identity transfer mixin tests (b3 Feature 2 — "add a device").
3
+ *
4
+ * Exercises the E2E crypto path THROUGH the mixins with two independent
5
+ * OxyServices instances (old device + new device) wired to a shared in-memory
6
+ * "relay" that never sees a decryption key:
7
+ * - ECDH symmetry: the old device seals with `ECDH(oldEphPriv, newEphPub)` and
8
+ * the new device opens with `ECDH(newEphPriv, oldEphPub)` — same transfer key.
9
+ * - full round-trip: the private key the old device holds is recovered and
10
+ * imported byte-for-byte on the new device.
11
+ * - tamper: a flipped ciphertext byte fails authentication (never imports).
12
+ *
13
+ * The socket is forced OFF (getSocketIO → null) so the deterministic poll path
14
+ * drives the flow; KeyManager + SignatureService.sign are stubbed (the identity
15
+ * key material and the approval signature are not the unit under test here — the
16
+ * server-side signature verification is covered in the api service tests).
17
+ */
18
+
19
+ jest.mock('../../session/socketLoader', () => ({
20
+ getSocketIO: jest.fn(async () => null),
21
+ }));
22
+
23
+ import { ec as EC } from 'elliptic';
24
+ import { OxyServices } from '../../OxyServices';
25
+ import { KeyManager } from '../../crypto/keyManager';
26
+ import { SignatureService } from '../../crypto/signatureService';
27
+ import { deriveSharedSecret } from '../../crypto/ecdh';
28
+ import { hkdfSha256 } from '../../crypto/kdf';
29
+ import { encryptAead, decryptAead } from '../../crypto/aead';
30
+ import { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8 } from '@noble/hashes/utils';
31
+ import type { DeviceTransferInfoResponse } from '@oxyhq/contracts';
32
+
33
+ const ec = new EC('secp256k1');
34
+
35
+ /** A shared in-memory relay: a single pairing row, mirroring the API's shape. */
36
+ interface RelayState {
37
+ pairingId: string;
38
+ newDeviceEphemeralPublicKey: string;
39
+ newDeviceLabel: string | null;
40
+ status: 'pending' | 'approved' | 'denied' | 'expired';
41
+ expiresAt: string;
42
+ oldDeviceEphemeralPublicKey: string | null;
43
+ ciphertext: string | null;
44
+ nonce: string | null;
45
+ }
46
+
47
+ function makeRelay() {
48
+ const state: { row: RelayState | null } = { row: null };
49
+ const infoDto = (): DeviceTransferInfoResponse => {
50
+ const row = state.row!;
51
+ const approved = row.status === 'approved';
52
+ return {
53
+ pairingId: row.pairingId,
54
+ newDeviceEphemeralPublicKey: row.newDeviceEphemeralPublicKey,
55
+ newDeviceLabel: row.newDeviceLabel,
56
+ status: row.status,
57
+ expiresAt: row.expiresAt,
58
+ oldDeviceEphemeralPublicKey: approved ? row.oldDeviceEphemeralPublicKey : null,
59
+ ciphertext: approved ? row.ciphertext : null,
60
+ nonce: approved ? row.nonce : null,
61
+ };
62
+ };
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ const handle = (method: string, url: string, data?: any): Promise<unknown> => {
65
+ if (method === 'POST' && url === '/identity/device-transfer/init') {
66
+ state.row = {
67
+ pairingId: 'a'.repeat(32),
68
+ newDeviceEphemeralPublicKey: data.newEphPub,
69
+ newDeviceLabel: data.newDeviceLabel ?? null,
70
+ status: 'pending',
71
+ expiresAt: new Date(Date.now() + 180_000).toISOString(),
72
+ oldDeviceEphemeralPublicKey: null,
73
+ ciphertext: null,
74
+ nonce: null,
75
+ };
76
+ return Promise.resolve({ pairingId: state.row.pairingId, expiresAt: state.row.expiresAt });
77
+ }
78
+ if (method === 'GET' && url.startsWith('/identity/device-transfer/')) {
79
+ return Promise.resolve(infoDto());
80
+ }
81
+ if (method === 'POST' && url.endsWith('/approve')) {
82
+ state.row!.oldDeviceEphemeralPublicKey = data.oldEphPub;
83
+ state.row!.ciphertext = data.ciphertext;
84
+ state.row!.nonce = data.nonce;
85
+ state.row!.status = 'approved';
86
+ return Promise.resolve({ success: true, pairingId: state.row!.pairingId, status: 'approved' });
87
+ }
88
+ if (method === 'POST' && url.endsWith('/deny')) {
89
+ state.row!.status = 'denied';
90
+ return Promise.resolve({ success: true, pairingId: state.row!.pairingId, status: 'denied' });
91
+ }
92
+ return Promise.reject(new Error(`unexpected request ${method} ${url}`));
93
+ };
94
+ return { state, handle };
95
+ }
96
+
97
+ describe('OxyServices.deviceTransfer', () => {
98
+ let identityPriv: string;
99
+ let identityPub: string;
100
+ let importedPrivateKey: string | null;
101
+
102
+ beforeEach(() => {
103
+ const idKey = ec.genKeyPair();
104
+ identityPriv = idKey.getPrivate('hex');
105
+ identityPub = idKey.getPublic('hex');
106
+ importedPrivateKey = null;
107
+
108
+ // Old device HOLDS the identity; new device IMPORTS it. One KeyManager is
109
+ // shared, but the two roles call disjoint methods (get* vs import).
110
+ jest.spyOn(KeyManager, 'getPrivateKey').mockResolvedValue(identityPriv);
111
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(identityPub);
112
+ jest.spyOn(KeyManager, 'importKeyPair').mockImplementation(async (priv: string) => {
113
+ importedPrivateKey = priv;
114
+ return ec.keyFromPrivate(priv, 'hex').getPublic('hex');
115
+ });
116
+ // The server (api service test) verifies the signature; here it is opaque.
117
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
118
+ });
119
+
120
+ afterEach(() => {
121
+ jest.restoreAllMocks();
122
+ });
123
+
124
+ it('clones the identity end-to-end (ECDH symmetry + full round-trip via the mixins)', async () => {
125
+ const relay = makeRelay();
126
+ const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
127
+ const oldDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
128
+ jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
129
+ jest.spyOn(oldDevice, 'makeRequest').mockImplementation(relay.handle as never);
130
+
131
+ // 1. New device registers a pairing (generates + stores its ephemeral key).
132
+ const init = await newDevice.initDeviceTransfer('New iPhone');
133
+ expect(init.pairingId).toHaveLength(32);
134
+ expect(relay.state.row?.newDeviceEphemeralPublicKey).toBe(init.newEphemeralPublicKey);
135
+
136
+ // 2. Old device resolves the QR handle and approves (seals the identity key).
137
+ const approveResult = await oldDevice.approveDeviceTransfer(init.pairingId);
138
+ expect(approveResult).toEqual({ success: true, pairingId: init.pairingId, status: 'approved' });
139
+ // The relay stored ONLY ephemeral pubkeys + opaque ciphertext — never the key.
140
+ expect(relay.state.row?.ciphertext).toBeTruthy();
141
+ expect(relay.state.row?.ciphertext).not.toContain(identityPriv);
142
+
143
+ // 3. New device awaits approval, decrypts, and imports the SAME private key.
144
+ const outcome = await new Promise<{ status: string; publicKey?: string }>((resolve) => {
145
+ newDevice.subscribeDeviceTransfer(init.pairingId, resolve);
146
+ });
147
+
148
+ expect(outcome).toEqual({ status: 'approved', publicKey: identityPub });
149
+ // The recovered private key is byte-for-byte the old device's identity key.
150
+ expect(importedPrivateKey).toBe(identityPriv);
151
+ // Imported WITHOUT overwrite — a fresh device must never clobber an identity.
152
+ expect(KeyManager.importKeyPair).toHaveBeenCalledWith(identityPriv, { overwrite: false });
153
+ });
154
+
155
+ it('reports a denied transfer to the subscriber without importing', async () => {
156
+ const relay = makeRelay();
157
+ const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
158
+ jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
159
+
160
+ await newDevice.initDeviceTransfer();
161
+ relay.state.row!.status = 'denied';
162
+
163
+ const outcome = await new Promise<{ status: string }>((resolve) => {
164
+ newDevice.subscribeDeviceTransfer(relay.state.row!.pairingId, resolve);
165
+ });
166
+
167
+ expect(outcome).toEqual({ status: 'denied' });
168
+ expect(KeyManager.importKeyPair).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('throws if subscribing to a pairing this instance never initiated', () => {
172
+ const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
173
+ expect(() => newDevice.subscribeDeviceTransfer('unknown-pair', () => {})).toThrow(
174
+ /initDeviceTransfer first/i,
175
+ );
176
+ });
177
+
178
+ it('refuses to approve a non-pending pairing', async () => {
179
+ const relay = makeRelay();
180
+ const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
181
+ const oldDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
182
+ jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
183
+ jest.spyOn(oldDevice, 'makeRequest').mockImplementation(relay.handle as never);
184
+
185
+ const init = await newDevice.initDeviceTransfer();
186
+ relay.state.row!.status = 'denied';
187
+
188
+ await expect(oldDevice.approveDeviceTransfer(init.pairingId)).rejects.toThrow(
189
+ /can no longer be approved/i,
190
+ );
191
+ });
192
+ });
193
+
194
+ /**
195
+ * The exact crypto derivation the mixin uses, pinned independently: two ephemeral
196
+ * pairs agree on a symmetric key, seal/open a JSON key blob, and any tampering
197
+ * with the ciphertext fails authentication.
198
+ */
199
+ describe('device-transfer crypto derivation', () => {
200
+ const HKDF_INFO = 'oxy-device-transfer-v1';
201
+ const deriveTransferKey = (shared: Uint8Array, pairingId: string): Uint8Array =>
202
+ hkdfSha256(shared, utf8ToBytes(pairingId), utf8ToBytes(HKDF_INFO), 32);
203
+
204
+ it('is symmetric and round-trips the sealed identity key', () => {
205
+ const pairingId = 'b'.repeat(32);
206
+ const oldEph = ec.genKeyPair();
207
+ const newEph = ec.genKeyPair();
208
+
209
+ const sharedOld = deriveSharedSecret(oldEph.getPrivate('hex'), newEph.getPublic('hex'));
210
+ const sharedNew = deriveSharedSecret(newEph.getPrivate('hex'), oldEph.getPublic('hex'));
211
+ expect(bytesToHex(sharedOld)).toBe(bytesToHex(sharedNew));
212
+
213
+ const keyOld = deriveTransferKey(sharedOld, pairingId);
214
+ const keyNew = deriveTransferKey(sharedNew, pairingId);
215
+ expect(bytesToHex(keyOld)).toBe(bytesToHex(keyNew));
216
+
217
+ const identity = { privateKey: 'ff'.repeat(32), publicKey: '04' + 'ab'.repeat(64) };
218
+ const { nonce, ciphertext } = encryptAead(keyOld, utf8ToBytes(JSON.stringify(identity)));
219
+
220
+ const opened = JSON.parse(bytesToUtf8(decryptAead(keyNew, nonce, ciphertext)));
221
+ expect(opened).toEqual(identity);
222
+ });
223
+
224
+ it('fails authentication when the ciphertext is tampered', () => {
225
+ const pairingId = 'c'.repeat(32);
226
+ const oldEph = ec.genKeyPair();
227
+ const newEph = ec.genKeyPair();
228
+ const key = deriveTransferKey(
229
+ deriveSharedSecret(oldEph.getPrivate('hex'), newEph.getPublic('hex')),
230
+ pairingId,
231
+ );
232
+ const { nonce, ciphertext } = encryptAead(key, utf8ToBytes('{"privateKey":"deadbeef"}'));
233
+
234
+ const tampered = Uint8Array.from(ciphertext);
235
+ tampered[0] ^= 0x01; // flip one bit
236
+ const keyNew = deriveTransferKey(
237
+ deriveSharedSecret(newEph.getPrivate('hex'), oldEph.getPublic('hex')),
238
+ pairingId,
239
+ );
240
+ expect(() => decryptAead(keyNew, nonce, tampered)).toThrow();
241
+ // And a wrong pairingId (wrong HKDF salt) also fails — binds to the pairing.
242
+ const wrongSaltKey = deriveTransferKey(
243
+ deriveSharedSecret(newEph.getPrivate('hex'), oldEph.getPublic('hex')),
244
+ 'd'.repeat(32),
245
+ );
246
+ expect(() => decryptAead(wrongSaltKey, nonce, ciphertext)).toThrow();
247
+ });
248
+
249
+ it('re-derives from hex the way the wire transports the material', () => {
250
+ const pairingId = 'e'.repeat(32);
251
+ const oldEph = ec.genKeyPair();
252
+ const newEph = ec.genKeyPair();
253
+ const keyOld = deriveTransferKey(
254
+ deriveSharedSecret(oldEph.getPrivate('hex'), newEph.getPublic('hex')),
255
+ pairingId,
256
+ );
257
+ const { nonce, ciphertext } = encryptAead(keyOld, utf8ToBytes('{"k":1}'));
258
+
259
+ // Wire form: hex strings (exactly what the mixin sends/receives).
260
+ const nonceHex = bytesToHex(nonce);
261
+ const ciphertextHex = bytesToHex(ciphertext);
262
+
263
+ const keyNew = deriveTransferKey(
264
+ deriveSharedSecret(newEph.getPrivate('hex'), oldEph.getPublic('hex')),
265
+ pairingId,
266
+ );
267
+ const opened = bytesToUtf8(decryptAead(keyNew, hexToBytes(nonceHex), hexToBytes(ciphertextHex)));
268
+ expect(opened).toBe('{"k":1}');
269
+ });
270
+ });
@@ -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
+ });
@@ -30,6 +30,7 @@ import { OxyServicesCivicMixin } from './OxyServices.civic';
30
30
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
31
31
  import { OxyServicesLinksMixin } from './OxyServices.links';
32
32
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
33
+ import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
33
34
 
34
35
  /**
35
36
  * Instance shape of every mixin in the pipeline, intersected. The runtime
@@ -64,6 +65,7 @@ type AllMixinInstances =
64
65
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
65
66
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
66
67
  & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
68
+ & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>>
67
69
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
68
70
 
69
71
  /**
@@ -138,6 +140,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
138
140
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
139
141
  OxyServicesDeviceBootMixin,
140
142
 
143
+ // Device-to-device identity transfer ("add a device"): E2E-encrypted key
144
+ // clone over a short-lived relay (b3 Feature 2).
145
+ OxyServicesDeviceTransferMixin,
146
+
141
147
  // Utility (last, can use all above)
142
148
  OxyServicesUtilityMixin,
143
149
  ];
@@ -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];