@mediaviz/sdk 1.0.79 → 1.0.81

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/sdk.cjs CHANGED
@@ -91,8 +91,15 @@ function requireErrors () {
91
91
  * @returns {OAuthError}
92
92
  */
93
93
  static fromResponse(status, body) {
94
- if (body && typeof body === 'object' && typeof body.error === 'string') {
95
- return new OAuthError(body.error, body.error_description ?? '', status, body);
94
+ // FastAPI wraps HTTPException payloads in a `detail` envelope, so an OAuth
95
+ // error arrives as { detail: { error, error_description } }. Unwrap it
96
+ // before falling back, or every server-side OAuth error reads as server_error.
97
+ const payload =
98
+ body && typeof body === 'object' && typeof body.error !== 'string' && body.detail
99
+ ? body.detail
100
+ : body;
101
+ if (payload && typeof payload === 'object' && typeof payload.error === 'string') {
102
+ return new OAuthError(payload.error, payload.error_description ?? '', status, body);
96
103
  }
97
104
  return new OAuthError('server_error', 'Unexpected server response', status, body);
98
105
  }
@@ -173,6 +180,9 @@ function requireClient () {
173
180
  const { postForm, postJson } = requireHttp();
174
181
  const { OAuthError } = requireErrors();
175
182
 
183
+ // In-flight dedup key for client_credentials re-mints (no refresh_token to key on).
184
+ const CLIENT_CREDENTIALS_KEY = Symbol('client_credentials');
185
+
176
186
  class OAuthClient {
177
187
  /**
178
188
  * @param {import('./types').OAuthClientConfig} config
@@ -188,12 +198,22 @@ function requireClient () {
188
198
  */
189
199
  static async registerClient(params) {
190
200
  const baseUrl = params.baseUrl.replace(/\/+$/, '');
191
- return postJson(`${baseUrl}/oauth/clients`, {
201
+ const body = {
192
202
  client_name: params.clientName,
193
203
  client_type: params.clientType,
194
204
  redirect_uris: params.redirectUris,
195
205
  is_first_party: params.isFirstParty,
196
- });
206
+ };
207
+ // grant_types/company_id are required to provision a confidential
208
+ // client_credentials (machine-to-machine) client; omitted bodies keep the
209
+ // legacy public/authorization_code shape. The endpoint is admin-gated, so a
210
+ // bearer token must be supplied for the request to authenticate.
211
+ if (params.grantTypes !== undefined) body.grant_types = params.grantTypes;
212
+ if (params.companyId !== undefined) body.company_id = params.companyId;
213
+ const headers = params.accessToken
214
+ ? { Authorization: `Bearer ${params.accessToken}` }
215
+ : {};
216
+ return postJson(`${baseUrl}/oauth/clients`, body, headers);
197
217
  }
198
218
 
199
219
  /**
@@ -311,15 +331,27 @@ function requireClient () {
311
331
  return { data: json, updatedTokens: null };
312
332
  }
313
333
 
314
- // 401: attempt refresh — propagate OAuthError if refresh fails.
315
- // Concurrent request() callers with the same refresh_token share one in-flight
316
- // call so the server only sees one rotation (the second would get invalid_grant).
317
- let pending = this._inFlightRefreshes.get(refreshToken);
334
+ // 401: re-authenticate, then retry once.
335
+ // - User sessions (refresh_token present) use the refresh_token grant. The server
336
+ // rotates the token one-time, so concurrent callers share one in-flight call
337
+ // (a second rotation would get invalid_grant).
338
+ // - Machine-to-machine sessions (no refresh_token, client_secret configured) re-mint
339
+ // via client_credentials, which issues no refresh token to rotate (RFC 6749 §4.4).
340
+ // - With neither a refresh_token nor a client_secret there is nothing to retry with.
341
+ if (!refreshToken && !this._config.clientSecret) {
342
+ throw OAuthError.fromResponse(401, await firstResponse.json());
343
+ }
344
+
345
+ const reauthKey = refreshToken || CLIENT_CREDENTIALS_KEY;
346
+ let pending = this._inFlightRefreshes.get(reauthKey);
318
347
  if (!pending) {
319
- pending = this.refreshAccessToken(refreshToken).finally(() => {
320
- this._inFlightRefreshes.delete(refreshToken);
348
+ const reauth = refreshToken
349
+ ? this.refreshAccessToken(refreshToken)
350
+ : this.getClientCredentialsToken();
351
+ pending = reauth.finally(() => {
352
+ this._inFlightRefreshes.delete(reauthKey);
321
353
  });
322
- this._inFlightRefreshes.set(refreshToken, pending);
354
+ this._inFlightRefreshes.set(reauthKey, pending);
323
355
  }
324
356
  const newTokens = await pending;
325
357
 
@@ -401,6 +433,9 @@ function requireTypes () {
401
433
  * @property {string} clientType - "public" or "confidential"
402
434
  * @property {string[]} redirectUris - HTTPS redirect URIs
403
435
  * @property {boolean} isFirstParty - Whether this is a first-party client
436
+ * @property {string[]} [grantTypes] - Allowed grants, e.g. ["client_credentials"]; required for machine-to-machine clients
437
+ * @property {number} [companyId] - Company to bind the client to; required for the client_credentials grant
438
+ * @property {string} [accessToken] - Admin bearer token; the registration endpoint is authorization-gated
404
439
  */
405
440
 
406
441
  /**
@@ -1498,7 +1533,7 @@ class Search {
1498
1533
  return data;
1499
1534
  }
1500
1535
 
1501
- async searchProjectPhotosParametrized(projectTableName, { andSearchText, orSearchText, notSearchText, city, country, state, albums, dateMin, dateMax, dateNullAnd, dateNullOr, dateOrder, bestOfSimilarSetsOnly, splitByTier, size } = {}) {
1536
+ async searchProjectPhotosParametrized(projectTableName, { andSearchText, orSearchText, notSearchText, city, country, state, albums, dateMin, dateMax, dateNullAnd, dateNullOr, ascOrDesc, bestOfSimilarSetsOnly, splitByTier, lastId, limit } = {}) {
1502
1537
  this._ctx.requireTokens();
1503
1538
  let path = `/api/v1/search/parametrized/${encodeURIComponent(projectTableName)}/`;
1504
1539
  const query = new URLSearchParams();
@@ -1513,10 +1548,25 @@ class Search {
1513
1548
  if (dateMax !== undefined) (Array.isArray(dateMax) ? dateMax : [dateMax]).forEach(v => query.append('date_max', v));
1514
1549
  if (dateNullAnd !== undefined) (Array.isArray(dateNullAnd) ? dateNullAnd : [dateNullAnd]).forEach(v => query.append('date_null_and', v));
1515
1550
  if (dateNullOr !== undefined) (Array.isArray(dateNullOr) ? dateNullOr : [dateNullOr]).forEach(v => query.append('date_null_or', v));
1516
- if (dateOrder !== undefined) (Array.isArray(dateOrder) ? dateOrder : [dateOrder]).forEach(v => query.append('date_order', v));
1551
+ if (ascOrDesc !== undefined) (Array.isArray(ascOrDesc) ? ascOrDesc : [ascOrDesc]).forEach(v => query.append('asc_or_desc', v));
1517
1552
  if (bestOfSimilarSetsOnly !== undefined) (Array.isArray(bestOfSimilarSetsOnly) ? bestOfSimilarSetsOnly : [bestOfSimilarSetsOnly]).forEach(v => query.append('best_of_similar_sets_only', v));
1518
1553
  if (splitByTier !== undefined) (Array.isArray(splitByTier) ? splitByTier : [splitByTier]).forEach(v => query.append('split_by_tier', v));
1519
- if (size !== undefined) (Array.isArray(size) ? size : [size]).forEach(v => query.append('size', v));
1554
+ if (lastId !== undefined) (Array.isArray(lastId) ? lastId : [lastId]).forEach(v => query.append('last_id', v));
1555
+ if (limit !== undefined) (Array.isArray(limit) ? limit : [limit]).forEach(v => query.append('limit', v));
1556
+ const qs = query.toString();
1557
+ if (qs) path += '?' + qs;
1558
+ const { data } = await this._ctx.client.request(path, 'GET', this._ctx.accessToken, this._ctx.refreshToken);
1559
+ return data;
1560
+ }
1561
+
1562
+ async recallProjectPhotoIds(projectTableName, { ascOrDesc, bestOfSimilarSetsOnly, lastId, limit } = {}) {
1563
+ this._ctx.requireTokens();
1564
+ let path = `/api/v1/search/recall/${encodeURIComponent(projectTableName)}/`;
1565
+ const query = new URLSearchParams();
1566
+ if (ascOrDesc !== undefined) (Array.isArray(ascOrDesc) ? ascOrDesc : [ascOrDesc]).forEach(v => query.append('asc_or_desc', v));
1567
+ if (bestOfSimilarSetsOnly !== undefined) (Array.isArray(bestOfSimilarSetsOnly) ? bestOfSimilarSetsOnly : [bestOfSimilarSetsOnly]).forEach(v => query.append('best_of_similar_sets_only', v));
1568
+ if (lastId !== undefined) (Array.isArray(lastId) ? lastId : [lastId]).forEach(v => query.append('last_id', v));
1569
+ if (limit !== undefined) (Array.isArray(limit) ? limit : [limit]).forEach(v => query.append('limit', v));
1520
1570
  const qs = query.toString();
1521
1571
  if (qs) path += '?' + qs;
1522
1572
  const { data } = await this._ctx.client.request(path, 'GET', this._ctx.accessToken, this._ctx.refreshToken);
package/dist/sdk.d.cts CHANGED
@@ -455,7 +455,8 @@ export class Projects {
455
455
 
456
456
  export class Search {
457
457
  searchProjectPhotos(projectTableName: string, options?: { andParams?: any, andStringParams?: any, orParams?: any, orStringParams?: any, notParams?: any, notStringParams?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], dateOrder?: string | string[], customAlbumId?: number | number[], bestOfSimilarSetsOnly?: boolean | boolean[], curatedAlbumId?: number | number[], splitByTier?: boolean | boolean[] }): Promise<Record<string, any>>;
458
- searchProjectPhotosParametrized(projectTableName: string, options?: { andSearchText?: any, orSearchText?: any, notSearchText?: any, city?: any, country?: any, state?: any, albums?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], dateOrder?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[], size?: number | number[] }): Promise<Record<string, any>>;
458
+ searchProjectPhotosParametrized(projectTableName: string, options?: { andSearchText?: any, orSearchText?: any, notSearchText?: any, city?: any, country?: any, state?: any, albums?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], ascOrDesc?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[], lastId?: number | number[], limit?: number | number[] }): Promise<Record<string, any>>;
459
+ recallProjectPhotoIds(projectTableName: string, options?: { ascOrDesc?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], lastId?: number | number[], limit?: number | number[] }): Promise<any>;
459
460
  searchProjectPhotosNaturalLanguage(projectTableName: string, searchText: string, size?: number, options?: { minCosine?: number | number[], labelMinCosine?: number | number[], labelTopK?: number | number[], labelDelta?: number | number[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[] }): Promise<Record<string, any>>;
460
461
  getProjectSavedSearches(projectTableName: string): Promise<any>;
461
462
  getSavedSearchById(searchId: number): Promise<SearchDisplay>;
package/dist/sdk.d.ts CHANGED
@@ -455,7 +455,8 @@ export class Projects {
455
455
 
456
456
  export class Search {
457
457
  searchProjectPhotos(projectTableName: string, options?: { andParams?: any, andStringParams?: any, orParams?: any, orStringParams?: any, notParams?: any, notStringParams?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], dateOrder?: string | string[], customAlbumId?: number | number[], bestOfSimilarSetsOnly?: boolean | boolean[], curatedAlbumId?: number | number[], splitByTier?: boolean | boolean[] }): Promise<Record<string, any>>;
458
- searchProjectPhotosParametrized(projectTableName: string, options?: { andSearchText?: any, orSearchText?: any, notSearchText?: any, city?: any, country?: any, state?: any, albums?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], dateOrder?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[], size?: number | number[] }): Promise<Record<string, any>>;
458
+ searchProjectPhotosParametrized(projectTableName: string, options?: { andSearchText?: any, orSearchText?: any, notSearchText?: any, city?: any, country?: any, state?: any, albums?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], ascOrDesc?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[], lastId?: number | number[], limit?: number | number[] }): Promise<Record<string, any>>;
459
+ recallProjectPhotoIds(projectTableName: string, options?: { ascOrDesc?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], lastId?: number | number[], limit?: number | number[] }): Promise<any>;
459
460
  searchProjectPhotosNaturalLanguage(projectTableName: string, searchText: string, size?: number, options?: { minCosine?: number | number[], labelMinCosine?: number | number[], labelTopK?: number | number[], labelDelta?: number | number[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[] }): Promise<Record<string, any>>;
460
461
  getProjectSavedSearches(projectTableName: string): Promise<any>;
461
462
  getSavedSearchById(searchId: number): Promise<SearchDisplay>;
package/dist/sdk.esm.d.ts CHANGED
@@ -455,7 +455,8 @@ export class Projects {
455
455
 
456
456
  export class Search {
457
457
  searchProjectPhotos(projectTableName: string, options?: { andParams?: any, andStringParams?: any, orParams?: any, orStringParams?: any, notParams?: any, notStringParams?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], dateOrder?: string | string[], customAlbumId?: number | number[], bestOfSimilarSetsOnly?: boolean | boolean[], curatedAlbumId?: number | number[], splitByTier?: boolean | boolean[] }): Promise<Record<string, any>>;
458
- searchProjectPhotosParametrized(projectTableName: string, options?: { andSearchText?: any, orSearchText?: any, notSearchText?: any, city?: any, country?: any, state?: any, albums?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], dateOrder?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[], size?: number | number[] }): Promise<Record<string, any>>;
458
+ searchProjectPhotosParametrized(projectTableName: string, options?: { andSearchText?: any, orSearchText?: any, notSearchText?: any, city?: any, country?: any, state?: any, albums?: any, dateMin?: string | string[], dateMax?: string | string[], dateNullAnd?: boolean | boolean[], dateNullOr?: boolean | boolean[], ascOrDesc?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[], lastId?: number | number[], limit?: number | number[] }): Promise<Record<string, any>>;
459
+ recallProjectPhotoIds(projectTableName: string, options?: { ascOrDesc?: string | string[], bestOfSimilarSetsOnly?: boolean | boolean[], lastId?: number | number[], limit?: number | number[] }): Promise<any>;
459
460
  searchProjectPhotosNaturalLanguage(projectTableName: string, searchText: string, size?: number, options?: { minCosine?: number | number[], labelMinCosine?: number | number[], labelTopK?: number | number[], labelDelta?: number | number[], bestOfSimilarSetsOnly?: boolean | boolean[], splitByTier?: boolean | boolean[] }): Promise<Record<string, any>>;
460
461
  getProjectSavedSearches(projectTableName: string): Promise<any>;
461
462
  getSavedSearchById(searchId: number): Promise<SearchDisplay>;
package/dist/sdk.esm.js CHANGED
@@ -89,8 +89,15 @@ function requireErrors () {
89
89
  * @returns {OAuthError}
90
90
  */
91
91
  static fromResponse(status, body) {
92
- if (body && typeof body === 'object' && typeof body.error === 'string') {
93
- return new OAuthError(body.error, body.error_description ?? '', status, body);
92
+ // FastAPI wraps HTTPException payloads in a `detail` envelope, so an OAuth
93
+ // error arrives as { detail: { error, error_description } }. Unwrap it
94
+ // before falling back, or every server-side OAuth error reads as server_error.
95
+ const payload =
96
+ body && typeof body === 'object' && typeof body.error !== 'string' && body.detail
97
+ ? body.detail
98
+ : body;
99
+ if (payload && typeof payload === 'object' && typeof payload.error === 'string') {
100
+ return new OAuthError(payload.error, payload.error_description ?? '', status, body);
94
101
  }
95
102
  return new OAuthError('server_error', 'Unexpected server response', status, body);
96
103
  }
@@ -171,6 +178,9 @@ function requireClient () {
171
178
  const { postForm, postJson } = requireHttp();
172
179
  const { OAuthError } = requireErrors();
173
180
 
181
+ // In-flight dedup key for client_credentials re-mints (no refresh_token to key on).
182
+ const CLIENT_CREDENTIALS_KEY = Symbol('client_credentials');
183
+
174
184
  class OAuthClient {
175
185
  /**
176
186
  * @param {import('./types').OAuthClientConfig} config
@@ -186,12 +196,22 @@ function requireClient () {
186
196
  */
187
197
  static async registerClient(params) {
188
198
  const baseUrl = params.baseUrl.replace(/\/+$/, '');
189
- return postJson(`${baseUrl}/oauth/clients`, {
199
+ const body = {
190
200
  client_name: params.clientName,
191
201
  client_type: params.clientType,
192
202
  redirect_uris: params.redirectUris,
193
203
  is_first_party: params.isFirstParty,
194
- });
204
+ };
205
+ // grant_types/company_id are required to provision a confidential
206
+ // client_credentials (machine-to-machine) client; omitted bodies keep the
207
+ // legacy public/authorization_code shape. The endpoint is admin-gated, so a
208
+ // bearer token must be supplied for the request to authenticate.
209
+ if (params.grantTypes !== undefined) body.grant_types = params.grantTypes;
210
+ if (params.companyId !== undefined) body.company_id = params.companyId;
211
+ const headers = params.accessToken
212
+ ? { Authorization: `Bearer ${params.accessToken}` }
213
+ : {};
214
+ return postJson(`${baseUrl}/oauth/clients`, body, headers);
195
215
  }
196
216
 
197
217
  /**
@@ -309,15 +329,27 @@ function requireClient () {
309
329
  return { data: json, updatedTokens: null };
310
330
  }
311
331
 
312
- // 401: attempt refresh — propagate OAuthError if refresh fails.
313
- // Concurrent request() callers with the same refresh_token share one in-flight
314
- // call so the server only sees one rotation (the second would get invalid_grant).
315
- let pending = this._inFlightRefreshes.get(refreshToken);
332
+ // 401: re-authenticate, then retry once.
333
+ // - User sessions (refresh_token present) use the refresh_token grant. The server
334
+ // rotates the token one-time, so concurrent callers share one in-flight call
335
+ // (a second rotation would get invalid_grant).
336
+ // - Machine-to-machine sessions (no refresh_token, client_secret configured) re-mint
337
+ // via client_credentials, which issues no refresh token to rotate (RFC 6749 §4.4).
338
+ // - With neither a refresh_token nor a client_secret there is nothing to retry with.
339
+ if (!refreshToken && !this._config.clientSecret) {
340
+ throw OAuthError.fromResponse(401, await firstResponse.json());
341
+ }
342
+
343
+ const reauthKey = refreshToken || CLIENT_CREDENTIALS_KEY;
344
+ let pending = this._inFlightRefreshes.get(reauthKey);
316
345
  if (!pending) {
317
- pending = this.refreshAccessToken(refreshToken).finally(() => {
318
- this._inFlightRefreshes.delete(refreshToken);
346
+ const reauth = refreshToken
347
+ ? this.refreshAccessToken(refreshToken)
348
+ : this.getClientCredentialsToken();
349
+ pending = reauth.finally(() => {
350
+ this._inFlightRefreshes.delete(reauthKey);
319
351
  });
320
- this._inFlightRefreshes.set(refreshToken, pending);
352
+ this._inFlightRefreshes.set(reauthKey, pending);
321
353
  }
322
354
  const newTokens = await pending;
323
355
 
@@ -399,6 +431,9 @@ function requireTypes () {
399
431
  * @property {string} clientType - "public" or "confidential"
400
432
  * @property {string[]} redirectUris - HTTPS redirect URIs
401
433
  * @property {boolean} isFirstParty - Whether this is a first-party client
434
+ * @property {string[]} [grantTypes] - Allowed grants, e.g. ["client_credentials"]; required for machine-to-machine clients
435
+ * @property {number} [companyId] - Company to bind the client to; required for the client_credentials grant
436
+ * @property {string} [accessToken] - Admin bearer token; the registration endpoint is authorization-gated
402
437
  */
403
438
 
404
439
  /**
@@ -1496,7 +1531,7 @@ class Search {
1496
1531
  return data;
1497
1532
  }
1498
1533
 
1499
- async searchProjectPhotosParametrized(projectTableName, { andSearchText, orSearchText, notSearchText, city, country, state, albums, dateMin, dateMax, dateNullAnd, dateNullOr, dateOrder, bestOfSimilarSetsOnly, splitByTier, size } = {}) {
1534
+ async searchProjectPhotosParametrized(projectTableName, { andSearchText, orSearchText, notSearchText, city, country, state, albums, dateMin, dateMax, dateNullAnd, dateNullOr, ascOrDesc, bestOfSimilarSetsOnly, splitByTier, lastId, limit } = {}) {
1500
1535
  this._ctx.requireTokens();
1501
1536
  let path = `/api/v1/search/parametrized/${encodeURIComponent(projectTableName)}/`;
1502
1537
  const query = new URLSearchParams();
@@ -1511,10 +1546,25 @@ class Search {
1511
1546
  if (dateMax !== undefined) (Array.isArray(dateMax) ? dateMax : [dateMax]).forEach(v => query.append('date_max', v));
1512
1547
  if (dateNullAnd !== undefined) (Array.isArray(dateNullAnd) ? dateNullAnd : [dateNullAnd]).forEach(v => query.append('date_null_and', v));
1513
1548
  if (dateNullOr !== undefined) (Array.isArray(dateNullOr) ? dateNullOr : [dateNullOr]).forEach(v => query.append('date_null_or', v));
1514
- if (dateOrder !== undefined) (Array.isArray(dateOrder) ? dateOrder : [dateOrder]).forEach(v => query.append('date_order', v));
1549
+ if (ascOrDesc !== undefined) (Array.isArray(ascOrDesc) ? ascOrDesc : [ascOrDesc]).forEach(v => query.append('asc_or_desc', v));
1515
1550
  if (bestOfSimilarSetsOnly !== undefined) (Array.isArray(bestOfSimilarSetsOnly) ? bestOfSimilarSetsOnly : [bestOfSimilarSetsOnly]).forEach(v => query.append('best_of_similar_sets_only', v));
1516
1551
  if (splitByTier !== undefined) (Array.isArray(splitByTier) ? splitByTier : [splitByTier]).forEach(v => query.append('split_by_tier', v));
1517
- if (size !== undefined) (Array.isArray(size) ? size : [size]).forEach(v => query.append('size', v));
1552
+ if (lastId !== undefined) (Array.isArray(lastId) ? lastId : [lastId]).forEach(v => query.append('last_id', v));
1553
+ if (limit !== undefined) (Array.isArray(limit) ? limit : [limit]).forEach(v => query.append('limit', v));
1554
+ const qs = query.toString();
1555
+ if (qs) path += '?' + qs;
1556
+ const { data } = await this._ctx.client.request(path, 'GET', this._ctx.accessToken, this._ctx.refreshToken);
1557
+ return data;
1558
+ }
1559
+
1560
+ async recallProjectPhotoIds(projectTableName, { ascOrDesc, bestOfSimilarSetsOnly, lastId, limit } = {}) {
1561
+ this._ctx.requireTokens();
1562
+ let path = `/api/v1/search/recall/${encodeURIComponent(projectTableName)}/`;
1563
+ const query = new URLSearchParams();
1564
+ if (ascOrDesc !== undefined) (Array.isArray(ascOrDesc) ? ascOrDesc : [ascOrDesc]).forEach(v => query.append('asc_or_desc', v));
1565
+ if (bestOfSimilarSetsOnly !== undefined) (Array.isArray(bestOfSimilarSetsOnly) ? bestOfSimilarSetsOnly : [bestOfSimilarSetsOnly]).forEach(v => query.append('best_of_similar_sets_only', v));
1566
+ if (lastId !== undefined) (Array.isArray(lastId) ? lastId : [lastId]).forEach(v => query.append('last_id', v));
1567
+ if (limit !== undefined) (Array.isArray(limit) ? limit : [limit]).forEach(v => query.append('limit', v));
1518
1568
  const qs = query.toString();
1519
1569
  if (qs) path += '?' + qs;
1520
1570
  const { data } = await this._ctx.client.request(path, 'GET', this._ctx.accessToken, this._ctx.refreshToken);
package/dist/sdk.umd.js CHANGED
@@ -95,8 +95,15 @@
95
95
  * @returns {OAuthError}
96
96
  */
97
97
  static fromResponse(status, body) {
98
- if (body && typeof body === 'object' && typeof body.error === 'string') {
99
- return new OAuthError(body.error, body.error_description ?? '', status, body);
98
+ // FastAPI wraps HTTPException payloads in a `detail` envelope, so an OAuth
99
+ // error arrives as { detail: { error, error_description } }. Unwrap it
100
+ // before falling back, or every server-side OAuth error reads as server_error.
101
+ const payload =
102
+ body && typeof body === 'object' && typeof body.error !== 'string' && body.detail
103
+ ? body.detail
104
+ : body;
105
+ if (payload && typeof payload === 'object' && typeof payload.error === 'string') {
106
+ return new OAuthError(payload.error, payload.error_description ?? '', status, body);
100
107
  }
101
108
  return new OAuthError('server_error', 'Unexpected server response', status, body);
102
109
  }
@@ -177,6 +184,9 @@
177
184
  const { postForm, postJson } = requireHttp();
178
185
  const { OAuthError } = requireErrors();
179
186
 
187
+ // In-flight dedup key for client_credentials re-mints (no refresh_token to key on).
188
+ const CLIENT_CREDENTIALS_KEY = Symbol('client_credentials');
189
+
180
190
  class OAuthClient {
181
191
  /**
182
192
  * @param {import('./types').OAuthClientConfig} config
@@ -192,12 +202,22 @@
192
202
  */
193
203
  static async registerClient(params) {
194
204
  const baseUrl = params.baseUrl.replace(/\/+$/, '');
195
- return postJson(`${baseUrl}/oauth/clients`, {
205
+ const body = {
196
206
  client_name: params.clientName,
197
207
  client_type: params.clientType,
198
208
  redirect_uris: params.redirectUris,
199
209
  is_first_party: params.isFirstParty,
200
- });
210
+ };
211
+ // grant_types/company_id are required to provision a confidential
212
+ // client_credentials (machine-to-machine) client; omitted bodies keep the
213
+ // legacy public/authorization_code shape. The endpoint is admin-gated, so a
214
+ // bearer token must be supplied for the request to authenticate.
215
+ if (params.grantTypes !== undefined) body.grant_types = params.grantTypes;
216
+ if (params.companyId !== undefined) body.company_id = params.companyId;
217
+ const headers = params.accessToken
218
+ ? { Authorization: `Bearer ${params.accessToken}` }
219
+ : {};
220
+ return postJson(`${baseUrl}/oauth/clients`, body, headers);
201
221
  }
202
222
 
203
223
  /**
@@ -315,15 +335,27 @@
315
335
  return { data: json, updatedTokens: null };
316
336
  }
317
337
 
318
- // 401: attempt refresh — propagate OAuthError if refresh fails.
319
- // Concurrent request() callers with the same refresh_token share one in-flight
320
- // call so the server only sees one rotation (the second would get invalid_grant).
321
- let pending = this._inFlightRefreshes.get(refreshToken);
338
+ // 401: re-authenticate, then retry once.
339
+ // - User sessions (refresh_token present) use the refresh_token grant. The server
340
+ // rotates the token one-time, so concurrent callers share one in-flight call
341
+ // (a second rotation would get invalid_grant).
342
+ // - Machine-to-machine sessions (no refresh_token, client_secret configured) re-mint
343
+ // via client_credentials, which issues no refresh token to rotate (RFC 6749 §4.4).
344
+ // - With neither a refresh_token nor a client_secret there is nothing to retry with.
345
+ if (!refreshToken && !this._config.clientSecret) {
346
+ throw OAuthError.fromResponse(401, await firstResponse.json());
347
+ }
348
+
349
+ const reauthKey = refreshToken || CLIENT_CREDENTIALS_KEY;
350
+ let pending = this._inFlightRefreshes.get(reauthKey);
322
351
  if (!pending) {
323
- pending = this.refreshAccessToken(refreshToken).finally(() => {
324
- this._inFlightRefreshes.delete(refreshToken);
352
+ const reauth = refreshToken
353
+ ? this.refreshAccessToken(refreshToken)
354
+ : this.getClientCredentialsToken();
355
+ pending = reauth.finally(() => {
356
+ this._inFlightRefreshes.delete(reauthKey);
325
357
  });
326
- this._inFlightRefreshes.set(refreshToken, pending);
358
+ this._inFlightRefreshes.set(reauthKey, pending);
327
359
  }
328
360
  const newTokens = await pending;
329
361
 
@@ -405,6 +437,9 @@
405
437
  * @property {string} clientType - "public" or "confidential"
406
438
  * @property {string[]} redirectUris - HTTPS redirect URIs
407
439
  * @property {boolean} isFirstParty - Whether this is a first-party client
440
+ * @property {string[]} [grantTypes] - Allowed grants, e.g. ["client_credentials"]; required for machine-to-machine clients
441
+ * @property {number} [companyId] - Company to bind the client to; required for the client_credentials grant
442
+ * @property {string} [accessToken] - Admin bearer token; the registration endpoint is authorization-gated
408
443
  */
409
444
 
410
445
  /**
@@ -1502,7 +1537,7 @@
1502
1537
  return data;
1503
1538
  }
1504
1539
 
1505
- async searchProjectPhotosParametrized(projectTableName, { andSearchText, orSearchText, notSearchText, city, country, state, albums, dateMin, dateMax, dateNullAnd, dateNullOr, dateOrder, bestOfSimilarSetsOnly, splitByTier, size } = {}) {
1540
+ async searchProjectPhotosParametrized(projectTableName, { andSearchText, orSearchText, notSearchText, city, country, state, albums, dateMin, dateMax, dateNullAnd, dateNullOr, ascOrDesc, bestOfSimilarSetsOnly, splitByTier, lastId, limit } = {}) {
1506
1541
  this._ctx.requireTokens();
1507
1542
  let path = `/api/v1/search/parametrized/${encodeURIComponent(projectTableName)}/`;
1508
1543
  const query = new URLSearchParams();
@@ -1517,10 +1552,25 @@
1517
1552
  if (dateMax !== undefined) (Array.isArray(dateMax) ? dateMax : [dateMax]).forEach(v => query.append('date_max', v));
1518
1553
  if (dateNullAnd !== undefined) (Array.isArray(dateNullAnd) ? dateNullAnd : [dateNullAnd]).forEach(v => query.append('date_null_and', v));
1519
1554
  if (dateNullOr !== undefined) (Array.isArray(dateNullOr) ? dateNullOr : [dateNullOr]).forEach(v => query.append('date_null_or', v));
1520
- if (dateOrder !== undefined) (Array.isArray(dateOrder) ? dateOrder : [dateOrder]).forEach(v => query.append('date_order', v));
1555
+ if (ascOrDesc !== undefined) (Array.isArray(ascOrDesc) ? ascOrDesc : [ascOrDesc]).forEach(v => query.append('asc_or_desc', v));
1521
1556
  if (bestOfSimilarSetsOnly !== undefined) (Array.isArray(bestOfSimilarSetsOnly) ? bestOfSimilarSetsOnly : [bestOfSimilarSetsOnly]).forEach(v => query.append('best_of_similar_sets_only', v));
1522
1557
  if (splitByTier !== undefined) (Array.isArray(splitByTier) ? splitByTier : [splitByTier]).forEach(v => query.append('split_by_tier', v));
1523
- if (size !== undefined) (Array.isArray(size) ? size : [size]).forEach(v => query.append('size', v));
1558
+ if (lastId !== undefined) (Array.isArray(lastId) ? lastId : [lastId]).forEach(v => query.append('last_id', v));
1559
+ if (limit !== undefined) (Array.isArray(limit) ? limit : [limit]).forEach(v => query.append('limit', v));
1560
+ const qs = query.toString();
1561
+ if (qs) path += '?' + qs;
1562
+ const { data } = await this._ctx.client.request(path, 'GET', this._ctx.accessToken, this._ctx.refreshToken);
1563
+ return data;
1564
+ }
1565
+
1566
+ async recallProjectPhotoIds(projectTableName, { ascOrDesc, bestOfSimilarSetsOnly, lastId, limit } = {}) {
1567
+ this._ctx.requireTokens();
1568
+ let path = `/api/v1/search/recall/${encodeURIComponent(projectTableName)}/`;
1569
+ const query = new URLSearchParams();
1570
+ if (ascOrDesc !== undefined) (Array.isArray(ascOrDesc) ? ascOrDesc : [ascOrDesc]).forEach(v => query.append('asc_or_desc', v));
1571
+ if (bestOfSimilarSetsOnly !== undefined) (Array.isArray(bestOfSimilarSetsOnly) ? bestOfSimilarSetsOnly : [bestOfSimilarSetsOnly]).forEach(v => query.append('best_of_similar_sets_only', v));
1572
+ if (lastId !== undefined) (Array.isArray(lastId) ? lastId : [lastId]).forEach(v => query.append('last_id', v));
1573
+ if (limit !== undefined) (Array.isArray(limit) ? limit : [limit]).forEach(v => query.append('limit', v));
1524
1574
  const qs = query.toString();
1525
1575
  if (qs) path += '?' + qs;
1526
1576
  const { data } = await this._ctx.client.request(path, 'GET', this._ctx.accessToken, this._ctx.refreshToken);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediaviz/sdk",
3
- "version": "1.0.79",
3
+ "version": "1.0.81",
4
4
  "description": "MediaViz JavaScript SDK \u2014 auto-generated public endpoint client.",
5
5
  "license": "MIT",
6
6
  "repository": {