@oxyhq/core 10.2.0 → 11.0.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/boot/sessionColdBoot.js +3 -3
- package/dist/cjs/crypto/keyManager.js +68 -69
- package/dist/cjs/crypto/signatureService.js +2 -2
- package/dist/cjs/index.js +17 -20
- package/dist/cjs/logger/index.js +149 -0
- package/dist/cjs/mixins/OxyServices.assets.js +4 -4
- package/dist/cjs/mixins/OxyServices.auth.js +104 -2
- package/dist/cjs/mixins/OxyServices.language.js +3 -5
- package/dist/cjs/mixins/OxyServices.privacy.js +2 -4
- package/dist/cjs/mixins/OxyServices.security.js +7 -11
- package/dist/cjs/mixins/OxyServices.topics.js +25 -15
- package/dist/cjs/mixins/OxyServices.user.js +4 -4
- package/dist/cjs/mixins/OxyServices.utility.js +18 -18
- package/dist/cjs/session/SessionClient.js +17 -17
- package/dist/cjs/session/accountDialogController.js +12 -12
- package/dist/cjs/session/authStateStore.js +5 -5
- package/dist/cjs/session/refresh.js +4 -4
- package/dist/cjs/session/socketLoader.js +2 -2
- package/dist/cjs/utils/asyncUtils.js +2 -2
- package/dist/cjs/utils/avatarUtils.js +2 -1
- package/dist/cjs/utils/deviceManager.js +7 -5
- package/dist/cjs/utils/errorUtils.js +3 -3
- package/dist/cjs/utils/oauthPkce.js +3 -3
- package/dist/cjs/utils/requestUtils.js +1 -1
- package/dist/cjs/utils/webauthnOrigin.js +51 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +1 -1
- package/dist/esm/crypto/keyManager.js +1 -2
- package/dist/esm/crypto/signatureService.js +1 -1
- package/dist/esm/index.js +6 -3
- package/dist/esm/logger/index.js +140 -0
- package/dist/esm/mixins/OxyServices.assets.js +2 -2
- package/dist/esm/mixins/OxyServices.auth.js +103 -1
- package/dist/esm/mixins/OxyServices.language.js +3 -5
- package/dist/esm/mixins/OxyServices.privacy.js +2 -4
- package/dist/esm/mixins/OxyServices.security.js +7 -11
- package/dist/esm/mixins/OxyServices.topics.js +25 -15
- package/dist/esm/mixins/OxyServices.user.js +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +1 -1
- package/dist/esm/session/SessionClient.js +1 -1
- package/dist/esm/session/accountDialogController.js +1 -1
- package/dist/esm/session/authStateStore.js +1 -1
- package/dist/esm/session/refresh.js +1 -1
- package/dist/esm/session/socketLoader.js +1 -1
- package/dist/esm/utils/asyncUtils.js +1 -1
- package/dist/esm/utils/avatarUtils.js +2 -1
- package/dist/esm/utils/deviceManager.js +7 -5
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/esm/utils/oauthPkce.js +1 -1
- package/dist/esm/utils/requestUtils.js +1 -1
- package/dist/esm/utils/webauthnOrigin.js +48 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -4
- package/dist/types/logger/index.d.ts +104 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +58 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +3 -3
- package/dist/types/models/Topic.d.ts +10 -0
- package/dist/types/utils/requestUtils.d.ts +1 -1
- package/dist/types/utils/webauthnOrigin.d.ts +32 -0
- package/package.json +17 -2
- package/src/boot/sessionColdBoot.ts +1 -1
- package/src/crypto/keyManager.ts +1 -2
- package/src/crypto/signatureService.ts +1 -1
- package/src/index.ts +22 -19
- package/src/logger/__tests__/logger.test.ts +207 -0
- package/src/logger/index.ts +217 -0
- package/src/mixins/OxyServices.assets.ts +3 -3
- package/src/mixins/OxyServices.auth.ts +134 -1
- package/src/mixins/OxyServices.language.ts +3 -5
- package/src/mixins/OxyServices.privacy.ts +2 -4
- package/src/mixins/OxyServices.security.ts +7 -11
- package/src/mixins/OxyServices.topics.ts +47 -17
- package/src/mixins/OxyServices.user.ts +1 -1
- package/src/mixins/OxyServices.utility.ts +1 -1
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +1 -1
- package/src/mixins/__tests__/topics.test.ts +156 -0
- package/src/mixins/__tests__/webauthnAuth.test.ts +206 -0
- package/src/models/Topic.ts +11 -0
- package/src/session/SessionClient.ts +1 -1
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +1 -1
- package/src/session/__tests__/accountDialogController.test.ts +1 -1
- package/src/session/accountDialogController.ts +1 -1
- package/src/session/authStateStore.ts +1 -1
- package/src/session/refresh.ts +1 -1
- package/src/session/socketLoader.ts +1 -1
- package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
- package/src/utils/asyncUtils.ts +1 -1
- package/src/utils/avatarUtils.ts +3 -1
- package/src/utils/deviceManager.ts +8 -5
- package/src/utils/errorUtils.ts +1 -1
- package/src/utils/oauthPkce.ts +1 -1
- package/src/utils/requestUtils.ts +1 -1
- package/src/utils/webauthnOrigin.ts +52 -0
- package/dist/cjs/shared/utils/debugUtils.js +0 -80
- package/dist/cjs/utils/loggerUtils.js +0 -126
- package/dist/esm/shared/utils/debugUtils.js +0 -72
- package/dist/esm/utils/loggerUtils.js +0 -115
- package/dist/types/shared/utils/debugUtils.d.ts +0 -48
- package/dist/types/utils/loggerUtils.d.ts +0 -48
- package/src/shared/utils/__tests__/debugUtils.test.ts +0 -55
- package/src/shared/utils/debugUtils.ts +0 -78
- package/src/utils/loggerUtils.ts +0 -153
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topics mixin envelope-unwrapping tests.
|
|
3
|
+
*
|
|
4
|
+
* oxy-api returns ENVELOPES for the list/collection topic routes
|
|
5
|
+
* (`{ topics, total, limit, offset }`, `{ categories }`, `{ topics }`,
|
|
6
|
+
* `{ topics: <name-keyed map> }`), while the SDK's `unwrapResponse` only unwraps
|
|
7
|
+
* the `{ data }` success shape. These tests pin the mixin's own unwrapping so the
|
|
8
|
+
* public methods genuinely return `TopicData[]` / the pagination envelope — not a
|
|
9
|
+
* raw wrapper that a caller would `.map` over and crash on.
|
|
10
|
+
*
|
|
11
|
+
* They drive the REAL `makeRequest` path by mocking `globalThis.fetch`, so the
|
|
12
|
+
* `unwrapResponse` pass-through (no `data` key) is exercised end to end.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { OxyServices } from '../../OxyServices';
|
|
16
|
+
|
|
17
|
+
/** A raw JSON `Response` — the body is exactly what the topic routes return. */
|
|
18
|
+
function rawResponse(body: unknown): Response {
|
|
19
|
+
return new Response(JSON.stringify(body), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A non-verified JWT whose payload decodes to the given claims. Puts the SDK in
|
|
27
|
+
* an authenticated state so the `POST /topics/resolve` carries a bearer header
|
|
28
|
+
* and skips the CSRF-token pre-fetch (which would otherwise consume a mock).
|
|
29
|
+
*/
|
|
30
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
31
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
32
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
33
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
34
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const topic = (id: string, name: string, extra: Record<string, unknown> = {}) => ({
|
|
38
|
+
_id: id,
|
|
39
|
+
name,
|
|
40
|
+
slug: name,
|
|
41
|
+
displayName: name,
|
|
42
|
+
description: '',
|
|
43
|
+
type: 'topic',
|
|
44
|
+
source: 'ai',
|
|
45
|
+
aliases: [],
|
|
46
|
+
isActive: true,
|
|
47
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
48
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
49
|
+
...extra,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('topics mixin envelope unwrapping', () => {
|
|
53
|
+
let originalFetch: typeof globalThis.fetch;
|
|
54
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
55
|
+
let oxy: OxyServices;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
originalFetch = globalThis.fetch;
|
|
59
|
+
fetchMock = jest.fn();
|
|
60
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
61
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid', enableRetry: false });
|
|
62
|
+
// Authenticate so the resolve POST skips the CSRF pre-fetch. Harmless for GETs.
|
|
63
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
globalThis.fetch = originalFetch;
|
|
68
|
+
jest.clearAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('getTopicCategories', () => {
|
|
72
|
+
it('unwraps { categories } into a TopicData[]', async () => {
|
|
73
|
+
fetchMock.mockResolvedValueOnce(
|
|
74
|
+
rawResponse({ categories: [topic('c1', 'tech', { type: 'category' }), topic('c2', 'sports', { type: 'category' })] }),
|
|
75
|
+
);
|
|
76
|
+
const result = await oxy.getTopicCategories();
|
|
77
|
+
expect(Array.isArray(result)).toBe(true);
|
|
78
|
+
expect(result.map((t) => t._id)).toEqual(['c1', 'c2']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns [] when the envelope omits categories', async () => {
|
|
82
|
+
fetchMock.mockResolvedValueOnce(rawResponse({}));
|
|
83
|
+
await expect(oxy.getTopicCategories()).resolves.toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('searchTopics', () => {
|
|
88
|
+
it('unwraps { topics } into a TopicData[]', async () => {
|
|
89
|
+
fetchMock.mockResolvedValueOnce(rawResponse({ topics: [topic('t1', 'react'), topic('t2', 'reactivity')] }));
|
|
90
|
+
const result = await oxy.searchTopics('react');
|
|
91
|
+
expect(result.map((t) => t.name)).toEqual(['react', 'reactivity']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns [] when the envelope omits topics', async () => {
|
|
95
|
+
fetchMock.mockResolvedValueOnce(rawResponse({}));
|
|
96
|
+
await expect(oxy.searchTopics('nothing')).resolves.toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('listTopics', () => {
|
|
101
|
+
it('returns the full pagination envelope', async () => {
|
|
102
|
+
fetchMock.mockResolvedValueOnce(
|
|
103
|
+
rawResponse({ topics: [topic('t1', 'a'), topic('t2', 'b')], total: 42, limit: 2, offset: 10 }),
|
|
104
|
+
);
|
|
105
|
+
const result = await oxy.listTopics({ limit: 2, offset: 10 });
|
|
106
|
+
expect(result.topics.map((t) => t._id)).toEqual(['t1', 't2']);
|
|
107
|
+
expect(result.total).toBe(42);
|
|
108
|
+
expect(result.limit).toBe(2);
|
|
109
|
+
expect(result.offset).toBe(10);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('falls back to the requested limit/offset and topics.length when the server omits them', async () => {
|
|
113
|
+
fetchMock.mockResolvedValueOnce(rawResponse({ topics: [topic('t1', 'a')] }));
|
|
114
|
+
const result = await oxy.listTopics({ limit: 5, offset: 3 });
|
|
115
|
+
expect(result.topics).toHaveLength(1);
|
|
116
|
+
expect(result.total).toBe(1);
|
|
117
|
+
expect(result.limit).toBe(5);
|
|
118
|
+
expect(result.offset).toBe(3);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns an empty envelope when the server returns nothing useful', async () => {
|
|
122
|
+
fetchMock.mockResolvedValueOnce(rawResponse({}));
|
|
123
|
+
const result = await oxy.listTopics();
|
|
124
|
+
expect(result).toEqual({ topics: [], total: 0, limit: 0, offset: 0 });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('resolveTopicNames', () => {
|
|
129
|
+
it('flattens the name-keyed { topics } map into a TopicData[]', async () => {
|
|
130
|
+
// POST /topics/resolve returns a Record keyed by lowercased name, NOT an array.
|
|
131
|
+
fetchMock.mockResolvedValueOnce(
|
|
132
|
+
rawResponse({ topics: { react: topic('t1', 'react'), vue: topic('t2', 'vue') } }),
|
|
133
|
+
);
|
|
134
|
+
const result = await oxy.resolveTopicNames([
|
|
135
|
+
{ name: 'react', type: 'topic' },
|
|
136
|
+
{ name: 'vue', type: 'topic' },
|
|
137
|
+
]);
|
|
138
|
+
expect(Array.isArray(result)).toBe(true);
|
|
139
|
+
expect(result.map((t) => t.name).sort()).toEqual(['react', 'vue']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns [] when the map is empty or missing', async () => {
|
|
143
|
+
fetchMock.mockResolvedValueOnce(rawResponse({ topics: {} }));
|
|
144
|
+
await expect(oxy.resolveTopicNames([])).resolves.toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('getTopicBySlug (single, no envelope)', () => {
|
|
149
|
+
it('returns the raw TopicData unchanged', async () => {
|
|
150
|
+
fetchMock.mockResolvedValueOnce(rawResponse(topic('t1', 'react')));
|
|
151
|
+
const result = await oxy.getTopicBySlug('react');
|
|
152
|
+
expect(result._id).toBe('t1');
|
|
153
|
+
expect(result.name).toBe('react');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn / passkey auth methods on OxyServices
|
|
3
|
+
* (`webauthnRegisterOptions` / `webauthnRegisterVerify` /
|
|
4
|
+
* `webauthnLoginOptions` / `webauthnLoginVerify`).
|
|
5
|
+
*
|
|
6
|
+
* Stubs `makeRequest` (HTTP-mock style, like `passwordSignIn.test.ts`) and
|
|
7
|
+
* asserts: each method hits the right endpoint with the right body; the opaque
|
|
8
|
+
* ceremony `response` is forwarded verbatim; the login/verify + register/verify
|
|
9
|
+
* (signup) paths parse the login contract and plant the access token on the
|
|
10
|
+
* session arm; register/verify (link) returns `{ success, message }` WITHOUT
|
|
11
|
+
* planting a token.
|
|
12
|
+
*/
|
|
13
|
+
import type { LoginResult } from '@oxyhq/contracts';
|
|
14
|
+
import { OxyServices } from '../../OxyServices';
|
|
15
|
+
|
|
16
|
+
const SESSION_ARM: LoginResult = {
|
|
17
|
+
sessionId: 'sess-1',
|
|
18
|
+
deviceId: 'dev-1',
|
|
19
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
20
|
+
accessToken: 'access-1',
|
|
21
|
+
deviceSecret: 'ds-secret-1',
|
|
22
|
+
user: { id: 'user-1', username: 'u' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const LINK_RESULT = { success: true as const, message: 'Passkey registered successfully' };
|
|
26
|
+
|
|
27
|
+
// Opaque browser ceremony payloads — Oxy never inspects these, so shape is
|
|
28
|
+
// irrelevant beyond "forwarded verbatim under `response`".
|
|
29
|
+
const CREATE_RESPONSE = { id: 'cred-abc', rawId: 'cred-abc', response: { attestationObject: 'ao' }, type: 'public-key' };
|
|
30
|
+
const GET_RESPONSE = { id: 'cred-abc', rawId: 'cred-abc', response: { signature: 'sig' }, type: 'public-key' };
|
|
31
|
+
|
|
32
|
+
describe('webauthnRegisterOptions', () => {
|
|
33
|
+
let oxy: OxyServices;
|
|
34
|
+
let makeRequest: jest.SpyInstance;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
38
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => jest.restoreAllMocks());
|
|
41
|
+
|
|
42
|
+
it('POSTs to /auth/webauthn/register/options with the username', async () => {
|
|
43
|
+
const options = { challenge: 'c', rp: { id: 'oxy.so' } };
|
|
44
|
+
makeRequest.mockResolvedValueOnce(options);
|
|
45
|
+
const result = await oxy.webauthnRegisterOptions('alice');
|
|
46
|
+
expect(result).toBe(options);
|
|
47
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
48
|
+
'POST',
|
|
49
|
+
'/auth/webauthn/register/options',
|
|
50
|
+
{ username: 'alice' },
|
|
51
|
+
{ cache: false },
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('omits username from the body when not provided (link flow)', async () => {
|
|
56
|
+
makeRequest.mockResolvedValueOnce({ challenge: 'c' });
|
|
57
|
+
await oxy.webauthnRegisterOptions();
|
|
58
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
59
|
+
'POST',
|
|
60
|
+
'/auth/webauthn/register/options',
|
|
61
|
+
{},
|
|
62
|
+
{ cache: false },
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('webauthnLoginOptions', () => {
|
|
68
|
+
let oxy: OxyServices;
|
|
69
|
+
let makeRequest: jest.SpyInstance;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
73
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => jest.restoreAllMocks());
|
|
76
|
+
|
|
77
|
+
it('POSTs to /auth/webauthn/login/options with the username (username-first)', async () => {
|
|
78
|
+
const options = { challenge: 'c', allowCredentials: [{ id: 'cred-abc' }] };
|
|
79
|
+
makeRequest.mockResolvedValueOnce(options);
|
|
80
|
+
const result = await oxy.webauthnLoginOptions('alice');
|
|
81
|
+
expect(result).toBe(options);
|
|
82
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
83
|
+
'POST',
|
|
84
|
+
'/auth/webauthn/login/options',
|
|
85
|
+
{ username: 'alice' },
|
|
86
|
+
{ cache: false },
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('omits username for the usernameless / discoverable flow', async () => {
|
|
91
|
+
makeRequest.mockResolvedValueOnce({ challenge: 'c', allowCredentials: [] });
|
|
92
|
+
await oxy.webauthnLoginOptions();
|
|
93
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
94
|
+
'POST',
|
|
95
|
+
'/auth/webauthn/login/options',
|
|
96
|
+
{},
|
|
97
|
+
{ cache: false },
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('webauthnRegisterVerify', () => {
|
|
103
|
+
let oxy: OxyServices;
|
|
104
|
+
let makeRequest: jest.SpyInstance;
|
|
105
|
+
let setTokens: jest.SpyInstance;
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
109
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
110
|
+
setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
|
|
111
|
+
});
|
|
112
|
+
afterEach(() => jest.restoreAllMocks());
|
|
113
|
+
|
|
114
|
+
it('signup branch: parses LoginSessionResult, plants the token, threads the envelope', async () => {
|
|
115
|
+
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
116
|
+
const result = await oxy.webauthnRegisterVerify(CREATE_RESPONSE, {
|
|
117
|
+
username: 'alice',
|
|
118
|
+
deviceName: 'Phone',
|
|
119
|
+
deviceFingerprint: 'fp-1',
|
|
120
|
+
});
|
|
121
|
+
expect(result).toEqual(SESSION_ARM);
|
|
122
|
+
expect('twoFactorRequired' in result).toBe(false);
|
|
123
|
+
if (!('twoFactorRequired' in result) && 'sessionId' in result) {
|
|
124
|
+
expect(result.deviceId).toBe('dev-1');
|
|
125
|
+
expect(result.deviceSecret).toBe('ds-secret-1');
|
|
126
|
+
}
|
|
127
|
+
expect(setTokens).toHaveBeenCalledWith('access-1');
|
|
128
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
129
|
+
'POST',
|
|
130
|
+
'/auth/webauthn/register/verify',
|
|
131
|
+
{ response: CREATE_RESPONSE, username: 'alice', deviceName: 'Phone', deviceFingerprint: 'fp-1' },
|
|
132
|
+
{ cache: false },
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('link branch: returns { success, message } WITHOUT planting a token', async () => {
|
|
137
|
+
makeRequest.mockResolvedValueOnce(LINK_RESULT);
|
|
138
|
+
const result = await oxy.webauthnRegisterVerify(CREATE_RESPONSE, { deviceName: 'Laptop' });
|
|
139
|
+
expect(result).toEqual(LINK_RESULT);
|
|
140
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
141
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
142
|
+
'POST',
|
|
143
|
+
'/auth/webauthn/register/verify',
|
|
144
|
+
{ response: CREATE_RESPONSE, deviceName: 'Laptop' },
|
|
145
|
+
{ cache: false },
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws on an unexpected response shape', async () => {
|
|
150
|
+
makeRequest.mockResolvedValueOnce({ nope: true });
|
|
151
|
+
await expect(oxy.webauthnRegisterVerify(CREATE_RESPONSE)).rejects.toThrow();
|
|
152
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('webauthnLoginVerify', () => {
|
|
157
|
+
let oxy: OxyServices;
|
|
158
|
+
let makeRequest: jest.SpyInstance;
|
|
159
|
+
let setTokens: jest.SpyInstance;
|
|
160
|
+
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
163
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
164
|
+
setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
|
|
165
|
+
});
|
|
166
|
+
afterEach(() => jest.restoreAllMocks());
|
|
167
|
+
|
|
168
|
+
it('parses LoginSessionResult, plants the token, and threads the device envelope', async () => {
|
|
169
|
+
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
170
|
+
const result = await oxy.webauthnLoginVerify(GET_RESPONSE, {
|
|
171
|
+
deviceName: 'Phone',
|
|
172
|
+
deviceFingerprint: 'fp-1',
|
|
173
|
+
deviceId: 'dev-persisted',
|
|
174
|
+
});
|
|
175
|
+
expect(result).toEqual(SESSION_ARM);
|
|
176
|
+
expect('twoFactorRequired' in result).toBe(false);
|
|
177
|
+
if (!('twoFactorRequired' in result)) {
|
|
178
|
+
expect(result.deviceId).toBe('dev-1');
|
|
179
|
+
expect(result.deviceSecret).toBe('ds-secret-1');
|
|
180
|
+
}
|
|
181
|
+
expect(setTokens).toHaveBeenCalledWith('access-1');
|
|
182
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
183
|
+
'POST',
|
|
184
|
+
'/auth/webauthn/login/verify',
|
|
185
|
+
{ response: GET_RESPONSE, deviceName: 'Phone', deviceFingerprint: 'fp-1', deviceId: 'dev-persisted' },
|
|
186
|
+
{ cache: false },
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('POSTs the bare response body when no envelope is passed', async () => {
|
|
191
|
+
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
192
|
+
await oxy.webauthnLoginVerify(GET_RESPONSE);
|
|
193
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
194
|
+
'POST',
|
|
195
|
+
'/auth/webauthn/login/verify',
|
|
196
|
+
{ response: GET_RESPONSE },
|
|
197
|
+
{ cache: false },
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('throws on an unexpected response shape', async () => {
|
|
202
|
+
makeRequest.mockResolvedValueOnce({ nope: true });
|
|
203
|
+
await expect(oxy.webauthnLoginVerify(GET_RESPONSE)).rejects.toThrow();
|
|
204
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/models/Topic.ts
CHANGED
|
@@ -33,3 +33,14 @@ export interface TopicData {
|
|
|
33
33
|
createdAt: string;
|
|
34
34
|
updatedAt: string;
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Paginated result of {@link OxyServices.listTopics}. Mirrors the `GET /topics`
|
|
39
|
+
* envelope so callers keep access to `total`/`offset` for pagination.
|
|
40
|
+
*/
|
|
41
|
+
export interface TopicListResult {
|
|
42
|
+
topics: TopicData[];
|
|
43
|
+
total: number;
|
|
44
|
+
limit: number;
|
|
45
|
+
offset: number;
|
|
46
|
+
}
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
sessionAccountsChangedEventSchema,
|
|
7
7
|
type DeviceSessionState,
|
|
8
8
|
} from '@oxyhq/contracts';
|
|
9
|
-
import { logger } from '../
|
|
9
|
+
import { logger } from '../logger';
|
|
10
10
|
import { getSocketIO } from './socketLoader';
|
|
11
11
|
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
12
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
2
|
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
3
|
-
import { logger } from '../../
|
|
3
|
+
import { logger } from '../../logger';
|
|
4
4
|
|
|
5
5
|
const STATE = (rev: number): DeviceSessionState => ({
|
|
6
6
|
deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000,
|
|
@@ -5,7 +5,7 @@ import type { SessionLoginResponse, MinimalUserData } from '../../models/session
|
|
|
5
5
|
import type { AccountNode } from '../../mixins/OxyServices.accounts';
|
|
6
6
|
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
7
7
|
import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
|
|
8
|
-
import { logger } from '../../
|
|
8
|
+
import { logger } from '../../logger';
|
|
9
9
|
import {
|
|
10
10
|
AccountDialogController,
|
|
11
11
|
createAccountDialogController,
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import type { OxyServices } from '../OxyServices';
|
|
30
30
|
import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
31
31
|
import type { User } from '../models/interfaces';
|
|
32
|
-
import { logger } from '../
|
|
32
|
+
import { logger } from '../logger';
|
|
33
33
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
34
34
|
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
|
|
35
35
|
import {
|
package/src/session/refresh.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
|
|
|
26
26
|
import type { AuthStateStore, PersistedAuthState } from './authStateStore';
|
|
27
27
|
import { isNative } from '../utils/platform';
|
|
28
28
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
29
|
-
import { logger } from '../
|
|
29
|
+
import { logger } from '../logger';
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Lead time (ms) before access-token expiry at which the proactive scheduler
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `isOxyRpOrigin()` — client-side WebAuthn relying-party origin guard.
|
|
3
|
+
*
|
|
4
|
+
* Runs under the `node` test environment, where `globalThis.location` is
|
|
5
|
+
* genuinely absent by default (mirroring native/SSR). Each case defines a fake
|
|
6
|
+
* `location` with the host under test and restores the original afterwards.
|
|
7
|
+
*/
|
|
8
|
+
import { isOxyRpOrigin } from '../webauthnOrigin';
|
|
9
|
+
|
|
10
|
+
describe('isOxyRpOrigin', () => {
|
|
11
|
+
const originalLocation = globalThis.location;
|
|
12
|
+
|
|
13
|
+
const setHostname = (hostname: unknown): void => {
|
|
14
|
+
Object.defineProperty(globalThis, 'location', {
|
|
15
|
+
configurable: true,
|
|
16
|
+
value: hostname === undefined ? undefined : { hostname },
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
Object.defineProperty(globalThis, 'location', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
value: originalLocation,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns true on the apex oxy.so', () => {
|
|
28
|
+
setHostname('oxy.so');
|
|
29
|
+
expect(isOxyRpOrigin()).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns true on an oxy.so subdomain', () => {
|
|
33
|
+
setHostname('accounts.oxy.so');
|
|
34
|
+
expect(isOxyRpOrigin()).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns true on a deep oxy.so subdomain', () => {
|
|
38
|
+
setHostname('sub.accounts.oxy.so');
|
|
39
|
+
expect(isOxyRpOrigin()).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns true on loopback hosts', () => {
|
|
43
|
+
for (const host of ['localhost', '127.0.0.1', '[::1]']) {
|
|
44
|
+
setHostname(host);
|
|
45
|
+
expect(isOxyRpOrigin()).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is case-insensitive on the host', () => {
|
|
50
|
+
setHostname('Accounts.OXY.So');
|
|
51
|
+
expect(isOxyRpOrigin()).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns false on an unrelated origin', () => {
|
|
55
|
+
setHostname('evil.com');
|
|
56
|
+
expect(isOxyRpOrigin()).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns false on a look-alike suffix without the dot boundary', () => {
|
|
60
|
+
setHostname('evil-oxy.so');
|
|
61
|
+
expect(isOxyRpOrigin()).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns false when oxy.so is only a subdomain label of an attacker apex', () => {
|
|
65
|
+
setHostname('oxy.so.evil.com');
|
|
66
|
+
expect(isOxyRpOrigin()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns false when there is no location (native / SSR)', () => {
|
|
70
|
+
setHostname(undefined);
|
|
71
|
+
expect(isOxyRpOrigin()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns false when the hostname is not a string', () => {
|
|
75
|
+
setHostname(123);
|
|
76
|
+
expect(isOxyRpOrigin()).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns false when the hostname is empty', () => {
|
|
80
|
+
setHostname('');
|
|
81
|
+
expect(isOxyRpOrigin()).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/utils/asyncUtils.ts
CHANGED
package/src/utils/avatarUtils.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { logger } from '../logger';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Minimal interface for services that can update asset visibility.
|
|
3
5
|
* Kept loose to avoid mixin type-inference issues with the OxyServices class.
|
|
@@ -31,7 +33,7 @@ export async function updateAvatarVisibility(
|
|
|
31
33
|
? (visError as Error & { status: number }).status
|
|
32
34
|
: undefined;
|
|
33
35
|
if (status !== 404) {
|
|
34
|
-
|
|
36
|
+
logger.error(`[${contextName}] Failed to update avatar visibility for ${fileId}`, visError, { component: contextName });
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { loadAsyncStorage } from '@oxyhq/protocol';
|
|
2
|
+
import { createLogger } from '../logger';
|
|
3
|
+
|
|
4
|
+
const log = createLogger('DeviceManager');
|
|
2
5
|
|
|
3
6
|
export interface DeviceFingerprint {
|
|
4
7
|
userAgent: string;
|
|
@@ -55,7 +58,7 @@ export class DeviceManager {
|
|
|
55
58
|
removeItem: storage.removeItem.bind(storage),
|
|
56
59
|
};
|
|
57
60
|
} catch (error) {
|
|
58
|
-
|
|
61
|
+
log.error('AsyncStorage not available in React Native', error);
|
|
59
62
|
throw new Error('AsyncStorage is required in React Native environment');
|
|
60
63
|
}
|
|
61
64
|
} else {
|
|
@@ -112,7 +115,7 @@ export class DeviceManager {
|
|
|
112
115
|
// Create new device info
|
|
113
116
|
return await this.createNewDeviceInfo();
|
|
114
117
|
} catch (error) {
|
|
115
|
-
|
|
118
|
+
log.error('Error getting device info', error);
|
|
116
119
|
return await this.createNewDeviceInfo();
|
|
117
120
|
}
|
|
118
121
|
}
|
|
@@ -140,7 +143,7 @@ export class DeviceManager {
|
|
|
140
143
|
const storage = await this.getStorage();
|
|
141
144
|
await storage.setItem(this.DEVICE_KEY, JSON.stringify(deviceInfo));
|
|
142
145
|
} catch (error) {
|
|
143
|
-
|
|
146
|
+
log.error('Error saving device info', error);
|
|
144
147
|
}
|
|
145
148
|
}
|
|
146
149
|
|
|
@@ -153,7 +156,7 @@ export class DeviceManager {
|
|
|
153
156
|
deviceInfo.deviceName = deviceName;
|
|
154
157
|
await this.saveDeviceInfo(deviceInfo);
|
|
155
158
|
} catch (error) {
|
|
156
|
-
|
|
159
|
+
log.error('Error updating device name', error);
|
|
157
160
|
}
|
|
158
161
|
}
|
|
159
162
|
|
|
@@ -165,7 +168,7 @@ export class DeviceManager {
|
|
|
165
168
|
const storage = await this.getStorage();
|
|
166
169
|
await storage.removeItem(this.DEVICE_KEY);
|
|
167
170
|
} catch (error) {
|
|
168
|
-
|
|
171
|
+
log.error('Error clearing device info', error);
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
|
package/src/utils/errorUtils.ts
CHANGED
package/src/utils/oauthPkce.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { isNodeJS, isReactNative, loadExpoCrypto, loadNodeCrypto, sha256 } from '@oxyhq/protocol';
|
|
20
|
-
import { logger } from '
|
|
20
|
+
import { logger } from '../logger';
|
|
21
21
|
|
|
22
22
|
/** The central Oxy IdP authorization endpoint used by default. */
|
|
23
23
|
export const OXY_AUTHORIZE_URL = 'https://auth.oxy.so/authorize';
|
|
@@ -169,7 +169,7 @@ export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
|
|
|
169
169
|
* Simple logger with level support
|
|
170
170
|
*
|
|
171
171
|
* Lightweight logger for HTTP clients and utilities.
|
|
172
|
-
* For
|
|
172
|
+
* For structured, namespaced, level-gated logging, use `@oxyhq/core/logger`.
|
|
173
173
|
*
|
|
174
174
|
* @example
|
|
175
175
|
* ```typescript
|