@oxyhq/core 3.14.0 → 3.16.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.
Files changed (42) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +3 -1
  3. package/dist/cjs/mixins/OxyServices.applications.js +43 -0
  4. package/dist/cjs/mixins/OxyServices.assets.js +14 -35
  5. package/dist/cjs/mixins/OxyServices.auth.js +12 -1
  6. package/dist/cjs/mixins/OxyServices.links.js +68 -0
  7. package/dist/cjs/mixins/OxyServices.nodes.js +175 -0
  8. package/dist/cjs/mixins/index.js +8 -0
  9. package/dist/cjs/utils/ssoBounce.js +48 -0
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/index.js +1 -1
  12. package/dist/esm/mixins/OxyServices.applications.js +43 -0
  13. package/dist/esm/mixins/OxyServices.assets.js +14 -35
  14. package/dist/esm/mixins/OxyServices.auth.js +12 -1
  15. package/dist/esm/mixins/OxyServices.links.js +65 -0
  16. package/dist/esm/mixins/OxyServices.nodes.js +172 -0
  17. package/dist/esm/mixins/index.js +8 -0
  18. package/dist/esm/utils/ssoBounce.js +46 -0
  19. package/dist/types/.tsbuildinfo +1 -1
  20. package/dist/types/index.d.ts +4 -2
  21. package/dist/types/mixins/OxyServices.applications.d.ts +51 -0
  22. package/dist/types/mixins/OxyServices.assets.d.ts +8 -29
  23. package/dist/types/mixins/OxyServices.auth.d.ts +8 -0
  24. package/dist/types/mixins/OxyServices.links.d.ts +102 -0
  25. package/dist/types/mixins/OxyServices.nodes.d.ts +242 -0
  26. package/dist/types/mixins/index.d.ts +3 -1
  27. package/dist/types/utils/ssoBounce.d.ts +61 -0
  28. package/package.json +1 -1
  29. package/src/index.ts +5 -0
  30. package/src/mixins/OxyServices.applications.ts +79 -0
  31. package/src/mixins/OxyServices.assets.ts +14 -44
  32. package/src/mixins/OxyServices.auth.ts +35 -1
  33. package/src/mixins/OxyServices.links.ts +103 -0
  34. package/src/mixins/OxyServices.nodes.ts +348 -0
  35. package/src/mixins/__tests__/OxyServices.links.test.ts +154 -0
  36. package/src/mixins/__tests__/OxyServices.nodes.test.ts +341 -0
  37. package/src/mixins/__tests__/commonsSignIn.test.ts +41 -16
  38. package/src/mixins/__tests__/connectedApps.test.ts +123 -0
  39. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +10 -24
  40. package/src/mixins/index.ts +10 -0
  41. package/src/utils/__tests__/ssoBounce.test.ts +28 -0
  42. package/src/utils/ssoBounce.ts +69 -0
package/src/index.ts CHANGED
@@ -98,6 +98,7 @@ export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
98
98
  export type {
99
99
  Application,
100
100
  PublicApplication,
101
+ ConnectedApp,
101
102
  ApplicationMember,
102
103
  ApplicationCredential,
103
104
  ApplicationRole,
@@ -210,6 +211,7 @@ export type {
210
211
  IssueCredentialInput,
211
212
  RevokeCredentialResult,
212
213
  } from './mixins/OxyServices.civic';
214
+ export type { UserNodeStatus, UserNodeMode, UserNodeController, UserNodeLivenessStatus, RegisterNodeInput, RemoveNodeResult } from './mixins/OxyServices.nodes';
213
215
 
214
216
  // ---------------------------------------------------------------------------
215
217
  // Auth helpers (token refresh, error normalisation, retry policies)
@@ -532,13 +534,16 @@ export {
532
534
  ssoDestKey,
533
535
  ssoNoSessionKey,
534
536
  ssoAttemptedKey,
537
+ ssoPriorSessionKey,
535
538
  ssoCallbackBootstrapKey,
536
539
  ssoNavigate,
537
540
  getSsoCallbackBootstrapScript,
538
541
  buildSsoBounceUrl,
539
542
  isCentralIdPOrigin,
540
543
  guardActive,
544
+ allowSsoBounce,
541
545
  } from './utils/ssoBounce';
546
+ export type { SsoBounceGate } from './utils/ssoBounce';
542
547
 
543
548
  export { runColdBoot } from './utils/coldBoot';
544
549
  export type {
@@ -164,6 +164,32 @@ export interface PublicApplication {
164
164
  developerName?: string;
165
165
  }
166
166
 
167
+ /**
168
+ * A connected (OAuth-authorized) application from the current user's point of
169
+ * view: an application the user has granted access to via the consent flow.
170
+ *
171
+ * Returned by `GET /auth/grants` and rendered in the user-facing "Connected
172
+ * apps" management surface. Keyed by `applicationId` (the application's Mongo
173
+ * `_id`) rather than a credential/client id, so the grant — and a subsequent
174
+ * {@link OxyServicesApplicationsMixin.revokeAppGrant} — survive credential
175
+ * rotation. This is a display shape: it carries the application's name/logo and
176
+ * the granted scopes, never any membership or credential material.
177
+ */
178
+ export interface ConnectedApp {
179
+ /** The connected application's Mongo `_id`. Use this to revoke the grant. */
180
+ applicationId: string;
181
+ /** Human-readable application name shown to the user. */
182
+ name: string;
183
+ /** Optional logo URL for the application. */
184
+ logoUrl?: string;
185
+ /** OAuth scopes the user has granted to the application. */
186
+ scopes: string[];
187
+ /** ISO timestamp of when the user first authorized the application. */
188
+ firstGrantedAt: string;
189
+ /** ISO timestamp of when the grant was last exercised. */
190
+ lastUsedAt: string;
191
+ }
192
+
167
193
  /** Input accepted by `createApplication`. Staff-only fields are not settable here. */
168
194
  export interface CreateApplicationInput {
169
195
  name: string;
@@ -309,6 +335,59 @@ export function OxyServicesApplicationsMixin<T extends typeof OxyServicesBase>(B
309
335
  }
310
336
  }
311
337
 
338
+ /**
339
+ * List the OAuth-authorized applications the current user has connected —
340
+ * the third-party apps the user granted access to via the consent flow.
341
+ * Each entry is a {@link ConnectedApp} carrying the application's display
342
+ * identity, the granted scopes, and when the grant was first made and last
343
+ * exercised. Requires an authenticated session.
344
+ *
345
+ * Backed by `GET /auth/grants`. The response is briefly cached
346
+ * (identity-scoped); {@link revokeAppGrant} busts that cache so a revoke is
347
+ * reflected on the next read.
348
+ */
349
+ async listConnectedApps(): Promise<ConnectedApp[]> {
350
+ try {
351
+ return await this.makeRequest<ConnectedApp[]>(
352
+ 'GET',
353
+ '/auth/grants',
354
+ undefined,
355
+ { cache: true, cacheTTL: CACHE_TIMES.SHORT },
356
+ );
357
+ } catch (error) {
358
+ throw this.handleError(error);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Revoke the current user's grant for a connected application, identified by
364
+ * its application `_id` (a {@link ConnectedApp.applicationId}, NOT a
365
+ * credential/client id — keyed by application so the revocation survives
366
+ * credential rotation). After this the application can no longer act on the
367
+ * user's behalf until it is re-authorized.
368
+ *
369
+ * Backed by `DELETE /auth/grants/:applicationId`. On success the cached
370
+ * connected-apps list (`GET:/auth/grants`) is invalidated so the next
371
+ * {@link listConnectedApps} read reflects the removal.
372
+ *
373
+ * @param applicationId - The connected application's Mongo `_id`.
374
+ */
375
+ async revokeAppGrant(applicationId: string): Promise<void> {
376
+ try {
377
+ await this.makeRequest<{ revoked: boolean }>(
378
+ 'DELETE',
379
+ `/auth/grants/${applicationId}`,
380
+ undefined,
381
+ { cache: false },
382
+ );
383
+ // A revoke removes an entry from the user's connected-apps list; bust
384
+ // the cached `GET /auth/grants` so the next read re-fetches.
385
+ this.clearCacheEntry('GET:/auth/grants');
386
+ } catch (error) {
387
+ throw this.handleError(error);
388
+ }
389
+ }
390
+
312
391
  /**
313
392
  * List applications the current user is an active member of.
314
393
  *
@@ -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
+ }