@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.
- package/README.md +36 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +4 -13
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -31
- package/dist/cjs/mixins/OxyServices.user.js +50 -44
- package/dist/cjs/server/index.js +9 -6
- package/dist/cjs/server/rateLimit.js +3 -0
- package/dist/cjs/server/securityHeaders.js +234 -0
- package/dist/cjs/session/accountDialogController.js +6 -8
- package/dist/cjs/utils/apiUtils.js +40 -10
- package/dist/cjs/utils/oauthPkce.js +1 -5
- package/dist/cjs/utils/officialOrigins.js +3 -73
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +3 -4
- package/dist/esm/mixins/OxyServices.deviceBoot.js +1 -32
- package/dist/esm/mixins/OxyServices.user.js +51 -45
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/rateLimit.js +3 -0
- package/dist/esm/server/securityHeaders.js +224 -0
- package/dist/esm/session/accountDialogController.js +6 -8
- package/dist/esm/utils/apiUtils.js +39 -10
- package/dist/esm/utils/oauthPkce.js +0 -4
- package/dist/esm/utils/officialOrigins.js +3 -68
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +5 -7
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -12
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +1 -5
- package/dist/types/mixins/OxyServices.user.d.ts +27 -6
- package/dist/types/server/index.d.ts +3 -1
- package/dist/types/server/securityHeaders.d.ts +154 -0
- package/dist/types/session/accountDialogController.d.ts +9 -15
- package/dist/types/utils/apiUtils.d.ts +48 -6
- package/dist/types/utils/oauthPkce.d.ts +11 -7
- package/dist/types/utils/officialOrigins.d.ts +3 -13
- package/package.json +10 -5
- package/src/index.ts +9 -14
- package/src/mixins/OxyServices.auth.ts +6 -13
- package/src/mixins/OxyServices.deviceBoot.ts +0 -47
- package/src/mixins/OxyServices.user.ts +60 -49
- package/src/mixins/__tests__/commonsSignIn.test.ts +9 -2
- package/src/mixins/__tests__/followGraphPagination.test.ts +250 -0
- package/src/server/__tests__/securityHeaders.test.ts +244 -0
- package/src/server/index.ts +17 -8
- package/src/server/rateLimit.ts +3 -0
- package/src/server/securityHeaders.ts +304 -0
- package/src/session/__tests__/accountDialogController.test.ts +3 -5
- package/src/session/accountDialogController.ts +12 -18
- package/src/utils/__tests__/officialOrigins.test.ts +0 -57
- package/src/utils/apiUtils.ts +64 -15
- package/src/utils/oauthPkce.ts +11 -9
- package/src/utils/officialOrigins.ts +3 -70
- package/dist/cjs/session/hubSync.js +0 -55
- package/dist/esm/session/hubSync.js +0 -51
- package/dist/types/session/hubSync.d.ts +0 -20
- package/src/session/__tests__/hubSync.test.ts +0 -51
- 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,
|
|
152
|
-
export {
|
|
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,
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
package/dist/esm/server/index.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
481
|
-
//
|
|
482
|
-
//
|
|
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
|
-
*
|
|
987
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
const
|
|
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
|
-
|
|
26
|
+
query[key] = String(value);
|
|
14
27
|
}
|
|
15
28
|
}
|
|
16
|
-
return
|
|
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
|
|
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
|
|
63
|
+
* @returns Query record with pagination
|
|
35
64
|
*/
|
|
36
65
|
export function buildPaginationParams(params) {
|
|
37
|
-
return
|
|
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}.
|