@oxyhq/core 13.0.0 → 14.0.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.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Follow-graph pagination + ordering tests.
3
+ *
4
+ * Regression coverage for the silent "every page is page one" bug.
5
+ * `buildPaginationParams` used to return a `URLSearchParams`, which it then
6
+ * handed to `makeRequest` as a GET's `params`. `HttpService` reads that object
7
+ * with `Object.keys(...)` in TWO places — `buildURL` (decide whether to append
8
+ * a query string) and `generateBaseCacheKey` (build the cache key) — and
9
+ * `Object.keys(new URLSearchParams({ limit: '20' }))` is `[]`, because a
10
+ * `URLSearchParams` exposes its entries through iterator methods rather than
11
+ * own enumerable properties. The consequences were both invisible from the
12
+ * call site:
13
+ *
14
+ * - no query string was ever sent, so every caller silently got the server's
15
+ * DEFAULT page no matter which `limit`/`offset` it asked for, and
16
+ * - every page collapsed onto ONE cache key, so page 2 was served page 1's
17
+ * cached body without a network call.
18
+ *
19
+ * These tests assert on the URL `fetch` actually received (not on the helper's
20
+ * return value), so they fail against the old `URLSearchParams` implementation
21
+ * and cannot pass vacuously.
22
+ */
23
+
24
+ import { OxyServices } from '../../OxyServices';
25
+
26
+ /**
27
+ * Build a non-verified JWT whose payload decodes to the given claims.
28
+ * `jwtDecode` only base64url-decodes the middle segment (no signature check).
29
+ */
30
+ function makeJwt(payload: Record<string, unknown>): string {
31
+ const b64url = (obj: Record<string, unknown>): string =>
32
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
33
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
34
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
35
+ }
36
+
37
+ /** A paginated `{ data, pagination }` body — passed through by `unwrapResponse`. */
38
+ function pageResponse(data: unknown[], total = 100, hasMore = true): Response {
39
+ return new Response(JSON.stringify({ data, pagination: { total, hasMore } }), {
40
+ status: 200,
41
+ headers: { 'content-type': 'application/json' },
42
+ });
43
+ }
44
+
45
+ /** A JSON `{ data: ... }` success envelope. */
46
+ function jsonResponse(data: unknown): Response {
47
+ return new Response(JSON.stringify({ data }), {
48
+ status: 200,
49
+ headers: { 'content-type': 'application/json' },
50
+ });
51
+ }
52
+
53
+ describe('follow-graph pagination and ordering', () => {
54
+ let originalFetch: typeof globalThis.fetch;
55
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
56
+ let oxy: OxyServices;
57
+
58
+ /** The URL string passed to `fetch` on call `n` (0-based). */
59
+ const requestedUrl = (n: number): string => String(fetchMock.mock.calls[n][0]);
60
+
61
+ beforeEach(() => {
62
+ originalFetch = globalThis.fetch;
63
+ fetchMock = jest.fn();
64
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
65
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
66
+ oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
67
+ });
68
+
69
+ afterEach(() => {
70
+ globalThis.fetch = originalFetch;
71
+ jest.clearAllMocks();
72
+ });
73
+
74
+ describe('the request actually carries the pagination the caller asked for', () => {
75
+ it('sends limit and offset on getUserFollowers', async () => {
76
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
77
+ await oxy.getUserFollowers('target-1', { limit: 20, offset: 40 });
78
+
79
+ const url = new URL(requestedUrl(0));
80
+ expect(url.pathname).toBe('/users/target-1/followers');
81
+ expect(url.searchParams.get('limit')).toBe('20');
82
+ expect(url.searchParams.get('offset')).toBe('40');
83
+ });
84
+
85
+ it('sends limit and offset on getUserFollowing', async () => {
86
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
87
+ await oxy.getUserFollowing('target-1', { limit: 5, offset: 10 });
88
+
89
+ const url = new URL(requestedUrl(0));
90
+ expect(url.pathname).toBe('/users/target-1/following');
91
+ expect(url.searchParams.get('limit')).toBe('5');
92
+ expect(url.searchParams.get('offset')).toBe('10');
93
+ });
94
+
95
+ it('sends limit and offset on getUserMutuals', async () => {
96
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
97
+ await oxy.getUserMutuals('target-1', { limit: 7, offset: 14 });
98
+
99
+ const url = new URL(requestedUrl(0));
100
+ expect(url.pathname).toBe('/users/target-1/mutuals');
101
+ expect(url.searchParams.get('limit')).toBe('7');
102
+ expect(url.searchParams.get('offset')).toBe('14');
103
+ });
104
+
105
+ it('sends limit on the id-only graph seeds', async () => {
106
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
107
+ await oxy.getMutualUserIds({ limit: 33 });
108
+ expect(new URL(requestedUrl(0)).searchParams.get('limit')).toBe('33');
109
+
110
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
111
+ await oxy.getFollowsOfFollowsIds({ limit: 44 });
112
+ expect(new URL(requestedUrl(1)).searchParams.get('limit')).toBe('44');
113
+ });
114
+
115
+ it('omits the query string entirely when no pagination is given', async () => {
116
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
117
+ await oxy.getUserFollowers('target-1');
118
+
119
+ expect(requestedUrl(0)).toBe('http://test.invalid/users/target-1/followers');
120
+ });
121
+ });
122
+
123
+ describe('each page is its own cache entry', () => {
124
+ it('does NOT serve page 1 cached body to a page 2 request', async () => {
125
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'a' }]));
126
+ const first = await oxy.getUserFollowers('target-1', { limit: 1, offset: 0 });
127
+ expect(first.followers).toEqual([{ id: 'a' }]);
128
+ expect(fetchMock).toHaveBeenCalledTimes(1);
129
+
130
+ // Different offset ⇒ different cache key ⇒ a real second network call.
131
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'b' }]));
132
+ const second = await oxy.getUserFollowers('target-1', { limit: 1, offset: 1 });
133
+
134
+ expect(fetchMock).toHaveBeenCalledTimes(2);
135
+ expect(second.followers).toEqual([{ id: 'b' }]);
136
+ expect(new URL(requestedUrl(1)).searchParams.get('offset')).toBe('1');
137
+ });
138
+
139
+ it('still serves a warm cache hit for the SAME page', async () => {
140
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'a' }]));
141
+ await oxy.getUserFollowers('target-1', { limit: 1, offset: 0 });
142
+ await oxy.getUserFollowers('target-1', { limit: 1, offset: 0 });
143
+
144
+ expect(fetchMock).toHaveBeenCalledTimes(1);
145
+ });
146
+ });
147
+
148
+ describe('sort', () => {
149
+ it('sends sort=oldest and keeps it out of the request when unset', async () => {
150
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
151
+ await oxy.getUserFollowers('target-1', { limit: 10, sort: 'oldest' });
152
+ expect(new URL(requestedUrl(0)).searchParams.get('sort')).toBe('oldest');
153
+
154
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
155
+ await oxy.getUserFollowers('target-1', { limit: 10 });
156
+ expect(new URL(requestedUrl(1)).searchParams.has('sort')).toBe(false);
157
+ });
158
+
159
+ it('discriminates the cache key, so flipping sort re-fetches', async () => {
160
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'newest' }]));
161
+ const recent = await oxy.getUserFollowers('target-1', { limit: 2, offset: 0, sort: 'recent' });
162
+ expect(recent.followers).toEqual([{ id: 'newest' }]);
163
+ expect(fetchMock).toHaveBeenCalledTimes(1);
164
+
165
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'oldest' }]));
166
+ const oldest = await oxy.getUserFollowers('target-1', { limit: 2, offset: 0, sort: 'oldest' });
167
+
168
+ expect(fetchMock).toHaveBeenCalledTimes(2);
169
+ expect(oldest.followers).toEqual([{ id: 'oldest' }]);
170
+ });
171
+
172
+ it('threads sort through getUserFollowing and getUserMutuals', async () => {
173
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
174
+ await oxy.getUserFollowing('target-1', { sort: 'oldest' });
175
+ expect(new URL(requestedUrl(0)).searchParams.get('sort')).toBe('oldest');
176
+
177
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
178
+ await oxy.getUserMutuals('target-1', { sort: 'oldest' });
179
+ expect(new URL(requestedUrl(1)).searchParams.get('sort')).toBe('oldest');
180
+ });
181
+ });
182
+
183
+ describe('follow writes invalidate the cached follower/following lists', () => {
184
+ it('re-fetches the followers list after followUser', async () => {
185
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'a' }], 1, false));
186
+ await oxy.getUserFollowers('target-1', { limit: 10, offset: 0 });
187
+ expect(fetchMock).toHaveBeenCalledTimes(1);
188
+
189
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
190
+ await oxy.followUser('target-1');
191
+ expect(fetchMock).toHaveBeenCalledTimes(2);
192
+
193
+ // The viewer is now a follower — the list must not come from cache.
194
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'a' }, { id: 'me' }], 2, false));
195
+ const after = await oxy.getUserFollowers('target-1', { limit: 10, offset: 0 });
196
+
197
+ expect(fetchMock).toHaveBeenCalledTimes(3);
198
+ expect(after.followers).toHaveLength(2);
199
+ });
200
+
201
+ it('re-fetches the viewer own following list after followUser', async () => {
202
+ fetchMock.mockResolvedValueOnce(pageResponse([], 0, false));
203
+ await oxy.getUserFollowing('me', { limit: 10, offset: 0 });
204
+ expect(fetchMock).toHaveBeenCalledTimes(1);
205
+
206
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
207
+ await oxy.followUser('target-1');
208
+
209
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'target-1' }], 1, false));
210
+ const after = await oxy.getUserFollowing('me', { limit: 10, offset: 0 });
211
+
212
+ expect(fetchMock).toHaveBeenCalledTimes(3);
213
+ expect(after.following).toEqual([{ id: 'target-1' }]);
214
+ });
215
+
216
+ it('invalidates every page and sort variant, not just the one that was read', async () => {
217
+ const clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
218
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
219
+
220
+ await oxy.followUser('target-1');
221
+
222
+ // Prefix invalidation is what makes this page/sort agnostic — an exact-key
223
+ // clear would only bust the single variant the caller happened to read.
224
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/target-1/followers');
225
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/target-1/mutuals');
226
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me/following');
227
+ clearPrefixSpy.mockRestore();
228
+ });
229
+
230
+ it('invalidates the follower lists of every id in a bulk follow', async () => {
231
+ const clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
232
+ fetchMock.mockResolvedValueOnce(
233
+ jsonResponse({
234
+ results: [
235
+ { userId: 'a', success: true, alreadyFollowing: false },
236
+ { userId: 'b', success: true, alreadyFollowing: false },
237
+ ],
238
+ followedCount: 2,
239
+ }),
240
+ );
241
+
242
+ await oxy.followUsers(['a', 'b']);
243
+
244
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/a/followers');
245
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/b/followers');
246
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me/following');
247
+ clearPrefixSpy.mockRestore();
248
+ });
249
+ });
250
+ });
@@ -0,0 +1,244 @@
1
+ import type { NextFunction, Request, Response } from 'express';
2
+ import {
3
+ buildOxyCspDirectives,
4
+ buildOxyPagesHeaders,
5
+ createOxySecurityHeaders,
6
+ formatOxyCspPolicy,
7
+ OXY_CSP_BASELINE,
8
+ type OxyCspExtensions,
9
+ } from '../securityHeaders';
10
+
11
+ const CLOUDFLARE_SCRIPT_HOST = 'https://static.cloudflareinsights.com';
12
+ const CLOUDFLARE_REPORT_HOST = 'https://cloudflareinsights.com';
13
+
14
+ /** Run the middleware and return the CSP header exactly as a browser would see it. */
15
+ function renderPolicy(options: Parameters<typeof createOxySecurityHeaders>[0]): string {
16
+ const headers: Record<string, string> = {};
17
+ const res = {
18
+ setHeader: (name: string, value: string | number | readonly string[]): void => {
19
+ headers[name] = String(value);
20
+ },
21
+ removeHeader: (): void => undefined,
22
+ } as unknown as Response;
23
+ const req = { method: 'GET', headers: {} } as unknown as Request;
24
+ const next = jest.fn() as unknown as NextFunction;
25
+
26
+ createOxySecurityHeaders(options)(req, res, next);
27
+ return headers['Content-Security-Policy'] ?? '';
28
+ }
29
+
30
+ /** The sources of one directive, parsed back out of the rendered header. */
31
+ function policySources(policy: string, directive: string): string[] {
32
+ const found = policy
33
+ .split(';')
34
+ .map((segment) => segment.trim())
35
+ .find((segment) => segment === directive || segment.startsWith(`${directive} `));
36
+ if (found === undefined) return [];
37
+ return found.split(/\s+/).slice(1);
38
+ }
39
+
40
+ describe('@oxyhq/core/server buildOxyCspDirectives', () => {
41
+ it('carries the Cloudflare Insights beacon on BOTH halves of the baseline', () => {
42
+ // The bug this helper exists for: the script host alone leaves the beacon
43
+ // loading but unable to report, which looks fixed and is not.
44
+ const directives = buildOxyCspDirectives();
45
+ expect(directives['script-src']).toContain(CLOUDFLARE_SCRIPT_HOST);
46
+ expect(directives['connect-src']).toContain(CLOUDFLARE_REPORT_HOST);
47
+ });
48
+
49
+ it('carries the Oxy platform origins every app reaches through the SDK', () => {
50
+ const directives = buildOxyCspDirectives();
51
+ expect(directives['connect-src']).toEqual(
52
+ expect.arrayContaining(['https://api.oxy.so', 'wss://api.oxy.so', 'https://cloud.oxy.so']),
53
+ );
54
+ expect(directives['img-src']).toContain('https://cloud.oxy.so');
55
+ expect(directives['media-src']).toContain('https://cloud.oxy.so');
56
+ });
57
+
58
+ it('emits the hardening floor: closed object-src/script-src-attr and upgrade-insecure-requests', () => {
59
+ const directives = buildOxyCspDirectives();
60
+ expect(directives['object-src']).toEqual(["'none'"]);
61
+ expect(directives['script-src-attr']).toEqual(["'none'"]);
62
+ expect(directives['frame-ancestors']).toEqual(["'none'"]);
63
+ expect(directives['upgrade-insecure-requests']).toEqual([]);
64
+ });
65
+
66
+ it('MERGES an extension into the baseline instead of replacing it', () => {
67
+ const directives = buildOxyCspDirectives({
68
+ scriptSrc: ['https://app.example.com'],
69
+ connectSrc: ['wss://api.example.com'],
70
+ });
71
+
72
+ // The extension is present…
73
+ expect(directives['script-src']).toContain('https://app.example.com');
74
+ expect(directives['connect-src']).toContain('wss://api.example.com');
75
+ // …and every baseline source SURVIVED it.
76
+ for (const source of OXY_CSP_BASELINE.scriptSrc ?? []) {
77
+ expect(directives['script-src']).toContain(source);
78
+ }
79
+ for (const source of OXY_CSP_BASELINE.connectSrc ?? []) {
80
+ expect(directives['connect-src']).toContain(source);
81
+ }
82
+ });
83
+
84
+ it("retains 'self' in every extended directive, including directives absent from the baseline", () => {
85
+ // The footgun: an explicit `script-src` replaces helmet's default and drops
86
+ // `'self'`, so the app's own bundle stops loading.
87
+ const extensions: OxyCspExtensions = {
88
+ scriptSrc: ['https://app.example.com'],
89
+ connectSrc: ['https://api.example.com'],
90
+ imgSrc: ['blob:'],
91
+ mediaSrc: ['blob:'],
92
+ styleSrc: ['https://fonts.example.com'],
93
+ fontSrc: ['https://fonts.example.com'],
94
+ // Not in the baseline at all — must still be seeded with 'self'.
95
+ frameSrc: ['https://player.vimeo.com'],
96
+ workerSrc: ['blob:'],
97
+ manifestSrc: ['https://cdn.example.com'],
98
+ scriptSrcElem: ['https://app.example.com'],
99
+ styleSrcElem: ['https://cdn.example.com'],
100
+ };
101
+ const directives = buildOxyCspDirectives(extensions);
102
+
103
+ for (const directive of Object.keys(extensions)) {
104
+ const headerName = directive.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
105
+ expect(directives[headerName]).toContain("'self'");
106
+ }
107
+ });
108
+
109
+ it('dedupes sources a caller repeats or that already exist in the baseline', () => {
110
+ const directives = buildOxyCspDirectives({
111
+ scriptSrc: ["'self'", CLOUDFLARE_SCRIPT_HOST, 'https://app.example.com'],
112
+ frameSrc: ["'self'", 'https://player.vimeo.com', 'https://player.vimeo.com'],
113
+ });
114
+
115
+ expect(directives['script-src']).toEqual([
116
+ "'self'",
117
+ CLOUDFLARE_SCRIPT_HOST,
118
+ 'https://app.example.com',
119
+ ]);
120
+ expect(directives['frame-src']).toEqual(["'self'", 'https://player.vimeo.com']);
121
+ });
122
+
123
+ it("drops the 'none' sentinel when a closed directive is opened, and keeps it otherwise", () => {
124
+ // 'none' alongside any other source is meaningless per the CSP spec, so an
125
+ // app that must be framable opts back in explicitly.
126
+ expect(buildOxyCspDirectives({ frameAncestors: ["'self'"] })['frame-ancestors']).toEqual([
127
+ "'self'",
128
+ ]);
129
+ expect(buildOxyCspDirectives({ objectSrc: [] })['object-src']).toEqual(["'none'"]);
130
+ });
131
+
132
+ it('rejects sources that would terminate the directive or the policy', () => {
133
+ expect(() => buildOxyCspDirectives({ scriptSrc: ["https://a.example.com; script-src 'unsafe-inline'"] })).toThrow(
134
+ /script-src/,
135
+ );
136
+ expect(() => buildOxyCspDirectives({ connectSrc: ['https://a.example.com,https://b.example.com'] })).toThrow(
137
+ /connect-src/,
138
+ );
139
+ expect(() => buildOxyCspDirectives({ imgSrc: [''] })).toThrow(/img-src/);
140
+ });
141
+
142
+ it('never mutates the exported baseline across calls', () => {
143
+ const before = [...(OXY_CSP_BASELINE.scriptSrc ?? [])];
144
+ buildOxyCspDirectives({ scriptSrc: ['https://app.example.com'] });
145
+ buildOxyCspDirectives({ scriptSrc: ['https://other.example.com'] });
146
+ expect([...(OXY_CSP_BASELINE.scriptSrc ?? [])]).toEqual(before);
147
+ expect(buildOxyCspDirectives()['script-src']).toEqual(before);
148
+ });
149
+ });
150
+
151
+ describe('@oxyhq/core/server formatOxyCspPolicy', () => {
152
+ it('serializes directives into a single CSP header value', () => {
153
+ const policy = formatOxyCspPolicy(buildOxyCspDirectives());
154
+ expect(policy).toContain("script-src 'self' https://static.cloudflareinsights.com");
155
+ expect(policy).toContain('upgrade-insecure-requests');
156
+ expect(policy.endsWith('upgrade-insecure-requests')).toBe(true);
157
+ });
158
+ });
159
+
160
+ describe('@oxyhq/core/server buildOxyPagesHeaders', () => {
161
+ it('emits a Cloudflare Pages _headers block with CSP and hardening headers', () => {
162
+ const block = buildOxyPagesHeaders();
163
+ expect(block.startsWith('/*\n')).toBe(true);
164
+ expect(block).toContain('Content-Security-Policy:');
165
+ expect(block).toContain(CLOUDFLARE_SCRIPT_HOST);
166
+ expect(block).toContain(CLOUDFLARE_REPORT_HOST);
167
+ expect(block).toContain('X-Frame-Options: DENY');
168
+ expect(block).toContain('Strict-Transport-Security:');
169
+ });
170
+
171
+ it('merges per-app CSP extensions into the deployed header', () => {
172
+ const block = buildOxyPagesHeaders({
173
+ csp: { workerSrc: ["'self'"], imgSrc: ['blob:', 'https:'] },
174
+ });
175
+ expect(block).toContain("worker-src 'self'");
176
+ expect(block).toContain('blob:');
177
+ expect(block).toContain('https:');
178
+ expect(block).toContain(CLOUDFLARE_SCRIPT_HOST);
179
+ });
180
+ });
181
+
182
+ describe('@oxyhq/core/server createOxySecurityHeaders', () => {
183
+ it('sends the resolved baseline as a real Content-Security-Policy header', () => {
184
+ const policy = renderPolicy({});
185
+
186
+ expect(policySources(policy, 'script-src')).toEqual(["'self'", CLOUDFLARE_SCRIPT_HOST]);
187
+ expect(policySources(policy, 'connect-src')).toEqual([
188
+ "'self'",
189
+ CLOUDFLARE_REPORT_HOST,
190
+ 'https://api.oxy.so',
191
+ 'wss://api.oxy.so',
192
+ 'https://cloud.oxy.so',
193
+ ]);
194
+ expect(policy).toContain('upgrade-insecure-requests');
195
+ });
196
+
197
+ it('sends merged app extensions without losing the baseline', () => {
198
+ const policy = renderPolicy({
199
+ csp: {
200
+ connectSrc: ['https://api.mention.earth', 'wss://api.mention.earth'],
201
+ frameSrc: ['https://www.youtube-nocookie.com'],
202
+ },
203
+ });
204
+
205
+ expect(policySources(policy, 'connect-src')).toEqual([
206
+ "'self'",
207
+ CLOUDFLARE_REPORT_HOST,
208
+ 'https://api.oxy.so',
209
+ 'wss://api.oxy.so',
210
+ 'https://cloud.oxy.so',
211
+ 'https://api.mention.earth',
212
+ 'wss://api.mention.earth',
213
+ ]);
214
+ expect(policySources(policy, 'frame-src')).toEqual([
215
+ "'self'",
216
+ 'https://www.youtube-nocookie.com',
217
+ ]);
218
+ });
219
+
220
+ it('passes non-CSP helmet options through and calls next()', () => {
221
+ const headers: Record<string, string> = {};
222
+ const res = {
223
+ setHeader: (name: string, value: string | number | readonly string[]): void => {
224
+ headers[name] = String(value);
225
+ },
226
+ removeHeader: (): void => undefined,
227
+ } as unknown as Response;
228
+ const next = jest.fn() as unknown as NextFunction & jest.Mock;
229
+
230
+ createOxySecurityHeaders({
231
+ helmet: {
232
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
233
+ frameguard: { action: 'deny' },
234
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
235
+ },
236
+ })({ method: 'GET', headers: {} } as unknown as Request, res, next);
237
+
238
+ expect(headers['Cross-Origin-Resource-Policy']).toBe('cross-origin');
239
+ expect(headers['X-Frame-Options']).toBe('DENY');
240
+ expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin');
241
+ expect(headers['Content-Security-Policy']).toContain("script-src 'self'");
242
+ expect(next).toHaveBeenCalledTimes(1);
243
+ });
244
+ });
@@ -63,6 +63,22 @@ export type {
63
63
  export { createOxyCors } from './cors';
64
64
  export type { OxyCorsOptions } from './cors';
65
65
 
66
+ // Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
67
+ // Oxy API/CDN origins) with additive, per-app extensions.
68
+ export {
69
+ buildOxyCspDirectives,
70
+ buildOxyPagesHeaders,
71
+ createOxySecurityHeaders,
72
+ formatOxyCspPolicy,
73
+ OXY_CSP_BASELINE,
74
+ } from './securityHeaders';
75
+ export type {
76
+ OxyCspDirective,
77
+ OxyCspExtensions,
78
+ OxyPagesHeadersOptions,
79
+ OxySecurityHeadersOptions,
80
+ } from './securityHeaders';
81
+
66
82
  // Constant-time secret comparison.
67
83
  export { verifySecret } from './verifySecret';
68
84