@oxyhq/core 10.2.0 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +13 -21
- package/dist/cjs/logger/index.js +149 -0
- package/dist/cjs/mixins/OxyServices.assets.js +4 -4
- package/dist/cjs/mixins/OxyServices.auth.js +2 -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/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 +2 -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 +1 -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/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +3 -4
- package/dist/types/logger/index.d.ts +104 -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/package.json +16 -1
- 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 +17 -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 +1 -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/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/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/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,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @oxyhq/core/logger — the ecosystem-wide logging chokepoint.
|
|
3
|
+
*
|
|
4
|
+
* A tiny, dependency-free, universal logger that works unchanged in React
|
|
5
|
+
* Native, browsers, Node, and Bun. Every Oxy app and package should log
|
|
6
|
+
* through here instead of calling `console.*` directly, so that level,
|
|
7
|
+
* formatting, and transport are controlled in ONE place.
|
|
8
|
+
*
|
|
9
|
+
* Design goals:
|
|
10
|
+
* - Zero dependencies. No Node-only imports, no `process.stdout` assumptions.
|
|
11
|
+
* - Four levels (`debug` | `info` | `warn` | `error`) plus `silent`.
|
|
12
|
+
* - Namespaced child loggers via `createLogger('mention:feed')` / `.child()`.
|
|
13
|
+
* - Structured context objects carried through to the sink.
|
|
14
|
+
* - A single pluggable sink so backends can pipe to pino / CloudWatch / etc.
|
|
15
|
+
* without touching any call site.
|
|
16
|
+
* - Debug is off in production by default (`__DEV__` on RN, `NODE_ENV`
|
|
17
|
+
* elsewhere); override globally with `configureLogger({ level })`.
|
|
18
|
+
*
|
|
19
|
+
* @module logger
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/* global __DEV__ */
|
|
23
|
+
declare const __DEV__: boolean | undefined;
|
|
24
|
+
|
|
25
|
+
/** Ordered severity levels. `silent` disables all output. */
|
|
26
|
+
export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
|
|
27
|
+
|
|
28
|
+
/** The levels that actually emit an entry (everything but `silent`). */
|
|
29
|
+
export type EmittableLogLevel = Exclude<LogLevel, 'silent'>;
|
|
30
|
+
|
|
31
|
+
const LEVEL_WEIGHT: Record<LogLevel, number> = {
|
|
32
|
+
silent: 0,
|
|
33
|
+
error: 1,
|
|
34
|
+
warn: 2,
|
|
35
|
+
info: 3,
|
|
36
|
+
debug: 4,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Structured, JSON-friendly context attached to a log line. The well-known
|
|
41
|
+
* keys are optional hints; arbitrary keys are allowed so callers can attach
|
|
42
|
+
* whatever structured fields their sink cares about.
|
|
43
|
+
*/
|
|
44
|
+
export interface LogContext {
|
|
45
|
+
component?: string;
|
|
46
|
+
method?: string;
|
|
47
|
+
userId?: string;
|
|
48
|
+
sessionId?: string;
|
|
49
|
+
requestId?: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A single normalized log record handed to the active sink. */
|
|
54
|
+
export interface LogEntry {
|
|
55
|
+
level: EmittableLogLevel;
|
|
56
|
+
message: string;
|
|
57
|
+
/** Colon-joined namespace, e.g. `mention:feed`. Absent for the root logger. */
|
|
58
|
+
namespace?: string;
|
|
59
|
+
/** Merged structured context (logger base context + per-call context). */
|
|
60
|
+
context?: LogContext;
|
|
61
|
+
/** The error value passed to `.error(message, error, …)`, if any. */
|
|
62
|
+
error?: unknown;
|
|
63
|
+
/** Extra variadic args passed after the context. */
|
|
64
|
+
args: unknown[];
|
|
65
|
+
/** ISO-8601 timestamp of when the entry was created. */
|
|
66
|
+
timestamp: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A transport that receives every emitted (level-passing) entry. */
|
|
70
|
+
export type LogSink = (entry: LogEntry) => void;
|
|
71
|
+
|
|
72
|
+
/** Global logger configuration. */
|
|
73
|
+
export interface LoggerConfig {
|
|
74
|
+
level: LogLevel;
|
|
75
|
+
sink: LogSink;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A namespaced logger. Instances are cheap; derive children with `.child()`. */
|
|
79
|
+
export interface Logger {
|
|
80
|
+
/** The logger's colon-joined namespace, if any. */
|
|
81
|
+
readonly namespace?: string;
|
|
82
|
+
debug(message: string, context?: LogContext, ...args: unknown[]): void;
|
|
83
|
+
info(message: string, context?: LogContext, ...args: unknown[]): void;
|
|
84
|
+
warn(message: string, context?: LogContext, ...args: unknown[]): void;
|
|
85
|
+
error(message: string, error?: unknown, context?: LogContext, ...args: unknown[]): void;
|
|
86
|
+
/** Derive a child logger with an extended namespace and merged base context. */
|
|
87
|
+
child(namespace: string, context?: LogContext): Logger;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True in development. Uses React Native's `__DEV__` when present, otherwise
|
|
92
|
+
* falls back to `process.env.NODE_ENV === 'development'`. Never throws.
|
|
93
|
+
*/
|
|
94
|
+
export function isDev(): boolean {
|
|
95
|
+
if (typeof __DEV__ !== 'undefined') return __DEV__ === true;
|
|
96
|
+
try {
|
|
97
|
+
return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatPrefix(entry: LogEntry): string {
|
|
104
|
+
const parts = [entry.timestamp, entry.level.toUpperCase()];
|
|
105
|
+
if (entry.namespace) parts.push(`[${entry.namespace}]`);
|
|
106
|
+
return parts.join(' ');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default sink: routes each entry to the matching `console` method. `info` and
|
|
111
|
+
* `debug` go to `console.log` (RN/browser-safe — `console.debug`/`console.info`
|
|
112
|
+
* are inconsistently surfaced across runtimes).
|
|
113
|
+
*/
|
|
114
|
+
export const consoleSink: LogSink = (entry) => {
|
|
115
|
+
const extras: unknown[] = [];
|
|
116
|
+
if (entry.context && Object.keys(entry.context).length > 0) extras.push(entry.context);
|
|
117
|
+
if (entry.error !== undefined) extras.push(entry.error);
|
|
118
|
+
if (entry.args.length > 0) extras.push(...entry.args);
|
|
119
|
+
|
|
120
|
+
const line = `${formatPrefix(entry)} ${entry.message}`;
|
|
121
|
+
if (entry.level === 'error') console.error(line, ...extras);
|
|
122
|
+
else if (entry.level === 'warn') console.warn(line, ...extras);
|
|
123
|
+
else console.log(line, ...extras);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function defaultLevel(): LogLevel {
|
|
127
|
+
return isDev() ? 'debug' : 'info';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const config: LoggerConfig = {
|
|
131
|
+
level: defaultLevel(),
|
|
132
|
+
sink: consoleSink,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Update the global logger configuration. Affects every logger instance
|
|
137
|
+
* (root and children) immediately. Pass `{ level }` to gate output and/or
|
|
138
|
+
* `{ sink }` to redirect transport (pino, CloudWatch, a test capture, …).
|
|
139
|
+
*/
|
|
140
|
+
export function configureLogger(partial: Partial<LoggerConfig>): void {
|
|
141
|
+
if (partial.level !== undefined) config.level = partial.level;
|
|
142
|
+
if (partial.sink !== undefined) config.sink = partial.sink;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read a snapshot of the current global configuration. */
|
|
146
|
+
export function getLoggerConfig(): Readonly<LoggerConfig> {
|
|
147
|
+
return { level: config.level, sink: config.sink };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Restore the default level (env-derived) and the console sink. */
|
|
151
|
+
export function resetLoggerConfig(): void {
|
|
152
|
+
config.level = defaultLevel();
|
|
153
|
+
config.sink = consoleSink;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function mergeContext(base?: LogContext, extra?: LogContext): LogContext | undefined {
|
|
157
|
+
if (!base) return extra;
|
|
158
|
+
if (!extra) return base;
|
|
159
|
+
return { ...base, ...extra };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function emit(
|
|
163
|
+
level: EmittableLogLevel,
|
|
164
|
+
namespace: string | undefined,
|
|
165
|
+
baseContext: LogContext | undefined,
|
|
166
|
+
message: string,
|
|
167
|
+
error: unknown,
|
|
168
|
+
context: LogContext | undefined,
|
|
169
|
+
args: unknown[],
|
|
170
|
+
): void {
|
|
171
|
+
if (LEVEL_WEIGHT[level] > LEVEL_WEIGHT[config.level]) return;
|
|
172
|
+
config.sink({
|
|
173
|
+
level,
|
|
174
|
+
message,
|
|
175
|
+
namespace,
|
|
176
|
+
context: mergeContext(baseContext, context),
|
|
177
|
+
error,
|
|
178
|
+
args,
|
|
179
|
+
timestamp: new Date().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function makeLogger(namespace: string | undefined, baseContext: LogContext | undefined): Logger {
|
|
184
|
+
return {
|
|
185
|
+
namespace,
|
|
186
|
+
debug: (message, context, ...args) =>
|
|
187
|
+
emit('debug', namespace, baseContext, message, undefined, context, args),
|
|
188
|
+
info: (message, context, ...args) =>
|
|
189
|
+
emit('info', namespace, baseContext, message, undefined, context, args),
|
|
190
|
+
warn: (message, context, ...args) =>
|
|
191
|
+
emit('warn', namespace, baseContext, message, undefined, context, args),
|
|
192
|
+
error: (message, error, context, ...args) =>
|
|
193
|
+
emit('error', namespace, baseContext, message, error, context, args),
|
|
194
|
+
child: (childNamespace, childContext) =>
|
|
195
|
+
makeLogger(
|
|
196
|
+
namespace ? `${namespace}:${childNamespace}` : childNamespace,
|
|
197
|
+
mergeContext(baseContext, childContext),
|
|
198
|
+
),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The shared root logger. Prefer a namespaced `createLogger(...)` in modules. */
|
|
203
|
+
export const logger: Logger = makeLogger(undefined, undefined);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Create a namespaced logger.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* const log = createLogger('mention:feed');
|
|
211
|
+
* log.info('loaded', { count: 20 });
|
|
212
|
+
* const sub = log.child('prefetch'); // namespace → 'mention:feed:prefetch'
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export function createLogger(namespace?: string, context?: LogContext): Logger {
|
|
216
|
+
return makeLogger(namespace, context);
|
|
217
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
3
|
import { isReactNative } from '@oxyhq/protocol';
|
|
4
|
-
import { logger } from '../
|
|
4
|
+
import { logger } from '../logger';
|
|
5
5
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -428,8 +428,8 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
428
428
|
|
|
429
429
|
return response;
|
|
430
430
|
} catch (error) {
|
|
431
|
-
|
|
432
|
-
|
|
431
|
+
logger.error('File upload error', error, { component: 'OxyServices.assets' });
|
|
432
|
+
|
|
433
433
|
let errorMessage = 'File upload failed';
|
|
434
434
|
|
|
435
435
|
if (error instanceof Error) {
|
|
@@ -13,7 +13,7 @@ import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
|
13
13
|
import { KeyManager } from '../crypto/keyManager';
|
|
14
14
|
import { SignatureService } from '../crypto/signatureService';
|
|
15
15
|
import { loadNodeCrypto } from '@oxyhq/protocol';
|
|
16
|
-
import { logger } from '../
|
|
16
|
+
import { logger } from '../logger';
|
|
17
17
|
import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -5,7 +5,7 @@ import { normalizeLocale, getPrimaryLanguage, getLanguageMetadata, getLanguageNa
|
|
|
5
5
|
import type { SupportedLanguage } from '../utils/languageUtils';
|
|
6
6
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
7
7
|
import { loadAsyncStorage } from '@oxyhq/protocol';
|
|
8
|
-
import {
|
|
8
|
+
import { logger } from '../logger';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Cross-mixin surface consumed by the language methods. `getCurrentUser` is
|
|
@@ -48,7 +48,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
48
48
|
removeItem: storage.removeItem.bind(storage),
|
|
49
49
|
};
|
|
50
50
|
} catch (error) {
|
|
51
|
-
|
|
51
|
+
logger.error('AsyncStorage not available in React Native', error, { component: 'OxyServices.language' });
|
|
52
52
|
throw new Error('AsyncStorage is required in React Native environment');
|
|
53
53
|
}
|
|
54
54
|
} else {
|
|
@@ -103,9 +103,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
103
103
|
|
|
104
104
|
return null;
|
|
105
105
|
} catch (error) {
|
|
106
|
-
|
|
107
|
-
console.warn('Failed to get current language:', error);
|
|
108
|
-
}
|
|
106
|
+
logger.warn('Failed to get current language', { component: 'OxyServices.language' }, error);
|
|
109
107
|
return null;
|
|
110
108
|
}
|
|
111
109
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { BlockedUser, RestrictedUser } from '../models/interfaces';
|
|
5
5
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
6
|
-
import {
|
|
6
|
+
import { logger } from '../logger';
|
|
7
7
|
|
|
8
8
|
export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
9
9
|
return class extends Base {
|
|
@@ -36,9 +36,7 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
36
36
|
});
|
|
37
37
|
} catch (error) {
|
|
38
38
|
// If there's an error, assume not in list to avoid breaking functionality
|
|
39
|
-
|
|
40
|
-
console.warn('Error checking user list:', error);
|
|
41
|
-
}
|
|
39
|
+
logger.warn('Error checking user list', { component: 'OxyServices.privacy' }, error);
|
|
42
40
|
return false;
|
|
43
41
|
}
|
|
44
42
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
5
5
|
import type { SecurityActivity, SecurityActivityResponse, SecurityEventType } from '../models/interfaces';
|
|
6
|
-
import {
|
|
6
|
+
import { logger } from '../logger';
|
|
7
7
|
|
|
8
8
|
export function OxyServicesSecurityMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
9
9
|
return class extends Base {
|
|
@@ -80,11 +80,9 @@ export function OxyServicesSecurityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
80
80
|
{ cache: false }
|
|
81
81
|
);
|
|
82
82
|
} catch (error) {
|
|
83
|
-
// Don't throw - logging failures shouldn't break user flow
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
console.warn('[OxyServices] Failed to log private key exported event:', error);
|
|
87
|
-
}
|
|
83
|
+
// Don't throw - logging failures shouldn't break user flow, but surface
|
|
84
|
+
// for monitoring via the shared logger sink.
|
|
85
|
+
logger.warn('[OxyServices] Failed to log private key exported event', { component: 'OxyServices.security' }, error);
|
|
88
86
|
}
|
|
89
87
|
}
|
|
90
88
|
|
|
@@ -102,11 +100,9 @@ export function OxyServicesSecurityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
102
100
|
{ cache: false }
|
|
103
101
|
);
|
|
104
102
|
} catch (error) {
|
|
105
|
-
// Don't throw - logging failures shouldn't break user flow
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
console.warn('[OxyServices] Failed to log backup created event:', error);
|
|
109
|
-
}
|
|
103
|
+
// Don't throw - logging failures shouldn't break user flow, but surface
|
|
104
|
+
// for monitoring via the shared logger sink.
|
|
105
|
+
logger.warn('[OxyServices] Failed to log backup created event', { component: 'OxyServices.security' }, error);
|
|
110
106
|
}
|
|
111
107
|
}
|
|
112
108
|
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Provides methods for topic discovery and management
|
|
5
5
|
*/
|
|
6
6
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
7
|
-
import type { TopicData, TopicTranslation } from '../models/Topic';
|
|
7
|
+
import type { TopicData, TopicListResult, TopicTranslation } from '../models/Topic';
|
|
8
8
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
9
9
|
|
|
10
10
|
export function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
@@ -22,10 +22,15 @@ export function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
22
22
|
try {
|
|
23
23
|
const params: Record<string, string> = {};
|
|
24
24
|
if (locale) params.locale = locale;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// `GET /topics/categories` returns `{ categories: TopicData[] }` — the
|
|
26
|
+
// SDK's `unwrapResponse` only unwraps `{ data }`, so unwrap here.
|
|
27
|
+
const response = await this.makeRequest<{ categories: TopicData[] }>(
|
|
28
|
+
'GET',
|
|
29
|
+
'/topics/categories',
|
|
30
|
+
params,
|
|
31
|
+
{ cache: true, cacheTTL: CACHE_TIMES.EXTRA_LONG }
|
|
32
|
+
);
|
|
33
|
+
return response.categories ?? [];
|
|
29
34
|
} catch (error) {
|
|
30
35
|
throw this.handleError(error);
|
|
31
36
|
}
|
|
@@ -41,9 +46,14 @@ export function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
41
46
|
try {
|
|
42
47
|
const params: Record<string, string | number> = { q: query };
|
|
43
48
|
if (limit) params.limit = limit;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
// `GET /topics/search` returns `{ topics: TopicData[] }` — unwrap it.
|
|
50
|
+
const response = await this.makeRequest<{ topics: TopicData[] }>(
|
|
51
|
+
'GET',
|
|
52
|
+
'/topics/search',
|
|
53
|
+
params,
|
|
54
|
+
{ cache: false }
|
|
55
|
+
);
|
|
56
|
+
return response.topics ?? [];
|
|
47
57
|
} catch (error) {
|
|
48
58
|
throw this.handleError(error);
|
|
49
59
|
}
|
|
@@ -52,7 +62,7 @@ export function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
52
62
|
/**
|
|
53
63
|
* List topics with optional filters
|
|
54
64
|
* @param options - Filter and pagination options
|
|
55
|
-
* @returns
|
|
65
|
+
* @returns Paginated topics envelope (`topics` plus `total`/`limit`/`offset`)
|
|
56
66
|
*/
|
|
57
67
|
async listTopics(options?: {
|
|
58
68
|
type?: string;
|
|
@@ -60,7 +70,7 @@ export function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
60
70
|
limit?: number;
|
|
61
71
|
offset?: number;
|
|
62
72
|
locale?: string;
|
|
63
|
-
}): Promise<
|
|
73
|
+
}): Promise<TopicListResult> {
|
|
64
74
|
try {
|
|
65
75
|
const params: Record<string, string | number> = {};
|
|
66
76
|
if (options?.type) params.type = options.type;
|
|
@@ -68,10 +78,23 @@ export function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
68
78
|
if (options?.limit) params.limit = options.limit;
|
|
69
79
|
if (options?.offset) params.offset = options.offset;
|
|
70
80
|
if (options?.locale) params.locale = options.locale;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
// `GET /topics` returns `{ topics, total, limit, offset }`. The pagination
|
|
82
|
+
// fields matter to callers, so return the whole envelope (typed) rather
|
|
83
|
+
// than throwing them away.
|
|
84
|
+
const response = await this.makeRequest<Partial<TopicListResult>>(
|
|
85
|
+
'GET',
|
|
86
|
+
'/topics',
|
|
87
|
+
params,
|
|
88
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT }
|
|
89
|
+
);
|
|
90
|
+
const requestedLimit = typeof params.limit === 'number' ? params.limit : 0;
|
|
91
|
+
const requestedOffset = typeof params.offset === 'number' ? params.offset : 0;
|
|
92
|
+
return {
|
|
93
|
+
topics: response.topics ?? [],
|
|
94
|
+
total: response.total ?? response.topics?.length ?? 0,
|
|
95
|
+
limit: response.limit ?? requestedLimit,
|
|
96
|
+
offset: response.offset ?? requestedOffset,
|
|
97
|
+
};
|
|
75
98
|
} catch (error) {
|
|
76
99
|
throw this.handleError(error);
|
|
77
100
|
}
|
|
@@ -102,9 +125,16 @@ export function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
102
125
|
names: Array<{ name: string; type: string }>
|
|
103
126
|
): Promise<TopicData[]> {
|
|
104
127
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
128
|
+
// `POST /topics/resolve` returns `{ topics: Record<name, TopicData> }`
|
|
129
|
+
// (a name-keyed map, not an array). Unwrap and flatten to the resolved
|
|
130
|
+
// topics; each TopicData carries its own `name` for re-keying.
|
|
131
|
+
const response = await this.makeRequest<{ topics: Record<string, TopicData> }>(
|
|
132
|
+
'POST',
|
|
133
|
+
'/topics/resolve',
|
|
134
|
+
{ names },
|
|
135
|
+
{ cache: false }
|
|
136
|
+
);
|
|
137
|
+
return Object.values(response.topics ?? {});
|
|
108
138
|
} catch (error) {
|
|
109
139
|
throw this.handleError(error);
|
|
110
140
|
}
|
|
@@ -22,7 +22,7 @@ import { buildSearchParams, buildPaginationParams, type PaginationParams } from
|
|
|
22
22
|
import { KeyManager } from '../crypto/keyManager';
|
|
23
23
|
import { SignatureService } from '../crypto/signatureService';
|
|
24
24
|
import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
|
|
25
|
-
import { logger } from '../
|
|
25
|
+
import { logger } from '../logger';
|
|
26
26
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -10,7 +10,7 @@ import type { ApiError, User } from '../models/interfaces';
|
|
|
10
10
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
11
11
|
import { loadNodeCrypto } from '@oxyhq/protocol';
|
|
12
12
|
import { buildUrl } from '../utils/apiUtils';
|
|
13
|
-
import { logger } from '../
|
|
13
|
+
import { logger } from '../logger';
|
|
14
14
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
15
15
|
|
|
16
16
|
interface JwtPayload {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { OxyServices } from '../../OxyServices';
|
|
16
|
-
import { logger } from '../../
|
|
16
|
+
import { logger } from '../../logger';
|
|
17
17
|
|
|
18
18
|
/** A JSON success `Response` mimicking the API's `{ data: ... }` envelope. */
|
|
19
19
|
function jsonResponse(data: unknown): Response {
|
|
@@ -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
|
+
});
|
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,
|