@oxyhq/core 3.9.1 → 3.10.1

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 (54) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +9 -0
  3. package/dist/cjs/i18n/locales/es-ES.json +9 -0
  4. package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
  6. package/dist/cjs/index.js +2 -3
  7. package/dist/cjs/mixins/OxyServices.assets.js +29 -6
  8. package/dist/cjs/mixins/OxyServices.utility.js +52 -23
  9. package/dist/cjs/server/cors.js +155 -0
  10. package/dist/cjs/server/index.js +21 -1
  11. package/dist/cjs/server/safeFetch.js +458 -0
  12. package/dist/cjs/server/verifySecret.js +50 -0
  13. package/dist/cjs/utils/fapiAutoDetect.js +12 -42
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/i18n/locales/en-US.json +9 -0
  16. package/dist/esm/i18n/locales/es-ES.json +9 -0
  17. package/dist/esm/i18n/locales/locales/en-US.json +9 -0
  18. package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
  19. package/dist/esm/index.js +1 -1
  20. package/dist/esm/mixins/OxyServices.assets.js +29 -6
  21. package/dist/esm/mixins/OxyServices.utility.js +52 -23
  22. package/dist/esm/server/cors.js +152 -0
  23. package/dist/esm/server/index.js +6 -0
  24. package/dist/esm/server/safeFetch.js +447 -0
  25. package/dist/esm/server/verifySecret.js +47 -0
  26. package/dist/esm/utils/fapiAutoDetect.js +12 -41
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
  30. package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
  31. package/dist/types/server/cors.d.ts +57 -0
  32. package/dist/types/server/index.d.ts +5 -0
  33. package/dist/types/server/safeFetch.d.ts +135 -0
  34. package/dist/types/server/verifySecret.d.ts +29 -0
  35. package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
  36. package/package.json +2 -1
  37. package/src/__tests__/authSocket.test.ts +96 -0
  38. package/src/i18n/locales/en-US.json +9 -0
  39. package/src/i18n/locales/es-ES.json +9 -0
  40. package/src/index.ts +1 -1
  41. package/src/mixins/OxyServices.assets.ts +40 -6
  42. package/src/mixins/OxyServices.utility.ts +57 -23
  43. package/src/mixins/__tests__/assetUpload.test.ts +191 -0
  44. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
  45. package/src/mixins/__tests__/serviceAuth.test.ts +30 -2
  46. package/src/server/__tests__/cors.test.ts +144 -0
  47. package/src/server/__tests__/safeFetch.test.ts +232 -0
  48. package/src/server/__tests__/verifySecret.test.ts +40 -0
  49. package/src/server/cors.ts +195 -0
  50. package/src/server/index.ts +30 -0
  51. package/src/server/safeFetch.ts +581 -0
  52. package/src/server/verifySecret.ts +52 -0
  53. package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
  54. package/src/utils/fapiAutoDetect.ts +12 -39
@@ -80,7 +80,7 @@ export type { LogContext } from './utils/loggerUtils';
80
80
  export { updateAvatarVisibility } from './utils/avatarUtils';
81
81
  export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccountFallbackHandle, formatPublicKeyHandle, mergeAccountsFromRefreshAll, getAccountColor, } from './utils/accountUtils';
82
82
  export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
83
- export { autoDetectAuthWebUrl, registrableApex, MULTIPART_TLDS } from './utils/fapiAutoDetect';
83
+ export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect';
84
84
  export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl';
85
85
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
86
86
  export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
@@ -1,5 +1,9 @@
1
1
  import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
+ interface FileDownloadUrlOptions {
4
+ /** Omit bearer access tokens from generated URLs, even when authenticated. */
5
+ omitToken?: boolean;
6
+ }
3
7
  export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T): {
4
8
  new (...args: any[]): {
5
9
  /**
@@ -32,7 +36,7 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
32
36
  *
33
37
  * For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
34
38
  */
35
- getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string;
39
+ getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number, options?: FileDownloadUrlOptions): string;
36
40
  /**
37
41
  * Get file download URL asynchronously (returns signed URL directly from CDN)
38
42
  */
@@ -168,3 +172,4 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
168
172
  }>;
169
173
  };
170
174
  } & T;
175
+ export {};
@@ -225,9 +225,9 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
225
225
  * Express.js middleware that enforces a specific service-token scope.
226
226
  *
227
227
  * Mount AFTER `auth()` / `serviceAuth()` — relies on `req.serviceApp` and
228
- * (when delegation is in effect) `req.serviceActingAs.scopes`. The scope
229
- * is granted if EITHER list contains it, mirroring the OAuth2 model where
230
- * the app's app-level scopes and the per-user delegated scopes both count.
228
+ * (when delegation is in effect) `req.serviceActingAs.scopes`. App-only
229
+ * service requests require the app scope. Delegated user requests require
230
+ * BOTH the app scope and the per-user delegation scope.
231
231
  *
232
232
  * Requests authenticated as a regular user (no service token) are rejected
233
233
  * with 403 — scope-protected endpoints are service-to-service by design.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Strict CORS allowlist for Oxy backends.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * App backends kept hand-rolling CORS, and the unsafe patterns recurred:
7
+ * - `Access-Control-Allow-Origin: *` together with credentials (which is
8
+ * spec-invalid AND a credential-leak vector), or
9
+ * - a "reflect whatever Origin the request carried" fallback (effectively
10
+ * `*` for credentialed requests — the Allo wildcard-fallback class).
11
+ *
12
+ * `createOxyCors` returns a self-contained Express middleware (no `cors`
13
+ * package dependency) that:
14
+ * - allows the Oxy apex origin family (anything under `*.${CENTRAL_IDP_APEX}`,
15
+ * i.e. `oxy.so` — covering `auth.oxy.so`, `api.oxy.so`, `accounts.oxy.so`,
16
+ * `console.oxy.so`, `inbox.oxy.so`, the marketing site, …) reusing the
17
+ * central-origin constants already in core, NOT a fresh hardcoded list,
18
+ * - allows the caller's explicit `appOrigins`,
19
+ * - DENIES everything else (no reflection, never a wildcard with credentials),
20
+ * - echoes back the EXACT matched origin (so credentialed requests work) and
21
+ * sets `Vary: Origin` for correct caching,
22
+ * - answers CORS preflight (`OPTIONS`) with `204`.
23
+ *
24
+ * Node/Express-only: exported solely from `@oxyhq/core/server`.
25
+ */
26
+ import type { RequestHandler } from 'express';
27
+ export interface OxyCorsOptions {
28
+ /**
29
+ * Explicit additional allowed origins (exact-origin match, e.g.
30
+ * `https://app.example.com`, `http://localhost:3000`). These are allowed IN
31
+ * ADDITION TO the Oxy apex origin family. Each is normalized via `new URL().origin`.
32
+ */
33
+ appOrigins?: string[];
34
+ /**
35
+ * Whether to emit `Access-Control-Allow-Credentials: true`. Default `true`
36
+ * (the Oxy ecosystem uses cookie/bearer credentials). Even when `true`, the
37
+ * helper NEVER emits a wildcard origin — only an exact matched origin.
38
+ */
39
+ allowCredentials?: boolean;
40
+ /** HTTP methods to allow. Defaults to the full standard set. */
41
+ methods?: string[];
42
+ /** Request headers to allow. Defaults to the common Oxy set. */
43
+ allowedHeaders?: string[];
44
+ /** Response headers to expose to the browser. Defaults to none. */
45
+ exposedHeaders?: string[];
46
+ /** Preflight cache lifetime in seconds. Default 86400 (24h). */
47
+ maxAgeSeconds?: number;
48
+ }
49
+ /**
50
+ * Create a strict Oxy CORS middleware. See module docs.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * app.use(createOxyCors({ appOrigins: ['https://app.example.com'] }));
55
+ * ```
56
+ */
57
+ export declare function createOxyCors(options?: OxyCorsOptions): RequestHandler;
@@ -18,3 +18,8 @@ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequir
18
18
  export type { OxyActingAsContext, OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, } from './auth';
19
19
  export { createOxyRateLimit } from './rateLimit';
20
20
  export type { OxyRateLimitOptions } from './rateLimit';
21
+ 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';
22
+ export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, SsrfCheckResult, } from './safeFetch';
23
+ export { createOxyCors } from './cors';
24
+ export type { OxyCorsOptions } from './cors';
25
+ export { verifySecret } from './verifySecret';
@@ -0,0 +1,135 @@
1
+ /**
2
+ * SSRF-safe upstream HTTP fetch for Oxy backends.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Every Oxy backend that contacts a caller-influenced URL — media proxies,
7
+ * the website MCP image upload/debug tools, federated fetches, link
8
+ * unfurlers — needs the exact same Server-Side Request Forgery (SSRF)
9
+ * defence. Apps were re-implementing it (or worse, omitting it), so the
10
+ * gold-standard primitive (originally `packages/backend/src/utils` in
11
+ * Mention) lives here ONCE.
12
+ *
13
+ * THE CONTRACT
14
+ * ------------
15
+ * - Every URL — including each redirect hop — is validated by
16
+ * {@link assertSafePublicUrl}: a real DNS resolution plus a denylist of
17
+ * private/reserved/metadata ranges (10/8, 127/8, 169.254.169.254, ::1, …).
18
+ * - The TCP connection is PINNED to the validated IP via a custom `lookup`,
19
+ * closing the DNS-rebind TOCTOU window — DNS is NOT re-resolved at connect
20
+ * time, so the address we validated is exactly the address Node connects to.
21
+ * - Redirects are followed manually (bounded) so every hop is re-validated and
22
+ * redirect bodies (potentially unbounded) are destroyed, not drained.
23
+ *
24
+ * Node-only: this module imports `node:http`/`node:https`/`node:dns` and is
25
+ * exported solely from `@oxyhq/core/server`. It MUST NOT be reachable from the
26
+ * browser `@oxyhq/core` entry.
27
+ */
28
+ import { type IncomingMessage, type IncomingHttpHeaders } from 'node:http';
29
+ /** Maximum accepted length of an input URL (DoS guard). */
30
+ export declare const MAX_URL_LENGTH = 2048;
31
+ /** The only network ports a safe fetch is allowed to reach upstream. */
32
+ export declare const ALLOWED_PORTS: ReadonlySet<number>;
33
+ /** Protocols a safe fetch is allowed to contact. */
34
+ export declare const ALLOWED_PROTOCOLS: ReadonlySet<string>;
35
+ /**
36
+ * Time-to-first-byte deadline: how long to wait for the upstream to establish
37
+ * the connection and send its RESPONSE HEADERS before aborting. Enforced via
38
+ * `req.setTimeout` on the `ClientRequest`; once headers arrive, the caller owns
39
+ * the (longer) streaming lifetime of the response body.
40
+ */
41
+ export declare const UPSTREAM_HEADERS_TIMEOUT_MS = 8000;
42
+ /** Maximum number of HTTP redirects to follow; each hop is re-validated. */
43
+ export declare const MAX_REDIRECTS = 5;
44
+ /** Default User-Agent presented to upstreams when the caller does not set one. */
45
+ export declare const DEFAULT_USER_AGENT = "OxyServices/1.0 (+https://oxy.so)";
46
+ /** Hostnames that must never be resolved or contacted, regardless of DNS. */
47
+ export declare const BLOCKED_HOSTNAMES: ReadonlySet<string>;
48
+ export interface SsrfCheckOk {
49
+ ok: true;
50
+ /** The validated literal IP the caller MUST connect to. */
51
+ ip: string;
52
+ /** IP family of the validated address (4 or 6). */
53
+ family: 4 | 6;
54
+ }
55
+ export interface SsrfCheckFail {
56
+ ok: false;
57
+ /** Human-readable, non-sensitive reason (safe to log; not echoed to clients). */
58
+ reason: string;
59
+ }
60
+ export type SsrfCheckResult = SsrfCheckOk | SsrfCheckFail;
61
+ /**
62
+ * Return true if a literal IP address is private/loopback/link-local/reserved/
63
+ * multicast/metadata and therefore must NOT be contacted.
64
+ */
65
+ export declare function isBlockedIp(rawIp: string): boolean;
66
+ /**
67
+ * Validate that a URL is syntactically a public http(s) URL and that its
68
+ * hostname resolves ONLY to non-blocked, public IP addresses.
69
+ *
70
+ * On success, returns the single validated IP (the first allowed record) that
71
+ * the HTTP client MUST pin its connection to. Every resolved address is checked;
72
+ * if ANY resolves into a blocked range the URL is rejected (an attacker
73
+ * controlling a multi-record DNS response cannot smuggle one internal IP past
74
+ * the check).
75
+ *
76
+ * Re-run this on EVERY redirect hop so a public hostname cannot redirect (or
77
+ * DNS-rebind) into an internal address.
78
+ */
79
+ export declare function assertSafePublicUrl(rawUrl: string): Promise<SsrfCheckResult>;
80
+ /** Marker error for a blocked SSRF target (map to 403 at the route layer). */
81
+ export declare class SsrfRejection extends Error {
82
+ constructor(reason: string);
83
+ }
84
+ /** Marker error for a generic upstream failure (map to 502 at the route layer). */
85
+ export declare class UpstreamError extends Error {
86
+ constructor(reason: string);
87
+ }
88
+ /** Options for {@link safeFetch}. */
89
+ export interface SafeFetchOptions {
90
+ /** HTTP method. Defaults to `GET`. */
91
+ method?: string;
92
+ /** Extra request headers. A `User-Agent` is added if none is provided. */
93
+ headers?: Record<string, string>;
94
+ /**
95
+ * Maximum number of redirects to follow (each re-validated). Defaults to
96
+ * {@link MAX_REDIRECTS}. Set to `0` to disallow redirects.
97
+ */
98
+ maxRedirects?: number;
99
+ /**
100
+ * Time-to-first-byte deadline in milliseconds (connect + response headers).
101
+ * Defaults to {@link UPSTREAM_HEADERS_TIMEOUT_MS}.
102
+ */
103
+ headersTimeoutMs?: number;
104
+ /**
105
+ * Optional external abort signal. When it fires the in-flight request is
106
+ * destroyed.
107
+ */
108
+ signal?: AbortSignal;
109
+ }
110
+ /** The validated, non-redirect response returned by {@link safeFetch}. */
111
+ export interface SafeFetchResult {
112
+ /**
113
+ * The first non-redirect response. The caller OWNS draining/destroying this
114
+ * stream (stream it to the client, or buffer a bounded prefix, then destroy).
115
+ */
116
+ response: IncomingMessage;
117
+ /** The HTTP status code of the response. */
118
+ status: number;
119
+ /** Response headers. */
120
+ headers: IncomingHttpHeaders;
121
+ /** The final, post-redirect URL that produced the response. */
122
+ finalUrl: string;
123
+ }
124
+ /**
125
+ * SSRF-safe HTTP(S) fetch. Validates the URL (and every redirect hop) against
126
+ * the private/metadata-range denylist, pins the connection to the validated IP,
127
+ * follows a bounded number of redirects (destroying redirect bodies), and
128
+ * returns the first non-redirect response.
129
+ *
130
+ * The caller owns the returned response stream — drain or destroy it.
131
+ *
132
+ * @throws {SsrfRejection} when any hop targets a blocked address/host/port.
133
+ * @throws {UpstreamError} on redirect-loop / malformed-redirect / timeout.
134
+ */
135
+ export declare function safeFetch(rawUrl: string, options?: SafeFetchOptions): Promise<SafeFetchResult>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Constant-time secret comparison for Oxy backends.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Backends kept comparing secrets with `provided !== EXPECTED`. A plain `===`/
7
+ * `!==` short-circuits on the first differing byte, leaking timing information
8
+ * an attacker can use to recover a secret byte-by-byte. This helper performs a
9
+ * constant-time comparison via `crypto.timingSafeEqual`, guarded by a length
10
+ * check, and never throws — replacing the `token !== SECRET` pattern (Alia
11
+ * docker-host / integrations webhook secrets, internal webhook bearers, etc.).
12
+ *
13
+ * Node-only (`node:crypto`); exported solely from `@oxyhq/core/server`.
14
+ */
15
+ /**
16
+ * Compare two secrets in constant time.
17
+ *
18
+ * Returns `true` iff both are non-empty strings of equal byte length with
19
+ * identical contents. Returns `false` — without throwing — when either value is
20
+ * not a string, when lengths differ, or when contents differ.
21
+ *
22
+ * The length-equality guard is required because `crypto.timingSafeEqual` throws
23
+ * on unequal-length buffers; comparing lengths first leaks only the LENGTH of
24
+ * the expected secret (already low-value / often public), never its bytes.
25
+ *
26
+ * @param provided - The untrusted, caller-supplied value (e.g. a request token).
27
+ * @param expected - The trusted secret to compare against.
28
+ */
29
+ export declare function verifySecret(provided: string, expected: string): boolean;
@@ -20,10 +20,9 @@
20
20
  * - SSR / non-browser (no `window`).
21
21
  * - `localhost`, `127.0.0.1`, IPv4/IPv6 literals.
22
22
  * - Hostnames with fewer than two labels.
23
- * - Hostnames whose trailing two labels form a known multi-part public
24
- * suffix (e.g. `co.uk`), where the naive `labels.slice(-2)` apex would be
25
- * an attacker-registrable suffix like `auth.co.uk` rather than the real
26
- * registrable domain.
23
+ * - Hostnames where a registrable domain cannot be determined from the
24
+ * Public Suffix List, including private hosted suffixes such as
25
+ * `github.io`, `pages.dev`, and `netlify.app`.
27
26
  *
28
27
  * When the page is already loaded ON the IdP itself (`auth.<anything>`),
29
28
  * the helper returns the current origin so the SDK keeps everything
@@ -35,21 +34,8 @@
35
34
  * is required for end-to-end FedCM correctness — no per-RP config.
36
35
  */
37
36
  /**
38
- * Known multi-part public suffixes where the registrable domain is the LAST
39
- * THREE labels, not two. Deriving an apex from `labels.slice(-2)` against any
40
- * of these would yield an attacker-registrable suffix (e.g. `auth.co.uk`),
41
- * so we bail out instead.
42
- *
43
- * This is intentionally a small, explicit allow-list rather than the full
44
- * Public Suffix List — it covers the suffixes the Oxy ecosystem's RPs use.
45
- * Any multi-part-TLD RP MUST extend this set (or wire in a proper PSL check)
46
- * before relying on this helper, otherwise auto-detection silently bails to
47
- * `undefined` and the consumer must pass `authWebUrl` explicitly.
48
- */
49
- export declare const MULTIPART_TLDS: ReadonlySet<string>;
50
- /**
51
- * Compute the bare registrable apex (eTLD+1) of a hostname, guarding against
52
- * multi-part public suffixes.
37
+ * Compute the bare registrable apex (eTLD+1) of a hostname using the Public
38
+ * Suffix List, including private hosted suffixes.
53
39
  *
54
40
  * This is the pure host-handling kernel shared by {@link autoDetectAuthWebUrl}
55
41
  * and the IdP worker — it performs NO protocol handling, NO `auth.` prefixing,
@@ -61,10 +47,7 @@ export declare const MULTIPART_TLDS: ReadonlySet<string>;
61
47
  * - IPv4 literals (`192.168.1.10`);
62
48
  * - IPv6 literals or any host carrying a port (`[::1]`, anything with `:`);
63
49
  * - single-label hosts (`intranet`, `localhost`);
64
- * - hosts whose trailing two labels form a known multi-part public suffix
65
- * (e.g. `foo.co.uk`), where `labels.slice(-2)` would yield an
66
- * attacker-registrable suffix (`co.uk`) rather than a real registrable
67
- * domain. Such hosts MUST configure `authWebUrl` explicitly.
50
+ * - public suffixes without a registrable label (e.g. `co.uk`, `github.io`).
68
51
  *
69
52
  * @param hostname - A bare hostname (no scheme), e.g. `www.mention.earth`.
70
53
  * @returns The eTLD+1 (`mention.earth`), or `null` when undefinable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.9.1",
3
+ "version": "3.10.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -105,6 +105,7 @@
105
105
  "invariant": "^2.2.4",
106
106
  "jwt-decode": "^4.0.0",
107
107
  "socket.io-client": "^4.8.1",
108
+ "tldts": "^7.0.22",
108
109
  "zod": "^3.25.64"
109
110
  },
110
111
  "peerDependencies": {
@@ -0,0 +1,96 @@
1
+ import { OxyServices } from '../OxyServices';
2
+
3
+ function jsonResponse(data: unknown): Response {
4
+ return new Response(JSON.stringify({ data }), {
5
+ status: 200,
6
+ headers: { 'content-type': 'application/json' },
7
+ });
8
+ }
9
+
10
+ function createJwt(payload: Record<string, unknown>): string {
11
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
12
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.forged-signature`;
13
+ }
14
+
15
+ async function runAuthSocket(oxy: OxyServices, token: string) {
16
+ const socket: {
17
+ handshake: { auth: { token: string } };
18
+ data?: Record<string, unknown>;
19
+ user?: { id: string; userId: string; sessionId?: string | null };
20
+ } = { handshake: { auth: { token } } };
21
+ let nextError: Error | undefined;
22
+
23
+ await oxy.authSocket()(socket, (err?: Error) => {
24
+ nextError = err;
25
+ });
26
+
27
+ return { socket, nextError };
28
+ }
29
+
30
+ describe('authSocket', () => {
31
+ const originalFetch = globalThis.fetch;
32
+
33
+ afterEach(() => {
34
+ globalThis.fetch = originalFetch;
35
+ jest.restoreAllMocks();
36
+ });
37
+
38
+ it('rejects decoded JWT payloads that do not include a server-validated session', async () => {
39
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
40
+ const fetchMock = jest.fn();
41
+ globalThis.fetch = fetchMock;
42
+
43
+ const { socket, nextError } = await runAuthSocket(oxy, createJwt({
44
+ userId: 'victimUserId',
45
+ exp: 4102444800,
46
+ }));
47
+
48
+ expect(nextError?.message).toBe('Session required');
49
+ expect(fetchMock).not.toHaveBeenCalled();
50
+ expect(socket.data?.userId).toBeUndefined();
51
+ expect(socket.user).toBeUndefined();
52
+ });
53
+
54
+ it('rejects tokens whose decoded user does not match the validated session user', async () => {
55
+ globalThis.fetch = async () =>
56
+ jsonResponse({
57
+ valid: true,
58
+ expiresAt: '2099-01-01T00:00:00.000Z',
59
+ lastActivity: '2026-06-24T00:00:00.000Z',
60
+ user: { id: 'realUserId', username: 'real', publicKey: 'pub_1' },
61
+ });
62
+
63
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
64
+ const { socket, nextError } = await runAuthSocket(oxy, createJwt({
65
+ userId: 'victimUserId',
66
+ sessionId: 'session_1',
67
+ exp: 4102444800,
68
+ }));
69
+
70
+ expect(nextError?.message).toBe('Session user mismatch');
71
+ expect(socket.data?.userId).toBeUndefined();
72
+ expect(socket.user).toBeUndefined();
73
+ });
74
+
75
+ it('attaches the validated session user when the decoded user matches', async () => {
76
+ globalThis.fetch = async () =>
77
+ jsonResponse({
78
+ valid: true,
79
+ expiresAt: '2099-01-01T00:00:00.000Z',
80
+ lastActivity: '2026-06-24T00:00:00.000Z',
81
+ user: { id: 'user_1', username: 'nate', publicKey: 'pub_1' },
82
+ });
83
+
84
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
85
+ const { socket, nextError } = await runAuthSocket(oxy, createJwt({
86
+ userId: 'user_1',
87
+ sessionId: 'session_1',
88
+ exp: 4102444800,
89
+ }));
90
+
91
+ expect(nextError).toBeUndefined();
92
+ expect(socket.data?.userId).toBe('user_1');
93
+ expect(socket.data?.sessionId).toBe('session_1');
94
+ expect(socket.user).toEqual({ id: 'user_1', userId: 'user_1', sessionId: 'session_1' });
95
+ });
96
+ });
@@ -129,6 +129,15 @@
129
129
  "title": "Reputation = Trust & Growth",
130
130
  "body": "Oxy Trust is a reputation system that reacts to what you do. Helpful, respectful, constructive actions earn it. Harmful or low‑effort stuff chips it away. More reputation can unlock benefits; low reputation can limit features. It keeps things fair and rewards real contribution."
131
131
  },
132
+ "name": {
133
+ "title": "What's your name?",
134
+ "body": "Add your name so people know who you are.",
135
+ "firstLabel": "First name",
136
+ "firstPlaceholder": "Your first name",
137
+ "lastLabel": "Last name",
138
+ "lastPlaceholder": "Your last name",
139
+ "saveFailed": "Could not save your name"
140
+ },
132
141
  "avatar": {
133
142
  "title": "Make It Yours",
134
143
  "body": "Add an avatar so people recognize you. It will show anywhere you show up here. Skip if you want — you can add it later.",
@@ -849,6 +849,15 @@
849
849
  "title": "Reputación = Confianza y crecimiento",
850
850
  "body": "Oxy Trust es un sistema de reputación que reacciona a lo que haces. Las acciones útiles, respetuosas y constructivas la aumentan. Las acciones dañinas o de poco esfuerzo la reducen. Más reputación puede desbloquear beneficios; poca reputación puede limitar funciones. Mantiene la justicia y recompensa la contribución real."
851
851
  },
852
+ "name": {
853
+ "title": "¿Cuál es tu nombre?",
854
+ "body": "Añade tu nombre para que la gente sepa quién eres.",
855
+ "firstLabel": "Nombre",
856
+ "firstPlaceholder": "Tu nombre",
857
+ "lastLabel": "Apellidos",
858
+ "lastPlaceholder": "Tus apellidos",
859
+ "saveFailed": "No se pudo guardar tu nombre"
860
+ },
852
861
  "avatar": {
853
862
  "title": "Hazlo tuyo",
854
863
  "body": "Añade un avatar para que te reconozcan. Se mostrará donde aparezcas aquí. Puedes omitirlo — puedes añadirlo más tarde.",
package/src/index.ts CHANGED
@@ -462,7 +462,7 @@ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
462
462
  // ---------------------------------------------------------------------------
463
463
  // Cross-domain SSO infrastructure
464
464
  // ---------------------------------------------------------------------------
465
- export { autoDetectAuthWebUrl, registrableApex, MULTIPART_TLDS } from './utils/fapiAutoDetect';
465
+ export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect';
466
466
 
467
467
  // Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
468
468
  export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl';
@@ -1,5 +1,11 @@
1
1
  import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
+ import { isReactNative } from '../utils/platform';
4
+
5
+ interface FileDownloadUrlOptions {
6
+ /** Omit bearer access tokens from generated URLs, even when authenticated. */
7
+ omitToken?: boolean;
8
+ }
3
9
 
4
10
  export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
5
11
  return class extends Base {
@@ -44,8 +50,13 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
44
50
  *
45
51
  * For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
46
52
  */
47
- getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string {
48
- const token = this.getClient().getAccessToken();
53
+ getFileDownloadUrl(
54
+ fileId: string,
55
+ variant?: string,
56
+ expiresIn?: number,
57
+ options: FileDownloadUrlOptions = {}
58
+ ): string {
59
+ const token = options.omitToken ? undefined : this.getClient().getAccessToken();
49
60
 
50
61
  // Public case: no auth token and no expiry requested → clean CDN URL.
51
62
  // CloudFront serves the public media origin under `${cloudURL}/<id>`.
@@ -212,10 +223,33 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
212
223
  } else if (typeof Blob !== 'undefined' && file instanceof Blob) {
213
224
  formData.append('file', file, fileName);
214
225
  } else if ('uri' in file && typeof (file as RNFileDescriptor).uri === 'string') {
215
- // React Native file descriptor — RN's FormData handles {uri, type, name} natively.
216
- // It reads the file from disk during the multipart request — no in-JS Blob
217
- // conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
218
- formData.append('file', file as unknown as Blob, fileName);
226
+ const descriptor = file as RNFileDescriptor;
227
+
228
+ if (isReactNative()) {
229
+ // React Native file descriptor RN's FormData handles {uri, type, name} natively.
230
+ // It reads the file from disk during the multipart request — no in-JS Blob
231
+ // conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
232
+ formData.append('file', descriptor as unknown as Blob, fileName);
233
+ } else {
234
+ // Web (browser/Node): the browser's FormData cannot read bytes from a plain
235
+ // { uri } object — it would serialize "[object Object]" and the server would
236
+ // store a 0-byte asset. Materialize the uri into a real Blob first. `fetch`
237
+ // resolves blob:, data:, and http(s): uris on web, so all picker outputs work.
238
+ const res = await fetch(descriptor.uri);
239
+ if (!res.ok) {
240
+ throw new Error(`Failed to read file from uri (status ${res.status})`);
241
+ }
242
+ const fetched = await res.blob();
243
+ // Preserve the descriptor's declared MIME type when the fetched blob has none.
244
+ const blob =
245
+ fetched.type === '' && descriptor.type
246
+ ? new Blob([fetched], { type: descriptor.type })
247
+ : fetched;
248
+ if (blob.size === 0) {
249
+ throw new Error('Cannot upload an empty file');
250
+ }
251
+ formData.append('file', blob, fileName);
252
+ }
219
253
  } else {
220
254
  throw new Error('Unsupported file input: expected File, Blob, or { uri, type?, name?, size? } descriptor');
221
255
  }