@oxyhq/core 13.0.0 → 13.2.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,304 @@
1
+ /**
2
+ * Shared security headers (Helmet + Content-Security-Policy) for Oxy backends.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * A CSP only governs an origin that serves DOCUMENTS; on a JSON API it governs
7
+ * no browsing context. The Oxy origins that serve HTML through Cloudflare have
8
+ * so far either hand-written their own policy or shipped none at all, and two
9
+ * bugs follow from that:
10
+ *
11
+ * 1. THE CLOUDFLARE INSIGHTS BEACON IS BLOCKED BY A HAND-WRITTEN POLICY.
12
+ * Cloudflare injects `<script src="https://static.cloudflareinsights.com/
13
+ * beacon.min.js/...">` into HTML it proxies. No application code loads it,
14
+ * so it cannot be allowlisted from the app side any other way, and an
15
+ * origin whose policy says `script-src 'self'` logs
16
+ * `Loading the script 'https://static.cloudflareinsights.com/beacon.min.js'
17
+ * violates the following Content Security Policy directive: "script-src
18
+ * 'self'"` and collects nothing. The beacon needs BOTH hosts, and they are
19
+ * different halves of the same feature: `static.cloudflareinsights.com`
20
+ * serves the script (`script-src`), `cloudflareinsights.com` receives the
21
+ * measurements (`connect-src`). Allowing only the script leaves the beacon
22
+ * loading but unable to report, which looks fixed and is not. Verified in
23
+ * production 2026-07-29: `mention.earth` serves HTML behind Cloudflare with
24
+ * the beacon injected and `script-src 'self'` — blocked; `oxy.so` had
25
+ * already allowlisted the same two hosts in its own static `_headers`,
26
+ * independently, which is the divergence this baseline exists to end.
27
+ *
28
+ * 2. AN EXPLICIT DIRECTIVE SILENTLY REPLACES HELMET'S DEFAULT.
29
+ * Writing `scriptSrc: ['https://example.com']` drops `'self'` — the page's
30
+ * own bundle stops loading (or, worse, only some lazily-loaded chunk does,
31
+ * so it ships). This helper makes that structurally impossible: callers can
32
+ * only ADD sources to the Oxy baseline, never replace a directive, and they
33
+ * cannot pass their own `contentSecurityPolicy` through to Helmet at all
34
+ * (the option is typed `never`).
35
+ *
36
+ * WHAT IT PROVIDES
37
+ * ----------------
38
+ * `createOxySecurityHeaders(options)` returns the Helmet middleware with the
39
+ * Oxy-wide CSP baseline applied, plus per-app extensions merged (and deduped)
40
+ * into it. Everything Helmet does that is NOT the CSP (HSTS, frameguard,
41
+ * referrer policy, CORP/COOP, …) is passed straight through, so an app keeps
42
+ * full control of those.
43
+ *
44
+ * `buildOxyCspDirectives(extensions)` is the same resolution as a pure
45
+ * function, for the Oxy document origins that are NOT Express — a Next.js
46
+ * `headers()`, a Cloudflare Pages `_headers` generator — so one policy can
47
+ * cover them without a second implementation.
48
+ *
49
+ * SCOPE: mount this on backends that serve HTML. A JSON-only API gains nothing
50
+ * from a source-list CSP; harden those with the non-CSP headers instead
51
+ * (`hsts`, `noSniff`, `frameguard`, CORP) rather than adding directives that
52
+ * apply to no document.
53
+ *
54
+ * Node/Express-only: exported solely from `@oxyhq/core/server`.
55
+ */
56
+
57
+ import type { RequestHandler } from 'express';
58
+ import helmet, { type HelmetOptions } from 'helmet';
59
+
60
+ /** CSP keyword for "this origin". Always present in every open baseline directive. */
61
+ const SELF = "'self'";
62
+
63
+ /** CSP keyword for a fully closed directive. Meaningless alongside any other source. */
64
+ const NONE = "'none'";
65
+
66
+ /**
67
+ * Cloudflare Web Analytics. Injected at the edge into proxied HTML — no Oxy app
68
+ * loads it, and no Oxy app should have to know these hostnames. Both are
69
+ * required: the script host, and the host the beacon reports to.
70
+ */
71
+ const CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN = 'https://static.cloudflareinsights.com';
72
+ const CLOUDFLARE_INSIGHTS_REPORT_ORIGIN = 'https://cloudflareinsights.com';
73
+
74
+ /**
75
+ * Oxy platform origins. Every Oxy web origin runs the SDK, which calls the Oxy
76
+ * API over HTTPS and Socket.IO, and resolves all canonical media through the
77
+ * Oxy CDN (`getFileDownloadUrl` → `cloud.oxy.so`).
78
+ */
79
+ const OXY_API_ORIGIN = 'https://api.oxy.so';
80
+ const OXY_API_WEBSOCKET_ORIGIN = 'wss://api.oxy.so';
81
+ const OXY_CDN_ORIGIN = 'https://cloud.oxy.so';
82
+
83
+ /** The CSP directives an Oxy app may extend, in Helmet's camelCase spelling. */
84
+ export type OxyCspDirective =
85
+ | 'baseUri'
86
+ | 'connectSrc'
87
+ | 'defaultSrc'
88
+ | 'fontSrc'
89
+ | 'formAction'
90
+ | 'frameAncestors'
91
+ | 'frameSrc'
92
+ | 'imgSrc'
93
+ | 'manifestSrc'
94
+ | 'mediaSrc'
95
+ | 'objectSrc'
96
+ | 'scriptSrc'
97
+ | 'scriptSrcAttr'
98
+ | 'scriptSrcElem'
99
+ | 'styleSrc'
100
+ | 'styleSrcElem'
101
+ | 'workerSrc';
102
+
103
+ /**
104
+ * Per-app ADDITIONS to the Oxy baseline, keyed by directive. Values are merged
105
+ * into the baseline and deduped — they never replace it, so `'self'` (and the
106
+ * Cloudflare beacon hosts) cannot be lost. Extending a directive the baseline
107
+ * does not define seeds it with `'self'` first, for the same reason.
108
+ */
109
+ export type OxyCspExtensions = Partial<Record<OxyCspDirective, readonly string[]>>;
110
+
111
+ /**
112
+ * The Oxy-wide CSP baseline. Deliberately the floor every Oxy origin needs, not
113
+ * a superset of what any one app allows — permissive sources an individual app
114
+ * wants (`https:` images, `blob:` media, embed hosts, LiveKit) are that app's
115
+ * extension, so each widening stays visible at its call site.
116
+ *
117
+ * `style-src` carries `'unsafe-inline'` because react-native-web injects its
118
+ * stylesheet as inline `<style>` at runtime; without it every Oxy web app
119
+ * renders unstyled.
120
+ */
121
+ export const OXY_CSP_BASELINE: Readonly<Partial<Record<OxyCspDirective, readonly string[]>>> =
122
+ Object.freeze({
123
+ defaultSrc: Object.freeze([SELF]),
124
+ baseUri: Object.freeze([SELF]),
125
+ formAction: Object.freeze([SELF]),
126
+ frameAncestors: Object.freeze([NONE]),
127
+ objectSrc: Object.freeze([NONE]),
128
+ scriptSrc: Object.freeze([SELF, CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN]),
129
+ scriptSrcAttr: Object.freeze([NONE]),
130
+ styleSrc: Object.freeze([SELF, "'unsafe-inline'"]),
131
+ imgSrc: Object.freeze([SELF, 'data:', OXY_CDN_ORIGIN]),
132
+ mediaSrc: Object.freeze([SELF, OXY_CDN_ORIGIN]),
133
+ fontSrc: Object.freeze([SELF, 'data:']),
134
+ connectSrc: Object.freeze([
135
+ SELF,
136
+ CLOUDFLARE_INSIGHTS_REPORT_ORIGIN,
137
+ OXY_API_ORIGIN,
138
+ OXY_API_WEBSOCKET_ORIGIN,
139
+ OXY_CDN_ORIGIN,
140
+ ]),
141
+ });
142
+
143
+ /** `connectSrc` → `connect-src`. Total over `OxyCspDirective` (all are camelCase ASCII). */
144
+ function toHeaderDirectiveName(directive: OxyCspDirective): string {
145
+ return directive.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
146
+ }
147
+
148
+ /**
149
+ * A source containing `;` or `,` would silently terminate the directive (or the
150
+ * whole policy) and hand the rest of the string to the browser as new
151
+ * directives. Helmet rejects these too; we reject them here so the pure builder
152
+ * is equally safe, and so the failure names the offending directive.
153
+ */
154
+ function assertValidSource(directive: OxyCspDirective, source: string): void {
155
+ if (typeof source !== 'string' || source.length === 0) {
156
+ throw new TypeError(`Oxy CSP: ${toHeaderDirectiveName(directive)} received an empty source.`);
157
+ }
158
+ if (source.includes(';') || source.includes(',')) {
159
+ throw new TypeError(
160
+ `Oxy CSP: ${toHeaderDirectiveName(directive)} source ${JSON.stringify(source)} may not contain ";" or ",".`,
161
+ );
162
+ }
163
+ }
164
+
165
+ /** Order-preserving, first-seen-wins dedupe. */
166
+ function dedupe(sources: readonly string[]): string[] {
167
+ return [...new Set(sources)];
168
+ }
169
+
170
+ /**
171
+ * Resolve the effective CSP directives: the Oxy baseline, with each app
172
+ * extension merged in and deduped.
173
+ *
174
+ * Merge rules:
175
+ * - A baseline directive is EXTENDED, never replaced — `'self'` and the
176
+ * Cloudflare beacon hosts always survive.
177
+ * - A directive absent from the baseline is seeded with `'self'`, so adding
178
+ * (say) an embed host to `frame-src` cannot lock the origin out of itself.
179
+ * - A directive whose baseline is exactly `'none'` is CLOSED: extending it
180
+ * drops the sentinel, because `'none'` alongside any other source is
181
+ * meaningless per the CSP spec. This is how an app that must be framable
182
+ * opts back in with `frameAncestors: ["'self'"]`.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * buildOxyCspDirectives({ frameSrc: ['https://player.vimeo.com'] });
187
+ * // → { ..., 'frame-src': ["'self'", 'https://player.vimeo.com'], ... }
188
+ * ```
189
+ */
190
+ export function buildOxyCspDirectives(extensions: OxyCspExtensions = {}): Record<string, string[]> {
191
+ const directiveNames = new Set<OxyCspDirective>([
192
+ ...(Object.keys(OXY_CSP_BASELINE) as OxyCspDirective[]),
193
+ ...(Object.keys(extensions) as OxyCspDirective[]),
194
+ ]);
195
+
196
+ const resolved: Record<string, string[]> = {};
197
+
198
+ for (const directive of directiveNames) {
199
+ const extras = extensions[directive] ?? [];
200
+ for (const source of extras) {
201
+ assertValidSource(directive, source);
202
+ }
203
+
204
+ const baseline = OXY_CSP_BASELINE[directive] ?? [SELF];
205
+ const isClosed = baseline.length === 1 && baseline[0] === NONE;
206
+ const merged = isClosed && extras.length > 0 ? extras : [...baseline, ...extras];
207
+
208
+ resolved[toHeaderDirectiveName(directive)] = dedupe(merged);
209
+ }
210
+
211
+ // Valueless directive: rewrite stray `http://` subresources to HTTPS rather
212
+ // than failing them, which matters for federated/user-supplied URLs.
213
+ resolved['upgrade-insecure-requests'] = [];
214
+
215
+ return resolved;
216
+ }
217
+
218
+ /**
219
+ * Serialize resolved CSP directives into the single-line header value browsers
220
+ * and Cloudflare `_headers` expect. Valueless directives (e.g.
221
+ * `upgrade-insecure-requests`) emit the name alone.
222
+ */
223
+ export function formatOxyCspPolicy(directives: Record<string, string[]>): string {
224
+ return Object.entries(directives)
225
+ .map(([name, sources]) => (sources.length === 0 ? name : `${name} ${sources.join(' ')}`))
226
+ .join('; ');
227
+ }
228
+
229
+ export interface OxyPagesHeadersOptions {
230
+ /** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
231
+ csp?: OxyCspExtensions;
232
+ /**
233
+ * Emit `Strict-Transport-Security` (default `true`). Cloudflare Pages serves
234
+ * HTTPS only, so static deploys should keep this on.
235
+ */
236
+ hsts?: boolean;
237
+ }
238
+
239
+ /**
240
+ * Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
241
+ * same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
242
+ * hardening headers Helmet would add on an Express HTML backend.
243
+ */
244
+ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): string {
245
+ const csp = formatOxyCspPolicy(buildOxyCspDirectives(options.csp));
246
+ const lines = [
247
+ '/*',
248
+ ` Content-Security-Policy: ${csp}`,
249
+ ' X-Frame-Options: DENY',
250
+ ' X-Content-Type-Options: nosniff',
251
+ ' Referrer-Policy: strict-origin-when-cross-origin',
252
+ ];
253
+ if (options.hsts !== false) {
254
+ lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
255
+ }
256
+ lines.push('');
257
+ return lines.join('\n');
258
+ }
259
+
260
+ export interface OxySecurityHeadersOptions {
261
+ /**
262
+ * Per-app additions to the Oxy CSP baseline. Merged, deduped, never
263
+ * replacing — see {@link buildOxyCspDirectives}.
264
+ */
265
+ csp?: OxyCspExtensions;
266
+ /**
267
+ * Everything Helmet does that is not the CSP: `hsts`, `frameguard`,
268
+ * `referrerPolicy`, `crossOriginResourcePolicy`, … Passed straight through.
269
+ *
270
+ * `contentSecurityPolicy` is typed `never` on purpose: the CSP is owned by
271
+ * this helper so the baseline cannot be replaced (nor the `'self'` guarantee
272
+ * bypassed) by an app that hands Helmet its own directive block. Extend it
273
+ * through `csp` instead.
274
+ */
275
+ helmet?: HelmetOptions & { contentSecurityPolicy?: never };
276
+ }
277
+
278
+ /**
279
+ * Build the shared Oxy security-headers middleware: Helmet with the Oxy CSP
280
+ * baseline plus this app's extensions.
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * app.use(createOxySecurityHeaders({
285
+ * csp: {
286
+ * connectSrc: ['https://api.example.com', 'wss://api.example.com'],
287
+ * frameSrc: ['https://player.vimeo.com'],
288
+ * },
289
+ * helmet: { crossOriginResourcePolicy: { policy: 'cross-origin' } },
290
+ * }));
291
+ * ```
292
+ */
293
+ export function createOxySecurityHeaders(options: OxySecurityHeadersOptions = {}): RequestHandler {
294
+ const { csp, helmet: helmetOptions } = options;
295
+ const directives = buildOxyCspDirectives(csp);
296
+
297
+ return helmet({
298
+ ...helmetOptions,
299
+ // `useDefaults: false`: the baseline above is the whole policy, so what the
300
+ // browser receives is exactly what `buildOxyCspDirectives` returns — no
301
+ // silent union with Helmet's defaults that tests would never see.
302
+ contentSecurityPolicy: { useDefaults: false, directives },
303
+ });
304
+ }
@@ -3,20 +3,46 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Build URL search parameters from an object
7
- * @param params Object with parameter key-value pairs
8
- * @returns URLSearchParams instance
6
+ * Build a plain query-parameter record from an object, stringifying values and
7
+ * dropping `undefined`/`null` entries.
8
+ *
9
+ * This is the shape `OxyServices.makeRequest` expects for a GET's `params`:
10
+ * `HttpService` inspects it with `Object.keys(...)` (both to decide whether to
11
+ * append a query string and to build the request's cache key), and
12
+ * `Object.keys(new URLSearchParams({ limit: '20' }))` is `[]` — a
13
+ * `URLSearchParams` exposes its entries through iterator methods, never as own
14
+ * enumerable properties. Passing one to `makeRequest` therefore silently drops
15
+ * the whole query string. Always hand `makeRequest` a plain record.
16
+ *
17
+ * Generic over the input object rather than taking `Record<string, unknown>`,
18
+ * because a TypeScript `interface` (`PaginationParams`, `FollowGraphParams`, …)
19
+ * has no implicit index signature and so is not assignable to that type.
9
20
  */
10
- export function buildSearchParams(params: Record<string, any>): URLSearchParams {
11
- const searchParams = new URLSearchParams();
12
-
13
- for (const [key, value] of Object.entries(params)) {
21
+ export function buildQueryParams<T extends object>(params: T): Record<string, string> {
22
+ const query: Record<string, string> = {};
23
+
24
+ // Widening the value to `unknown` is always sound; the default overload of
25
+ // `Object.entries` would otherwise infer `any` here.
26
+ for (const [key, value] of Object.entries(params) as [string, unknown][]) {
14
27
  if (value !== undefined && value !== null) {
15
- searchParams.append(key, value.toString());
28
+ query[key] = String(value);
16
29
  }
17
30
  }
18
-
19
- return searchParams;
31
+
32
+ return query;
33
+ }
34
+
35
+ /**
36
+ * Build URL search parameters from an object.
37
+ *
38
+ * For building a URL string only — see {@link buildQueryParams} for the shape
39
+ * `makeRequest` needs.
40
+ *
41
+ * @param params Object with parameter key-value pairs
42
+ * @returns URLSearchParams instance
43
+ */
44
+ export function buildSearchParams<T extends object>(params: T): URLSearchParams {
45
+ return new URLSearchParams(buildQueryParams(params));
20
46
  }
21
47
 
22
48
  /**
@@ -25,7 +51,7 @@ export function buildSearchParams(params: Record<string, any>): URLSearchParams
25
51
  * @param params Object with parameter key-value pairs
26
52
  * @returns Complete URL with search parameters
27
53
  */
28
- export function buildUrl(baseUrl: string, params?: Record<string, any>): string {
54
+ export function buildUrl<T extends object>(baseUrl: string, params?: T): string {
29
55
  if (!params) return baseUrl;
30
56
 
31
57
  const searchParams = buildSearchParams(params);
@@ -43,12 +69,35 @@ export interface PaginationParams {
43
69
  }
44
70
 
45
71
  /**
46
- * Build pagination search parameters
72
+ * Ordering for the follow-graph list endpoints (`/users/:id/followers`,
73
+ * `/users/:id/following`, `/users/:id/mutuals`).
74
+ *
75
+ * - `recent` — newest follow edge first (the server default).
76
+ * - `oldest` — oldest follow edge first.
77
+ */
78
+ export type FollowGraphSort = 'recent' | 'oldest';
79
+
80
+ /**
81
+ * Pagination plus the follow-graph ordering.
82
+ *
83
+ * Kept separate from {@link PaginationParams}, which is shared by endpoints
84
+ * that have no `sort` at all.
85
+ */
86
+ export interface FollowGraphParams extends PaginationParams {
87
+ sort?: FollowGraphSort;
88
+ }
89
+
90
+ /**
91
+ * Build pagination query parameters.
92
+ *
93
+ * Returns a plain record — NOT a `URLSearchParams` — because that is the only
94
+ * shape `makeRequest`/`HttpService` can read. See {@link buildQueryParams}.
95
+ *
47
96
  * @param params Pagination parameters
48
- * @returns URLSearchParams with pagination
97
+ * @returns Query record with pagination
49
98
  */
50
- export function buildPaginationParams(params: PaginationParams): URLSearchParams {
51
- return buildSearchParams(params);
99
+ export function buildPaginationParams(params: PaginationParams): Record<string, string> {
100
+ return buildQueryParams(params);
52
101
  }
53
102
 
54
103
  /**