@oxyhq/core 21.0.1 → 21.1.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.
@@ -84,7 +84,26 @@ function OxyServicesUtilityMixin(Base) {
84
84
  return cached.result;
85
85
  }
86
86
  try {
87
- const result = await this.makeRequest('GET', '/internal/service-acting-as/verify', { appId, userId }, { cache: false, retry: false, timeout: 5000 });
87
+ // The verify endpoint is service-to-service and admits only a
88
+ // platform-TRUSTED calling application, so this call must carry the
89
+ // VERIFIER's own service token. Sent explicitly rather than through
90
+ // `makeServiceRequest`, which would drop `retry: false` and the timeout
91
+ // — and those two are not incidental: this runs inside request-handling
92
+ // middleware, so an inner retry loop multiplies the latency of every
93
+ // delegated request by the number of attempts.
94
+ //
95
+ // A verifier with no service credentials configured throws here and
96
+ // lands in the catch below, which is the correct outcome. A host that
97
+ // cannot prove who it is has no business being told which users have
98
+ // delegated to which applications, and the 60s negative cache stops a
99
+ // misconfigured deployment from turning every request into a round trip.
100
+ const serviceToken = await this.getServiceToken();
101
+ const result = await this.makeRequest('GET', '/internal/service-acting-as/verify', { appId, userId }, {
102
+ cache: false,
103
+ retry: false,
104
+ timeout: 5000,
105
+ headers: { Authorization: `Bearer ${serviceToken}` },
106
+ });
88
107
  const authorized = Boolean(result && result.authorized);
89
108
  const verified = authorized
90
109
  ? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
@@ -781,6 +800,20 @@ function OxyServicesUtilityMixin(Base) {
781
800
  * service requests require the app scope. Delegated user requests require
782
801
  * BOTH the app scope and the per-user delegation scope.
783
802
  *
803
+ * The intersection is the point, not a redundancy, because the two scope
804
+ * lists answer different questions and neither implies the other:
805
+ *
806
+ * `serviceApp.scopes` what the PLATFORM allows this application to do
807
+ * (credential ∩ application ceiling, at mint time)
808
+ * `serviceActingAs.scopes` what THIS USER allowed it to do (`app_grants`)
809
+ *
810
+ * Requiring only the app scope would let an application do to a user
811
+ * something that user never consented to; requiring only the grant would let
812
+ * a user hand an application authority staff never gave it, so a revoked
813
+ * platform scope would keep working for every user who had already
814
+ * consented. Effective authority is the intersection, and this is where it
815
+ * is taken.
816
+ *
784
817
  * Requests authenticated as a regular user (no service token) are rejected
785
818
  * with 403 — scope-protected endpoints are service-to-service by design.
786
819
  *
@@ -16,6 +16,7 @@
16
16
  * one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
17
17
  * `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
18
18
  * - allows the caller's explicit `appOrigins`,
19
+ * - REFUSES the opaque origin on both sides (see `OPAQUE_ORIGIN`),
19
20
  * - DENIES everything else (no reflection, never a wildcard with credentials),
20
21
  * - echoes back the EXACT matched origin (so credentialed requests work) and
21
22
  * sets `Vary: Origin` for correct caching,
@@ -24,8 +25,12 @@
24
25
  * Node/Express-only: exported solely from `@oxyhq/core/server`.
25
26
  */
26
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.normalizeAppOrigins = normalizeAppOrigins;
29
+ exports.matchesAllowedOrigin = matchesAllowedOrigin;
27
30
  exports.createOxyCors = createOxyCors;
31
+ const logger_1 = require("../logger");
28
32
  const authWebUrl_1 = require("../utils/authWebUrl");
33
+ const log = (0, logger_1.createLogger)('OxyCors');
29
34
  /** Default HTTP methods allowed across origins. */
30
35
  const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
31
36
  /** Default request headers a browser may send on a credentialed cross-origin call. */
@@ -64,6 +69,29 @@ function isOxyFamilyOrigin(candidate) {
64
69
  return false;
65
70
  }
66
71
  }
72
+ /**
73
+ * The URL standard's serialization of an OPAQUE origin: the literal string
74
+ * `"null"`, which `new URL(x).origin` returns for every scheme that has no
75
+ * origin to speak of — `exp:`, `capacitor:`, `chrome-extension:`,
76
+ * `vscode-webview:`, and also `file:`, `data:` and `about:`.
77
+ *
78
+ * This value is why an allowlist may never store it. Every such scheme
79
+ * normalizes to the SAME `"null"`, so a set built by normalization cannot tell
80
+ * them apart: ONE opaque entry admits ALL of them. With credentials on and the
81
+ * raw header echoed back, a single `myapp://` in `appOrigins` turned this
82
+ * helper into "allow any custom-scheme browsing context" — measured live, an
83
+ * `exp://localhost:8150` entry answered `Origin: vscode-webview://…` with
84
+ * `access-control-allow-origin: vscode-webview://…` and
85
+ * `access-control-allow-credentials: true`.
86
+ *
87
+ * There is deliberately no escape hatch that matches such an origin by raw
88
+ * string instead. Admitting a custom-scheme browsing context to a CREDENTIALED
89
+ * allowlist is a distinct decision with its own threat model, and it must not
90
+ * arrive as a side effect of someone adding one line to `appOrigins`. Note
91
+ * also that a native client is not subject to CORS at all — React Native sends
92
+ * no `Origin` header — so a mobile app never needs an entry here.
93
+ */
94
+ const OPAQUE_ORIGIN = 'null';
67
95
  /** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
68
96
  function normalizeOrigin(raw) {
69
97
  try {
@@ -74,24 +102,65 @@ function normalizeOrigin(raw) {
74
102
  }
75
103
  }
76
104
  /**
77
- * Build the origin-matching predicate: true iff `origin` is in the built-in
78
- * HTTPS Oxy apex family OR exactly matches one of the configured app origins.
105
+ * Normalize the configured `appOrigins` into the exact-match set the
106
+ * CONFIGURE-SIDE half of the opaque-origin guard.
107
+ *
108
+ * An entry that is not a URL, or whose origin is opaque, is dropped and named
109
+ * in an error log. Dropped rather than thrown on because `appOrigins` is
110
+ * deployment configuration — at least one Oxy backend reads it from the
111
+ * environment — and a typo there must cost that one origin its CORS headers,
112
+ * never the whole service its boot. Both failure modes are equally SAFE (the
113
+ * entry is absent from the set either way), so the choice is purely about
114
+ * blast radius, and dropping keeps it to one origin whose requests then fail
115
+ * visibly in the browser.
116
+ *
117
+ * Exported for `__tests__/cors.socket.test.ts` and NOT re-exported from
118
+ * `server/index.ts`, so it is not part of the package's public surface. The
119
+ * two halves of the guard are separately exported because they are separately
120
+ * testable only that way: with this half in place the match-side half is
121
+ * unreachable through `createOxyCors`, so a test driving the public API alone
122
+ * would measure this function twice and the other one never.
79
123
  */
80
- function buildOriginAllowed(appOrigins) {
124
+ function normalizeAppOrigins(appOrigins) {
81
125
  const explicit = new Set();
82
126
  for (const raw of appOrigins) {
83
127
  const normalized = normalizeOrigin(raw);
84
- if (normalized)
85
- explicit.add(normalized);
128
+ if (normalized === null) {
129
+ log.error('CORS allowlist entry ignored: it is not a URL', undefined, { entry: raw });
130
+ continue;
131
+ }
132
+ if (normalized === OPAQUE_ORIGIN) {
133
+ log.error('CORS allowlist entry ignored: it has no origin to match against', undefined, {
134
+ entry: raw,
135
+ });
136
+ continue;
137
+ }
138
+ explicit.add(normalized);
86
139
  }
87
- return (origin) => {
88
- const normalized = normalizeOrigin(origin);
89
- if (normalized === null)
90
- return false;
91
- if (explicit.has(normalized))
92
- return true;
93
- return isOxyFamilyOrigin(normalized);
94
- };
140
+ return explicit;
141
+ }
142
+ /**
143
+ * Whether `origin` may be echoed back: it is in the built-in HTTPS Oxy apex
144
+ * family, or it exactly matches one of the configured app origins.
145
+ *
146
+ * The opaque-origin refusal here is the MATCH-SIDE half of the guard, and it
147
+ * is what makes the property hold regardless of how `explicit` was built — a
148
+ * set that somehow contains `"null"` still matches nothing, because no
149
+ * incoming origin ever normalizes past this line. `normalizeAppOrigins` is
150
+ * what stops such a set existing today; this is what stops it mattering.
151
+ *
152
+ * Exported for the same reason as `normalizeAppOrigins`, and likewise absent
153
+ * from `server/index.ts`.
154
+ */
155
+ function matchesAllowedOrigin(explicit, origin) {
156
+ const normalized = normalizeOrigin(origin);
157
+ if (normalized === null)
158
+ return false;
159
+ if (normalized === OPAQUE_ORIGIN)
160
+ return false;
161
+ if (explicit.has(normalized))
162
+ return true;
163
+ return isOxyFamilyOrigin(normalized);
95
164
  }
96
165
  /**
97
166
  * Create a strict Oxy CORS middleware. See module docs.
@@ -103,7 +172,7 @@ function buildOriginAllowed(appOrigins) {
103
172
  */
104
173
  function createOxyCors(options = {}) {
105
174
  const { appOrigins = [], allowCredentials = true, methods = DEFAULT_ALLOWED_METHODS, allowedHeaders = DEFAULT_ALLOWED_HEADERS, exposedHeaders = [], maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, } = options;
106
- const isOriginAllowed = buildOriginAllowed(appOrigins);
175
+ const explicitOrigins = normalizeAppOrigins(appOrigins);
107
176
  const methodsHeader = methods.join(', ');
108
177
  const allowedHeadersHeader = allowedHeaders.join(', ');
109
178
  const exposedHeadersHeader = exposedHeaders.join(', ');
@@ -121,7 +190,7 @@ function createOxyCors(options = {}) {
121
190
  }
122
191
  // Origin is present. Caching correctness: this response varies by Origin.
123
192
  res.setHeader('Vary', 'Origin');
124
- if (!isOriginAllowed(origin)) {
193
+ if (!matchesAllowedOrigin(explicitOrigins, origin)) {
125
194
  // DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
126
195
  // will block the cross-origin read. Preflights for denied origins get a
127
196
  // 204 with no CORS headers (the actual request then fails CORS).
@@ -16,7 +16,7 @@
16
16
  * ```
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.OXY_CSP_BASELINE = exports.formatOxyCspPolicy = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getRequiredOxyBillingPrincipal = exports.getOxyUserId = exports.getOxyRequestAttribution = exports.getOxyDelegatedUserId = exports.getOxyBillingPrincipal = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
19
+ exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.OXY_CSP_BASELINE = exports.inlineScriptCspHash = exports.formatOxyCspPolicy = exports.extractInlineScripts = exports.cspSourcesFor = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getRequiredOxyBillingPrincipal = exports.getOxyUserId = exports.getOxyRequestAttribution = exports.getOxyDelegatedUserId = exports.getOxyBillingPrincipal = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
20
20
  var auth_1 = require("./auth");
21
21
  Object.defineProperty(exports, "createOptionalOxyAuth", { enumerable: true, get: function () { return auth_1.createOptionalOxyAuth; } });
22
22
  Object.defineProperty(exports, "createOxyAuthMiddleware", { enumerable: true, get: function () { return auth_1.createOxyAuthMiddleware; } });
@@ -50,11 +50,20 @@ var cors_1 = require("./cors");
50
50
  Object.defineProperty(exports, "createOxyCors", { enumerable: true, get: function () { return cors_1.createOxyCors; } });
51
51
  // Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
52
52
  // Oxy API/CDN origins) with additive, per-app extensions.
53
+ //
54
+ // `extractInlineScripts` / `inlineScriptCspHash` / `cspSourcesFor` are exported
55
+ // so a post-deploy gate can ask the SERVED document and the SERVED policy the
56
+ // same questions `buildOxyPagesHeaders` asked the built ones. A gate that
57
+ // re-implemented the scan or the parse would be testing its own copy, and would
58
+ // agree with a broken original.
53
59
  var securityHeaders_1 = require("./securityHeaders");
54
60
  Object.defineProperty(exports, "buildOxyCspDirectives", { enumerable: true, get: function () { return securityHeaders_1.buildOxyCspDirectives; } });
55
61
  Object.defineProperty(exports, "buildOxyPagesHeaders", { enumerable: true, get: function () { return securityHeaders_1.buildOxyPagesHeaders; } });
56
62
  Object.defineProperty(exports, "createOxySecurityHeaders", { enumerable: true, get: function () { return securityHeaders_1.createOxySecurityHeaders; } });
63
+ Object.defineProperty(exports, "cspSourcesFor", { enumerable: true, get: function () { return securityHeaders_1.cspSourcesFor; } });
64
+ Object.defineProperty(exports, "extractInlineScripts", { enumerable: true, get: function () { return securityHeaders_1.extractInlineScripts; } });
57
65
  Object.defineProperty(exports, "formatOxyCspPolicy", { enumerable: true, get: function () { return securityHeaders_1.formatOxyCspPolicy; } });
66
+ Object.defineProperty(exports, "inlineScriptCspHash", { enumerable: true, get: function () { return securityHeaders_1.inlineScriptCspHash; } });
58
67
  Object.defineProperty(exports, "OXY_CSP_BASELINE", { enumerable: true, get: function () { return securityHeaders_1.OXY_CSP_BASELINE; } });
59
68
  // Constant-time secret comparison.
60
69
  var verifySecret_1 = require("./verifySecret");
@@ -34,6 +34,19 @@
34
34
  * cannot pass their own `contentSecurityPolicy` through to Helmet at all
35
35
  * (the option is typed `never`).
36
36
  *
37
+ * 3. A STATIC EXPO EXPORT SHIPS AN INLINE SCRIPT THE BASELINE FORBIDS.
38
+ * `web.output: 'static'` makes Expo Router emit
39
+ * `<script type="module">globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>`,
40
+ * which is what tells the client entry to call `hydrateRoot` instead of
41
+ * `createRoot().render()`. Nothing in app code puts it there, so — like the
42
+ * Cloudflare beacon above — an app cannot allowlist it from the app side.
43
+ * Measured on `accounts.oxy.so` 2026-08-21: blocked, so every visit threw
44
+ * away the server-rendered markup and re-rendered from scratch, with only a
45
+ * console error to show for it. The hashes are therefore DERIVED from the
46
+ * built output rather than hand-written (see {@link extractInlineScripts}):
47
+ * a hash pasted into config is correct exactly until the build changes one
48
+ * byte, and then it fails the same silent way.
49
+ *
37
50
  * WHAT IT PROVIDES
38
51
  * ----------------
39
52
  * `createOxySecurityHeaders(options)` returns the Helmet middleware with the
@@ -61,8 +74,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
61
74
  exports.OXY_CSP_BASELINE = void 0;
62
75
  exports.buildOxyCspDirectives = buildOxyCspDirectives;
63
76
  exports.formatOxyCspPolicy = formatOxyCspPolicy;
77
+ exports.cspSourcesFor = cspSourcesFor;
78
+ exports.extractInlineScripts = extractInlineScripts;
79
+ exports.inlineScriptCspHash = inlineScriptCspHash;
64
80
  exports.buildOxyPagesHeaders = buildOxyPagesHeaders;
65
81
  exports.createOxySecurityHeaders = createOxySecurityHeaders;
82
+ const node_crypto_1 = require("node:crypto");
66
83
  const helmet_1 = __importDefault(require("helmet"));
67
84
  /** CSP keyword for "this origin". Always present in every open baseline directive. */
68
85
  const SELF = "'self'";
@@ -186,13 +203,139 @@ function formatOxyCspPolicy(directives) {
186
203
  .map(([name, sources]) => (sources.length === 0 ? name : `${name} ${sources.join(' ')}`))
187
204
  .join('; ');
188
205
  }
206
+ /**
207
+ * The source list one directive carries in a serialized policy, or `[]` when
208
+ * the policy does not name that directive. The inverse of
209
+ * {@link formatOxyCspPolicy}, and the reason it lives here rather than beside
210
+ * either caller: the post-deploy gate parses the policy the ORIGIN serves while
211
+ * the unit test parses the one the middleware renders, so a copy in each would
212
+ * let the header shape change with the test still green and the gate reading
213
+ * `[]` — reporting every script blocked, which reads as a broken app rather
214
+ * than as a broken parser.
215
+ *
216
+ * A directive present with no sources (`upgrade-insecure-requests`) and a
217
+ * directive absent entirely both answer `[]`. Callers that need to tell those
218
+ * apart are asking a different question than "what is allowed here".
219
+ */
220
+ function cspSourcesFor(policy, directive) {
221
+ const segment = policy
222
+ .split(';')
223
+ .map((entry) => entry.trim())
224
+ .find((entry) => entry === directive || entry.startsWith(`${directive} `));
225
+ return segment === undefined ? [] : segment.split(/\s+/).slice(1);
226
+ }
227
+ /**
228
+ * Index of the `>` that closes a tag whose attribute region starts at `from`,
229
+ * or `-1` if the document ends first. Quote-aware: a `>` inside an attribute
230
+ * VALUE does not close the tag.
231
+ *
232
+ * No HTML any Oxy build currently emits contains such an attribute, so this is
233
+ * not load-bearing today — it is here because the same scanner reads the SERVED
234
+ * document in the post-deploy gate, and what an edge injects into that document
235
+ * is not ours to constrain. Getting it wrong is not a parse error: the body
236
+ * window shifts, the hash is computed over the wrong bytes, and the script is
237
+ * blocked exactly as if no hash had been derived at all.
238
+ */
239
+ function findTagEnd(html, from) {
240
+ let quote = null;
241
+ for (let index = from; index < html.length; index += 1) {
242
+ const character = html[index];
243
+ if (quote !== null) {
244
+ if (character === quote)
245
+ quote = null;
246
+ continue;
247
+ }
248
+ if (character === '"' || character === "'") {
249
+ quote = character;
250
+ continue;
251
+ }
252
+ if (character === '>')
253
+ return index;
254
+ }
255
+ return -1;
256
+ }
257
+ /**
258
+ * Every inline `<script>` body in an HTML document, in document order. A
259
+ * `<script src=…>` is a URL the source list already governs and is skipped.
260
+ *
261
+ * Scanned rather than matched with one regex because the two failure modes are
262
+ * not symmetric: an EXTRA body costs a redundant hash nobody notices, while a
263
+ * MISSED body silently reinstates the exact breakage this exists to prevent.
264
+ * So the scan errs toward finding them — it walks the open tag quote-aware
265
+ * instead of letting a `>` inside an attribute value truncate it.
266
+ *
267
+ * The type attribute is deliberately not consulted. Whether a given `type`
268
+ * executes is a browser decision (and it changes: `importmap` and
269
+ * `speculationrules` were both once inert), and pinning the exact bytes of a
270
+ * data block we ship ourselves weakens nothing.
271
+ */
272
+ function extractInlineScripts(html) {
273
+ const lowered = html.toLowerCase();
274
+ const bodies = [];
275
+ const openTag = /<script\b/gi;
276
+ let match = openTag.exec(html);
277
+ while (match !== null) {
278
+ const attributesStart = match.index + match[0].length;
279
+ const attributesEnd = findTagEnd(html, attributesStart);
280
+ if (attributesEnd < 0)
281
+ break;
282
+ const bodyStart = attributesEnd + 1;
283
+ const bodyEnd = lowered.indexOf('</script', bodyStart);
284
+ if (bodyEnd < 0)
285
+ break;
286
+ if (!/\bsrc\s*=/i.test(html.slice(attributesStart, attributesEnd))) {
287
+ bodies.push(html.slice(bodyStart, bodyEnd));
288
+ }
289
+ openTag.lastIndex = bodyEnd;
290
+ match = openTag.exec(html);
291
+ }
292
+ return bodies;
293
+ }
294
+ /**
295
+ * The `'sha256-…'` source that allows one inline script, hashed over its exact
296
+ * bytes as CSP specifies — no trimming, no normalization. One byte of
297
+ * whitespace either way is a different hash and the script stays blocked.
298
+ */
299
+ function inlineScriptCspHash(source) {
300
+ return `'sha256-${(0, node_crypto_1.createHash)('sha256').update(source, 'utf8').digest('base64')}'`;
301
+ }
302
+ /**
303
+ * Ceiling on how many derived inline-script hashes may enter one `_headers`
304
+ * block. Nothing in an Oxy app authors an inline script, so the realistic
305
+ * count is the ONE Expo Router hydration flag — deduped across every route's
306
+ * HTML, because it is byte-identical in all of them.
307
+ *
308
+ * The ceiling exists because one future change breaks that: a route loader
309
+ * makes Expo emit a SECOND inline script, `__EXPO_ROUTER_LOADER_DATA__`, whose
310
+ * bytes differ per route. The hashes stay CORRECT (they are derived from the
311
+ * same build that ships), but the count becomes the route count and the policy
312
+ * grows without bound on every response. That is a decision to take
313
+ * deliberately, so it arrives as a red build rather than a quietly enormous
314
+ * header.
315
+ */
316
+ const MAX_INLINE_SCRIPT_HASHES = 8;
189
317
  /**
190
318
  * Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
191
319
  * same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
192
320
  * hardening headers Helmet would add on an Express HTML backend.
321
+ *
322
+ * Adding a hash to `script-src` does not narrow it: per CSP Level 3 a hash is
323
+ * an additional source, so `'self'` and the beacon host keep matching external
324
+ * scripts. (It WOULD neutralize `'unsafe-inline'` in the same directive — which
325
+ * is why this hashes scripts only. `style-src` keeps `'unsafe-inline'` for
326
+ * react-native-web's runtime stylesheet, and a style hash would silently switch
327
+ * that off and render every Oxy web app unstyled.)
193
328
  */
194
329
  function buildOxyPagesHeaders(options = {}) {
195
- const csp = formatOxyCspPolicy(buildOxyCspDirectives(options.csp));
330
+ const hashes = [
331
+ ...new Set((options.html ?? []).flatMap(extractInlineScripts).map(inlineScriptCspHash)),
332
+ ];
333
+ if (hashes.length > MAX_INLINE_SCRIPT_HASHES) {
334
+ throw new RangeError(`Oxy CSP: ${hashes.length} distinct inline scripts in the built HTML exceeds the ${MAX_INLINE_SCRIPT_HASHES}-hash ceiling. A per-route inline data block (e.g. an Expo Router loader) is the likely cause; allow it deliberately rather than by raising this.`);
335
+ }
336
+ const csp = formatOxyCspPolicy(buildOxyCspDirectives(hashes.length === 0
337
+ ? options.csp
338
+ : { ...options.csp, scriptSrc: [...(options.csp?.scriptSrc ?? []), ...hashes] }));
196
339
  const lines = [
197
340
  '/*',
198
341
  ` Content-Security-Policy: ${csp}`,