@oxy.so/core 1.7.4 → 1.8.1

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.
@@ -13,8 +13,23 @@
13
13
  * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
14
  * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
15
  * (Node 18 script entrypoints, some embedded hosts) falls back to.
16
- * 3. React Native — `expo-crypto.getRandomBytes` (statically imported via the
17
- * per-platform `platform/crypto` module in `@oxy.so/protocol`).
16
+ * 3. React Native — `expo-crypto.getRandomBytes`, through the dependency-free
17
+ * `@oxy.so/protocol/random` entry.
18
+ *
19
+ * # Evaluation order — this module must import NOTHING that can capture the global
20
+ *
21
+ * `@noble/hashes` 1.x reads `globalThis.crypto` exactly once, when its
22
+ * `crypto.js` is evaluated, and `randomBytes` uses that captured binding for
23
+ * the lifetime of the app. ES imports are evaluated before the importing
24
+ * module's body, so every module this file imports runs BEFORE the shim below
25
+ * is installed. This file used to import `@oxy.so/protocol`'s root entry,
26
+ * which reaches `@noble/curves` → `@noble/hashes` through the envelope signer:
27
+ * on Hermes (no `globalThis.crypto` at startup) noble captured `undefined`
28
+ * before the shim existed, and every identity creation on Android failed with
29
+ * `crypto.getRandomValues must be defined`. Its imports are therefore limited
30
+ * to `buffer` and `@oxy.so/protocol/random`, both free of crypto libraries,
31
+ * and `index.ts` imports this file first. `__tests__/polyfillOrder.test.ts`
32
+ * guards both halves.
18
33
  *
19
34
  * Historically step (2) delegated to `@oxy.so/protocol`'s RN-only
20
35
  * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
@@ -49,7 +49,7 @@
49
49
  * schema, so a rename on either side fails a build rather than a customer's
50
50
  * request.
51
51
  */
52
- import type { CurrencyCode, ExactDecimal, InferenceEnvironment, InferenceErrorCode, InferenceFinishReason, InferenceMessage, InferenceRequestOutcome, InferenceAudioMediaType, InferenceStreamEvent, InferenceSpeechParameters, ModelCatalogueEntry, ResponseFormat, RoutingPolicyReference, RoutingProfile, ToolChoice, ToolDefinition, UnitPrice, UsageQuantity, UsageSource } from '@oxy.so/contracts';
52
+ import type { CurrencyCode, ExactDecimal, InferenceEnvironment, InferenceErrorCode, InferenceFinishReason, InferenceMessage, InferenceReasoning, InferenceRequestOutcome, InferenceAudioMediaType, InferenceStreamEvent, InferenceSpeechParameters, ModelCatalogueEntry, ResponseFormat, RoutingPolicyReference, RoutingProfile, ToolChoice, ToolDefinition, UnitPrice, UsageQuantity, UsageSource } from '@oxy.so/contracts';
53
53
  /** The base URL of the Oxy API, when a caller names none. */
54
54
  export declare const OXY_INFERENCE_BASE_URL = "https://api.oxy.so";
55
55
  /**
@@ -105,6 +105,12 @@ export interface OxyResponsesRequest {
105
105
  readonly tools?: readonly ToolDefinition[];
106
106
  readonly toolChoice?: ToolChoice;
107
107
  readonly responseFormat?: ResponseFormat;
108
+ /**
109
+ * `{ effort: 'low' | 'medium' | 'high' }`. Name only an effort the model
110
+ * lists in its catalogue entry's `capabilities.reasoningEfforts`; any other
111
+ * is refused with `invalid_request` (HTTP 400) before anything is charged.
112
+ */
113
+ readonly reasoning?: InferenceReasoning;
108
114
  /** Cost-attribution tags, echoed back on the receipt. At most 16. */
109
115
  readonly labels?: Readonly<Record<string, string>>;
110
116
  /** Your own correlation id, echoed on the response. */
@@ -282,9 +288,14 @@ export declare class OxyInferenceClient {
282
288
  * both see the PUBLIC catalogue; only an internal/system application's
283
289
  * service token sees internal-only routes.
284
290
  *
285
- * **`[]` is a normal answer**, and is the only answer today: the catalogue
286
- * is populated by operators, and a route is not publicly exposed until
287
- * somebody has reviewed the right to resell it.
291
+ * **`[]` is a normal answer**: a route is not publicly exposed until
292
+ * somebody has reviewed the right to resell it. An official application's
293
+ * credential also sees the internal catalogue Oxy keeps in step with
294
+ * Kaana automatically.
295
+ *
296
+ * Each entry's `capabilities.reasoningEfforts` lists the efforts a request
297
+ * may name in `reasoning.effort` (empty: none), and `releasedAt`, when
298
+ * present, is the date the upstream provider published the model.
288
299
  */
289
300
  listModels(options?: {
290
301
  signal?: AbortSignal;
@@ -47,6 +47,14 @@ export interface ServiceApp {
47
47
  appId: string;
48
48
  appName: string;
49
49
  scopes: string[];
50
+ /**
51
+ * `internal` when the caller is one of Oxy's own applications: app to app it
52
+ * is trusted outright — {@link requireScope} passes and it may act for a user
53
+ * without a delegation grant. `external` keeps every scope and grant rule.
54
+ * What the USER may do (their accounts, plan, limits) is still for the
55
+ * receiving service to decide. A token without the claim reads as `external`.
56
+ */
57
+ tier: 'internal' | 'external';
50
58
  /** The credentialId of the specific service credential that minted this token. */
51
59
  credentialId: string;
52
60
  /**
@@ -24,6 +24,8 @@ export interface OxyServiceAppContext {
24
24
  */
25
25
  ownerAccountId: string;
26
26
  environment: OxyServiceEnvironment;
27
+ /** `internal` for one of Oxy's own applications, else `external` (see `ServiceApp.tier`). */
28
+ tier: 'internal' | 'external';
27
29
  }
28
30
  export interface OxyServiceActingAsContext {
29
31
  userId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.7.4",
3
+ "version": "1.8.1",
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",
@@ -118,8 +118,8 @@
118
118
  "@oxy.so/telemetry": "^1.1.1",
119
119
  "@noble/ciphers": "^1.3.0",
120
120
  "@noble/hashes": "^1.8.0",
121
- "@oxy.so/contracts": "^1.3.0",
122
- "@oxy.so/protocol": "^1.1.0",
121
+ "@oxy.so/contracts": "^1.4.0",
122
+ "@oxy.so/protocol": "^1.1.1",
123
123
  "@scure/bip39": "^1.6.0",
124
124
  "buffer": "^6.0.3",
125
125
  "express-rate-limit": "^8.6.0",
@@ -13,7 +13,7 @@
13
13
  const mockGetRandomBytesRN = jest.fn<Uint8Array, [number]>();
14
14
  const mockState = { isNodeJS: true };
15
15
 
16
- jest.mock('@oxy.so/protocol', () => ({
16
+ jest.mock('@oxy.so/protocol/random', () => ({
17
17
  isNodeJS: () => mockState.isNodeJS,
18
18
  getRandomBytesRN: (byteCount: number) => mockGetRandomBytesRN(byteCount),
19
19
  }));
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The crypto polyfill must be installed before ANY `@noble/*` module evaluates.
3
+ *
4
+ * `@noble/hashes` 1.x's `crypto.js` captures `globalThis.crypto` once, at module
5
+ * evaluation, and `randomBytes` uses that captured binding forever. Hermes has
6
+ * no `globalThis.crypto` at startup, so if noble is evaluated before
7
+ * `./polyfill` installs the shim, every random draw throws
8
+ * `crypto.getRandomValues must be defined` — which is exactly how identity
9
+ * creation broke on Android: the polyfill imported `@oxy.so/protocol`'s root
10
+ * entry, whose envelope signer reaches `@noble/curves` → `@noble/hashes`, and
11
+ * imports are evaluated before the polyfill body.
12
+ *
13
+ * Node's resolver picks noble's `cryptoNode.js` (the `node` export condition),
14
+ * which never captures the global, so a plain Node run cannot see the bug. The
15
+ * suites below substitute the REAL `crypto.js` that Metro resolves for React
16
+ * Native, remove `globalThis.crypto`, and load core in a fresh module registry.
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+
22
+ type RandomBytes = (length?: number) => Uint8Array;
23
+
24
+ const ORIGINAL_CRYPTO_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
25
+
26
+ /** noble's browser / React Native `crypto.js`: the variant that captures the global. */
27
+ const NOBLE_CAPTURING_CRYPTO = join(dirname(require.resolve('@noble/hashes/utils')), 'crypto.js');
28
+
29
+ /** Remove `globalThis.crypto` the way a Hermes host starts: no property at all. */
30
+ function removeHostCrypto(): void {
31
+ if (!Reflect.deleteProperty(globalThis, 'crypto') || 'crypto' in globalThis) {
32
+ Object.defineProperty(globalThis, 'crypto', {
33
+ value: undefined,
34
+ configurable: true,
35
+ writable: true,
36
+ });
37
+ }
38
+ }
39
+
40
+ function restoreHostCrypto(): void {
41
+ Reflect.deleteProperty(globalThis, 'crypto');
42
+ if (ORIGINAL_CRYPTO_DESCRIPTOR) {
43
+ Object.defineProperty(globalThis, 'crypto', ORIGINAL_CRYPTO_DESCRIPTOR);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Run `body` in a fresh module registry that resolves `@noble/hashes/crypto` as
49
+ * a React Native bundle does, with no host `globalThis.crypto`.
50
+ */
51
+ function withReactNativeNobleAndNoHostCrypto(body: () => void): void {
52
+ removeHostCrypto();
53
+ try {
54
+ jest.isolateModules(() => {
55
+ jest.doMock('@noble/hashes/crypto', () => jest.requireActual(NOBLE_CAPTURING_CRYPTO));
56
+ body();
57
+ });
58
+ } finally {
59
+ restoreHostCrypto();
60
+ }
61
+ }
62
+
63
+ describe('crypto polyfill evaluation order', () => {
64
+ it('reproduces the capture: noble evaluated before the polyfill has no randomness', () => {
65
+ // Guards the harness itself — if this ever stops throwing, the substitution
66
+ // no longer models the capturing variant and the suites below prove nothing.
67
+ withReactNativeNobleAndNoHostCrypto(() => {
68
+ const { randomBytes } = require('@noble/hashes/utils') as { randomBytes: RandomBytes };
69
+ require('../polyfill');
70
+ expect(() => randomBytes(32)).toThrow('crypto.getRandomValues must be defined');
71
+ });
72
+ });
73
+
74
+ it('importing @oxy.so/core installs the shim before noble captures the global', () => {
75
+ withReactNativeNobleAndNoHostCrypto(() => {
76
+ require('../../index');
77
+ const { randomBytes } = require('@noble/hashes/utils') as { randomBytes: RandomBytes };
78
+
79
+ const bytes = randomBytes(32);
80
+ expect(bytes).toHaveLength(32);
81
+ expect(bytes.some((byte) => byte !== 0)).toBe(true);
82
+ });
83
+ });
84
+
85
+ it('generates an identity (key pair + recovery mnemonic) through core', () => {
86
+ withReactNativeNobleAndNoHostCrypto(() => {
87
+ const core = require('../../index') as typeof import('../../index');
88
+ const { generateMnemonic } = require('@scure/bip39') as typeof import('@scure/bip39');
89
+ const { wordlist } = require('@scure/bip39/wordlists/english') as {
90
+ wordlist: string[];
91
+ };
92
+
93
+ // The two random draws `RecoveryPhraseService.generateIdentityWithRecovery`
94
+ // and `KeyManager.generateKeyPairSync` make — the ones Commons failed on.
95
+ expect(generateMnemonic(wordlist, 128).split(' ')).toHaveLength(12);
96
+ const keyPair = core.KeyManager.generateKeyPairSync();
97
+ expect(keyPair.privateKey).toMatch(/^[0-9a-f]{64}$/);
98
+ });
99
+ });
100
+
101
+ it('the polyfill imports nothing that can reach a crypto library', () => {
102
+ const source = readFileSync(resolve(__dirname, '..', 'polyfill.ts'), 'utf8');
103
+ const specifiers = [...source.matchAll(/^import\s+[^'"]*?['"]([^'"]+)['"]/gm)].map(
104
+ (match) => match[1],
105
+ );
106
+ expect(specifiers.sort()).toEqual(['@oxy.so/protocol/random', 'buffer']);
107
+ });
108
+
109
+ it('the core entry imports the polyfill before anything else', () => {
110
+ const source = readFileSync(resolve(__dirname, '..', '..', 'index.ts'), 'utf8');
111
+ const firstModuleReference = source.match(/^(?:import|export)\s[^;]*?['"]([^'"]+)['"]/m);
112
+ expect(firstModuleReference?.[1]).toBe('./crypto/polyfill');
113
+ });
114
+ });
@@ -13,8 +13,23 @@
13
13
  * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
14
  * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
15
  * (Node 18 script entrypoints, some embedded hosts) falls back to.
16
- * 3. React Native — `expo-crypto.getRandomBytes` (statically imported via the
17
- * per-platform `platform/crypto` module in `@oxy.so/protocol`).
16
+ * 3. React Native — `expo-crypto.getRandomBytes`, through the dependency-free
17
+ * `@oxy.so/protocol/random` entry.
18
+ *
19
+ * # Evaluation order — this module must import NOTHING that can capture the global
20
+ *
21
+ * `@noble/hashes` 1.x reads `globalThis.crypto` exactly once, when its
22
+ * `crypto.js` is evaluated, and `randomBytes` uses that captured binding for
23
+ * the lifetime of the app. ES imports are evaluated before the importing
24
+ * module's body, so every module this file imports runs BEFORE the shim below
25
+ * is installed. This file used to import `@oxy.so/protocol`'s root entry,
26
+ * which reaches `@noble/curves` → `@noble/hashes` through the envelope signer:
27
+ * on Hermes (no `globalThis.crypto` at startup) noble captured `undefined`
28
+ * before the shim existed, and every identity creation on Android failed with
29
+ * `crypto.getRandomValues must be defined`. Its imports are therefore limited
30
+ * to `buffer` and `@oxy.so/protocol/random`, both free of crypto libraries,
31
+ * and `index.ts` imports this file first. `__tests__/polyfillOrder.test.ts`
32
+ * guards both halves.
18
33
  *
19
34
  * Historically step (2) delegated to `@oxy.so/protocol`'s RN-only
20
35
  * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
@@ -23,7 +38,7 @@
23
38
  */
24
39
 
25
40
  import { Buffer } from 'buffer';
26
- import { getRandomBytesRN, isNodeJS } from '@oxy.so/protocol';
41
+ import { getRandomBytesRN, isNodeJS } from '@oxy.so/protocol/random';
27
42
 
28
43
  const getGlobalObject = (): typeof globalThis => {
29
44
  if (typeof globalThis !== 'undefined') return globalThis;
@@ -125,7 +140,7 @@ const cryptoPolyfill: CryptoLike = {
125
140
  return array;
126
141
  }
127
142
  // React Native (and any non-Node host without WebCrypto): synchronous
128
- // expo-crypto via @oxy.so/protocol's RN `platform/crypto` variant.
143
+ // expo-crypto via @oxy.so/protocol's RN `platform/random` variant.
129
144
  const bytes = getRandomBytesRN(array.byteLength);
130
145
  const uint8View = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
131
146
  uint8View.set(bytes);
package/src/index.ts CHANGED
@@ -17,7 +17,10 @@
17
17
  * If a symbol does not appear here, it is NOT part of the public API.
18
18
  */
19
19
 
20
- // Ensure crypto polyfills are loaded before anything else
20
+ // Ensure crypto polyfills are loaded before anything else. This MUST stay the
21
+ // first import: `@noble/hashes` captures `globalThis.crypto` when it is
22
+ // evaluated, so the shim has to exist before any module below reaches it (see
23
+ // the evaluation-order note in `./crypto/polyfill`).
21
24
  import './crypto/polyfill';
22
25
 
23
26
  // ---------------------------------------------------------------------------
@@ -57,6 +57,7 @@ import type {
57
57
  InferenceErrorCode,
58
58
  InferenceFinishReason,
59
59
  InferenceMessage,
60
+ InferenceReasoning,
60
61
  InferenceRequestOutcome,
61
62
  InferenceAudioMediaType,
62
63
  InferenceStreamEvent,
@@ -140,6 +141,12 @@ export interface OxyResponsesRequest {
140
141
  readonly tools?: readonly ToolDefinition[];
141
142
  readonly toolChoice?: ToolChoice;
142
143
  readonly responseFormat?: ResponseFormat;
144
+ /**
145
+ * `{ effort: 'low' | 'medium' | 'high' }`. Name only an effort the model
146
+ * lists in its catalogue entry's `capabilities.reasoningEfforts`; any other
147
+ * is refused with `invalid_request` (HTTP 400) before anything is charged.
148
+ */
149
+ readonly reasoning?: InferenceReasoning;
143
150
  /** Cost-attribution tags, echoed back on the receipt. At most 16. */
144
151
  readonly labels?: Readonly<Record<string, string>>;
145
152
  /** Your own correlation id, echoed on the response. */
@@ -370,9 +377,14 @@ export class OxyInferenceClient {
370
377
  * both see the PUBLIC catalogue; only an internal/system application's
371
378
  * service token sees internal-only routes.
372
379
  *
373
- * **`[]` is a normal answer**, and is the only answer today: the catalogue
374
- * is populated by operators, and a route is not publicly exposed until
375
- * somebody has reviewed the right to resell it.
380
+ * **`[]` is a normal answer**: a route is not publicly exposed until
381
+ * somebody has reviewed the right to resell it. An official application's
382
+ * credential also sees the internal catalogue Oxy keeps in step with
383
+ * Kaana automatically.
384
+ *
385
+ * Each entry's `capabilities.reasoningEfforts` lists the efforts a request
386
+ * may name in `reasoning.effort` (empty: none), and `releasedAt`, when
387
+ * present, is the date the upstream provider published the model.
376
388
  */
377
389
  async listModels(options: { signal?: AbortSignal } = {}): Promise<ModelCatalogueEntry[]> {
378
390
  const body = await this.#request<CatalogueCollection<ModelCatalogueEntry>>(
@@ -296,6 +296,23 @@ describe('OxyInferenceClient', () => {
296
296
  expect(answer.requestId).toBe('req-1');
297
297
  });
298
298
 
299
+ it('sends a reasoning effort as the edge expects it', async () => {
300
+ const { impl, calls } = stubFetch([{ status: 200, body: {} }]);
301
+ const client = new OxyInferenceClient({
302
+ credential: 'k',
303
+ baseURL: 'http://test.invalid',
304
+ fetch: impl,
305
+ });
306
+
307
+ await client.respond({ model: 'acme/thinker', input: 'hello', reasoning: { effort: 'high' } });
308
+
309
+ expect(JSON.parse(String(calls[0].init.body))).toEqual({
310
+ model: 'acme/thinker',
311
+ input: 'hello',
312
+ reasoning: { effort: 'high' },
313
+ });
314
+ });
315
+
299
316
  it('serializes an exact routing-profile ID byte-for-byte', async () => {
300
317
  const { impl, calls } = stubFetch([{ status: 200, body: {} }]);
301
318
  const client = new OxyInferenceClient({
@@ -24,6 +24,7 @@ interface JwtPayload {
24
24
  ownerAccountId?: string;
25
25
  appName?: string;
26
26
  scopes?: string[];
27
+ tier?: string;
27
28
  aud?: string | string[];
28
29
  iss?: string;
29
30
  environment?: string;
@@ -78,6 +79,14 @@ export interface ServiceApp {
78
79
  appId: string;
79
80
  appName: string;
80
81
  scopes: string[];
82
+ /**
83
+ * `internal` when the caller is one of Oxy's own applications: app to app it
84
+ * is trusted outright — {@link requireScope} passes and it may act for a user
85
+ * without a delegation grant. `external` keeps every scope and grant rule.
86
+ * What the USER may do (their accounts, plan, limits) is still for the
87
+ * receiving service to decide. A token without the claim reads as `external`.
88
+ */
89
+ tier: 'internal' | 'external';
81
90
  /** The credentialId of the specific service credential that minted this token. */
82
91
  credentialId: string;
83
92
  /**
@@ -705,6 +714,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
705
714
  return res.status(401).json(error);
706
715
  }
707
716
 
717
+ const tier: ServiceApp['tier'] = decoded.tier === 'internal' ? 'internal' : 'external';
718
+
708
719
  // Read delegated user ID from header
709
720
  const oxyUserIdRaw = req.headers['x-oxy-user-id'];
710
721
  const oxyUserId = isExactNonEmptyServiceClaim(oxyUserIdRaw) ? oxyUserIdRaw : null;
@@ -725,12 +736,20 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
725
736
  return res.status(401).json(error);
726
737
  }
727
738
 
728
- // C3: a service may only act as a user when an explicit
729
- // ServiceActingAs grant exists for that (appId, userId) pair.
730
- // Without the grant we MUST refuse — silently attaching
731
- // `req.userId = oxyUserId` would let any service impersonate
732
- // any user simply by setting the header.
733
- if (oxyUserId) {
739
+ // One of Oxy's own applications acts for a user without a grant:
740
+ // inside the ecosystem that is trust, not consent. It still acts
741
+ // with the USER's authority only — what that user may do is for the
742
+ // receiving service to check.
743
+ if (oxyUserId && tier === 'internal') {
744
+ req.userId = oxyUserId;
745
+ req.user = { id: oxyUserId } as User;
746
+ req.serviceActingAs = { userId: oxyUserId, scopes: [] };
747
+ } else if (oxyUserId) {
748
+ // C3: an EXTERNAL service may only act as a user when an explicit
749
+ // ServiceActingAs grant exists for that (appId, userId) pair.
750
+ // Without the grant we MUST refuse — silently attaching
751
+ // `req.userId = oxyUserId` would let any service impersonate
752
+ // any user simply by setting the header.
734
753
  const grant = await oxyInstance.verifyServiceActingAs(appId, oxyUserId);
735
754
  if (!grant || !grant.authorized) {
736
755
  logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
@@ -781,6 +800,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
781
800
  ownerAccountId,
782
801
  scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
783
802
  environment,
803
+ tier,
784
804
  };
785
805
 
786
806
  if (debug) {
@@ -1255,6 +1275,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
1255
1275
  return;
1256
1276
  }
1257
1277
 
1278
+ // Oxy's own applications are trusted app to app; scopes are the
1279
+ // external lane.
1280
+ if (req.serviceApp.tier === 'internal') {
1281
+ next();
1282
+ return;
1283
+ }
1284
+
1258
1285
  const appHasScope = appScopes.includes(scope);
1259
1286
  const delegationHasScope = delegatedScopes.includes(scope);
1260
1287
  const hasRequiredScope = req.serviceActingAs
@@ -189,9 +189,42 @@ describe('C3: service-token acting-as enforcement', () => {
189
189
  ownerAccountId: 'owner-account-1',
190
190
  scopes: ['user:read'],
191
191
  environment: 'production',
192
+ // No `tier` claim: a token minted before it existed is external.
193
+ tier: 'external',
192
194
  });
193
195
  });
194
196
 
197
+ it("lets one of Oxy's own applications act for a user without a grant", async () => {
198
+ const verifySpy = jest.spyOn(oxy, 'verifyServiceActingAs');
199
+ const token = signServiceToken(
200
+ { appId: 'alia', appName: 'Alia', scopes: [], tier: 'internal' },
201
+ SERVICE_SECRET,
202
+ );
203
+ const req = makeReq({ headers: { authorization: `Bearer ${token}`, 'x-oxy-user-id': 'user-1' } });
204
+ const res = makeRes();
205
+ const next = jest.fn();
206
+
207
+ await oxy.auth({ jwtSecret: SERVICE_SECRET })(req as unknown as never, res as unknown as never, next as unknown as never);
208
+
209
+ expect(verifySpy).not.toHaveBeenCalled();
210
+ expect(next).toHaveBeenCalledTimes(1);
211
+ expect(req.userId).toBe('user-1');
212
+ expect(req.serviceApp?.tier).toBe('internal');
213
+ });
214
+
215
+ it('still refuses an EXTERNAL application acting for a user without a grant', async () => {
216
+ jest.spyOn(oxy, 'verifyServiceActingAs').mockResolvedValue(null);
217
+ const token = signServiceToken({ appId: 'app-1', appName: 'third-party', tier: 'external' }, SERVICE_SECRET);
218
+ const req = makeReq({ headers: { authorization: `Bearer ${token}`, 'x-oxy-user-id': 'user-1' } });
219
+ const res = makeRes();
220
+ const next = jest.fn();
221
+
222
+ await oxy.auth({ jwtSecret: SERVICE_SECRET })(req as unknown as never, res as unknown as never, next as unknown as never);
223
+
224
+ expect(next).not.toHaveBeenCalled();
225
+ expect(res.statusCode).toBe(403);
226
+ });
227
+
195
228
  it('does NOT call verifyServiceActingAs when X-Oxy-User-Id is absent (service acts as itself)', async () => {
196
229
  const verifySpy = jest
197
230
  .spyOn(oxy, 'verifyServiceActingAs')
@@ -682,6 +715,30 @@ describe('requireScope() middleware', () => {
682
715
  oxy = new OxyServices({ baseURL: 'http://test.invalid' });
683
716
  });
684
717
 
718
+ it("passes one of Oxy's own applications whatever scope is asked for", () => {
719
+ const req = makeReq();
720
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: [], tier: 'internal' };
721
+ req.serviceActingAs = { userId: 'u-1', scopes: [] };
722
+ const res = makeRes();
723
+ const next = jest.fn();
724
+
725
+ oxy.requireScope('files:write')(req as unknown as never, res as unknown as never, next as unknown as never);
726
+
727
+ expect(next).toHaveBeenCalledTimes(1);
728
+ });
729
+
730
+ it('holds an external application to its scopes', () => {
731
+ const req = makeReq();
732
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['user:read'], tier: 'external' };
733
+ const res = makeRes();
734
+ const next = jest.fn();
735
+
736
+ oxy.requireScope('files:write')(req as unknown as never, res as unknown as never, next as unknown as never);
737
+
738
+ expect(next).not.toHaveBeenCalled();
739
+ expect(res.statusCode).toBe(403);
740
+ });
741
+
685
742
  it('allows requests where the app holds the required scope', () => {
686
743
  const req = makeReq({
687
744
  // Simulate a fully-authenticated service request — auth() has already
@@ -28,6 +28,8 @@ export interface OxyServiceAppContext {
28
28
  */
29
29
  ownerAccountId: string;
30
30
  environment: OxyServiceEnvironment;
31
+ /** `internal` for one of Oxy's own applications, else `external` (see `ServiceApp.tier`). */
32
+ tier: 'internal' | 'external';
31
33
  }
32
34
 
33
35
  export interface OxyServiceActingAsContext {