@oxyhq/core 12.11.1 → 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.
Files changed (56) hide show
  1. package/README.md +36 -2
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +4 -13
  4. package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -31
  5. package/dist/cjs/mixins/OxyServices.user.js +50 -44
  6. package/dist/cjs/server/index.js +9 -6
  7. package/dist/cjs/server/rateLimit.js +3 -0
  8. package/dist/cjs/server/securityHeaders.js +234 -0
  9. package/dist/cjs/session/accountDialogController.js +6 -8
  10. package/dist/cjs/utils/apiUtils.js +40 -10
  11. package/dist/cjs/utils/oauthPkce.js +1 -5
  12. package/dist/cjs/utils/officialOrigins.js +3 -73
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/index.js +3 -4
  15. package/dist/esm/mixins/OxyServices.deviceBoot.js +1 -32
  16. package/dist/esm/mixins/OxyServices.user.js +51 -45
  17. package/dist/esm/server/index.js +4 -1
  18. package/dist/esm/server/rateLimit.js +3 -0
  19. package/dist/esm/server/securityHeaders.js +224 -0
  20. package/dist/esm/session/accountDialogController.js +6 -8
  21. package/dist/esm/utils/apiUtils.js +39 -10
  22. package/dist/esm/utils/oauthPkce.js +0 -4
  23. package/dist/esm/utils/officialOrigins.js +3 -68
  24. package/dist/types/.tsbuildinfo +1 -1
  25. package/dist/types/index.d.ts +5 -7
  26. package/dist/types/mixins/OxyServices.auth.d.ts +1 -12
  27. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +1 -5
  28. package/dist/types/mixins/OxyServices.user.d.ts +27 -6
  29. package/dist/types/server/index.d.ts +3 -1
  30. package/dist/types/server/securityHeaders.d.ts +154 -0
  31. package/dist/types/session/accountDialogController.d.ts +9 -15
  32. package/dist/types/utils/apiUtils.d.ts +48 -6
  33. package/dist/types/utils/oauthPkce.d.ts +11 -7
  34. package/dist/types/utils/officialOrigins.d.ts +3 -13
  35. package/package.json +10 -5
  36. package/src/index.ts +9 -14
  37. package/src/mixins/OxyServices.auth.ts +6 -13
  38. package/src/mixins/OxyServices.deviceBoot.ts +0 -47
  39. package/src/mixins/OxyServices.user.ts +60 -49
  40. package/src/mixins/__tests__/commonsSignIn.test.ts +9 -2
  41. package/src/mixins/__tests__/followGraphPagination.test.ts +250 -0
  42. package/src/server/__tests__/securityHeaders.test.ts +244 -0
  43. package/src/server/index.ts +17 -8
  44. package/src/server/rateLimit.ts +3 -0
  45. package/src/server/securityHeaders.ts +304 -0
  46. package/src/session/__tests__/accountDialogController.test.ts +3 -5
  47. package/src/session/accountDialogController.ts +12 -18
  48. package/src/utils/__tests__/officialOrigins.test.ts +0 -57
  49. package/src/utils/apiUtils.ts +64 -15
  50. package/src/utils/oauthPkce.ts +11 -9
  51. package/src/utils/officialOrigins.ts +3 -70
  52. package/dist/cjs/session/hubSync.js +0 -55
  53. package/dist/esm/session/hubSync.js +0 -51
  54. package/dist/types/session/hubSync.d.ts +0 -20
  55. package/src/session/__tests__/hubSync.test.ts +0 -51
  56. package/src/session/hubSync.ts +0 -79
package/dist/esm/index.js CHANGED
@@ -106,7 +106,7 @@ export { translate } from './i18n/index.js';
106
106
  // ---------------------------------------------------------------------------
107
107
  // API request / URL helpers
108
108
  // ---------------------------------------------------------------------------
109
- export { buildSearchParams, buildUrl, buildPaginationParams, safeJsonParse, } from './utils/apiUtils.js';
109
+ export { buildQueryParams, buildSearchParams, buildUrl, buildPaginationParams, safeJsonParse, } from './utils/apiUtils.js';
110
110
  export { ErrorCodes, createApiError, handleHttpError, validateRequiredFields, } from './utils/errorUtils.js';
111
111
  export { retryAsync } from './utils/asyncUtils.js';
112
112
  // ---------------------------------------------------------------------------
@@ -148,9 +148,8 @@ export { runColdBoot } from './utils/coldBoot.js';
148
148
  // OAuth 2.0 Authorization Code + PKCE helpers ("Sign in with Oxy" third party).
149
149
  // Standard OAuth against auth.oxy.so/authorize — no FedCM/cookies/SSO bounce.
150
150
  // ---------------------------------------------------------------------------
151
- export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_OAUTH_REDIRECT_URI_STORAGE_KEY, OXY_SILENT_OAUTH_ATTEMPTED_KEY, OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY, OXY_OAUTH_RETURN_PATH_STORAGE_KEY, normalizeOAuthRedirectUri, canonicalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, persistOAuthReturnPath, consumeOAuthReturnPath, } from './utils/oauthPkce.js';
152
- export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins.js';
153
- export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync.js';
151
+ export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_OAUTH_REDIRECT_URI_STORAGE_KEY, OXY_OAUTH_RETURN_PATH_STORAGE_KEY, normalizeOAuthRedirectUri, canonicalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, persistOAuthReturnPath, consumeOAuthReturnPath, } from './utils/oauthPkce.js';
152
+ export { isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, } from './utils/officialOrigins.js';
154
153
  // ---------------------------------------------------------------------------
155
154
  // Session sync (device-scoped multi-account session client)
156
155
  // ---------------------------------------------------------------------------
@@ -13,7 +13,7 @@
13
13
  * the cold boot / re-mint handler own persistence and `setTokens`, so the same
14
14
  * primitive can be reused from either without double-planting.
15
15
  */
16
- import { deviceTokenMintResponseSchema, deviceHubTicketIssueResponseSchema, deviceHubTicketRedeemResponseSchema, safeParseContract, } from '@oxyhq/contracts';
16
+ import { deviceTokenMintResponseSchema, safeParseContract, } from '@oxyhq/contracts';
17
17
  /**
18
18
  * The server's `401 account_not_on_device` for a PINNED mint: the requested
19
19
  * `accountId` is not (or is no longer) a live account of this device session.
@@ -105,36 +105,5 @@ export function OxyServicesDeviceBootMixin(Base) {
105
105
  throw normalized;
106
106
  }
107
107
  }
108
- /** Mint a one-time hub sync ticket (bearer required). */
109
- async issueHubTicket(returnOrigin) {
110
- try {
111
- const res = await this.makeRequest('POST', '/session/device/hub-ticket', { returnOrigin }, { cache: false });
112
- const parsed = safeParseContract(deviceHubTicketIssueResponseSchema, res);
113
- if (!parsed) {
114
- throw new Error('session/device/hub-ticket returned an unexpected response shape');
115
- }
116
- return parsed;
117
- }
118
- catch (error) {
119
- throw this.handleError(error);
120
- }
121
- }
122
- /** Redeem a hub sync ticket for a fresh device secret (public). */
123
- async redeemHubTicket(ticket, returnOrigin) {
124
- try {
125
- const res = await this.makeRequest('POST', '/session/device/redeem-ticket', { ticket, returnOrigin },
126
- // Public device-hub sync mint (bearer-less). Same control-plane class as
127
- // the device-secret mint — bypassQueue so it never waits for a slot.
128
- { cache: false, skipAuth: true, bypassQueue: true });
129
- const parsed = safeParseContract(deviceHubTicketRedeemResponseSchema, res);
130
- if (!parsed) {
131
- throw new Error('session/device/redeem-ticket returned an unexpected response shape');
132
- }
133
- return parsed;
134
- }
135
- catch (error) {
136
- throw this.handleError(error);
137
- }
138
- }
139
108
  };
140
109
  }
@@ -1,5 +1,5 @@
1
1
  import { recommendationRequestSchema } from '@oxyhq/contracts';
2
- import { buildSearchParams, buildPaginationParams } from '../utils/apiUtils.js';
2
+ import { buildQueryParams, buildPaginationParams, } from '../utils/apiUtils.js';
3
3
  import { KeyManager } from '../crypto/keyManager.js';
4
4
  import { SignatureService } from '../crypto/signatureService.js';
5
5
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity.js';
@@ -65,10 +65,7 @@ export function OxyServicesUserMixin(Base) {
65
65
  */
66
66
  async searchProfiles(query, pagination) {
67
67
  try {
68
- const params = { query, ...pagination };
69
- const searchParams = buildSearchParams(params);
70
- const paramsObj = Object.fromEntries(searchParams.entries());
71
- const response = await this.makeRequest('GET', '/profiles/search', paramsObj, {
68
+ const response = await this.makeRequest('GET', '/profiles/search', buildQueryParams({ query, ...pagination }), {
72
69
  cache: true,
73
70
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
74
71
  });
@@ -526,6 +523,41 @@ export function OxyServicesUserMixin(Base) {
526
523
  throw this.handleError(error);
527
524
  }
528
525
  }
526
+ /**
527
+ * Invalidate every cached read a follow/unfollow write invalidates.
528
+ *
529
+ * Shared by the four mutation entry points (`followUser`, `unfollowUser`,
530
+ * `followUsers`, `unfollowUsers`) so they can never drift on which caches a
531
+ * write busts.
532
+ *
533
+ * The follower/following/mutuals LISTS are cleared by PREFIX rather than by
534
+ * exact key. Those reads are paginated and ordered, so one logical list is
535
+ * spread across many content-addressed keys
536
+ * (`GET:/users/<id>/followers:{"limit":"20","offset":"40","sort":"oldest"}`);
537
+ * an exact-key clear would only bust whichever page/sort variant happened to
538
+ * be read last and would leave every other page stale. `clearCacheByPrefix`
539
+ * deletes all of them, and all identity-scoped variants of each.
540
+ */
541
+ invalidateFollowGraphCaches(targetUserIds) {
542
+ for (const id of targetUserIds) {
543
+ this.clearCacheEntry(`GET:/users/${id}/follow-status`);
544
+ // Profile fetches embed viewer-relative `relationship` — bust so a
545
+ // remount doesn't serve a stale isFollowing for up to 5 minutes.
546
+ this.clearCacheEntry(`GET:/users/${id}`);
547
+ // The target gained/lost a follower, and the viewer's presence in the
548
+ // target's "followers you know" set changed with it.
549
+ this.clearCacheByPrefix(`GET:/users/${id}/followers`);
550
+ this.clearCacheByPrefix(`GET:/users/${id}/mutuals`);
551
+ }
552
+ this.clearCacheByPrefix('GET:/profiles/username/');
553
+ this.clearCacheByPrefix('GET:/profiles/resolve');
554
+ // The write changed the viewer's OWN following list and graph.
555
+ const viewerId = this.getCurrentUserId();
556
+ if (viewerId) {
557
+ this.clearCacheByPrefix(`GET:/users/${viewerId}/following`);
558
+ }
559
+ this.clearCacheEntry('GET:/users/me/graph');
560
+ }
529
561
  /**
530
562
  * Follow a user.
531
563
  *
@@ -539,16 +571,7 @@ export function OxyServicesUserMixin(Base) {
539
571
  async followUser(userId) {
540
572
  try {
541
573
  const result = await this.makeRequest('POST', `/users/${userId}/follow`, undefined, { cache: false });
542
- this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
543
- // Profile fetches embed viewer-relative `relationship` — bust so a
544
- // remount doesn't serve a stale isFollowing for up to 5 minutes.
545
- this.clearCacheEntry(`GET:/users/${userId}`);
546
- this.clearCacheByPrefix('GET:/profiles/username/');
547
- this.clearCacheByPrefix('GET:/profiles/resolve');
548
- // The follow changed the viewer's graph — bust the cached consolidated
549
- // `GET /users/me/graph` so the next read reflects the new following/
550
- // mutual set instead of the stale pre-write snapshot.
551
- this.clearCacheEntry('GET:/users/me/graph');
574
+ this.invalidateFollowGraphCaches([userId]);
552
575
  return result;
553
576
  }
554
577
  catch (error) {
@@ -569,15 +592,7 @@ export function OxyServicesUserMixin(Base) {
569
592
  }
570
593
  try {
571
594
  const result = await this.makeRequest('POST', '/users/follow/bulk', { userIds }, { cache: false });
572
- // Bust each affected user's cached follow-status (see `followUser`).
573
- for (const id of userIds) {
574
- this.clearCacheEntry(`GET:/users/${id}/follow-status`);
575
- this.clearCacheEntry(`GET:/users/${id}`);
576
- }
577
- this.clearCacheByPrefix('GET:/profiles/username/');
578
- this.clearCacheByPrefix('GET:/profiles/resolve');
579
- // The batch changed the viewer's graph — bust the consolidated cache.
580
- this.clearCacheEntry('GET:/users/me/graph');
595
+ this.invalidateFollowGraphCaches(userIds);
581
596
  return result;
582
597
  }
583
598
  catch (error) {
@@ -598,15 +613,7 @@ export function OxyServicesUserMixin(Base) {
598
613
  }
599
614
  try {
600
615
  const result = await this.makeRequest('POST', '/users/unfollow/bulk', { userIds }, { cache: false });
601
- // Bust each affected user's cached follow-status (see `followUser`).
602
- for (const id of userIds) {
603
- this.clearCacheEntry(`GET:/users/${id}/follow-status`);
604
- this.clearCacheEntry(`GET:/users/${id}`);
605
- }
606
- this.clearCacheByPrefix('GET:/profiles/username/');
607
- this.clearCacheByPrefix('GET:/profiles/resolve');
608
- // The batch changed the viewer's graph — bust the consolidated cache.
609
- this.clearCacheEntry('GET:/users/me/graph');
616
+ this.invalidateFollowGraphCaches(userIds);
610
617
  return result;
611
618
  }
612
619
  catch (error) {
@@ -619,13 +626,7 @@ export function OxyServicesUserMixin(Base) {
619
626
  async unfollowUser(userId) {
620
627
  try {
621
628
  const result = await this.makeRequest('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
622
- // Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
623
- this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
624
- this.clearCacheEntry(`GET:/users/${userId}`);
625
- this.clearCacheByPrefix('GET:/profiles/username/');
626
- this.clearCacheByPrefix('GET:/profiles/resolve');
627
- // The unfollow changed the viewer's graph — bust the consolidated cache.
628
- this.clearCacheEntry('GET:/users/me/graph');
629
+ this.invalidateFollowGraphCaches([userId]);
629
630
  return result;
630
631
  }
631
632
  catch (error) {
@@ -684,11 +685,16 @@ export function OxyServicesUserMixin(Base) {
684
685
  }
685
686
  }
686
687
  /**
687
- * Get user followers
688
+ * Get user followers.
689
+ *
690
+ * `sort` orders the underlying follow edges — `recent` (newest first, the
691
+ * server default) or `oldest`. Because the response is cached and the cache
692
+ * key is content-addressed on the query params, each `limit`/`offset`/`sort`
693
+ * combination is its own entry.
688
694
  */
689
695
  async getUserFollowers(userId, pagination) {
690
696
  try {
691
- const params = buildPaginationParams(pagination || {});
697
+ const params = buildQueryParams(pagination || {});
692
698
  const response = await this.makeRequest('GET', `/users/${userId}/followers`, params, {
693
699
  cache: true,
694
700
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -704,11 +710,11 @@ export function OxyServicesUserMixin(Base) {
704
710
  }
705
711
  }
706
712
  /**
707
- * Get user following
713
+ * Get user following. `sort` behaves as in {@link getUserFollowers}.
708
714
  */
709
715
  async getUserFollowing(userId, pagination) {
710
716
  try {
711
- const params = buildPaginationParams(pagination || {});
717
+ const params = buildQueryParams(pagination || {});
712
718
  const response = await this.makeRequest('GET', `/users/${userId}/following`, params, {
713
719
  cache: true,
714
720
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -729,7 +735,7 @@ export function OxyServicesUserMixin(Base) {
729
735
  */
730
736
  async getUserMutuals(userId, pagination) {
731
737
  try {
732
- const params = buildPaginationParams(pagination || {});
738
+ const params = buildQueryParams(pagination || {});
733
739
  const response = await this.makeRequest('GET', `/users/${userId}/mutuals`, params, {
734
740
  cache: true,
735
741
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -20,6 +20,9 @@ export { createOxyRateLimit } from './rateLimit.js';
20
20
  export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch.js';
21
21
  // Strict CORS allowlist (Oxy apex family + explicit app origins).
22
22
  export { createOxyCors } from './cors.js';
23
+ // Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
24
+ // Oxy API/CDN origins) with additive, per-app extensions.
25
+ export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, formatOxyCspPolicy, OXY_CSP_BASELINE, } from './securityHeaders.js';
23
26
  // Constant-time secret comparison.
24
27
  export { verifySecret } from './verifySecret.js';
25
28
  // Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
@@ -27,4 +30,4 @@ export { verifySecret } from './verifySecret.js';
27
30
  // Pure host handling (no browser deps), so it is safe on the server subpath and
28
31
  // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
29
32
  export { registrableApex } from '../utils/registrableApex.js';
30
- export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from '../utils/officialOrigins.js';
33
+ export { isOfficialWebOrigin } from '../utils/officialOrigins.js';
@@ -168,6 +168,9 @@ export function createOxyRateLimit(oxy, options = {}) {
168
168
  standardHeaders: true,
169
169
  legacyHeaders: false,
170
170
  skip,
171
+ // hashAnonymousIp already buckets IPv6 to /56 before HMAC; disable the v8
172
+ // static source scan that false-positives on req.ip (ERR_ERL_KEY_GEN_IPV6).
173
+ validate: { keyGeneratorIpFallback: false },
171
174
  });
172
175
  return (req, res, next) => {
173
176
  // Skipped paths bypass BOTH session resolution and limiting — cheap and
@@ -0,0 +1,224 @@
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
+ import helmet from 'helmet';
57
+ /** CSP keyword for "this origin". Always present in every open baseline directive. */
58
+ const SELF = "'self'";
59
+ /** CSP keyword for a fully closed directive. Meaningless alongside any other source. */
60
+ const NONE = "'none'";
61
+ /**
62
+ * Cloudflare Web Analytics. Injected at the edge into proxied HTML — no Oxy app
63
+ * loads it, and no Oxy app should have to know these hostnames. Both are
64
+ * required: the script host, and the host the beacon reports to.
65
+ */
66
+ const CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN = 'https://static.cloudflareinsights.com';
67
+ const CLOUDFLARE_INSIGHTS_REPORT_ORIGIN = 'https://cloudflareinsights.com';
68
+ /**
69
+ * Oxy platform origins. Every Oxy web origin runs the SDK, which calls the Oxy
70
+ * API over HTTPS and Socket.IO, and resolves all canonical media through the
71
+ * Oxy CDN (`getFileDownloadUrl` → `cloud.oxy.so`).
72
+ */
73
+ const OXY_API_ORIGIN = 'https://api.oxy.so';
74
+ const OXY_API_WEBSOCKET_ORIGIN = 'wss://api.oxy.so';
75
+ const OXY_CDN_ORIGIN = 'https://cloud.oxy.so';
76
+ /**
77
+ * The Oxy-wide CSP baseline. Deliberately the floor every Oxy origin needs, not
78
+ * a superset of what any one app allows — permissive sources an individual app
79
+ * wants (`https:` images, `blob:` media, embed hosts, LiveKit) are that app's
80
+ * extension, so each widening stays visible at its call site.
81
+ *
82
+ * `style-src` carries `'unsafe-inline'` because react-native-web injects its
83
+ * stylesheet as inline `<style>` at runtime; without it every Oxy web app
84
+ * renders unstyled.
85
+ */
86
+ export const OXY_CSP_BASELINE = Object.freeze({
87
+ defaultSrc: Object.freeze([SELF]),
88
+ baseUri: Object.freeze([SELF]),
89
+ formAction: Object.freeze([SELF]),
90
+ frameAncestors: Object.freeze([NONE]),
91
+ objectSrc: Object.freeze([NONE]),
92
+ scriptSrc: Object.freeze([SELF, CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN]),
93
+ scriptSrcAttr: Object.freeze([NONE]),
94
+ styleSrc: Object.freeze([SELF, "'unsafe-inline'"]),
95
+ imgSrc: Object.freeze([SELF, 'data:', OXY_CDN_ORIGIN]),
96
+ mediaSrc: Object.freeze([SELF, OXY_CDN_ORIGIN]),
97
+ fontSrc: Object.freeze([SELF, 'data:']),
98
+ connectSrc: Object.freeze([
99
+ SELF,
100
+ CLOUDFLARE_INSIGHTS_REPORT_ORIGIN,
101
+ OXY_API_ORIGIN,
102
+ OXY_API_WEBSOCKET_ORIGIN,
103
+ OXY_CDN_ORIGIN,
104
+ ]),
105
+ });
106
+ /** `connectSrc` → `connect-src`. Total over `OxyCspDirective` (all are camelCase ASCII). */
107
+ function toHeaderDirectiveName(directive) {
108
+ return directive.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
109
+ }
110
+ /**
111
+ * A source containing `;` or `,` would silently terminate the directive (or the
112
+ * whole policy) and hand the rest of the string to the browser as new
113
+ * directives. Helmet rejects these too; we reject them here so the pure builder
114
+ * is equally safe, and so the failure names the offending directive.
115
+ */
116
+ function assertValidSource(directive, source) {
117
+ if (typeof source !== 'string' || source.length === 0) {
118
+ throw new TypeError(`Oxy CSP: ${toHeaderDirectiveName(directive)} received an empty source.`);
119
+ }
120
+ if (source.includes(';') || source.includes(',')) {
121
+ throw new TypeError(`Oxy CSP: ${toHeaderDirectiveName(directive)} source ${JSON.stringify(source)} may not contain ";" or ",".`);
122
+ }
123
+ }
124
+ /** Order-preserving, first-seen-wins dedupe. */
125
+ function dedupe(sources) {
126
+ return [...new Set(sources)];
127
+ }
128
+ /**
129
+ * Resolve the effective CSP directives: the Oxy baseline, with each app
130
+ * extension merged in and deduped.
131
+ *
132
+ * Merge rules:
133
+ * - A baseline directive is EXTENDED, never replaced — `'self'` and the
134
+ * Cloudflare beacon hosts always survive.
135
+ * - A directive absent from the baseline is seeded with `'self'`, so adding
136
+ * (say) an embed host to `frame-src` cannot lock the origin out of itself.
137
+ * - A directive whose baseline is exactly `'none'` is CLOSED: extending it
138
+ * drops the sentinel, because `'none'` alongside any other source is
139
+ * meaningless per the CSP spec. This is how an app that must be framable
140
+ * opts back in with `frameAncestors: ["'self'"]`.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * buildOxyCspDirectives({ frameSrc: ['https://player.vimeo.com'] });
145
+ * // → { ..., 'frame-src': ["'self'", 'https://player.vimeo.com'], ... }
146
+ * ```
147
+ */
148
+ export function buildOxyCspDirectives(extensions = {}) {
149
+ const directiveNames = new Set([
150
+ ...Object.keys(OXY_CSP_BASELINE),
151
+ ...Object.keys(extensions),
152
+ ]);
153
+ const resolved = {};
154
+ for (const directive of directiveNames) {
155
+ const extras = extensions[directive] ?? [];
156
+ for (const source of extras) {
157
+ assertValidSource(directive, source);
158
+ }
159
+ const baseline = OXY_CSP_BASELINE[directive] ?? [SELF];
160
+ const isClosed = baseline.length === 1 && baseline[0] === NONE;
161
+ const merged = isClosed && extras.length > 0 ? extras : [...baseline, ...extras];
162
+ resolved[toHeaderDirectiveName(directive)] = dedupe(merged);
163
+ }
164
+ // Valueless directive: rewrite stray `http://` subresources to HTTPS rather
165
+ // than failing them, which matters for federated/user-supplied URLs.
166
+ resolved['upgrade-insecure-requests'] = [];
167
+ return resolved;
168
+ }
169
+ /**
170
+ * Serialize resolved CSP directives into the single-line header value browsers
171
+ * and Cloudflare `_headers` expect. Valueless directives (e.g.
172
+ * `upgrade-insecure-requests`) emit the name alone.
173
+ */
174
+ export function formatOxyCspPolicy(directives) {
175
+ return Object.entries(directives)
176
+ .map(([name, sources]) => (sources.length === 0 ? name : `${name} ${sources.join(' ')}`))
177
+ .join('; ');
178
+ }
179
+ /**
180
+ * Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
181
+ * same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
182
+ * hardening headers Helmet would add on an Express HTML backend.
183
+ */
184
+ export function buildOxyPagesHeaders(options = {}) {
185
+ const csp = formatOxyCspPolicy(buildOxyCspDirectives(options.csp));
186
+ const lines = [
187
+ '/*',
188
+ ` Content-Security-Policy: ${csp}`,
189
+ ' X-Frame-Options: DENY',
190
+ ' X-Content-Type-Options: nosniff',
191
+ ' Referrer-Policy: strict-origin-when-cross-origin',
192
+ ];
193
+ if (options.hsts !== false) {
194
+ lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
195
+ }
196
+ lines.push('');
197
+ return lines.join('\n');
198
+ }
199
+ /**
200
+ * Build the shared Oxy security-headers middleware: Helmet with the Oxy CSP
201
+ * baseline plus this app's extensions.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * app.use(createOxySecurityHeaders({
206
+ * csp: {
207
+ * connectSrc: ['https://api.example.com', 'wss://api.example.com'],
208
+ * frameSrc: ['https://player.vimeo.com'],
209
+ * },
210
+ * helmet: { crossOriginResourcePolicy: { policy: 'cross-origin' } },
211
+ * }));
212
+ * ```
213
+ */
214
+ export function createOxySecurityHeaders(options = {}) {
215
+ const { csp, helmet: helmetOptions } = options;
216
+ const directives = buildOxyCspDirectives(csp);
217
+ return helmet({
218
+ ...helmetOptions,
219
+ // `useDefaults: false`: the baseline above is the whole policy, so what the
220
+ // browser receives is exactly what `buildOxyCspDirectives` returns — no
221
+ // silent union with Helmet's defaults that tests would never see.
222
+ contentSecurityPolicy: { useDefaults: false, directives },
223
+ });
224
+ }
@@ -477,9 +477,9 @@ export class AccountDialogController {
477
477
  user: result.user,
478
478
  accessToken: result.accessToken,
479
479
  }, result.user,
480
- // A switch is IN-PLACE: commit without the hub-sync redirect (the
481
- // device is already known/synced). Cross-tab/app propagation rides the
482
- // server's `session_state` socket broadcast, not a navigation.
480
+ // A switch is IN-PLACE: use the switch commit funnel (not sign-in).
481
+ // Cross-tab/app propagation rides the server's `session_state` socket
482
+ // broadcast, not a navigation.
483
483
  { fromSwitch: true });
484
484
  }
485
485
  // Re-project + refetch immediately; the subscription also fires.
@@ -982,11 +982,9 @@ export class AccountDialogController {
982
982
  * consumer's commit funnel (durable persist + hydration); falls back to
983
983
  * `SessionClient.registerAndActivate` (registration + activation only).
984
984
  *
985
- * A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
986
- * so it never runs the cross-origin hub-sync redirect; a SIGN-IN uses
987
- * `commitSession` (which may hub-sync on an official web origin). When the
988
- * switch funnel is not wired it falls back to the sign-in funnel, then to
989
- * `registerAndActivate`.
985
+ * A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel;
986
+ * a SIGN-IN uses `commitSession`. When the switch funnel is not wired it falls
987
+ * back to the sign-in funnel, then to `registerAndActivate`.
990
988
  */
991
989
  async commitAuthorizedSession(session, user, opts) {
992
990
  const commit = opts?.fromSwitch
@@ -2,18 +2,43 @@
2
2
  * Utility functions for common API patterns
3
3
  */
4
4
  /**
5
- * Build URL search parameters from an object
6
- * @param params Object with parameter key-value pairs
7
- * @returns URLSearchParams instance
5
+ * Build a plain query-parameter record from an object, stringifying values and
6
+ * dropping `undefined`/`null` entries.
7
+ *
8
+ * This is the shape `OxyServices.makeRequest` expects for a GET's `params`:
9
+ * `HttpService` inspects it with `Object.keys(...)` (both to decide whether to
10
+ * append a query string and to build the request's cache key), and
11
+ * `Object.keys(new URLSearchParams({ limit: '20' }))` is `[]` — a
12
+ * `URLSearchParams` exposes its entries through iterator methods, never as own
13
+ * enumerable properties. Passing one to `makeRequest` therefore silently drops
14
+ * the whole query string. Always hand `makeRequest` a plain record.
15
+ *
16
+ * Generic over the input object rather than taking `Record<string, unknown>`,
17
+ * because a TypeScript `interface` (`PaginationParams`, `FollowGraphParams`, …)
18
+ * has no implicit index signature and so is not assignable to that type.
8
19
  */
9
- export function buildSearchParams(params) {
10
- const searchParams = new URLSearchParams();
20
+ export function buildQueryParams(params) {
21
+ const query = {};
22
+ // Widening the value to `unknown` is always sound; the default overload of
23
+ // `Object.entries` would otherwise infer `any` here.
11
24
  for (const [key, value] of Object.entries(params)) {
12
25
  if (value !== undefined && value !== null) {
13
- searchParams.append(key, value.toString());
26
+ query[key] = String(value);
14
27
  }
15
28
  }
16
- return searchParams;
29
+ return query;
30
+ }
31
+ /**
32
+ * Build URL search parameters from an object.
33
+ *
34
+ * For building a URL string only — see {@link buildQueryParams} for the shape
35
+ * `makeRequest` needs.
36
+ *
37
+ * @param params Object with parameter key-value pairs
38
+ * @returns URLSearchParams instance
39
+ */
40
+ export function buildSearchParams(params) {
41
+ return new URLSearchParams(buildQueryParams(params));
17
42
  }
18
43
  /**
19
44
  * Build URL with search parameters
@@ -29,12 +54,16 @@ export function buildUrl(baseUrl, params) {
29
54
  return queryString ? `${baseUrl}?${queryString}` : baseUrl;
30
55
  }
31
56
  /**
32
- * Build pagination search parameters
57
+ * Build pagination query parameters.
58
+ *
59
+ * Returns a plain record — NOT a `URLSearchParams` — because that is the only
60
+ * shape `makeRequest`/`HttpService` can read. See {@link buildQueryParams}.
61
+ *
33
62
  * @param params Pagination parameters
34
- * @returns URLSearchParams with pagination
63
+ * @returns Query record with pagination
35
64
  */
36
65
  export function buildPaginationParams(params) {
37
- return buildSearchParams(params);
66
+ return buildQueryParams(params);
38
67
  }
39
68
  /**
40
69
  * Safe JSON parsing with error handling
@@ -145,10 +145,6 @@ export const OXY_OAUTH_STATE_STORAGE_KEY = 'oxy_oauth_state';
145
145
  export const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = 'oxy_oauth_code_verifier';
146
146
  /** `sessionStorage` key — the exact `redirect_uri` sent on the authorize request. */
147
147
  export const OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = 'oxy.oauth_redirect_uri';
148
- /** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
149
- export const OXY_SILENT_OAUTH_ATTEMPTED_KEY = 'oxy.silent_oauth_attempted';
150
- /** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
151
- export const OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = 'oxy.cross_origin_restore_attempted';
152
148
  /**
153
149
  * `sessionStorage` key for the in-app path to return to after an authorize
154
150
  * round trip. See {@link persistOAuthReturnPath}.