@oxyhq/core 12.7.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.
@@ -1,5 +1,6 @@
1
1
  import type { ApiError, User } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
+ import { type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
3
4
  /**
4
5
  * Result from the service-acting-as verification endpoint.
5
6
  * Confirms that a given service app holds an active delegation grant for
@@ -28,6 +29,8 @@ export interface ServiceApp {
28
29
  scopes: string[];
29
30
  /** The credentialId of the specific service credential that minted this token. */
30
31
  credentialId: string;
32
+ /** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
33
+ environment: OxyServiceEnvironment;
31
34
  }
32
35
  /**
33
36
  * Options for oxyClient.auth() middleware
@@ -1,5 +1,8 @@
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
+ export { OXY_SERVICE_ENVIRONMENTS };
5
+ export type { OxyServiceEnvironment };
3
6
  export interface OxyRequestUser {
4
7
  id: string;
5
8
  _id?: string;
@@ -13,6 +16,7 @@ export interface OxyServiceAppContext {
13
16
  appName: string;
14
17
  scopes: string[];
15
18
  credentialId: string;
19
+ environment: OxyServiceEnvironment;
16
20
  }
17
21
  export interface OxyServiceActingAsContext {
18
22
  userId: string;
@@ -14,8 +14,8 @@
14
14
  * app.use(createOxyRateLimit(oxy, { store: redisStore }));
15
15
  * ```
16
16
  */
17
- export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, } from './auth';
18
- export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, } from './auth';
17
+ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
18
+ export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
19
19
  export { createOxyRateLimit } from './rateLimit';
20
20
  export type { OxyRateLimitOptions } from './rateLimit';
21
21
  export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch';
@@ -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 declare const OXY_SERVICE_ENVIRONMENTS: readonly ["development", "staging", "production"];
17
+ export type OxyServiceEnvironment = (typeof OXY_SERVICE_ENVIRONMENTS)[number];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.7.0",
3
+ "version": "12.8.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -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) {
@@ -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];