@oxyhq/core 3.15.0 → 3.16.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.
@@ -1,9 +1,5 @@
1
1
  import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
- interface FileDownloadUrlOptions {
4
- /** Omit bearer access tokens from generated URLs, even when authenticated. */
5
- omitToken?: boolean;
6
- }
7
3
  export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T): {
8
4
  new (...args: any[]): {
9
5
  /**
@@ -11,32 +7,16 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
11
7
  */
12
8
  deleteFile(fileId: string): Promise<any>;
13
9
  /**
14
- * Build a synchronous file URL from an Oxy asset id.
10
+ * Build a synchronous, `<img src>`-ready file URL from an Oxy asset id.
15
11
  *
16
- * This is the single chokepoint every Oxy app uses to turn a stored file id
17
- * (avatars, post media, etc.) into a `<img src>`-ready URL, so it resolves to
18
- * one of two forms depending on whether the caller needs a signed/private URL:
19
- *
20
- * - **Public asset (default)** no access token planted on the client AND no
21
- * `expiresIn` requested returns the clean CDN form
22
- * `${cloudURL}/<id>[?variant=...]` (e.g. `https://cloud.oxy.so/<id>?variant=thumb`).
23
- * CloudFront resolves the id against the public media origin. No token,
24
- * `fallback`, or origin query params are emitted — these URLs are cacheable
25
- * and shareable.
26
- * - **Signed / private asset** — an access token is present on the client OR
27
- * `expiresIn` was passed (the caller explicitly wants an expiring/authorized
28
- * URL) → keeps the authenticated origin form
29
- * `${baseURL}/assets/<id>/stream?...&token=...`. Private assets are NOT on
30
- * the public CDN, so they must go through the API origin that can authorize
31
- * the request.
32
- *
33
- * `cloudURL` (default `https://cloud.oxy.so`) is configured once on the
34
- * `OxyServices` constructor and read via `getCloudURL()`; the API origin is
35
- * `getBaseURL()` (e.g. `https://api.oxy.so`).
36
- *
37
- * For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
12
+ * This method must never embed the caller's general access token in the
13
+ * returned URL. The URL is commonly rendered into DOM attributes, browser
14
+ * network panels, caches, and logs. Public asset URLs use the clean CDN
15
+ * origin; callers that need authorized/private access should use
16
+ * {@link getFileDownloadUrlAsync}, which asks the API for a scoped download
17
+ * URL instead of exposing the in-memory bearer token in a query string.
38
18
  */
39
- getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number, options?: FileDownloadUrlOptions): string;
19
+ getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string;
40
20
  /**
41
21
  * Get file download URL asynchronously (returns signed URL directly from CDN)
42
22
  */
@@ -172,4 +152,3 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
172
152
  }>;
173
153
  };
174
154
  } & T;
175
- export {};
@@ -94,6 +94,14 @@ export interface CommonsApprovalInfo {
94
94
  scopes: string[];
95
95
  /** The origin the session is bound to (the RP web origin), when applicable. */
96
96
  boundOrigin?: string;
97
+ /**
98
+ * Server-authoritative anti-phishing flag: `true` only when this device-flow
99
+ * sign-in was started from a verified, registered origin of a trusted app.
100
+ * The approver (Commons) shows a warning when this is `false`. Always present
101
+ * — a missing/non-boolean server value is coerced to `false` (fail-safe to
102
+ * "not verified") by {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
103
+ */
104
+ originVerified: boolean;
97
105
  /** Server-authoritative expiry (epoch milliseconds). */
98
106
  expiresAt: number;
99
107
  /** Session lifecycle status. */
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Link Preview (unfurl) Mixin
3
+ *
4
+ * Resolves link previews ("unfurls") through the Oxy API so every Oxy app
5
+ * stops scraping link metadata locally. The API owns resolution and re-hosts
6
+ * the preview `image`/`favicon` on Oxy media (`cloud.oxy.so/<fileId>`), so
7
+ * consumers render the returned URLs directly with no per-app proxy.
8
+ *
9
+ * Wire shapes (`LinkPreview`, `LinkPreviewBatchResponse`) are the single source
10
+ * of truth in `@oxyhq/contracts`; this mixin imports them rather than
11
+ * redefining them so producer (oxy-api) and consumers cannot drift.
12
+ *
13
+ * Caching note: these GET/POST reads are NOT cached at the SDK layer (`cache:
14
+ * false`). A preview can be returned `'pending'` first and `'resolved'` on a
15
+ * later read, so an SDK GET cache would pin the stale `'pending'` snapshot.
16
+ * App-side caching (React Query / stores) owns this responsibility.
17
+ */
18
+ import type { LinkPreview } from '@oxyhq/contracts';
19
+ import type { OxyServicesBase } from '../OxyServices.base';
20
+ export declare function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(Base: T): {
21
+ new (...args: any[]): {
22
+ /**
23
+ * Resolve a single link preview via `GET /links/preview?url=<encoded>&wait=0|1`.
24
+ *
25
+ * @param url - The URL to unfurl. Sent percent-encoded in the query string.
26
+ * @param opts.wait - When `true`, asks the server to resolve synchronously
27
+ * (`wait=1`) instead of returning a `'pending'` placeholder for a
28
+ * first-seen URL. Defaults to `false` (`wait=0`).
29
+ *
30
+ * Not cached at the SDK layer: a `'pending'` result can become `'resolved'`
31
+ * on a later read, so caching here would serve the stale placeholder.
32
+ */
33
+ getLinkPreview(url: string, opts?: {
34
+ wait?: boolean;
35
+ }): Promise<LinkPreview>;
36
+ /**
37
+ * Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
38
+ *
39
+ * Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
40
+ * (the server-side cap). Chunks run concurrently and their `data` maps are
41
+ * merged into a single result keyed by the REQUESTED url (the exact string
42
+ * passed in `urls`) — matching the batch contract — so a caller can always
43
+ * look its own input back up.
44
+ *
45
+ * An empty / whitespace-only input resolves immediately with `{}` and
46
+ * performs no network call. A failure in any chunk surfaces (via
47
+ * `handleError`) rather than being swallowed.
48
+ */
49
+ getLinkPreviews(urls: string[]): Promise<Record<string, LinkPreview>>;
50
+ httpService: import("../HttpService").HttpService;
51
+ cloudURL: string;
52
+ config: import("../OxyServices.base").OxyConfig;
53
+ __resetTokensForTests(): void;
54
+ makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
55
+ getBaseURL(): string;
56
+ getSessionBaseUrl(): string;
57
+ getClient(): import("../HttpService").HttpService;
58
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
59
+ getMetrics(): {
60
+ totalRequests: number;
61
+ successfulRequests: number;
62
+ failedRequests: number;
63
+ cacheHits: number;
64
+ cacheMisses: number;
65
+ averageResponseTime: number;
66
+ };
67
+ clearCache(): void;
68
+ clearCacheEntry(key: string): void;
69
+ clearCacheByPrefix(prefix: string): number;
70
+ getCacheStats(): {
71
+ size: number;
72
+ hits: number;
73
+ misses: number;
74
+ hitRate: number;
75
+ };
76
+ getCloudURL(): string;
77
+ setTokens(accessToken: string): void;
78
+ clearTokens(): void;
79
+ onTokensChanged(listener: (accessToken: string | null) => void): () => void;
80
+ _cachedUserId: string | null | undefined;
81
+ _cachedAccessToken: string | null;
82
+ getCurrentUserId(): string | null;
83
+ hasValidToken(): boolean;
84
+ getAccessToken(): string | null;
85
+ setActingAs(userId: string | null): void;
86
+ getActingAs(): string | null;
87
+ waitForAuth(timeoutMs?: number): Promise<boolean>;
88
+ withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
89
+ maxRetries?: number;
90
+ retryDelay?: number;
91
+ authTimeoutMs?: number;
92
+ }): Promise<T_1>;
93
+ validate(): Promise<boolean>;
94
+ handleError(error: unknown): Error;
95
+ healthCheck(): Promise<{
96
+ status: string;
97
+ users?: number;
98
+ timestamp?: string;
99
+ [key: string]: any;
100
+ }>;
101
+ };
102
+ } & T;
@@ -31,6 +31,7 @@ import { OxyServicesContactsMixin } from './OxyServices.contacts';
31
31
  import { OxyServicesAppDataMixin } from './OxyServices.appData';
32
32
  import { OxyServicesCivicMixin } from './OxyServices.civic';
33
33
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
34
+ import { OxyServicesLinksMixin } from './OxyServices.links';
34
35
  /**
35
36
  * Instance shape of every mixin in the pipeline, intersected. The runtime
36
37
  * `composeOxyServices()` produces a class whose instances expose all of
@@ -40,7 +41,7 @@ import { OxyServicesNodesMixin } from './OxyServices.nodes';
40
41
  * If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
41
42
  * are visible without a cast.
42
43
  */
43
- type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSilentAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<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 OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
44
+ type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSilentAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<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 OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
44
45
  /**
45
46
  * Constructor type for the fully composed mixin pipeline. Each mixin returns
46
47
  * 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": "3.15.0",
3
+ "version": "3.16.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -98,7 +98,7 @@
98
98
  }
99
99
  },
100
100
  "dependencies": {
101
- "@oxyhq/contracts": "0.4.0",
101
+ "@oxyhq/contracts": "0.5.0",
102
102
  "bip39": "^3.1.0",
103
103
  "buffer": "^6.0.3",
104
104
  "elliptic": "^6.6.1",
@@ -2,11 +2,6 @@ import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, A
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
3
  import { isReactNative } from '../utils/platform';
4
4
 
5
- interface FileDownloadUrlOptions {
6
- /** Omit bearer access tokens from generated URLs, even when authenticated. */
7
- omitToken?: boolean;
8
- }
9
-
10
5
  export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
11
6
  return class extends Base {
12
7
  constructor(...args: any[]) {
@@ -25,55 +20,30 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
25
20
  }
26
21
 
27
22
  /**
28
- * Build a synchronous file URL from an Oxy asset id.
29
- *
30
- * This is the single chokepoint every Oxy app uses to turn a stored file id
31
- * (avatars, post media, etc.) into a `<img src>`-ready URL, so it resolves to
32
- * one of two forms depending on whether the caller needs a signed/private URL:
33
- *
34
- * - **Public asset (default)** — no access token planted on the client AND no
35
- * `expiresIn` requested → returns the clean CDN form
36
- * `${cloudURL}/<id>[?variant=...]` (e.g. `https://cloud.oxy.so/<id>?variant=thumb`).
37
- * CloudFront resolves the id against the public media origin. No token,
38
- * `fallback`, or origin query params are emitted — these URLs are cacheable
39
- * and shareable.
40
- * - **Signed / private asset** — an access token is present on the client OR
41
- * `expiresIn` was passed (the caller explicitly wants an expiring/authorized
42
- * URL) → keeps the authenticated origin form
43
- * `${baseURL}/assets/<id>/stream?...&token=...`. Private assets are NOT on
44
- * the public CDN, so they must go through the API origin that can authorize
45
- * the request.
23
+ * Build a synchronous, `<img src>`-ready file URL from an Oxy asset id.
46
24
  *
47
- * `cloudURL` (default `https://cloud.oxy.so`) is configured once on the
48
- * `OxyServices` constructor and read via `getCloudURL()`; the API origin is
49
- * `getBaseURL()` (e.g. `https://api.oxy.so`).
50
- *
51
- * For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
25
+ * This method must never embed the caller's general access token in the
26
+ * returned URL. The URL is commonly rendered into DOM attributes, browser
27
+ * network panels, caches, and logs. Public asset URLs use the clean CDN
28
+ * origin; callers that need authorized/private access should use
29
+ * {@link getFileDownloadUrlAsync}, which asks the API for a scoped download
30
+ * URL instead of exposing the in-memory bearer token in a query string.
52
31
  */
53
- getFileDownloadUrl(
54
- fileId: string,
55
- variant?: string,
56
- expiresIn?: number,
57
- options: FileDownloadUrlOptions = {}
58
- ): string {
59
- const token = options.omitToken ? undefined : this.getClient().getAccessToken();
60
-
61
- // Public case: no auth token and no expiry requested → clean CDN URL.
62
- // CloudFront serves the public media origin under `${cloudURL}/<id>`.
63
- if (!token && !expiresIn) {
32
+ getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string {
33
+ // Never embed the in-memory bearer token: this URL is rendered into DOM
34
+ // attributes, browser network panels, caches, and logs. Public assets get
35
+ // the clean CDN origin; private/authorized access goes through
36
+ // `getFileDownloadUrlAsync`.
37
+ if (!expiresIn) {
64
38
  const variantQs = variant ? `?variant=${encodeURIComponent(variant)}` : '';
65
39
  return `${this.getCloudURL()}/${encodeURIComponent(fileId)}${variantQs}`;
66
40
  }
67
41
 
68
- // Signed / private case: route through the authenticated API origin's
69
- // stream endpoint so the request can be authorized (private assets are not
70
- // exposed on the public CDN).
71
42
  const base = this.getBaseURL();
72
43
  const params = new URLSearchParams();
73
44
  if (variant) params.set('variant', variant);
74
- if (expiresIn) params.set('expiresIn', String(expiresIn));
45
+ params.set('expiresIn', String(expiresIn));
75
46
  params.set('fallback', 'placeholderVisible');
76
- if (token) params.set('token', token);
77
47
 
78
48
  const qs = params.toString();
79
49
  return `${base}/assets/${encodeURIComponent(fileId)}/stream${qs ? `?${qs}` : ''}`;
@@ -125,12 +125,35 @@ export interface CommonsApprovalInfo {
125
125
  scopes: string[];
126
126
  /** The origin the session is bound to (the RP web origin), when applicable. */
127
127
  boundOrigin?: string;
128
+ /**
129
+ * Server-authoritative anti-phishing flag: `true` only when this device-flow
130
+ * sign-in was started from a verified, registered origin of a trusted app.
131
+ * The approver (Commons) shows a warning when this is `false`. Always present
132
+ * — a missing/non-boolean server value is coerced to `false` (fail-safe to
133
+ * "not verified") by {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
134
+ */
135
+ originVerified: boolean;
128
136
  /** Server-authoritative expiry (epoch milliseconds). */
129
137
  expiresAt: number;
130
138
  /** Session lifecycle status. */
131
139
  status: string;
132
140
  }
133
141
 
142
+ /**
143
+ * @internal Raw server response of `GET /auth/session/approve-info/:code`.
144
+ * `originVerified` is typed loosely here because older servers may omit it (or
145
+ * send a non-boolean); the SDK coerces it to a strict `boolean` when mapping
146
+ * into {@link CommonsApprovalInfo}.
147
+ */
148
+ interface CommonsApprovalInfoResponse {
149
+ application: PublicApplication;
150
+ scopes: string[];
151
+ boundOrigin?: string;
152
+ originVerified?: unknown;
153
+ expiresAt: number;
154
+ status: string;
155
+ }
156
+
134
157
  /** Result of approving / denying a "Sign in with Oxy" request. */
135
158
  export interface CommonsSignInActionResult {
136
159
  success: boolean;
@@ -826,12 +849,23 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
826
849
  */
827
850
  async getCommonsApprovalInfo(authorizeCode: string): Promise<CommonsApprovalInfo> {
828
851
  try {
829
- return await this.makeRequest<CommonsApprovalInfo>(
852
+ const raw = await this.makeRequest<CommonsApprovalInfoResponse>(
830
853
  'GET',
831
854
  `/auth/session/approve-info/${encodeURIComponent(authorizeCode)}`,
832
855
  undefined,
833
856
  { cache: false }
834
857
  );
858
+ return {
859
+ application: raw.application,
860
+ scopes: raw.scopes,
861
+ boundOrigin: raw.boundOrigin,
862
+ // Fail-safe: only a literal boolean `true` counts as verified. A
863
+ // missing or non-boolean value (older server, malformed response)
864
+ // coerces to `false` so a stale server can never imply trust.
865
+ originVerified: raw.originVerified === true,
866
+ expiresAt: raw.expiresAt,
867
+ status: raw.status,
868
+ };
835
869
  } catch (error) {
836
870
  throw this.handleError(error);
837
871
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Link Preview (unfurl) Mixin
3
+ *
4
+ * Resolves link previews ("unfurls") through the Oxy API so every Oxy app
5
+ * stops scraping link metadata locally. The API owns resolution and re-hosts
6
+ * the preview `image`/`favicon` on Oxy media (`cloud.oxy.so/<fileId>`), so
7
+ * consumers render the returned URLs directly with no per-app proxy.
8
+ *
9
+ * Wire shapes (`LinkPreview`, `LinkPreviewBatchResponse`) are the single source
10
+ * of truth in `@oxyhq/contracts`; this mixin imports them rather than
11
+ * redefining them so producer (oxy-api) and consumers cannot drift.
12
+ *
13
+ * Caching note: these GET/POST reads are NOT cached at the SDK layer (`cache:
14
+ * false`). A preview can be returned `'pending'` first and `'resolved'` on a
15
+ * later read, so an SDK GET cache would pin the stale `'pending'` snapshot.
16
+ * App-side caching (React Query / stores) owns this responsibility.
17
+ */
18
+ import type { LinkPreview, LinkPreviewBatchResponse } from '@oxyhq/contracts';
19
+ import type { OxyServicesBase } from '../OxyServices.base';
20
+ import { buildUrl } from '../utils/apiUtils';
21
+
22
+ /**
23
+ * Maximum number of URLs sent per `POST /links/previews` request. Matches the
24
+ * server-side batch cap (`linkPreviewBatchRequestSchema`'s `.max(50)`); larger
25
+ * inputs are split into multiple chunked calls and the result maps merged,
26
+ * mirroring how `getUsersByIds` chunks at 100.
27
+ */
28
+ const LINK_PREVIEWS_CHUNK_SIZE = 50;
29
+
30
+ export function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(Base: T) {
31
+ return class extends Base {
32
+ constructor(...args: any[]) {
33
+ super(...(args as [any]));
34
+ }
35
+
36
+ /**
37
+ * Resolve a single link preview via `GET /links/preview?url=<encoded>&wait=0|1`.
38
+ *
39
+ * @param url - The URL to unfurl. Sent percent-encoded in the query string.
40
+ * @param opts.wait - When `true`, asks the server to resolve synchronously
41
+ * (`wait=1`) instead of returning a `'pending'` placeholder for a
42
+ * first-seen URL. Defaults to `false` (`wait=0`).
43
+ *
44
+ * Not cached at the SDK layer: a `'pending'` result can become `'resolved'`
45
+ * on a later read, so caching here would serve the stale placeholder.
46
+ */
47
+ async getLinkPreview(url: string, opts?: { wait?: boolean }): Promise<LinkPreview> {
48
+ try {
49
+ const path = buildUrl('/links/preview', { url, wait: opts?.wait ? 1 : 0 });
50
+ return await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
51
+ } catch (error) {
52
+ throw this.handleError(error);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
58
+ *
59
+ * Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
60
+ * (the server-side cap). Chunks run concurrently and their `data` maps are
61
+ * merged into a single result keyed by the REQUESTED url (the exact string
62
+ * passed in `urls`) — matching the batch contract — so a caller can always
63
+ * look its own input back up.
64
+ *
65
+ * An empty / whitespace-only input resolves immediately with `{}` and
66
+ * performs no network call. A failure in any chunk surfaces (via
67
+ * `handleError`) rather than being swallowed.
68
+ */
69
+ async getLinkPreviews(urls: string[]): Promise<Record<string, LinkPreview>> {
70
+ const uniqueUrls = Array.from(
71
+ new Set(urls.filter((u): u is string => typeof u === 'string' && u.trim().length > 0)),
72
+ );
73
+ if (uniqueUrls.length === 0) {
74
+ return {};
75
+ }
76
+
77
+ const chunks: string[][] = [];
78
+ for (let i = 0; i < uniqueUrls.length; i += LINK_PREVIEWS_CHUNK_SIZE) {
79
+ chunks.push(uniqueUrls.slice(i, i + LINK_PREVIEWS_CHUNK_SIZE));
80
+ }
81
+
82
+ try {
83
+ const responses = await Promise.all(
84
+ chunks.map((chunk) =>
85
+ this.makeRequest<LinkPreviewBatchResponse>(
86
+ 'POST',
87
+ '/links/previews',
88
+ { urls: chunk },
89
+ { cache: false },
90
+ ),
91
+ ),
92
+ );
93
+
94
+ return responses.reduce<Record<string, LinkPreview>>(
95
+ (merged, response) => Object.assign(merged, response?.data ?? {}),
96
+ {},
97
+ );
98
+ } catch (error) {
99
+ throw this.handleError(error);
100
+ }
101
+ }
102
+ };
103
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Link-preview mixin tests.
3
+ *
4
+ * Stubs `makeRequest` so the tests run with no network, then asserts:
5
+ * - `getLinkPreview` builds `GET /links/preview?url=<percent-encoded>&wait=0|1`,
6
+ * percent-encoding the target URL so its `?`/`&`/`=` cannot break out of the
7
+ * query string, defaults `wait` to `0`, sends `wait=1` when `{ wait: true }`,
8
+ * reads uncached (`cache:false`), returns the preview, and surfaces errors.
9
+ * - `getLinkPreviews` de-duplicates input, no-ops to `{}` on empty input,
10
+ * chunks at 50 URLs/request (`POST /links/previews`), merges every chunk's
11
+ * `data` map keyed by the requested url, and surfaces a chunk failure.
12
+ */
13
+
14
+ import type { LinkPreview, LinkPreviewBatchResponse } from '@oxyhq/contracts';
15
+ import { OxyServices } from '../../OxyServices';
16
+
17
+ const sampleResolved: LinkPreview = {
18
+ url: 'https://news.example.com/a',
19
+ status: 'resolved',
20
+ title: 'Headline',
21
+ description: 'Lede',
22
+ image: 'https://cloud.oxy.so/img123',
23
+ siteName: 'Example News',
24
+ favicon: 'https://cloud.oxy.so/fav123',
25
+ resolvedAt: '2026-06-28T00:00:00.000Z',
26
+ };
27
+
28
+ describe('OxyServices.links', () => {
29
+ let oxy: OxyServices;
30
+ let makeRequestSpy: jest.SpyInstance;
31
+
32
+ beforeEach(() => {
33
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
34
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
35
+ });
36
+
37
+ afterEach(() => {
38
+ jest.restoreAllMocks();
39
+ });
40
+
41
+ describe('getLinkPreview', () => {
42
+ it('percent-encodes the URL and defaults wait=0', async () => {
43
+ makeRequestSpy.mockResolvedValueOnce(sampleResolved);
44
+
45
+ const result = await oxy.getLinkPreview('https://news.example.com/a?b=c&d=e');
46
+
47
+ expect(result).toEqual(sampleResolved);
48
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
49
+ expect(makeRequestSpy).toHaveBeenCalledWith(
50
+ 'GET',
51
+ '/links/preview?url=https%3A%2F%2Fnews.example.com%2Fa%3Fb%3Dc%26d%3De&wait=0',
52
+ undefined,
53
+ { cache: false },
54
+ );
55
+ });
56
+
57
+ it('sends wait=1 when opts.wait is true', async () => {
58
+ makeRequestSpy.mockResolvedValueOnce({ url: 'https://x.test/', status: 'pending' });
59
+
60
+ await oxy.getLinkPreview('https://x.test/', { wait: true });
61
+
62
+ expect(makeRequestSpy).toHaveBeenCalledWith(
63
+ 'GET',
64
+ '/links/preview?url=https%3A%2F%2Fx.test%2F&wait=1',
65
+ undefined,
66
+ { cache: false },
67
+ );
68
+ });
69
+
70
+ it('surfaces errors via handleError', async () => {
71
+ makeRequestSpy.mockRejectedValueOnce(new Error('boom'));
72
+
73
+ await expect(oxy.getLinkPreview('https://x.test/')).rejects.toThrow('boom');
74
+ });
75
+ });
76
+
77
+ describe('getLinkPreviews', () => {
78
+ it('returns {} and performs no network call for empty / whitespace input', async () => {
79
+ await expect(oxy.getLinkPreviews([])).resolves.toEqual({});
80
+ await expect(oxy.getLinkPreviews(['', ' '])).resolves.toEqual({});
81
+ expect(makeRequestSpy).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('de-duplicates and sends a single chunk for <= 50 unique URLs', async () => {
85
+ const response: LinkPreviewBatchResponse = {
86
+ data: { 'https://a.test/': sampleResolved },
87
+ };
88
+ makeRequestSpy.mockResolvedValueOnce(response);
89
+
90
+ const result = await oxy.getLinkPreviews([
91
+ 'https://a.test/',
92
+ 'https://a.test/', // duplicate
93
+ ' ', // dropped
94
+ ]);
95
+
96
+ expect(result).toEqual(response.data);
97
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
98
+ expect(makeRequestSpy).toHaveBeenCalledWith(
99
+ 'POST',
100
+ '/links/previews',
101
+ { urls: ['https://a.test/'] },
102
+ { cache: false },
103
+ );
104
+ });
105
+
106
+ it('chunks at 50 URLs per request and merges each chunk data map', async () => {
107
+ const urls = Array.from({ length: 120 }, (_, i) => `https://site.test/${i}`);
108
+
109
+ makeRequestSpy.mockImplementation(
110
+ async (
111
+ _method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
112
+ _url: string,
113
+ data?: { urls: string[] },
114
+ ): Promise<LinkPreviewBatchResponse> => {
115
+ const chunkUrls = data?.urls ?? [];
116
+ const dataMap: Record<string, LinkPreview> = {};
117
+ for (const u of chunkUrls) {
118
+ dataMap[u] = { url: u, status: 'resolved', title: `t-${u}` };
119
+ }
120
+ return { data: dataMap };
121
+ },
122
+ );
123
+
124
+ const result = await oxy.getLinkPreviews(urls);
125
+
126
+ // 120 unique URLs => 50 + 50 + 20 across three POSTs.
127
+ expect(makeRequestSpy).toHaveBeenCalledTimes(3);
128
+ const chunkSizes = makeRequestSpy.mock.calls.map((call) => (call[2] as { urls: string[] }).urls.length);
129
+ expect(chunkSizes).toEqual([50, 50, 20]);
130
+
131
+ // Every requested URL is present in the merged, request-keyed map.
132
+ expect(Object.keys(result)).toHaveLength(120);
133
+ expect(result['https://site.test/0']).toEqual({
134
+ url: 'https://site.test/0',
135
+ status: 'resolved',
136
+ title: 't-https://site.test/0',
137
+ });
138
+ expect(result['https://site.test/119']).toEqual({
139
+ url: 'https://site.test/119',
140
+ status: 'resolved',
141
+ title: 't-https://site.test/119',
142
+ });
143
+ });
144
+
145
+ it('surfaces a chunk failure via handleError', async () => {
146
+ const urls = Array.from({ length: 60 }, (_, i) => `https://site.test/${i}`);
147
+ makeRequestSpy
148
+ .mockResolvedValueOnce({ data: {} })
149
+ .mockRejectedValueOnce(new Error('chunk failed'));
150
+
151
+ await expect(oxy.getLinkPreviews(urls)).rejects.toThrow('chunk failed');
152
+ });
153
+ });
154
+ });