@oxyhq/core 1.11.15 → 1.11.17

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.
@@ -28,6 +28,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
28
28
  export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
29
29
  export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount } from './mixins/OxyServices.managedAccounts';
30
30
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse } from './mixins/OxyServices.contacts';
31
+ export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
31
32
  export { KeyManager, SignatureService, RecoveryPhraseService, IdentityAlreadyExistsError, IdentityPersistError, } from './crypto';
32
33
  export type { KeyPair, SignedMessage, AuthChallenge, RecoveryPhraseResult } from './crypto';
33
34
  export * from './models/interfaces';
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Per-user App-Data Mixin
3
+ *
4
+ * Thin client around `/users/me/app-data/...` — a generic per-user JSON KV
5
+ * store on the API. Authenticated callers can read, write, list, and delete
6
+ * entries scoped to their own user account.
7
+ *
8
+ * Identifier rules (must match the API):
9
+ * - Both `namespace` and `key` must match `/^[a-z0-9_-]{1,64}$/u`.
10
+ *
11
+ * Limits (enforced by the API):
12
+ * - Serialized JSON values are capped at 64 KB.
13
+ * - Writes are rate-limited to 100 / minute / user.
14
+ *
15
+ * Intended use cases are small bits of cross-device app state — e.g. Academy
16
+ * course progress, "last viewed" markers, dismissed banner flags. Do not use
17
+ * this for large blobs or anything that needs server-side querying; it's a
18
+ * write-it-and-read-it-back store.
19
+ */
20
+ import type { OxyServicesBase } from '../OxyServices.base';
21
+ /** Thrown when a namespace or key fails the kebab/snake-case validator. */
22
+ export declare class OxyAppDataIdentifierError extends Error {
23
+ constructor(field: 'namespace' | 'key', value: string);
24
+ }
25
+ export declare function OxyServicesAppDataMixin<T extends typeof OxyServicesBase>(Base: T): {
26
+ new (...args: any[]): {
27
+ /**
28
+ * Read the value stored under `(namespace, key)` for the current user.
29
+ *
30
+ * @returns The previously-stored value, or `null` if nothing has been
31
+ * stored yet. Never throws on "not found" — a missing entry is
32
+ * semantically a `null` value.
33
+ */
34
+ getAppData<TValue = unknown>(namespace: string, key: string): Promise<TValue | null>;
35
+ /**
36
+ * Upsert the value under `(namespace, key)` for the current user.
37
+ *
38
+ * Returns the value the server confirmed it stored — typically the same
39
+ * value the caller passed in, but consumers should prefer the returned
40
+ * value (the API is the source of truth).
41
+ *
42
+ * @throws OxyAppDataIdentifierError when namespace or key is malformed.
43
+ */
44
+ setAppData<TValue = unknown>(namespace: string, key: string, value: TValue): Promise<TValue>;
45
+ /**
46
+ * Delete the value stored under `(namespace, key)` for the current user.
47
+ *
48
+ * Idempotent — resolves successfully whether or not the entry existed.
49
+ */
50
+ deleteAppData(namespace: string, key: string): Promise<void>;
51
+ /**
52
+ * List every entry stored under `namespace` for the current user.
53
+ *
54
+ * Returns an empty object when the namespace contains no entries (the
55
+ * endpoint never 404s on an empty namespace).
56
+ */
57
+ listAppData<TValue = unknown>(namespace: string): Promise<Record<string, TValue>>;
58
+ httpService: import("../HttpService").HttpService;
59
+ cloudURL: string;
60
+ config: import("../OxyServices.base").OxyConfig;
61
+ __resetTokensForTests(): void;
62
+ makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
63
+ getBaseURL(): string;
64
+ getClient(): import("../HttpService").HttpService;
65
+ getMetrics(): {
66
+ totalRequests: number;
67
+ successfulRequests: number;
68
+ failedRequests: number;
69
+ cacheHits: number;
70
+ cacheMisses: number;
71
+ averageResponseTime: number;
72
+ };
73
+ clearCache(): void;
74
+ clearCacheEntry(key: string): void;
75
+ clearCacheByPrefix(prefix: string): number;
76
+ getCacheStats(): {
77
+ size: number;
78
+ hits: number;
79
+ misses: number;
80
+ hitRate: number;
81
+ };
82
+ getCloudURL(): string;
83
+ setTokens(accessToken: string, refreshToken?: string): void;
84
+ clearTokens(): void;
85
+ _cachedUserId: string | null | undefined;
86
+ _cachedAccessToken: string | null;
87
+ getCurrentUserId(): string | null;
88
+ hasValidToken(): boolean;
89
+ getAccessToken(): string | null;
90
+ setActingAs(userId: string | null): void;
91
+ getActingAs(): string | null;
92
+ waitForAuth(timeoutMs?: number): Promise<boolean>;
93
+ withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
94
+ maxRetries?: number;
95
+ retryDelay?: number;
96
+ authTimeoutMs?: number;
97
+ }): Promise<T_1>;
98
+ validate(): Promise<boolean>;
99
+ handleError(error: unknown): Error;
100
+ healthCheck(): Promise<{
101
+ status: string;
102
+ users?: number;
103
+ timestamp?: string;
104
+ [key: string]: any;
105
+ }>;
106
+ };
107
+ } & T;
@@ -193,11 +193,47 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
193
193
  }>>;
194
194
  /**
195
195
  * Get access token by session ID
196
+ *
197
+ * SECURITY: this endpoint requires the caller to already hold a
198
+ * bearer token whose user owns the referenced session (C1 hardening
199
+ * in the API). For the device-flow / QR sign-in case where the
200
+ * client has no bearer token yet, use `claimSessionByToken` instead.
196
201
  */
197
202
  getTokenBySession(sessionId: string): Promise<{
198
203
  accessToken: string;
199
204
  expiresAt: string;
200
205
  }>;
206
+ /**
207
+ * Exchange a device-flow sessionToken for the first access token.
208
+ *
209
+ * The originating client holds a 128-bit `sessionToken` that nobody
210
+ * else has seen — it was generated client-side, sent once on
211
+ * `POST /auth/session/create`, and is never echoed back. After
212
+ * another authenticated device approves the session via
213
+ * `POST /auth/session/authorize/{sessionToken}` (bearer-authed) and
214
+ * the auth socket / poll loop notifies this client, the client
215
+ * exchanges its `sessionToken` here for the first access token,
216
+ * refresh token, sessionId, and the authorized user.
217
+ *
218
+ * This call requires no Authorization header — the high-entropy
219
+ * `sessionToken` IS the credential (RFC 8628 §3.4). The exchange is
220
+ * single-use; replay attempts are rejected with 401.
221
+ *
222
+ * @param sessionToken - The same sessionToken the SDK passed to
223
+ * `POST /auth/session/create` at the start of the flow.
224
+ * @param options.deviceFingerprint - Optional fingerprint of the
225
+ * originating client device.
226
+ */
227
+ claimSessionByToken(sessionToken: string, options?: {
228
+ deviceFingerprint?: string;
229
+ }): Promise<{
230
+ accessToken: string;
231
+ refreshToken: string;
232
+ sessionId: string;
233
+ deviceId: string;
234
+ expiresAt: string;
235
+ user: User;
236
+ }>;
201
237
  /**
202
238
  * Get sessions by session ID
203
239
  */
@@ -25,6 +25,7 @@ import { OxyServicesFeaturesMixin } from './OxyServices.features';
25
25
  import { OxyServicesTopicsMixin } from './OxyServices.topics';
26
26
  import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
27
27
  import { OxyServicesContactsMixin } from './OxyServices.contacts';
28
+ import { OxyServicesAppDataMixin } from './OxyServices.appData';
28
29
  /**
29
30
  * Instance shape of every mixin in the pipeline, intersected. The runtime
30
31
  * `composeOxyServices()` produces a class whose instances expose all of
@@ -34,7 +35,7 @@ import { OxyServicesContactsMixin } from './OxyServices.contacts';
34
35
  * If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
35
36
  * are visible without a cast.
36
37
  */
37
- type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPopupAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeveloperMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
38
+ type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPopupAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeveloperMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
38
39
  /**
39
40
  * Constructor type for the fully composed mixin pipeline. Each mixin returns
40
41
  * a new constructor that augments its input; reducing across the pipeline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.15",
3
+ "version": "1.11.17",
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",
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
35
35
  export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
36
36
  export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount } from './mixins/OxyServices.managedAccounts';
37
37
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse } from './mixins/OxyServices.contacts';
38
+ export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
38
39
 
39
40
  // --- Crypto / Identity ---
40
41
  export {
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Per-user App-Data Mixin
3
+ *
4
+ * Thin client around `/users/me/app-data/...` — a generic per-user JSON KV
5
+ * store on the API. Authenticated callers can read, write, list, and delete
6
+ * entries scoped to their own user account.
7
+ *
8
+ * Identifier rules (must match the API):
9
+ * - Both `namespace` and `key` must match `/^[a-z0-9_-]{1,64}$/u`.
10
+ *
11
+ * Limits (enforced by the API):
12
+ * - Serialized JSON values are capped at 64 KB.
13
+ * - Writes are rate-limited to 100 / minute / user.
14
+ *
15
+ * Intended use cases are small bits of cross-device app state — e.g. Academy
16
+ * course progress, "last viewed" markers, dismissed banner flags. Do not use
17
+ * this for large blobs or anything that needs server-side querying; it's a
18
+ * write-it-and-read-it-back store.
19
+ */
20
+
21
+ import type { OxyServicesBase } from '../OxyServices.base';
22
+
23
+ /**
24
+ * Identifier validator — mirror of the API regex. Validating client-side
25
+ * gives consumers a clean throw before the request even leaves the device.
26
+ */
27
+ const APP_DATA_IDENTIFIER_PATTERN = /^[a-z0-9_-]{1,64}$/u;
28
+
29
+ /** Thrown when a namespace or key fails the kebab/snake-case validator. */
30
+ export class OxyAppDataIdentifierError extends Error {
31
+ constructor(field: 'namespace' | 'key', value: string) {
32
+ super(
33
+ `Invalid app-data ${field} "${value}": must match [a-z0-9_-]{1,64} (lowercase letters, digits, dashes, underscores).`,
34
+ );
35
+ this.name = 'OxyAppDataIdentifierError';
36
+ }
37
+ }
38
+
39
+ function assertIdentifier(field: 'namespace' | 'key', value: string): void {
40
+ if (!APP_DATA_IDENTIFIER_PATTERN.test(value)) {
41
+ throw new OxyAppDataIdentifierError(field, value);
42
+ }
43
+ }
44
+
45
+ /** Wire shape of `GET /users/me/app-data/:namespace/:key`. */
46
+ interface AppDataValueResponse<T> {
47
+ value: T | null;
48
+ }
49
+
50
+ /** Wire shape of `GET /users/me/app-data/:namespace`. */
51
+ interface AppDataNamespaceResponse<T> {
52
+ entries: Record<string, T>;
53
+ }
54
+
55
+ export function OxyServicesAppDataMixin<T extends typeof OxyServicesBase>(Base: T) {
56
+ return class extends Base {
57
+ constructor(...args: any[]) {
58
+ super(...(args as [any]));
59
+ }
60
+
61
+ /**
62
+ * Read the value stored under `(namespace, key)` for the current user.
63
+ *
64
+ * @returns The previously-stored value, or `null` if nothing has been
65
+ * stored yet. Never throws on "not found" — a missing entry is
66
+ * semantically a `null` value.
67
+ */
68
+ async getAppData<TValue = unknown>(
69
+ namespace: string,
70
+ key: string,
71
+ ): Promise<TValue | null> {
72
+ assertIdentifier('namespace', namespace);
73
+ assertIdentifier('key', key);
74
+
75
+ return this.withAuthRetry(async () => {
76
+ const response = await this.makeRequest<AppDataValueResponse<TValue>>(
77
+ 'GET',
78
+ `/users/me/app-data/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
79
+ undefined,
80
+ { cache: false },
81
+ );
82
+ return response?.value ?? null;
83
+ }, 'getAppData');
84
+ }
85
+
86
+ /**
87
+ * Upsert the value under `(namespace, key)` for the current user.
88
+ *
89
+ * Returns the value the server confirmed it stored — typically the same
90
+ * value the caller passed in, but consumers should prefer the returned
91
+ * value (the API is the source of truth).
92
+ *
93
+ * @throws OxyAppDataIdentifierError when namespace or key is malformed.
94
+ */
95
+ async setAppData<TValue = unknown>(
96
+ namespace: string,
97
+ key: string,
98
+ value: TValue,
99
+ ): Promise<TValue> {
100
+ assertIdentifier('namespace', namespace);
101
+ assertIdentifier('key', key);
102
+
103
+ return this.withAuthRetry(async () => {
104
+ const response = await this.makeRequest<AppDataValueResponse<TValue>>(
105
+ 'PUT',
106
+ `/users/me/app-data/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
107
+ { value },
108
+ { cache: false },
109
+ );
110
+ // The server echoes the stored value back; fall back to the caller's
111
+ // input only if the server somehow omitted it (defensive — the route
112
+ // always sets it).
113
+ return (response?.value ?? value) as TValue;
114
+ }, 'setAppData');
115
+ }
116
+
117
+ /**
118
+ * Delete the value stored under `(namespace, key)` for the current user.
119
+ *
120
+ * Idempotent — resolves successfully whether or not the entry existed.
121
+ */
122
+ async deleteAppData(namespace: string, key: string): Promise<void> {
123
+ assertIdentifier('namespace', namespace);
124
+ assertIdentifier('key', key);
125
+
126
+ await this.withAuthRetry(async () => {
127
+ await this.makeRequest<void>(
128
+ 'DELETE',
129
+ `/users/me/app-data/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
130
+ undefined,
131
+ { cache: false },
132
+ );
133
+ }, 'deleteAppData');
134
+ }
135
+
136
+ /**
137
+ * List every entry stored under `namespace` for the current user.
138
+ *
139
+ * Returns an empty object when the namespace contains no entries (the
140
+ * endpoint never 404s on an empty namespace).
141
+ */
142
+ async listAppData<TValue = unknown>(
143
+ namespace: string,
144
+ ): Promise<Record<string, TValue>> {
145
+ assertIdentifier('namespace', namespace);
146
+
147
+ return this.withAuthRetry(async () => {
148
+ const response = await this.makeRequest<AppDataNamespaceResponse<TValue>>(
149
+ 'GET',
150
+ `/users/me/app-data/${encodeURIComponent(namespace)}`,
151
+ undefined,
152
+ { cache: false },
153
+ );
154
+ return response?.entries ?? {};
155
+ }, 'listAppData');
156
+ }
157
+ };
158
+ }
@@ -440,6 +440,11 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
440
440
 
441
441
  /**
442
442
  * Get access token by session ID
443
+ *
444
+ * SECURITY: this endpoint requires the caller to already hold a
445
+ * bearer token whose user owns the referenced session (C1 hardening
446
+ * in the API). For the device-flow / QR sign-in case where the
447
+ * client has no bearer token yet, use `claimSessionByToken` instead.
443
448
  */
444
449
  async getTokenBySession(sessionId: string): Promise<{ accessToken: string; expiresAt: string }> {
445
450
  try {
@@ -449,9 +454,67 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
449
454
  undefined,
450
455
  { cache: false, retry: false }
451
456
  );
452
-
457
+
453
458
  this.setTokens(res.accessToken);
454
-
459
+
460
+ return res;
461
+ } catch (error) {
462
+ throw this.handleError(error);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Exchange a device-flow sessionToken for the first access token.
468
+ *
469
+ * The originating client holds a 128-bit `sessionToken` that nobody
470
+ * else has seen — it was generated client-side, sent once on
471
+ * `POST /auth/session/create`, and is never echoed back. After
472
+ * another authenticated device approves the session via
473
+ * `POST /auth/session/authorize/{sessionToken}` (bearer-authed) and
474
+ * the auth socket / poll loop notifies this client, the client
475
+ * exchanges its `sessionToken` here for the first access token,
476
+ * refresh token, sessionId, and the authorized user.
477
+ *
478
+ * This call requires no Authorization header — the high-entropy
479
+ * `sessionToken` IS the credential (RFC 8628 §3.4). The exchange is
480
+ * single-use; replay attempts are rejected with 401.
481
+ *
482
+ * @param sessionToken - The same sessionToken the SDK passed to
483
+ * `POST /auth/session/create` at the start of the flow.
484
+ * @param options.deviceFingerprint - Optional fingerprint of the
485
+ * originating client device.
486
+ */
487
+ async claimSessionByToken(
488
+ sessionToken: string,
489
+ options: { deviceFingerprint?: string } = {}
490
+ ): Promise<{
491
+ accessToken: string;
492
+ refreshToken: string;
493
+ sessionId: string;
494
+ deviceId: string;
495
+ expiresAt: string;
496
+ user: User;
497
+ }> {
498
+ try {
499
+ const res = await this.makeRequest<{
500
+ accessToken: string;
501
+ refreshToken: string;
502
+ sessionId: string;
503
+ deviceId: string;
504
+ expiresAt: string;
505
+ user: User;
506
+ }>(
507
+ 'POST',
508
+ '/auth/session/claim',
509
+ {
510
+ sessionToken,
511
+ ...(options.deviceFingerprint ? { deviceFingerprint: options.deviceFingerprint } : {}),
512
+ },
513
+ { cache: false, retry: false }
514
+ );
515
+
516
+ this.setTokens(res.accessToken, res.refreshToken);
517
+
455
518
  return res;
456
519
  } catch (error) {
457
520
  throw this.handleError(error);
@@ -0,0 +1,203 @@
1
+ /**
2
+ * App-Data Mixin Tests
3
+ *
4
+ * Exercises the typed helpers around `/users/me/app-data/...`. We stub
5
+ * `makeRequest` so the tests run without a network or a database — what we
6
+ * care about here is request shape (method, URL, body), identifier
7
+ * validation, and response handling (`null` when missing, echo on write).
8
+ *
9
+ * The mixin sits behind `withAuthRetry`, which polls for a token before
10
+ * running the operation. We force a token in via `__resetTokensForTests`'
11
+ * companion path (set access token directly) so the auth wait short-circuits.
12
+ */
13
+
14
+ import { OxyServices } from '../../OxyServices';
15
+ import { OxyAppDataIdentifierError } from '../OxyServices.appData';
16
+
17
+ const setAccessTokenForTest = (oxy: OxyServices): void => {
18
+ // Tokens are managed by HttpService — `hasAccessToken()` is the gate the
19
+ // `withAuthRetry` loop polls. Reaching in via the public httpService and
20
+ // calling setTokens with a dummy avoids us needing to expose new test
21
+ // hooks just for this.
22
+ oxy.httpService.setTokens('test-token', '');
23
+ };
24
+
25
+ describe('OxyServices.appData', () => {
26
+ let oxy: OxyServices;
27
+ let makeRequestSpy: jest.SpyInstance;
28
+
29
+ beforeEach(() => {
30
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
31
+ setAccessTokenForTest(oxy);
32
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
33
+ });
34
+
35
+ afterEach(() => {
36
+ makeRequestSpy.mockRestore();
37
+ });
38
+
39
+ describe('getAppData', () => {
40
+ it('returns the stored value when the API responds with one', async () => {
41
+ makeRequestSpy.mockResolvedValue({ value: { completed: ['intro'] } });
42
+
43
+ const result = await oxy.getAppData<{ completed: string[] }>(
44
+ 'academy',
45
+ 'getting-started',
46
+ );
47
+
48
+ expect(result).toEqual({ completed: ['intro'] });
49
+ expect(makeRequestSpy).toHaveBeenCalledWith(
50
+ 'GET',
51
+ '/users/me/app-data/academy/getting-started',
52
+ undefined,
53
+ expect.objectContaining({ cache: false }),
54
+ );
55
+ });
56
+
57
+ it('returns null when the API responds with `value: null`', async () => {
58
+ makeRequestSpy.mockResolvedValue({ value: null });
59
+ const result = await oxy.getAppData('academy', 'unknown');
60
+ expect(result).toBeNull();
61
+ });
62
+
63
+ it('returns null when the response object is missing `value`', async () => {
64
+ makeRequestSpy.mockResolvedValue({});
65
+ const result = await oxy.getAppData('academy', 'unknown');
66
+ expect(result).toBeNull();
67
+ });
68
+
69
+ it('URL-encodes namespace and key path segments', async () => {
70
+ makeRequestSpy.mockResolvedValue({ value: 'ok' });
71
+ await oxy.getAppData('a-b_c', 'd-e_f');
72
+ // URL-encoding is a no-op for our allowed character set, but we still
73
+ // run through encodeURIComponent — make sure that's wired so the call
74
+ // site doesn't accidentally bypass it later.
75
+ expect(makeRequestSpy.mock.calls[0][1]).toBe('/users/me/app-data/a-b_c/d-e_f');
76
+ });
77
+
78
+ it('throws OxyAppDataIdentifierError for invalid namespace', async () => {
79
+ await expect(oxy.getAppData('Bad Namespace', 'k')).rejects.toBeInstanceOf(
80
+ OxyAppDataIdentifierError,
81
+ );
82
+ expect(makeRequestSpy).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it('throws OxyAppDataIdentifierError for invalid key', async () => {
86
+ await expect(oxy.getAppData('ns', 'Bad/Key')).rejects.toBeInstanceOf(
87
+ OxyAppDataIdentifierError,
88
+ );
89
+ expect(makeRequestSpy).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it('rejects empty identifiers (regex requires at least one char)', async () => {
93
+ await expect(oxy.getAppData('', 'k')).rejects.toBeInstanceOf(
94
+ OxyAppDataIdentifierError,
95
+ );
96
+ await expect(oxy.getAppData('n', '')).rejects.toBeInstanceOf(
97
+ OxyAppDataIdentifierError,
98
+ );
99
+ });
100
+
101
+ it('rejects identifiers longer than 64 chars', async () => {
102
+ const tooLong = 'a'.repeat(65);
103
+ await expect(oxy.getAppData(tooLong, 'k')).rejects.toBeInstanceOf(
104
+ OxyAppDataIdentifierError,
105
+ );
106
+ });
107
+
108
+ it('surfaces API errors (e.g. 401) via withAuthRetry', async () => {
109
+ const err = Object.assign(new Error('Authentication required'), {
110
+ response: { status: 401 },
111
+ });
112
+ makeRequestSpy.mockRejectedValue(err);
113
+ await expect(oxy.getAppData('academy', 'getting-started')).rejects.toThrow();
114
+ });
115
+ });
116
+
117
+ describe('setAppData', () => {
118
+ it('writes the value and returns the server-echoed value', async () => {
119
+ makeRequestSpy.mockResolvedValue({ value: { completed: ['intro'] } });
120
+
121
+ const result = await oxy.setAppData('academy', 'getting-started', {
122
+ completed: ['intro'],
123
+ });
124
+
125
+ expect(result).toEqual({ completed: ['intro'] });
126
+ expect(makeRequestSpy).toHaveBeenCalledWith(
127
+ 'PUT',
128
+ '/users/me/app-data/academy/getting-started',
129
+ { value: { completed: ['intro'] } },
130
+ expect.objectContaining({ cache: false }),
131
+ );
132
+ });
133
+
134
+ it('falls back to the caller value when the server response omits it', async () => {
135
+ makeRequestSpy.mockResolvedValue({});
136
+ const result = await oxy.setAppData('academy', 'k', 'hello');
137
+ expect(result).toBe('hello');
138
+ });
139
+
140
+ it('throws OxyAppDataIdentifierError before issuing a request', async () => {
141
+ await expect(oxy.setAppData('UPPER', 'k', 1)).rejects.toBeInstanceOf(
142
+ OxyAppDataIdentifierError,
143
+ );
144
+ expect(makeRequestSpy).not.toHaveBeenCalled();
145
+ });
146
+ });
147
+
148
+ describe('deleteAppData', () => {
149
+ it('issues a DELETE and resolves', async () => {
150
+ makeRequestSpy.mockResolvedValue(undefined);
151
+
152
+ await expect(oxy.deleteAppData('academy', 'getting-started')).resolves.toBeUndefined();
153
+ expect(makeRequestSpy).toHaveBeenCalledWith(
154
+ 'DELETE',
155
+ '/users/me/app-data/academy/getting-started',
156
+ undefined,
157
+ expect.objectContaining({ cache: false }),
158
+ );
159
+ });
160
+
161
+ it('throws OxyAppDataIdentifierError on invalid identifiers', async () => {
162
+ await expect(oxy.deleteAppData('ns', 'BAD KEY')).rejects.toBeInstanceOf(
163
+ OxyAppDataIdentifierError,
164
+ );
165
+ });
166
+ });
167
+
168
+ describe('listAppData', () => {
169
+ it('returns the entries map from the API', async () => {
170
+ makeRequestSpy.mockResolvedValue({
171
+ entries: {
172
+ 'getting-started': { completed: ['intro'] },
173
+ 'using-oxy-id': { completed: [] },
174
+ },
175
+ });
176
+
177
+ const result = await oxy.listAppData<{ completed: string[] }>('academy');
178
+
179
+ expect(result).toEqual({
180
+ 'getting-started': { completed: ['intro'] },
181
+ 'using-oxy-id': { completed: [] },
182
+ });
183
+ expect(makeRequestSpy).toHaveBeenCalledWith(
184
+ 'GET',
185
+ '/users/me/app-data/academy',
186
+ undefined,
187
+ expect.objectContaining({ cache: false }),
188
+ );
189
+ });
190
+
191
+ it('returns an empty object when the API returns no entries', async () => {
192
+ makeRequestSpy.mockResolvedValue({});
193
+ const result = await oxy.listAppData('academy');
194
+ expect(result).toEqual({});
195
+ });
196
+
197
+ it('throws OxyAppDataIdentifierError on invalid namespace', async () => {
198
+ await expect(oxy.listAppData('Bad Namespace')).rejects.toBeInstanceOf(
199
+ OxyAppDataIdentifierError,
200
+ );
201
+ });
202
+ });
203
+ });
@@ -26,6 +26,7 @@ import { OxyServicesFeaturesMixin } from './OxyServices.features';
26
26
  import { OxyServicesTopicsMixin } from './OxyServices.topics';
27
27
  import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
28
28
  import { OxyServicesContactsMixin } from './OxyServices.contacts';
29
+ import { OxyServicesAppDataMixin } from './OxyServices.appData';
29
30
 
30
31
  /**
31
32
  * Instance shape of every mixin in the pipeline, intersected. The runtime
@@ -56,6 +57,7 @@ type AllMixinInstances =
56
57
  & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>>
57
58
  & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>>
58
59
  & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>>
60
+ & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
59
61
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
60
62
 
61
63
  /**
@@ -115,6 +117,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
115
117
  OxyServicesTopicsMixin,
116
118
  OxyServicesManagedAccountsMixin,
117
119
  OxyServicesContactsMixin,
120
+ OxyServicesAppDataMixin,
118
121
 
119
122
  // Utility (last, can use all above)
120
123
  OxyServicesUtilityMixin,