@oxyhq/core 21.0.0 → 21.0.2

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 (62) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +47 -8
  3. package/dist/cjs/i18n/locales/en-US.json +7 -2
  4. package/dist/cjs/i18n/locales/es-ES.json +7 -2
  5. package/dist/cjs/i18n/locales/locales/en-US.json +7 -2
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -2
  7. package/dist/cjs/index.js +8 -1
  8. package/dist/cjs/inference/OxyInferenceClient.js +330 -0
  9. package/dist/cjs/mixins/OxyServices.accounts.js +5 -72
  10. package/dist/cjs/mixins/OxyServices.inference.js +59 -0
  11. package/dist/cjs/mixins/OxyServices.utility.js +18 -6
  12. package/dist/cjs/mixins/index.js +6 -0
  13. package/dist/cjs/server/auth.js +76 -0
  14. package/dist/cjs/server/cors.js +84 -15
  15. package/dist/cjs/server/index.js +5 -1
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/HttpService.js +47 -8
  18. package/dist/esm/i18n/locales/en-US.json +7 -2
  19. package/dist/esm/i18n/locales/es-ES.json +7 -2
  20. package/dist/esm/i18n/locales/locales/en-US.json +7 -2
  21. package/dist/esm/i18n/locales/locales/es-ES.json +7 -2
  22. package/dist/esm/index.js +4 -0
  23. package/dist/esm/inference/OxyInferenceClient.js +325 -0
  24. package/dist/esm/mixins/OxyServices.accounts.js +5 -72
  25. package/dist/esm/mixins/OxyServices.inference.js +56 -0
  26. package/dist/esm/mixins/OxyServices.utility.js +18 -6
  27. package/dist/esm/mixins/index.js +6 -0
  28. package/dist/esm/server/auth.js +72 -0
  29. package/dist/esm/server/cors.js +82 -15
  30. package/dist/esm/server/index.js +1 -1
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/HttpService.d.ts +39 -1
  33. package/dist/types/index.d.ts +3 -1
  34. package/dist/types/inference/OxyInferenceClient.d.ts +324 -0
  35. package/dist/types/mixins/OxyServices.accounts.d.ts +73 -95
  36. package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
  37. package/dist/types/mixins/OxyServices.utility.d.ts +44 -13
  38. package/dist/types/mixins/index.d.ts +2 -1
  39. package/dist/types/server/auth.d.ts +80 -0
  40. package/dist/types/server/cors.d.ts +41 -0
  41. package/dist/types/server/index.d.ts +2 -2
  42. package/package.json +2 -2
  43. package/src/HttpService.ts +50 -10
  44. package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
  45. package/src/i18n/locales/en-US.json +7 -2
  46. package/src/i18n/locales/es-ES.json +7 -2
  47. package/src/index.ts +19 -7
  48. package/src/inference/OxyInferenceClient.ts +590 -0
  49. package/src/inference/__tests__/OxyInferenceClient.test.ts +383 -0
  50. package/src/mixins/OxyServices.accounts.ts +75 -176
  51. package/src/mixins/OxyServices.inference.ts +57 -0
  52. package/src/mixins/OxyServices.utility.ts +58 -14
  53. package/src/mixins/__tests__/accounts.test.ts +57 -102
  54. package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
  55. package/src/mixins/__tests__/serviceAuth.test.ts +2 -0
  56. package/src/mixins/index.ts +8 -0
  57. package/src/server/__tests__/cors.socket.test.ts +225 -0
  58. package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
  59. package/src/server/auth.ts +118 -0
  60. package/src/server/cors.ts +87 -12
  61. package/src/server/index.ts +6 -0
  62. package/src/session/__tests__/accountDialogShape.test.ts +118 -0
@@ -17,9 +17,6 @@ import type { User } from '../../models/interfaces';
17
17
  import type {
18
18
  AccountNode,
19
19
  AccountMember,
20
- AccountCredential,
21
- AccountCredentialWithSecret,
22
- RotateAccountCredentialResult,
23
20
  Application,
24
21
  ApplicationCredential,
25
22
  ApplicationCredentialWithSecret,
@@ -62,20 +59,6 @@ const accountNodeFixture: AccountNode = {
62
59
  childCount: 2,
63
60
  };
64
61
 
65
- const credentialFixture: AccountCredential = {
66
- _id: 'cred1',
67
- accountId: 'acc1',
68
- name: 'bot-prod',
69
- publicKey: 'oxy_dk_abc',
70
- type: 'service',
71
- environment: 'production',
72
- scopes: ['user:read'],
73
- status: 'active',
74
- createdByUserId: 'u1',
75
- createdAt: '2026-06-29T00:00:00.000Z',
76
- updatedAt: '2026-06-29T00:00:00.000Z',
77
- };
78
-
79
62
  const appFixture: Application = {
80
63
  _id: 'app1',
81
64
  name: 'Mention',
@@ -476,91 +459,6 @@ describe('OxyServices.accounts', () => {
476
459
  });
477
460
  });
478
461
 
479
- describe('listAccountCredentials', () => {
480
- it('unwraps the `credentials` array and caches the read', async () => {
481
- makeRequestSpy.mockResolvedValue({ credentials: [credentialFixture] });
482
-
483
- const result = await oxy.listAccountCredentials('acc1');
484
-
485
- expect(result).toEqual([credentialFixture]);
486
- expect(makeRequestSpy).toHaveBeenCalledWith(
487
- 'GET',
488
- '/accounts/acc1/credentials',
489
- undefined,
490
- expect.objectContaining({ cache: true }),
491
- );
492
- });
493
-
494
- it('returns an empty array when `credentials` is absent', async () => {
495
- makeRequestSpy.mockResolvedValue({});
496
- expect(await oxy.listAccountCredentials('acc1')).toEqual([]);
497
- });
498
- });
499
-
500
- describe('createAccountCredential', () => {
501
- it('posts the config, returns the secret result, and busts the credentials cache', async () => {
502
- const created: AccountCredentialWithSecret = {
503
- credential: credentialFixture,
504
- secret: 'sk_once',
505
- };
506
- makeRequestSpy.mockResolvedValue(created);
507
-
508
- const result = await oxy.createAccountCredential('acc1', {
509
- name: 'bot-prod',
510
- environment: 'production',
511
- });
512
-
513
- expect(result).toEqual(created);
514
- expect(makeRequestSpy).toHaveBeenCalledWith(
515
- 'POST',
516
- '/accounts/acc1/credentials',
517
- { name: 'bot-prod', environment: 'production' },
518
- expect.objectContaining({ cache: false }),
519
- );
520
- expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/credentials');
521
- });
522
- });
523
-
524
- describe('rotateAccountCredential', () => {
525
- it('posts to /rotate, encodes ids, returns the audit result, and busts credentials', async () => {
526
- const rotated: RotateAccountCredentialResult = {
527
- credential: { ...credentialFixture, _id: 'cred2' },
528
- secret: 'sk_new',
529
- rotatedFrom: 'cred1',
530
- graceExpiresAt: '2026-07-06T00:00:00.000Z',
531
- };
532
- makeRequestSpy.mockResolvedValue(rotated);
533
-
534
- const result = await oxy.rotateAccountCredential('acc1', 'cred 1');
535
-
536
- expect(result).toEqual(rotated);
537
- expect(makeRequestSpy).toHaveBeenCalledWith(
538
- 'POST',
539
- '/accounts/acc1/credentials/cred%201/rotate',
540
- undefined,
541
- expect.objectContaining({ cache: false }),
542
- );
543
- expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/credentials');
544
- });
545
- });
546
-
547
- describe('revokeAccountCredential', () => {
548
- it('deletes the credential and busts the credentials cache', async () => {
549
- makeRequestSpy.mockResolvedValue({ success: true });
550
-
551
- const result = await oxy.revokeAccountCredential('acc1', 'cred1');
552
-
553
- expect(result).toEqual({ success: true });
554
- expect(makeRequestSpy).toHaveBeenCalledWith(
555
- 'DELETE',
556
- '/accounts/acc1/credentials/cred1',
557
- undefined,
558
- expect.objectContaining({ cache: false }),
559
- );
560
- expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/credentials');
561
- });
562
- });
563
-
564
462
  describe('createApp', () => {
565
463
  it('posts the payload, unwraps `application`, and busts every app list', async () => {
566
464
  makeRequestSpy.mockResolvedValue({ application: appFixture });
@@ -678,6 +576,39 @@ describe('OxyServices.accounts', () => {
678
576
  );
679
577
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/applications/app1/credentials');
680
578
  });
579
+
580
+ it("forwards a machine credential's expiresInSeconds and its one-time token", async () => {
581
+ // A `machine` credential's material arrives in `token`, never `secret` —
582
+ // a surface rendering "the secret" must not silently print an API key.
583
+ const created: ApplicationCredentialWithSecret = {
584
+ credential: { ...appCredentialFixture, type: 'machine', tokenPrefix: 'oxy_sk_0123456789abcdef' },
585
+ secret: null,
586
+ token: 'oxy_sk_0123456789abcdef_' + 'a'.repeat(64),
587
+ };
588
+ makeRequestSpy.mockResolvedValue(created);
589
+
590
+ const result = await oxy.createAppCredential('app1', {
591
+ name: 'ci-runner',
592
+ type: 'machine',
593
+ environment: 'production',
594
+ scopes: ['inference:invoke'],
595
+ expiresInSeconds: 86_400,
596
+ });
597
+
598
+ expect(result.token).toBe(created.token);
599
+ expect(makeRequestSpy).toHaveBeenCalledWith(
600
+ 'POST',
601
+ '/applications/app1/credentials',
602
+ {
603
+ name: 'ci-runner',
604
+ type: 'machine',
605
+ environment: 'production',
606
+ scopes: ['inference:invoke'],
607
+ expiresInSeconds: 86_400,
608
+ },
609
+ expect.objectContaining({ cache: false }),
610
+ );
611
+ });
681
612
  });
682
613
 
683
614
  describe('rotateAppCredential', () => {
@@ -701,6 +632,30 @@ describe('OxyServices.accounts', () => {
701
632
  );
702
633
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/applications/app1/credentials');
703
634
  });
635
+
636
+ it('forwards an opt-in rotation grace window', async () => {
637
+ // Omitting it (the case above) sends `undefined`, and the server then
638
+ // revokes the superseded machine token immediately. Sending it must reach
639
+ // the body, or a caller asking for zero-downtime rotation would silently
640
+ // get the instant-revoke behaviour instead.
641
+ const rotated: RotateApplicationCredentialResult = {
642
+ credential: { ...appCredentialFixture, _id: 'appcred2', type: 'machine' },
643
+ secret: null,
644
+ token: 'oxy_sk_fedcba9876543210_' + 'b'.repeat(64),
645
+ rotatedFrom: 'appcred1',
646
+ graceExpiresAt: '2026-08-17T00:00:00.000Z',
647
+ };
648
+ makeRequestSpy.mockResolvedValue(rotated);
649
+
650
+ await oxy.rotateAppCredential('app1', 'appcred1', { graceSeconds: 3600 });
651
+
652
+ expect(makeRequestSpy).toHaveBeenCalledWith(
653
+ 'POST',
654
+ '/applications/app1/credentials/appcred1/rotate',
655
+ { graceSeconds: 3600 },
656
+ expect.objectContaining({ cache: false }),
657
+ );
658
+ });
704
659
  });
705
660
 
706
661
  describe('revokeAppCredential', () => {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `oxyServices.inference()` — the binding, which is the only thing this mixin
3
+ * does.
4
+ *
5
+ * Two properties matter and neither is visible from the client's own suite: the
6
+ * client must inherit THIS instance's base URL, and it must read the bearer
7
+ * LATE, so a token planted after construction is the one that gets sent. A
8
+ * factory that captured `getAccessToken()` eagerly would pass every test that
9
+ * signs in first and fail on the cold-boot ordering every app actually has.
10
+ *
11
+ * Driven through the real global `fetch`, spied — the factory takes no
12
+ * transport, so a test that built its own client would be asserting against a
13
+ * re-implementation of the thing under test rather than against it.
14
+ */
15
+
16
+ import { OxyServices } from '../../OxyServices';
17
+ import { OxyInferenceClient } from '../../inference/OxyInferenceClient';
18
+
19
+ describe('OxyServices.inference()', () => {
20
+ let fetchSpy: jest.SpyInstance;
21
+
22
+ beforeEach(() => {
23
+ fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
24
+ new Response(JSON.stringify({ data: [], count: 0 }), {
25
+ status: 200,
26
+ headers: { 'Content-Type': 'application/json' },
27
+ }),
28
+ );
29
+ });
30
+
31
+ afterEach(() => {
32
+ fetchSpy.mockRestore();
33
+ });
34
+
35
+ it('returns one memoized client, so a consumer can hold it', () => {
36
+ const oxy = new OxyServices({ baseURL: 'http://test.invalid' });
37
+
38
+ const first = oxy.inference();
39
+ expect(first).toBeInstanceOf(OxyInferenceClient);
40
+ expect(oxy.inference()).toBe(first);
41
+ });
42
+
43
+ it('binds this instance base URL and reads the bearer at request time', async () => {
44
+ const oxy = new OxyServices({ baseURL: 'http://test.invalid' });
45
+ const client = oxy.inference();
46
+
47
+ // The token is planted AFTER the client exists — the cold-boot order.
48
+ oxy.setTokens('planted-later');
49
+ await client.listModels();
50
+
51
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
52
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
53
+ expect(String(url)).toBe('http://test.invalid/v1/models');
54
+ expect((init.headers as Record<string, string>)['Authorization']).toBe(
55
+ 'Bearer planted-later',
56
+ );
57
+ });
58
+ });
@@ -50,6 +50,7 @@ const signServiceToken = (claims: ServiceTokenClaims, secret: string): string =>
50
50
  aud: 'oxy-api',
51
51
  iss: 'oxy-auth',
52
52
  credentialId: 'cred-1',
53
+ ownerAccountId: 'owner-account-1',
53
54
  environment: 'production',
54
55
  ...claims,
55
56
  };
@@ -184,6 +185,7 @@ describe('C3: service-token acting-as enforcement', () => {
184
185
  appId: 'app-1',
185
186
  appName: 'trusted-service',
186
187
  credentialId: 'cred-1',
188
+ ownerAccountId: 'owner-account-1',
187
189
  scopes: ['user:read'],
188
190
  environment: 'production',
189
191
  });
@@ -33,6 +33,7 @@ import { OxyServicesChainsMixin } from './OxyServices.chains';
33
33
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
34
34
  import { OxyServicesLinksMixin } from './OxyServices.links';
35
35
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
36
+ import { OxyServicesInferenceMixin } from './OxyServices.inference';
36
37
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
37
38
  import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
38
39
 
@@ -72,6 +73,7 @@ type AllMixinInstances =
72
73
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
73
74
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
74
75
  & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
76
+ & InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>>
75
77
  & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
76
78
  & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>>
77
79
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
@@ -156,6 +158,12 @@ const MIXIN_PIPELINE: MixinFunction[] = [
156
158
  // shared across applications, with per-application context on top.
157
159
  OxyServicesFollowGraphMixin,
158
160
 
161
+ // The inference model catalogue (#972). Reads only, and deliberately no
162
+ // request/stream/receipt methods — the public inference edge those would
163
+ // call is workstream 4 and does not exist yet. See
164
+ // `docs/inference/README.md` for what is and is not built.
165
+ OxyServicesInferenceMixin,
166
+
159
167
  // Device-first token mint: the client half of the zero-cookie transport
160
168
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
161
169
  OxyServicesDeviceBootMixin,
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `createOxyCors` over a REAL socket, plus the two halves of the opaque-origin
3
+ * guard.
4
+ *
5
+ * `cors.test.ts` drives the middleware with a fake `Request`/`Response` pair,
6
+ * which is fine for header bookkeeping but cannot answer the question this
7
+ * suite exists for: what a client actually receives. These tests start an
8
+ * Express app on an ephemeral port and read the response headers off the wire.
9
+ *
10
+ * `node:http` rather than `fetch`: `Origin` is a forbidden header name for
11
+ * `fetch`, so the request under test has to be built by hand.
12
+ */
13
+
14
+ import http from 'node:http';
15
+ import type { AddressInfo } from 'node:net';
16
+ import express from 'express';
17
+ import { configureLogger, resetLoggerConfig } from '../../logger';
18
+ import type { LogEntry } from '../../logger';
19
+ import { createOxyCors, matchesAllowedOrigin, normalizeAppOrigins } from '../cors';
20
+ import type { OxyCorsOptions } from '../cors';
21
+
22
+ interface CorsResponse {
23
+ status: number;
24
+ allowOrigin: string | undefined;
25
+ allowCredentials: string | undefined;
26
+ }
27
+
28
+ /** Start an Express app carrying `createOxyCors(options)` on an ephemeral port. */
29
+ async function startServer(options: OxyCorsOptions): Promise<http.Server> {
30
+ const app = express();
31
+ app.use(createOxyCors(options));
32
+ app.get('/catalogue', (_req, res) => {
33
+ res.json({ ok: true });
34
+ });
35
+ const server = http.createServer(app);
36
+ await new Promise<void>((resolve) => {
37
+ server.listen(0, '127.0.0.1', resolve);
38
+ });
39
+ return server;
40
+ }
41
+
42
+ async function stopServer(server: http.Server): Promise<void> {
43
+ await new Promise<void>((resolve, reject) => {
44
+ server.close((error) => (error ? reject(error) : resolve()));
45
+ });
46
+ }
47
+
48
+ /** Issue a real request carrying `Origin` and read the CORS headers back. */
49
+ function requestWithOrigin(
50
+ server: http.Server,
51
+ method: 'GET' | 'OPTIONS',
52
+ origin: string,
53
+ ): Promise<CorsResponse> {
54
+ const { port } = server.address() as AddressInfo;
55
+ return new Promise((resolve, reject) => {
56
+ const req = http.request(
57
+ {
58
+ host: '127.0.0.1',
59
+ port,
60
+ method,
61
+ path: '/catalogue',
62
+ headers: { Origin: origin, 'Access-Control-Request-Method': 'GET' },
63
+ },
64
+ (res) => {
65
+ res.resume();
66
+ res.on('end', () => {
67
+ resolve({
68
+ status: res.statusCode ?? 0,
69
+ allowOrigin: res.headers['access-control-allow-origin'],
70
+ allowCredentials: res.headers['access-control-allow-credentials'],
71
+ });
72
+ });
73
+ res.on('error', reject);
74
+ },
75
+ );
76
+ req.on('error', reject);
77
+ req.end();
78
+ });
79
+ }
80
+
81
+ /**
82
+ * The exact shape that was live: one custom-scheme entry alongside ordinary
83
+ * ones. Every origin below normalizes to the opaque origin, so before the fix
84
+ * the `exp://` entry admitted all of them.
85
+ */
86
+ const CONFIGURED_WITH_OPAQUE_ENTRY = ['https://app.example.com', 'exp://localhost:8150'];
87
+ const OTHER_OPAQUE_SCHEME_ORIGINS = [
88
+ 'vscode-webview://abc123',
89
+ 'capacitor://localhost',
90
+ 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
91
+ 'file://',
92
+ ];
93
+
94
+ describe('createOxyCors over a real socket', () => {
95
+ let server: http.Server;
96
+
97
+ beforeAll(async () => {
98
+ // The dropped entry logs at error level by design; that log is asserted in
99
+ // the configure-side suite below. Swallow it here so this suite's output
100
+ // carries only its own failures.
101
+ configureLogger({ sink: () => undefined });
102
+ server = await startServer({ appOrigins: CONFIGURED_WITH_OPAQUE_ENTRY });
103
+ resetLoggerConfig();
104
+ });
105
+
106
+ afterAll(async () => {
107
+ await stopServer(server);
108
+ });
109
+
110
+ it('POSITIVE CONTROL: a configured https origin gets its headers over the wire', async () => {
111
+ for (const method of ['GET', 'OPTIONS'] as const) {
112
+ const res = await requestWithOrigin(server, method, 'https://app.example.com');
113
+ expect(res.allowOrigin).toBe('https://app.example.com');
114
+ expect(res.allowCredentials).toBe('true');
115
+ }
116
+ });
117
+
118
+ it('POSITIVE CONTROL: the built-in Oxy apex family still gets its headers', async () => {
119
+ const res = await requestWithOrigin(server, 'GET', 'https://auth.oxy.so');
120
+ expect(res.allowOrigin).toBe('https://auth.oxy.so');
121
+ expect(res.allowCredentials).toBe('true');
122
+ });
123
+
124
+ it('a custom-scheme origin is NOT admitted by a custom-scheme allowlist entry', async () => {
125
+ for (const origin of OTHER_OPAQUE_SCHEME_ORIGINS) {
126
+ for (const method of ['GET', 'OPTIONS'] as const) {
127
+ const res = await requestWithOrigin(server, method, origin);
128
+ expect(res.allowOrigin).toBeUndefined();
129
+ expect(res.allowCredentials).toBeUndefined();
130
+ }
131
+ }
132
+ });
133
+
134
+ it('the opaque-origin entry does not admit even ITSELF', async () => {
135
+ const res = await requestWithOrigin(server, 'GET', 'exp://localhost:8150');
136
+ expect(res.allowOrigin).toBeUndefined();
137
+ expect(res.allowCredentials).toBeUndefined();
138
+ });
139
+
140
+ it('NEGATIVE CONTROL: a literal `Origin: null` is refused, as it always was', async () => {
141
+ const res = await requestWithOrigin(server, 'GET', 'null');
142
+ expect(res.allowOrigin).toBeUndefined();
143
+ expect(res.allowCredentials).toBeUndefined();
144
+ });
145
+
146
+ it('NEGATIVE CONTROL: an unrelated https origin is refused', async () => {
147
+ const res = await requestWithOrigin(server, 'GET', 'https://evil.example.com');
148
+ expect(res.allowOrigin).toBeUndefined();
149
+ expect(res.allowCredentials).toBeUndefined();
150
+ });
151
+ });
152
+
153
+ /**
154
+ * The CONFIGURE-SIDE half. Its behavioural effect is masked by the match-side
155
+ * half — with both in place, deleting this one changes no response — so what
156
+ * this suite asserts is the observable the drop-rather-than-throw decision
157
+ * rests on: the entry is named in an error log, and nothing else stops.
158
+ */
159
+ describe('normalizeAppOrigins (configure side)', () => {
160
+ let entries: LogEntry[];
161
+
162
+ beforeEach(() => {
163
+ entries = [];
164
+ configureLogger({ sink: (entry) => entries.push(entry) });
165
+ });
166
+
167
+ afterEach(() => {
168
+ resetLoggerConfig();
169
+ });
170
+
171
+ it('drops an opaque-origin entry from the set and names it in an error log', () => {
172
+ const explicit = normalizeAppOrigins(CONFIGURED_WITH_OPAQUE_ENTRY);
173
+
174
+ expect([...explicit]).toEqual(['https://app.example.com']);
175
+ expect(explicit.has('null')).toBe(false);
176
+
177
+ const dropped = entries.filter((entry) => entry.level === 'error');
178
+ expect(dropped).toHaveLength(1);
179
+ expect(dropped[0]?.context?.entry).toBe('exp://localhost:8150');
180
+ expect(dropped[0]?.message).toContain('no origin to match against');
181
+ });
182
+
183
+ it('drops an entry that is not a URL at all, separately named', () => {
184
+ const explicit = normalizeAppOrigins(['not a url', 'https://app.example.com']);
185
+
186
+ expect([...explicit]).toEqual(['https://app.example.com']);
187
+ const dropped = entries.filter((entry) => entry.level === 'error');
188
+ expect(dropped).toHaveLength(1);
189
+ expect(dropped[0]?.context?.entry).toBe('not a url');
190
+ expect(dropped[0]?.message).toContain('not a URL');
191
+ });
192
+
193
+ it('VACUITY FLOOR: a wholly valid list is dropped from silently', () => {
194
+ const explicit = normalizeAppOrigins(['https://app.example.com', 'http://localhost:3000']);
195
+
196
+ expect([...explicit].sort()).toEqual(['http://localhost:3000', 'https://app.example.com']);
197
+ expect(entries.filter((entry) => entry.level === 'error')).toHaveLength(0);
198
+ });
199
+ });
200
+
201
+ /**
202
+ * The MATCH-SIDE half, driven with the hostile precondition the configure side
203
+ * prevents: a set that already contains the opaque origin. This is the only
204
+ * way to observe this half — through `createOxyCors` it is unreachable, so a
205
+ * test there would measure `normalizeAppOrigins` and report on this.
206
+ */
207
+ describe('matchesAllowedOrigin (match side)', () => {
208
+ const poisoned: ReadonlySet<string> = new Set(['null', 'https://app.example.com']);
209
+
210
+ it('refuses every opaque-scheme origin even against a set containing "null"', () => {
211
+ for (const origin of [...OTHER_OPAQUE_SCHEME_ORIGINS, 'exp://localhost:8150']) {
212
+ expect(matchesAllowedOrigin(poisoned, origin)).toBe(false);
213
+ }
214
+ });
215
+
216
+ it('POSITIVE CONTROL: the same poisoned set still matches its real entry', () => {
217
+ expect(matchesAllowedOrigin(poisoned, 'https://app.example.com')).toBe(true);
218
+ expect(matchesAllowedOrigin(poisoned, 'https://auth.oxy.so')).toBe(true);
219
+ expect(matchesAllowedOrigin(poisoned, 'https://evil.example.com')).toBe(false);
220
+ });
221
+
222
+ it('NEGATIVE CONTROL: a literal `null` never reaches the set lookup either', () => {
223
+ expect(matchesAllowedOrigin(poisoned, 'null')).toBe(false);
224
+ });
225
+ });