@oxyhq/core 3.10.0 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/i18n/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/es-ES.json +9 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
- package/dist/cjs/index.js +2 -3
- package/dist/cjs/mixins/OxyServices.assets.js +29 -6
- package/dist/cjs/mixins/OxyServices.utility.js +52 -23
- package/dist/cjs/utils/fapiAutoDetect.js +12 -42
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/i18n/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/es-ES.json +9 -0
- package/dist/esm/i18n/locales/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.assets.js +29 -6
- package/dist/esm/mixins/OxyServices.utility.js +52 -23
- package/dist/esm/utils/fapiAutoDetect.js +12 -41
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
- package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
- package/package.json +2 -1
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/i18n/locales/en-US.json +9 -0
- package/src/i18n/locales/es-ES.json +9 -0
- package/src/index.ts +1 -1
- package/src/mixins/OxyServices.assets.ts +40 -6
- package/src/mixins/OxyServices.utility.ts +57 -23
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +30 -2
- package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
- package/src/utils/fapiAutoDetect.ts +12 -39
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
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`.
|
|
229
|
-
*
|
|
230
|
-
* the app
|
|
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.
|
|
@@ -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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
-
* -
|
|
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.10.
|
|
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
|
|
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(
|
|
48
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
}
|
|
@@ -856,8 +856,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
856
856
|
return next(new Error('Invalid token'));
|
|
857
857
|
}
|
|
858
858
|
|
|
859
|
-
const
|
|
860
|
-
if (!
|
|
859
|
+
const claimedUserId = decoded.userId || decoded.id;
|
|
860
|
+
if (!claimedUserId) {
|
|
861
861
|
return next(new Error('Invalid token payload'));
|
|
862
862
|
}
|
|
863
863
|
|
|
@@ -866,24 +866,39 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
866
866
|
return next(new Error('Token expired'));
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
}, validateErr);
|
|
884
|
-
}
|
|
885
|
-
return next(new Error('Session validation failed'));
|
|
869
|
+
// A server-validated session is mandatory. A bare decoded JWT proves
|
|
870
|
+
// nothing — the signature is not verified here, so without a session
|
|
871
|
+
// round-trip a forged token could claim any user id.
|
|
872
|
+
if (!decoded.sessionId) {
|
|
873
|
+
return next(new Error('Session required'));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
let userId = claimedUserId;
|
|
877
|
+
try {
|
|
878
|
+
const result = await oxyInstance.validateSession(decoded.sessionId, {
|
|
879
|
+
useHeaderValidation: true,
|
|
880
|
+
});
|
|
881
|
+
if (!result || !result.valid || !result.user) {
|
|
882
|
+
return next(new Error('Session invalid'));
|
|
886
883
|
}
|
|
884
|
+
|
|
885
|
+
// The session is the source of truth. The client-claimed user id
|
|
886
|
+
// must match the server-validated identity, otherwise a valid
|
|
887
|
+
// session could be paired with a forged user id.
|
|
888
|
+
const validatedUserId = getUserIdentityId(result.user);
|
|
889
|
+
if (!validatedUserId || validatedUserId !== claimedUserId) {
|
|
890
|
+
return next(new Error('Session user mismatch'));
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
userId = validatedUserId;
|
|
894
|
+
} catch (validateErr) {
|
|
895
|
+
if (debug) {
|
|
896
|
+
logger.debug('[oxy.authSocket] Session validation failed', {
|
|
897
|
+
component: 'auth',
|
|
898
|
+
method: 'authSocket',
|
|
899
|
+
}, validateErr);
|
|
900
|
+
}
|
|
901
|
+
return next(new Error('Session validation failed'));
|
|
887
902
|
}
|
|
888
903
|
|
|
889
904
|
// Attach user data to socket. We expose BOTH `socket.data.userId`
|
|
@@ -953,9 +968,9 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
953
968
|
* Express.js middleware that enforces a specific service-token scope.
|
|
954
969
|
*
|
|
955
970
|
* Mount AFTER `auth()` / `serviceAuth()` — relies on `req.serviceApp` and
|
|
956
|
-
* (when delegation is in effect) `req.serviceActingAs.scopes`.
|
|
957
|
-
*
|
|
958
|
-
* the app
|
|
971
|
+
* (when delegation is in effect) `req.serviceActingAs.scopes`. App-only
|
|
972
|
+
* service requests require the app scope. Delegated user requests require
|
|
973
|
+
* BOTH the app scope and the per-user delegation scope.
|
|
959
974
|
*
|
|
960
975
|
* Requests authenticated as a regular user (no service token) are rejected
|
|
961
976
|
* with 403 — scope-protected endpoints are service-to-service by design.
|
|
@@ -988,7 +1003,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
988
1003
|
return;
|
|
989
1004
|
}
|
|
990
1005
|
|
|
991
|
-
|
|
1006
|
+
const appHasScope = appScopes.includes(scope);
|
|
1007
|
+
const delegationHasScope = delegatedScopes.includes(scope);
|
|
1008
|
+
const hasRequiredScope = req.serviceActingAs
|
|
1009
|
+
? appHasScope && delegationHasScope
|
|
1010
|
+
: appHasScope;
|
|
1011
|
+
|
|
1012
|
+
if (hasRequiredScope) {
|
|
992
1013
|
next();
|
|
993
1014
|
return;
|
|
994
1015
|
}
|
|
@@ -1051,6 +1072,19 @@ async function verifyServiceTokenSignature(token: string, secret: string): Promi
|
|
|
1051
1072
|
* access token signed by the same shared secret could be replayed as a
|
|
1052
1073
|
* service token because no claim binding existed.
|
|
1053
1074
|
*/
|
|
1075
|
+
/**
|
|
1076
|
+
* Resolve the canonical user id from a validated session's user object.
|
|
1077
|
+
*
|
|
1078
|
+
* The API serializer emits `id`, but some upstream shapes carry the raw Mongo
|
|
1079
|
+
* `_id` instead. We accept either, but only a non-empty string — anything else
|
|
1080
|
+
* means the validated identity is unusable and the caller must reject.
|
|
1081
|
+
*/
|
|
1082
|
+
function getUserIdentityId(user: User): string | null {
|
|
1083
|
+
const candidate = (user as { id?: unknown; _id?: unknown }).id
|
|
1084
|
+
?? (user as { id?: unknown; _id?: unknown })._id;
|
|
1085
|
+
return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1054
1088
|
function verifyServiceTokenClaims(
|
|
1055
1089
|
decoded: JwtPayload,
|
|
1056
1090
|
expected: { audience: string; issuer: string },
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OxyServices.assetUpload()` multipart-body tests.
|
|
3
|
+
*
|
|
4
|
+
* `assetUpload` accepts three input shapes: a web `File`, a web `Blob`, or a
|
|
5
|
+
* React Native `{ uri, type?, name?, size? }` descriptor. The descriptor path
|
|
6
|
+
* is platform-sensitive:
|
|
7
|
+
*
|
|
8
|
+
* - React Native — RN's FormData reads the file from disk via the uri during
|
|
9
|
+
* the multipart request, so the descriptor is appended as-is.
|
|
10
|
+
* - Web (browser/Node) — the browser's FormData CANNOT read bytes from a plain
|
|
11
|
+
* `{ uri }` object (it would serialize `[object Object]` → the server stores
|
|
12
|
+
* a 0-byte asset). The uri must be materialized into a real `Blob` via
|
|
13
|
+
* `fetch` before appending. An empty fetched blob must throw instead of
|
|
14
|
+
* silently uploading an empty asset.
|
|
15
|
+
*
|
|
16
|
+
* These tests assert exactly which value lands in the FormData `file` part for
|
|
17
|
+
* each platform, and that an empty web source is rejected.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { OxyServices } from '../../OxyServices';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Captures every `FormData.append` call so a test can inspect the multipart body
|
|
24
|
+
* that `assetUpload` built without sending a real network request.
|
|
25
|
+
*/
|
|
26
|
+
function captureUpload(oxy: OxyServices) {
|
|
27
|
+
const appended: Array<{ name: string; value: unknown; fileName?: string }> = [];
|
|
28
|
+
// Capture-only: do NOT delegate to the real (undici) FormData.append. Node's
|
|
29
|
+
// undici rejects a plain { uri } object as not-a-Blob, but real React Native
|
|
30
|
+
// FormData accepts it — the test asserts on captured args, not a built body.
|
|
31
|
+
const appendSpy = jest
|
|
32
|
+
.spyOn(FormData.prototype, 'append')
|
|
33
|
+
.mockImplementation(function (this: FormData, name: string, value: unknown, fileName?: string) {
|
|
34
|
+
appended.push({ name, value, fileName });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const requestSpy = jest
|
|
38
|
+
.spyOn(oxy.getClient(), 'request')
|
|
39
|
+
.mockResolvedValue({ file: { id: 'asset123' } } as never);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
appended,
|
|
43
|
+
requestSpy,
|
|
44
|
+
restore: () => {
|
|
45
|
+
appendSpy.mockRestore();
|
|
46
|
+
requestSpy.mockRestore();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('OxyServices.assetUpload — uri descriptor', () => {
|
|
52
|
+
const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
|
|
53
|
+
const originalFetch = globalThis.fetch;
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
jest.restoreAllMocks();
|
|
57
|
+
if (originalNavigator === undefined) {
|
|
58
|
+
delete (globalThis as { navigator?: unknown }).navigator;
|
|
59
|
+
} else {
|
|
60
|
+
(globalThis as { navigator?: unknown }).navigator = originalNavigator;
|
|
61
|
+
}
|
|
62
|
+
globalThis.fetch = originalFetch;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('web (NOT React Native)', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
// Node/jsdom-like: no React Native navigator → isReactNative() === false.
|
|
68
|
+
delete (globalThis as { navigator?: unknown }).navigator;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('materializes a blob: uri into a real, non-empty Blob before appending', async () => {
|
|
72
|
+
const bytes = new Blob([new Uint8Array([1, 2, 3, 4, 5])], { type: 'image/png' });
|
|
73
|
+
const fetchMock = jest
|
|
74
|
+
.fn()
|
|
75
|
+
.mockResolvedValue({ ok: true, status: 200, blob: async () => bytes });
|
|
76
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
77
|
+
|
|
78
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
79
|
+
const capture = captureUpload(oxy);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await oxy.assetUpload({ uri: 'blob:https://app.test/abc', type: 'image/png', name: 'avatar.png' });
|
|
83
|
+
|
|
84
|
+
expect(fetchMock).toHaveBeenCalledWith('blob:https://app.test/abc');
|
|
85
|
+
|
|
86
|
+
const filePart = capture.appended.find((p) => p.name === 'file');
|
|
87
|
+
expect(filePart).toBeDefined();
|
|
88
|
+
// The appended value is the fetched Blob with real bytes — NOT the { uri } object.
|
|
89
|
+
expect(filePart?.value).toBeInstanceOf(Blob);
|
|
90
|
+
expect((filePart?.value as Blob).size).toBe(5);
|
|
91
|
+
expect((filePart?.value as { uri?: string }).uri).toBeUndefined();
|
|
92
|
+
expect(filePart?.fileName).toBe('avatar.png');
|
|
93
|
+
} finally {
|
|
94
|
+
capture.restore();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('wraps a typeless fetched blob with the descriptor MIME type', async () => {
|
|
99
|
+
const typeless = new Blob([new Uint8Array([9, 9, 9])]); // type === ''
|
|
100
|
+
const fetchMock = jest
|
|
101
|
+
.fn()
|
|
102
|
+
.mockResolvedValue({ ok: true, status: 200, blob: async () => typeless });
|
|
103
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
104
|
+
|
|
105
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
106
|
+
const capture = captureUpload(oxy);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await oxy.assetUpload({ uri: 'data:application/octet-stream;base64,CQkJ', type: 'image/jpeg', name: 'x.jpg' });
|
|
110
|
+
|
|
111
|
+
const filePart = capture.appended.find((p) => p.name === 'file');
|
|
112
|
+
expect(filePart?.value).toBeInstanceOf(Blob);
|
|
113
|
+
expect((filePart?.value as Blob).size).toBe(3);
|
|
114
|
+
expect((filePart?.value as Blob).type).toBe('image/jpeg');
|
|
115
|
+
} finally {
|
|
116
|
+
capture.restore();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('throws "Cannot upload an empty file" when the fetched blob is empty', async () => {
|
|
121
|
+
const empty = new Blob([], { type: 'image/png' });
|
|
122
|
+
const fetchMock = jest
|
|
123
|
+
.fn()
|
|
124
|
+
.mockResolvedValue({ ok: true, status: 200, blob: async () => empty });
|
|
125
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
126
|
+
|
|
127
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
128
|
+
const capture = captureUpload(oxy);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await expect(
|
|
132
|
+
oxy.assetUpload({ uri: 'blob:https://app.test/empty', type: 'image/png', name: 'empty.png' }),
|
|
133
|
+
).rejects.toThrow('Cannot upload an empty file');
|
|
134
|
+
|
|
135
|
+
// Nothing was sent — the empty source surfaces instead of creating a 0-byte asset.
|
|
136
|
+
expect(capture.requestSpy).not.toHaveBeenCalled();
|
|
137
|
+
} finally {
|
|
138
|
+
capture.restore();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('throws when the uri cannot be fetched (non-ok response)', async () => {
|
|
143
|
+
const fetchMock = jest
|
|
144
|
+
.fn()
|
|
145
|
+
.mockResolvedValue({ ok: false, status: 404, blob: async () => new Blob([]) });
|
|
146
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
147
|
+
|
|
148
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
149
|
+
const capture = captureUpload(oxy);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await expect(
|
|
153
|
+
oxy.assetUpload({ uri: 'https://cdn.test/missing.png', type: 'image/png', name: 'missing.png' }),
|
|
154
|
+
).rejects.toThrow('Failed to read file from uri (status 404)');
|
|
155
|
+
expect(capture.requestSpy).not.toHaveBeenCalled();
|
|
156
|
+
} finally {
|
|
157
|
+
capture.restore();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('React Native', () => {
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
// Make isReactNative() === true: navigator.product === 'ReactNative'.
|
|
165
|
+
(globalThis as { navigator?: unknown }).navigator = { product: 'ReactNative' };
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('appends the descriptor as-is and never calls fetch', async () => {
|
|
169
|
+
const fetchMock = jest.fn();
|
|
170
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
171
|
+
|
|
172
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
173
|
+
const capture = captureUpload(oxy);
|
|
174
|
+
|
|
175
|
+
const descriptor = { uri: 'file:///tmp/avatar.png', type: 'image/png', name: 'avatar.png', size: 1024 };
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await oxy.assetUpload(descriptor);
|
|
179
|
+
|
|
180
|
+
// RN path: the raw descriptor object lands in the multipart body unchanged.
|
|
181
|
+
const filePart = capture.appended.find((p) => p.name === 'file');
|
|
182
|
+
expect(filePart?.value).toBe(descriptor);
|
|
183
|
+
expect(filePart?.fileName).toBe('avatar.png');
|
|
184
|
+
// No in-JS materialization on RN — FormData reads the file from the uri.
|
|
185
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
186
|
+
} finally {
|
|
187
|
+
capture.restore();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -49,6 +49,19 @@ describe('OxyServices.getFileDownloadUrl', () => {
|
|
|
49
49
|
'https://cloud.oxy.so/a%2Fb%20c?variant=large%20size',
|
|
50
50
|
);
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
it('can omit the token for persisted public image URLs while authenticated', () => {
|
|
54
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
55
|
+
oxy.setTokens('access-token-abc');
|
|
56
|
+
|
|
57
|
+
const url = oxy.getFileDownloadUrl('file123', 'thumb', undefined, {
|
|
58
|
+
omitToken: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(url).toBe('https://cloud.oxy.so/file123?variant=thumb');
|
|
62
|
+
expect(url).not.toContain('access-token-abc');
|
|
63
|
+
expect(url).not.toContain('token=');
|
|
64
|
+
});
|
|
52
65
|
});
|
|
53
66
|
|
|
54
67
|
describe('signed / private assets → authenticated API origin', () => {
|