@oxyhq/core 3.9.1 → 3.10.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.
Files changed (54) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +9 -0
  3. package/dist/cjs/i18n/locales/es-ES.json +9 -0
  4. package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
  6. package/dist/cjs/index.js +2 -3
  7. package/dist/cjs/mixins/OxyServices.assets.js +29 -6
  8. package/dist/cjs/mixins/OxyServices.utility.js +52 -23
  9. package/dist/cjs/server/cors.js +155 -0
  10. package/dist/cjs/server/index.js +21 -1
  11. package/dist/cjs/server/safeFetch.js +458 -0
  12. package/dist/cjs/server/verifySecret.js +50 -0
  13. package/dist/cjs/utils/fapiAutoDetect.js +12 -42
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/i18n/locales/en-US.json +9 -0
  16. package/dist/esm/i18n/locales/es-ES.json +9 -0
  17. package/dist/esm/i18n/locales/locales/en-US.json +9 -0
  18. package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
  19. package/dist/esm/index.js +1 -1
  20. package/dist/esm/mixins/OxyServices.assets.js +29 -6
  21. package/dist/esm/mixins/OxyServices.utility.js +52 -23
  22. package/dist/esm/server/cors.js +152 -0
  23. package/dist/esm/server/index.js +6 -0
  24. package/dist/esm/server/safeFetch.js +447 -0
  25. package/dist/esm/server/verifySecret.js +47 -0
  26. package/dist/esm/utils/fapiAutoDetect.js +12 -41
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
  30. package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
  31. package/dist/types/server/cors.d.ts +57 -0
  32. package/dist/types/server/index.d.ts +5 -0
  33. package/dist/types/server/safeFetch.d.ts +135 -0
  34. package/dist/types/server/verifySecret.d.ts +29 -0
  35. package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
  36. package/package.json +2 -1
  37. package/src/__tests__/authSocket.test.ts +96 -0
  38. package/src/i18n/locales/en-US.json +9 -0
  39. package/src/i18n/locales/es-ES.json +9 -0
  40. package/src/index.ts +1 -1
  41. package/src/mixins/OxyServices.assets.ts +40 -6
  42. package/src/mixins/OxyServices.utility.ts +57 -23
  43. package/src/mixins/__tests__/assetUpload.test.ts +191 -0
  44. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
  45. package/src/mixins/__tests__/serviceAuth.test.ts +30 -2
  46. package/src/server/__tests__/cors.test.ts +144 -0
  47. package/src/server/__tests__/safeFetch.test.ts +232 -0
  48. package/src/server/__tests__/verifySecret.test.ts +40 -0
  49. package/src/server/cors.ts +195 -0
  50. package/src/server/index.ts +30 -0
  51. package/src/server/safeFetch.ts +581 -0
  52. package/src/server/verifySecret.ts +52 -0
  53. package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
  54. package/src/utils/fapiAutoDetect.ts +12 -39
@@ -856,8 +856,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
856
856
  return next(new Error('Invalid token'));
857
857
  }
858
858
 
859
- const userId = decoded.userId || decoded.id;
860
- if (!userId) {
859
+ const claimedUserId = decoded.userId || decoded.id;
860
+ if (!claimedUserId) {
861
861
  return next(new Error('Invalid token payload'));
862
862
  }
863
863
 
@@ -866,24 +866,39 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
866
866
  return next(new Error('Token expired'));
867
867
  }
868
868
 
869
- // Validate session if available
870
- if (decoded.sessionId) {
871
- try {
872
- const result = await oxyInstance.validateSession(decoded.sessionId, {
873
- useHeaderValidation: true,
874
- });
875
- if (!result || !result.valid) {
876
- return next(new Error('Session invalid'));
877
- }
878
- } catch (validateErr) {
879
- if (debug) {
880
- logger.debug('[oxy.authSocket] Session validation failed', {
881
- component: 'auth',
882
- method: 'authSocket',
883
- }, validateErr);
884
- }
885
- return next(new Error('Session validation failed'));
869
+ // A server-validated session is mandatory. A bare decoded JWT proves
870
+ // nothing — the signature is not verified here, so without a session
871
+ // round-trip a forged token could claim any user id.
872
+ if (!decoded.sessionId) {
873
+ return next(new Error('Session required'));
874
+ }
875
+
876
+ let userId = claimedUserId;
877
+ try {
878
+ const result = await oxyInstance.validateSession(decoded.sessionId, {
879
+ useHeaderValidation: true,
880
+ });
881
+ if (!result || !result.valid || !result.user) {
882
+ return next(new Error('Session invalid'));
886
883
  }
884
+
885
+ // The session is the source of truth. The client-claimed user id
886
+ // must match the server-validated identity, otherwise a valid
887
+ // session could be paired with a forged user id.
888
+ const validatedUserId = getUserIdentityId(result.user);
889
+ if (!validatedUserId || validatedUserId !== claimedUserId) {
890
+ return next(new Error('Session user mismatch'));
891
+ }
892
+
893
+ userId = validatedUserId;
894
+ } catch (validateErr) {
895
+ if (debug) {
896
+ logger.debug('[oxy.authSocket] Session validation failed', {
897
+ component: 'auth',
898
+ method: 'authSocket',
899
+ }, validateErr);
900
+ }
901
+ return next(new Error('Session validation failed'));
887
902
  }
888
903
 
889
904
  // Attach user data to socket. We expose BOTH `socket.data.userId`
@@ -953,9 +968,9 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
953
968
  * Express.js middleware that enforces a specific service-token scope.
954
969
  *
955
970
  * Mount AFTER `auth()` / `serviceAuth()` — relies on `req.serviceApp` and
956
- * (when delegation is in effect) `req.serviceActingAs.scopes`. The scope
957
- * is granted if EITHER list contains it, mirroring the OAuth2 model where
958
- * the app's app-level scopes and the per-user delegated scopes both count.
971
+ * (when delegation is in effect) `req.serviceActingAs.scopes`. App-only
972
+ * service requests require the app scope. Delegated user requests require
973
+ * BOTH the app scope and the per-user delegation scope.
959
974
  *
960
975
  * Requests authenticated as a regular user (no service token) are rejected
961
976
  * with 403 — scope-protected endpoints are service-to-service by design.
@@ -988,7 +1003,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
988
1003
  return;
989
1004
  }
990
1005
 
991
- if (appScopes.includes(scope) || delegatedScopes.includes(scope)) {
1006
+ const appHasScope = appScopes.includes(scope);
1007
+ const delegationHasScope = delegatedScopes.includes(scope);
1008
+ const hasRequiredScope = req.serviceActingAs
1009
+ ? appHasScope && delegationHasScope
1010
+ : appHasScope;
1011
+
1012
+ if (hasRequiredScope) {
992
1013
  next();
993
1014
  return;
994
1015
  }
@@ -1051,6 +1072,19 @@ async function verifyServiceTokenSignature(token: string, secret: string): Promi
1051
1072
  * access token signed by the same shared secret could be replayed as a
1052
1073
  * service token because no claim binding existed.
1053
1074
  */
1075
+ /**
1076
+ * Resolve the canonical user id from a validated session's user object.
1077
+ *
1078
+ * The API serializer emits `id`, but some upstream shapes carry the raw Mongo
1079
+ * `_id` instead. We accept either, but only a non-empty string — anything else
1080
+ * means the validated identity is unusable and the caller must reject.
1081
+ */
1082
+ function getUserIdentityId(user: User): string | null {
1083
+ const candidate = (user as { id?: unknown; _id?: unknown }).id
1084
+ ?? (user as { id?: unknown; _id?: unknown })._id;
1085
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
1086
+ }
1087
+
1054
1088
  function verifyServiceTokenClaims(
1055
1089
  decoded: JwtPayload,
1056
1090
  expected: { audience: string; issuer: string },
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `OxyServices.assetUpload()` multipart-body tests.
3
+ *
4
+ * `assetUpload` accepts three input shapes: a web `File`, a web `Blob`, or a
5
+ * React Native `{ uri, type?, name?, size? }` descriptor. The descriptor path
6
+ * is platform-sensitive:
7
+ *
8
+ * - React Native — RN's FormData reads the file from disk via the uri during
9
+ * the multipart request, so the descriptor is appended as-is.
10
+ * - Web (browser/Node) — the browser's FormData CANNOT read bytes from a plain
11
+ * `{ uri }` object (it would serialize `[object Object]` → the server stores
12
+ * a 0-byte asset). The uri must be materialized into a real `Blob` via
13
+ * `fetch` before appending. An empty fetched blob must throw instead of
14
+ * silently uploading an empty asset.
15
+ *
16
+ * These tests assert exactly which value lands in the FormData `file` part for
17
+ * each platform, and that an empty web source is rejected.
18
+ */
19
+
20
+ import { OxyServices } from '../../OxyServices';
21
+
22
+ /**
23
+ * Captures every `FormData.append` call so a test can inspect the multipart body
24
+ * that `assetUpload` built without sending a real network request.
25
+ */
26
+ function captureUpload(oxy: OxyServices) {
27
+ const appended: Array<{ name: string; value: unknown; fileName?: string }> = [];
28
+ // Capture-only: do NOT delegate to the real (undici) FormData.append. Node's
29
+ // undici rejects a plain { uri } object as not-a-Blob, but real React Native
30
+ // FormData accepts it — the test asserts on captured args, not a built body.
31
+ const appendSpy = jest
32
+ .spyOn(FormData.prototype, 'append')
33
+ .mockImplementation(function (this: FormData, name: string, value: unknown, fileName?: string) {
34
+ appended.push({ name, value, fileName });
35
+ });
36
+
37
+ const requestSpy = jest
38
+ .spyOn(oxy.getClient(), 'request')
39
+ .mockResolvedValue({ file: { id: 'asset123' } } as never);
40
+
41
+ return {
42
+ appended,
43
+ requestSpy,
44
+ restore: () => {
45
+ appendSpy.mockRestore();
46
+ requestSpy.mockRestore();
47
+ },
48
+ };
49
+ }
50
+
51
+ describe('OxyServices.assetUpload — uri descriptor', () => {
52
+ const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
53
+ const originalFetch = globalThis.fetch;
54
+
55
+ afterEach(() => {
56
+ jest.restoreAllMocks();
57
+ if (originalNavigator === undefined) {
58
+ delete (globalThis as { navigator?: unknown }).navigator;
59
+ } else {
60
+ (globalThis as { navigator?: unknown }).navigator = originalNavigator;
61
+ }
62
+ globalThis.fetch = originalFetch;
63
+ });
64
+
65
+ describe('web (NOT React Native)', () => {
66
+ beforeEach(() => {
67
+ // Node/jsdom-like: no React Native navigator → isReactNative() === false.
68
+ delete (globalThis as { navigator?: unknown }).navigator;
69
+ });
70
+
71
+ it('materializes a blob: uri into a real, non-empty Blob before appending', async () => {
72
+ const bytes = new Blob([new Uint8Array([1, 2, 3, 4, 5])], { type: 'image/png' });
73
+ const fetchMock = jest
74
+ .fn()
75
+ .mockResolvedValue({ ok: true, status: 200, blob: async () => bytes });
76
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
77
+
78
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
79
+ const capture = captureUpload(oxy);
80
+
81
+ try {
82
+ await oxy.assetUpload({ uri: 'blob:https://app.test/abc', type: 'image/png', name: 'avatar.png' });
83
+
84
+ expect(fetchMock).toHaveBeenCalledWith('blob:https://app.test/abc');
85
+
86
+ const filePart = capture.appended.find((p) => p.name === 'file');
87
+ expect(filePart).toBeDefined();
88
+ // The appended value is the fetched Blob with real bytes — NOT the { uri } object.
89
+ expect(filePart?.value).toBeInstanceOf(Blob);
90
+ expect((filePart?.value as Blob).size).toBe(5);
91
+ expect((filePart?.value as { uri?: string }).uri).toBeUndefined();
92
+ expect(filePart?.fileName).toBe('avatar.png');
93
+ } finally {
94
+ capture.restore();
95
+ }
96
+ });
97
+
98
+ it('wraps a typeless fetched blob with the descriptor MIME type', async () => {
99
+ const typeless = new Blob([new Uint8Array([9, 9, 9])]); // type === ''
100
+ const fetchMock = jest
101
+ .fn()
102
+ .mockResolvedValue({ ok: true, status: 200, blob: async () => typeless });
103
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
104
+
105
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
106
+ const capture = captureUpload(oxy);
107
+
108
+ try {
109
+ await oxy.assetUpload({ uri: 'data:application/octet-stream;base64,CQkJ', type: 'image/jpeg', name: 'x.jpg' });
110
+
111
+ const filePart = capture.appended.find((p) => p.name === 'file');
112
+ expect(filePart?.value).toBeInstanceOf(Blob);
113
+ expect((filePart?.value as Blob).size).toBe(3);
114
+ expect((filePart?.value as Blob).type).toBe('image/jpeg');
115
+ } finally {
116
+ capture.restore();
117
+ }
118
+ });
119
+
120
+ it('throws "Cannot upload an empty file" when the fetched blob is empty', async () => {
121
+ const empty = new Blob([], { type: 'image/png' });
122
+ const fetchMock = jest
123
+ .fn()
124
+ .mockResolvedValue({ ok: true, status: 200, blob: async () => empty });
125
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
126
+
127
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
128
+ const capture = captureUpload(oxy);
129
+
130
+ try {
131
+ await expect(
132
+ oxy.assetUpload({ uri: 'blob:https://app.test/empty', type: 'image/png', name: 'empty.png' }),
133
+ ).rejects.toThrow('Cannot upload an empty file');
134
+
135
+ // Nothing was sent — the empty source surfaces instead of creating a 0-byte asset.
136
+ expect(capture.requestSpy).not.toHaveBeenCalled();
137
+ } finally {
138
+ capture.restore();
139
+ }
140
+ });
141
+
142
+ it('throws when the uri cannot be fetched (non-ok response)', async () => {
143
+ const fetchMock = jest
144
+ .fn()
145
+ .mockResolvedValue({ ok: false, status: 404, blob: async () => new Blob([]) });
146
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
147
+
148
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
149
+ const capture = captureUpload(oxy);
150
+
151
+ try {
152
+ await expect(
153
+ oxy.assetUpload({ uri: 'https://cdn.test/missing.png', type: 'image/png', name: 'missing.png' }),
154
+ ).rejects.toThrow('Failed to read file from uri (status 404)');
155
+ expect(capture.requestSpy).not.toHaveBeenCalled();
156
+ } finally {
157
+ capture.restore();
158
+ }
159
+ });
160
+ });
161
+
162
+ describe('React Native', () => {
163
+ beforeEach(() => {
164
+ // Make isReactNative() === true: navigator.product === 'ReactNative'.
165
+ (globalThis as { navigator?: unknown }).navigator = { product: 'ReactNative' };
166
+ });
167
+
168
+ it('appends the descriptor as-is and never calls fetch', async () => {
169
+ const fetchMock = jest.fn();
170
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
171
+
172
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
173
+ const capture = captureUpload(oxy);
174
+
175
+ const descriptor = { uri: 'file:///tmp/avatar.png', type: 'image/png', name: 'avatar.png', size: 1024 };
176
+
177
+ try {
178
+ await oxy.assetUpload(descriptor);
179
+
180
+ // RN path: the raw descriptor object lands in the multipart body unchanged.
181
+ const filePart = capture.appended.find((p) => p.name === 'file');
182
+ expect(filePart?.value).toBe(descriptor);
183
+ expect(filePart?.fileName).toBe('avatar.png');
184
+ // No in-JS materialization on RN — FormData reads the file from the uri.
185
+ expect(fetchMock).not.toHaveBeenCalled();
186
+ } finally {
187
+ capture.restore();
188
+ }
189
+ });
190
+ });
191
+ });
@@ -49,6 +49,19 @@ describe('OxyServices.getFileDownloadUrl', () => {
49
49
  'https://cloud.oxy.so/a%2Fb%20c?variant=large%20size',
50
50
  );
51
51
  });
52
+
53
+ it('can omit the token for persisted public image URLs while authenticated', () => {
54
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
55
+ oxy.setTokens('access-token-abc');
56
+
57
+ const url = oxy.getFileDownloadUrl('file123', 'thumb', undefined, {
58
+ omitToken: true,
59
+ });
60
+
61
+ expect(url).toBe('https://cloud.oxy.so/file123?variant=thumb');
62
+ expect(url).not.toContain('access-token-abc');
63
+ expect(url).not.toContain('token=');
64
+ });
52
65
  });
53
66
 
54
67
  describe('signed / private assets → authenticated API origin', () => {
@@ -672,9 +672,9 @@ describe('requireScope() middleware', () => {
672
672
  expect(res.headersSent).toBe(false);
673
673
  });
674
674
 
675
- it('allows requests where the delegation grant carries the required scope', () => {
675
+ it('allows delegated requests only when both app and delegation carry the required scope', () => {
676
676
  const req = makeReq();
677
- req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: [] };
677
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['user:read'] };
678
678
  req.serviceActingAs = { userId: 'u-1', scopes: ['user:read'] };
679
679
  const res = makeRes();
680
680
  const next = jest.fn();
@@ -684,6 +684,34 @@ describe('requireScope() middleware', () => {
684
684
  expect(next).toHaveBeenCalledTimes(1);
685
685
  });
686
686
 
687
+ it('rejects delegated requests when only the app carries the required scope', () => {
688
+ const req = makeReq();
689
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['files:write'] };
690
+ req.serviceActingAs = { userId: 'u-1', scopes: ['profile:read'] };
691
+ const res = makeRes();
692
+ const next = jest.fn();
693
+
694
+ oxy.requireScope('files:write')(req as unknown as never, res as unknown as never, next as unknown as never);
695
+
696
+ expect(next).not.toHaveBeenCalled();
697
+ expect(res.statusCode).toBe(403);
698
+ expect(res.body).toMatchObject({ code: 'INSUFFICIENT_SCOPE' });
699
+ });
700
+
701
+ it('rejects delegated requests when only the delegation carries the required scope', () => {
702
+ const req = makeReq();
703
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['profile:read'] };
704
+ req.serviceActingAs = { userId: 'u-1', scopes: ['files:write'] };
705
+ const res = makeRes();
706
+ const next = jest.fn();
707
+
708
+ oxy.requireScope('files:write')(req as unknown as never, res as unknown as never, next as unknown as never);
709
+
710
+ expect(next).not.toHaveBeenCalled();
711
+ expect(res.statusCode).toBe(403);
712
+ expect(res.body).toMatchObject({ code: 'INSUFFICIENT_SCOPE' });
713
+ });
714
+
687
715
  it('rejects requests missing the required scope with 403', () => {
688
716
  const req = makeReq();
689
717
  req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['user:read'] };
@@ -0,0 +1,144 @@
1
+ import type { NextFunction, Request, Response } from 'express';
2
+ import { createOxyCors } from '../cors';
3
+
4
+ interface FakeResponse extends Response {
5
+ __headers: Record<string, string>;
6
+ __statusSent: number | null;
7
+ }
8
+
9
+ function makeRequest(method: string, origin?: string, acrHeaders?: string): Request {
10
+ const headers: Record<string, string> = {};
11
+ if (origin !== undefined) headers.origin = origin;
12
+ if (acrHeaders !== undefined) headers['access-control-request-headers'] = acrHeaders;
13
+ return { method, headers } as unknown as Request;
14
+ }
15
+
16
+ function makeResponse(): FakeResponse {
17
+ const res = {
18
+ __headers: {},
19
+ __statusSent: null,
20
+ } as FakeResponse;
21
+ res.setHeader = jest.fn((name: string, value: string | number | readonly string[]) => {
22
+ res.__headers[name] = String(value);
23
+ return res;
24
+ }) as unknown as Response['setHeader'];
25
+ res.sendStatus = jest.fn((code: number) => {
26
+ res.__statusSent = code;
27
+ return res;
28
+ }) as unknown as Response['sendStatus'];
29
+ return res;
30
+ }
31
+
32
+ function makeNext(): NextFunction & jest.Mock {
33
+ return jest.fn() as unknown as NextFunction & jest.Mock;
34
+ }
35
+
36
+ describe('@oxyhq/core/server createOxyCors', () => {
37
+ it('allows the Oxy apex family (apex + any subdomain) and echoes the exact origin', () => {
38
+ const mw = createOxyCors();
39
+ for (const origin of [
40
+ 'https://oxy.so',
41
+ 'https://auth.oxy.so',
42
+ 'https://api.oxy.so',
43
+ 'https://accounts.oxy.so',
44
+ 'https://console.oxy.so',
45
+ 'https://inbox.oxy.so',
46
+ ]) {
47
+ const req = makeRequest('GET', origin);
48
+ const res = makeResponse();
49
+ const next = makeNext();
50
+ mw(req, res, next);
51
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe(origin);
52
+ expect(res.__headers['Access-Control-Allow-Credentials']).toBe('true');
53
+ expect(res.__headers.Vary).toBe('Origin');
54
+ expect(next).toHaveBeenCalledTimes(1);
55
+ }
56
+ });
57
+
58
+ it('allows explicit appOrigins', () => {
59
+ const mw = createOxyCors({ appOrigins: ['https://app.example.com', 'http://localhost:3000'] });
60
+ for (const origin of ['https://app.example.com', 'http://localhost:3000']) {
61
+ const req = makeRequest('GET', origin);
62
+ const res = makeResponse();
63
+ const next = makeNext();
64
+ mw(req, res, next);
65
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe(origin);
66
+ expect(next).toHaveBeenCalledTimes(1);
67
+ }
68
+ });
69
+
70
+ it('DENIES other origins — never reflects them, never wildcards', () => {
71
+ const mw = createOxyCors({ appOrigins: ['https://app.example.com'] });
72
+ for (const origin of [
73
+ 'https://evil.com',
74
+ 'https://oxy.so.evil.com', // suffix attack
75
+ 'https://notoxy.so', // different apex
76
+ 'https://example.com',
77
+ ]) {
78
+ const req = makeRequest('GET', origin);
79
+ const res = makeResponse();
80
+ const next = makeNext();
81
+ mw(req, res, next);
82
+ expect(res.__headers['Access-Control-Allow-Origin']).toBeUndefined();
83
+ expect(res.__headers['Access-Control-Allow-Origin']).not.toBe('*');
84
+ expect(res.__headers['Access-Control-Allow-Origin']).not.toBe(origin);
85
+ // request still passes to the app; the browser enforces the missing ACAO.
86
+ expect(next).toHaveBeenCalledTimes(1);
87
+ }
88
+ });
89
+
90
+ it('NEVER emits wildcard ACAO together with credentials', () => {
91
+ const mw = createOxyCors({ allowCredentials: true });
92
+ // Even an allowed origin gets the exact origin, never '*'.
93
+ const req = makeRequest('GET', 'https://auth.oxy.so');
94
+ const res = makeResponse();
95
+ mw(req, res, makeNext());
96
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe('https://auth.oxy.so');
97
+ expect(res.__headers['Access-Control-Allow-Origin']).not.toBe('*');
98
+ expect(res.__headers['Access-Control-Allow-Credentials']).toBe('true');
99
+ });
100
+
101
+ it('answers preflight (OPTIONS) for an allowed origin with 204 + method/header allows', () => {
102
+ const mw = createOxyCors({ appOrigins: ['https://app.example.com'] });
103
+ const req = makeRequest('OPTIONS', 'https://app.example.com', 'content-type, authorization');
104
+ const res = makeResponse();
105
+ const next = makeNext();
106
+ mw(req, res, next);
107
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe('https://app.example.com');
108
+ expect(res.__headers['Access-Control-Allow-Methods']).toContain('GET');
109
+ expect(res.__headers['Access-Control-Allow-Headers']).toBe('content-type, authorization');
110
+ expect(res.__headers['Access-Control-Max-Age']).toBeDefined();
111
+ expect(res.__statusSent).toBe(204);
112
+ expect(next).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it('answers preflight for a DENIED origin with 204 and NO CORS headers', () => {
116
+ const mw = createOxyCors();
117
+ const req = makeRequest('OPTIONS', 'https://evil.com');
118
+ const res = makeResponse();
119
+ const next = makeNext();
120
+ mw(req, res, next);
121
+ expect(res.__headers['Access-Control-Allow-Origin']).toBeUndefined();
122
+ expect(res.__statusSent).toBe(204);
123
+ expect(next).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('passes through same-origin / non-browser requests (no Origin header) without ACAO', () => {
127
+ const mw = createOxyCors();
128
+ const req = makeRequest('GET');
129
+ const res = makeResponse();
130
+ const next = makeNext();
131
+ mw(req, res, next);
132
+ expect(res.__headers['Access-Control-Allow-Origin']).toBeUndefined();
133
+ expect(next).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it('can disable credentials and still never wildcards', () => {
137
+ const mw = createOxyCors({ allowCredentials: false });
138
+ const req = makeRequest('GET', 'https://api.oxy.so');
139
+ const res = makeResponse();
140
+ mw(req, res, makeNext());
141
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe('https://api.oxy.so');
142
+ expect(res.__headers['Access-Control-Allow-Credentials']).toBeUndefined();
143
+ });
144
+ });