@logto/api 1.43.0 → 1.44.0-beta.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.
package/README.md CHANGED
@@ -27,30 +27,74 @@ For detailed setup instructions, visit: https://a.logto.io/m2m-mapi
27
27
  ```ts
28
28
  import { createManagementApi } from '@logto/api/management';
29
29
 
30
- // For Logto Cloud
31
- const { apiClient } = createManagementApi('your-tenant-id', {
30
+ const { apiClient } = createManagementApi({
31
+ tenantId: 'your-tenant-id',
32
32
  clientId: 'your-client-id',
33
33
  clientSecret: 'your-client-secret',
34
34
  });
35
35
 
36
- // Make API calls
37
- const response = await apiClient.GET('/api/users');
36
+ // Make API calls with lowercase or uppercase methods
37
+ const response = await apiClient.get('/api/users');
38
+ // const response = await apiClient.GET('/api/users');
38
39
  console.log(response.data);
39
40
  ```
40
41
 
42
+ The positional `createManagementApi(tenantId, options)` form remains supported.
43
+
44
+ #### Pagination
45
+
46
+ Use `paginate()` to iterate over items from a paginated `GET` endpoint. The path, query parameters,
47
+ path parameters, headers, and signal use the same generated types as `get()`.
48
+
49
+ ```ts
50
+ for await (const user of apiClient.paginate('/api/users')) {
51
+ console.log(user);
52
+ }
53
+ ```
54
+
55
+ The iterator follows the Management API pagination headers and stops when there are no more items.
56
+ It requests 100 items per page unless `page_size` is provided. When an audit log endpoint reports a
57
+ capped total, the iterator continues until it receives an empty page because the final page is
58
+ unknown. Breaking out of the loop stops further requests.
59
+
60
+ API error responses throw `ManagementApiPaginationError`, which exposes `status`, response metadata
61
+ and headers through `response`, and the parsed API error body as `cause`. The response body has
62
+ already been read. Network, timeout, and abort errors propagate unchanged.
63
+
41
64
  #### Self-hosted / OSS
42
65
 
43
66
  ```ts
44
67
  import { createManagementApi } from '@logto/api/management';
45
68
 
46
- const { apiClient } = createManagementApi('default', {
69
+ const { apiClient } = createManagementApi({
70
+ tenantId: 'default',
47
71
  clientId: 'your-client-id',
48
72
  clientSecret: 'your-client-secret',
49
73
  baseUrl: 'https://your-logto-instance.com',
50
- apiIndicator: 'https://your-logto-instance.com/api',
51
74
  });
52
75
  ```
53
76
 
77
+ If the Management API indicator cannot be derived from a tenant ID, omit `tenantId` and provide
78
+ both `baseUrl` and `apiIndicator`.
79
+
80
+ #### Timeouts
81
+
82
+ Token fetches and Management API network requests have separate 10-second timeouts. The API request
83
+ timeout starts after token retrieval, so a request that needs a new token can use both timeout
84
+ periods. Use `tokenRequestTimeout` and `requestTimeout` to configure them in seconds. Set either option
85
+ to `0` or a negative value to disable its timeout.
86
+
87
+ Use `getTokenRequestSignal` to return a custom signal for each token request. The signal is composed
88
+ with `tokenRequestTimeout`, and the first one to abort cancels the token request. A per-request
89
+ `signal` is similarly composed with `requestTimeout` for the Management API request. Timeouts and
90
+ custom cancellation reject the API call instead of returning a response object.
91
+
92
+ #### Token refresh
93
+
94
+ After a Management API request returns `401`, the next request fetches a new token. If the replacement
95
+ token also receives a `401`, it remains cached until a request succeeds or the token expires. The SDK
96
+ does not automatically retry the failed API request.
97
+
54
98
  #### Custom authentication
55
99
 
56
100
  For advanced use cases where you need full control over the authentication logic, use `createApiClient`:
@@ -67,7 +111,7 @@ const client = createApiClient({
67
111
  });
68
112
 
69
113
  // Type-safe API calls
70
- const response = await client.GET('/api/applications/{id}', {
114
+ const response = await client.get('/api/applications/{id}', {
71
115
  params: { path: { id: 'your-app-id' } },
72
116
  });
73
117
  ```
@@ -6,7 +6,7 @@ export type AccessToken = {
6
6
  scope?: string;
7
7
  };
8
8
  export declare class ClientCredentialsError extends Error {
9
- constructor(message: string);
9
+ constructor(message: string, options?: ErrorOptions);
10
10
  }
11
11
  export type ClientCredentialsOptions = {
12
12
  clientId: string;
@@ -15,9 +15,23 @@ export type ClientCredentialsOptions = {
15
15
  tokenParams?: Record<string, string>;
16
16
  /**
17
17
  * The time in seconds before the access token expires to consider it valid.
18
+ * A value greater than or equal to the token lifetime causes every call to fetch a new token,
19
+ * so cached-token invalidation protection cannot persist across calls.
18
20
  * @default 60
19
21
  */
20
22
  accessTokenExpiryLeeway?: Seconds;
23
+ /**
24
+ * The maximum time in seconds allowed for fetching an access token.
25
+ * Non-positive and infinite values disable the timeout. `NaN` uses the default value.
26
+ * @default 10
27
+ */
28
+ tokenRequestTimeout?: Seconds;
29
+ /**
30
+ * Returns an optional signal for each token request. The factory is called once per actual token
31
+ * fetch, so concurrent callers sharing a fetch also share its signal. The signal is composed with
32
+ * `tokenRequestTimeout`, and the first signal to abort cancels the request.
33
+ */
34
+ getTokenRequestSignal?: () => AbortSignal | undefined;
21
35
  };
22
36
  /**
23
37
  * A class handles client credentials for API authentication. It caches the access token
@@ -26,12 +40,25 @@ export type ClientCredentialsOptions = {
26
40
  export declare class ClientCredentials {
27
41
  protected options: ClientCredentialsOptions;
28
42
  protected accessToken?: AccessToken;
43
+ private accessTokenPromise?;
44
+ private awaitingValidationAfterRefresh;
29
45
  get accessTokenExpiryLeeway(): Seconds;
46
+ get tokenRequestTimeout(): Seconds;
30
47
  constructor(options: ClientCredentialsOptions);
31
48
  /**
32
49
  * Retrieves the access token, refreshing it if necessary.
33
- * @returns The access token as a string.
50
+ * Concurrent calls share the same token request.
51
+ * @returns The access token and its metadata.
34
52
  */
35
53
  getAccessToken(): Promise<AccessToken>;
54
+ /**
55
+ * Invalidates the cached token if it matches the token rejected by the API.
56
+ * A delayed response for an older token must not invalidate a newer cached token.
57
+ */
58
+ invalidateAccessToken(token: string): void;
59
+ /** Allows a future 401 to invalidate the token after it receives a successful API response. */
60
+ markAccessTokenAsValid(token: string): void;
61
+ private fetchAccessToken;
62
+ private requestAccessToken;
36
63
  }
37
64
  export {};
@@ -1,9 +1,29 @@
1
+ import { createTimeoutSignal, getAbortReason, normalizeTimeout } from './timeout.js';
1
2
  export class ClientCredentialsError extends Error {
2
- constructor(message) {
3
- super(message);
3
+ constructor(message, options) {
4
+ super(message, options);
4
5
  this.name = 'ClientCredentialsError';
5
6
  }
6
7
  }
8
+ const createAbortPromise = (signal) => {
9
+ // eslint-disable-next-line no-use-extend-native/no-use-extend-native -- `Promise.withResolvers` is standard since ES2024; the rule's built-in method list predates it.
10
+ const { promise, reject } = Promise.withResolvers();
11
+ const handleAbort = () => {
12
+ reject(getAbortReason(signal, 'Token request aborted'));
13
+ };
14
+ if (signal.aborted) {
15
+ handleAbort();
16
+ }
17
+ else {
18
+ signal.addEventListener('abort', handleAbort, { once: true });
19
+ }
20
+ return {
21
+ promise,
22
+ clear: () => {
23
+ signal.removeEventListener('abort', handleAbort);
24
+ },
25
+ };
26
+ };
7
27
  /**
8
28
  * A class handles client credentials for API authentication. It caches the access token
9
29
  * and provides methods to retrieve it, ensuring the token is valid and refreshing it when necessary.
@@ -12,46 +32,113 @@ export class ClientCredentials {
12
32
  get accessTokenExpiryLeeway() {
13
33
  return this.options.accessTokenExpiryLeeway ?? 60;
14
34
  }
35
+ get tokenRequestTimeout() {
36
+ return normalizeTimeout(this.options.tokenRequestTimeout);
37
+ }
15
38
  constructor(options) {
16
39
  this.options = options;
40
+ this.awaitingValidationAfterRefresh = false;
17
41
  }
18
42
  /**
19
43
  * Retrieves the access token, refreshing it if necessary.
20
- * @returns The access token as a string.
44
+ * Concurrent calls share the same token request.
45
+ * @returns The access token and its metadata.
21
46
  */
22
47
  async getAccessToken() {
23
48
  const now = new Date();
24
- // If the access token is not set or has expired, refresh it
25
- if (!this.accessToken?.expiresAt ||
26
- this.accessToken.expiresAt <= Math.floor(now.getTime() / 1000) + this.accessTokenExpiryLeeway) {
27
- const response = await fetch(this.options.tokenEndpoint, {
28
- method: 'POST',
29
- headers: {
30
- 'Content-Type': 'application/x-www-form-urlencoded',
31
- },
32
- body: new URLSearchParams({
33
- grant_type: 'client_credentials',
34
- client_id: this.options.clientId,
35
- client_secret: this.options.clientSecret,
36
- ...this.options.tokenParams,
37
- }).toString(),
38
- });
39
- if (!response.ok) {
40
- throw new ClientCredentialsError(`Failed to fetch access token: ${response.status} ${response.statusText}`);
49
+ // Return the cached token if it is still valid.
50
+ if (this.accessToken?.expiresAt &&
51
+ this.accessToken.expiresAt > Math.floor(now.getTime() / 1000) + this.accessTokenExpiryLeeway) {
52
+ return this.accessToken;
53
+ }
54
+ if (this.accessToken) {
55
+ // A natural expiry starts a new invalidation cycle.
56
+ this.awaitingValidationAfterRefresh = false;
57
+ }
58
+ if (this.accessTokenPromise) {
59
+ return this.accessTokenPromise;
60
+ }
61
+ try {
62
+ this.accessTokenPromise = this.fetchAccessToken();
63
+ this.accessToken = await this.accessTokenPromise;
64
+ return this.accessToken;
65
+ }
66
+ finally {
67
+ this.accessTokenPromise = undefined;
68
+ }
69
+ }
70
+ /**
71
+ * Invalidates the cached token if it matches the token rejected by the API.
72
+ * A delayed response for an older token must not invalidate a newer cached token.
73
+ */
74
+ invalidateAccessToken(token) {
75
+ if (this.accessToken?.value === token && !this.awaitingValidationAfterRefresh) {
76
+ this.accessToken = undefined;
77
+ this.awaitingValidationAfterRefresh = true;
78
+ }
79
+ }
80
+ /** Allows a future 401 to invalidate the token after it receives a successful API response. */
81
+ markAccessTokenAsValid(token) {
82
+ if (this.accessToken?.value === token) {
83
+ this.awaitingValidationAfterRefresh = false;
84
+ }
85
+ }
86
+ async fetchAccessToken() {
87
+ const timeout = createTimeoutSignal(this.tokenRequestTimeout, 'Token request timed out');
88
+ try {
89
+ const customSignal = this.options.getTokenRequestSignal?.();
90
+ const signal = customSignal
91
+ ? AbortSignal.any([timeout.signal, customSignal])
92
+ : timeout.signal;
93
+ const abort = createAbortPromise(signal);
94
+ try {
95
+ // Guard against Response implementations that do not propagate request aborts to json().
96
+ return await Promise.race([this.requestAccessToken(signal), abort.promise]);
41
97
  }
42
- const data = await response.json();
43
- if (typeof data !== 'object' || data === null || !('access_token' in data)) {
44
- throw new ClientCredentialsError('Invalid response from token endpoint');
98
+ finally {
99
+ abort.clear();
45
100
  }
46
- if (!('expires_in' in data) || typeof data.expires_in !== 'number') {
47
- throw new ClientCredentialsError('Invalid or missing expires_in in token response');
101
+ }
102
+ catch (error) {
103
+ if (error instanceof ClientCredentialsError) {
104
+ throw error;
48
105
  }
49
- this.accessToken = {
50
- value: String(data.access_token),
51
- expiresAt: Math.floor(now.getTime() / 1000) + data.expires_in,
52
- scope: 'scope' in data && typeof data.scope === 'string' ? data.scope : undefined,
53
- };
106
+ throw new ClientCredentialsError(`Failed to fetch access token: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
107
+ }
108
+ finally {
109
+ timeout.clear();
110
+ }
111
+ }
112
+ async requestAccessToken(signal) {
113
+ const now = new Date();
114
+ const response = await fetch(this.options.tokenEndpoint, {
115
+ method: 'POST',
116
+ redirect: 'error',
117
+ signal,
118
+ headers: {
119
+ 'Content-Type': 'application/x-www-form-urlencoded',
120
+ },
121
+ body: new URLSearchParams({
122
+ grant_type: 'client_credentials',
123
+ client_id: this.options.clientId,
124
+ client_secret: this.options.clientSecret,
125
+ ...this.options.tokenParams,
126
+ }).toString(),
127
+ });
128
+ if (!response.ok) {
129
+ throw new ClientCredentialsError(`Failed to fetch access token: ${response.status} ${response.statusText}`);
130
+ }
131
+ const data = await response.json();
132
+ if (typeof data !== 'object' || data === null || !('access_token' in data)) {
133
+ throw new ClientCredentialsError('Invalid response from token endpoint');
134
+ }
135
+ if (!('expires_in' in data) || typeof data.expires_in !== 'number') {
136
+ throw new ClientCredentialsError('Invalid or missing expires_in in token response');
54
137
  }
55
- return this.accessToken;
138
+ return {
139
+ value: String(data.access_token),
140
+ expiresAt: Math.floor(now.getTime() / 1000) + data.expires_in,
141
+ scope: 'scope' in data && typeof data.scope === 'string' ? data.scope : undefined,
142
+ };
56
143
  }
57
144
  }
@@ -1,5 +1 @@
1
- /**
2
- * This file was auto-generated by openapi-typescript.
3
- * Do not make direct changes to the file.
4
- */
5
1
  export {};
@@ -1,6 +1,8 @@
1
1
  import { type Client } from 'openapi-fetch';
2
2
  import { ClientCredentials } from './client-credentials.js';
3
3
  import { type paths } from './generated-types/management.js';
4
+ import { type PaginateMethod } from './pagination.js';
5
+ export { ManagementApiPaginationError } from './pagination.js';
4
6
  /**
5
7
  * Options for creating a Management API client.
6
8
  */
@@ -17,22 +19,52 @@ export type CreateManagementApiOptions = {
17
19
  */
18
20
  clientSecret: string;
19
21
  /**
20
- * Override the base URL generated from the tenant ID.
21
- * Useful for testing or custom deployments.
22
+ * The base URL for the Logto instance. Required when the tenant ID is omitted.
23
+ * Trailing slashes are ignored.
22
24
  */
23
25
  baseUrl?: string;
24
26
  /**
25
- * Override the API indicator for the management API.
26
- * Useful for testing or custom deployments.
27
+ * The resource indicator for the Management API. Required when the tenant ID is omitted.
27
28
  */
28
29
  apiIndicator?: string;
30
+ /**
31
+ * The maximum time in seconds allowed for fetching an access token.
32
+ * Non-positive and infinite values disable the timeout. `NaN` uses the default value.
33
+ * @default 10
34
+ */
35
+ tokenRequestTimeout?: number;
36
+ /**
37
+ * Returns an optional signal for each token request. The factory is called once per actual token
38
+ * fetch, so concurrent callers sharing a fetch also share its signal. The signal is composed with
39
+ * `tokenRequestTimeout`, and the first signal to abort cancels the request.
40
+ */
41
+ getTokenRequestSignal?: () => AbortSignal | undefined;
42
+ /**
43
+ * The maximum time in seconds allowed for the Management API network request after token
44
+ * retrieval. It is composed with per-request signals, and a timeout rejects the API call.
45
+ * Non-positive and infinite values disable the timeout. `NaN` uses the default value.
46
+ * @default 10
47
+ */
48
+ requestTimeout?: number;
29
49
  };
50
+ /**
51
+ * Object options for creating a Management API client. Provide a tenant ID to derive the defaults,
52
+ * or provide both the base URL and API indicator directly.
53
+ */
54
+ export type CreateManagementApiConfig = (CreateManagementApiOptions & {
55
+ tenantId: string;
56
+ }) | (CreateManagementApiOptions & {
57
+ tenantId?: never;
58
+ baseUrl: string;
59
+ apiIndicator: string;
60
+ });
30
61
  /**
31
62
  * Options for creating an API client with custom token authentication.
32
63
  */
33
64
  export type CreateApiClientOptions = {
34
65
  /**
35
66
  * The base URL for the Management API.
67
+ * Trailing slashes are ignored.
36
68
  */
37
69
  baseUrl: string;
38
70
  /**
@@ -40,6 +72,13 @@ export type CreateApiClientOptions = {
40
72
  * This function will be called for each request that requires authentication.
41
73
  */
42
74
  getToken: () => Promise<string>;
75
+ /**
76
+ * The maximum time in seconds allowed for the API network request after `getToken()` resolves.
77
+ * It is composed with per-request signals, and a timeout rejects the API call.
78
+ * Non-positive and infinite values disable the timeout. `NaN` uses the default value.
79
+ * @default 10
80
+ */
81
+ requestTimeout?: number;
43
82
  };
44
83
  /**
45
84
  * Returns the base URL for the Management API based on the tenant ID.
@@ -59,6 +98,21 @@ export declare const getManagementApiIndicator: (tenantId: string) => string;
59
98
  * This is used when requesting an access token for the Management API.
60
99
  */
61
100
  export declare const allScope = "all";
101
+ type LowercaseHttpMethods = {
102
+ get: Client<paths>['GET'];
103
+ put: Client<paths>['PUT'];
104
+ post: Client<paths>['POST'];
105
+ delete: Client<paths>['DELETE'];
106
+ options: Client<paths>['OPTIONS'];
107
+ head: Client<paths>['HEAD'];
108
+ patch: Client<paths>['PATCH'];
109
+ trace: Client<paths>['TRACE'];
110
+ };
111
+ type PaginationMethod = {
112
+ paginate: PaginateMethod<paths>;
113
+ };
114
+ /** A typed Management API client with lowercase HTTP methods and pagination support. */
115
+ export type ManagementApiClient = Client<paths> & LowercaseHttpMethods & PaginationMethod;
62
116
  /**
63
117
  * Creates an API client with custom token authentication.
64
118
  *
@@ -78,12 +132,12 @@ export declare const allScope = "all";
78
132
  * getToken: async () => getYourToken(),
79
133
  * });
80
134
  *
81
- * const response = await client.GET('/api/applications/{id}', {
135
+ * const response = await client.get('/api/applications/{id}', {
82
136
  * params: { path: { id: 'app-id' } },
83
137
  * });
84
138
  * ```
85
139
  */
86
- export declare function createApiClient(options: CreateApiClientOptions): Client<paths>;
140
+ export declare function createApiClient(options: CreateApiClientOptions): ManagementApiClient;
87
141
  type ManagementApiReturnType = {
88
142
  /**
89
143
  * The API client for the Management API.
@@ -91,14 +145,14 @@ type ManagementApiReturnType = {
91
145
  * This client is configured to use the provided client credentials
92
146
  * and will automatically include the access token in requests.
93
147
  */
94
- apiClient: Client<paths>;
148
+ apiClient: ManagementApiClient;
95
149
  /**
96
150
  * The client credentials instance used for authentication.
97
151
  */
98
152
  clientCredentials: ClientCredentials;
99
153
  };
100
154
  /**
101
- * Creates a Management API client with the specified tenant ID and options.
155
+ * Creates a Management API client from a tenant ID or an explicit base URL and API indicator.
102
156
  *
103
157
  * Before using this function, ensure that you have created a machine-to-machine application in
104
158
  * Logto and granted it access to the Management API. See the documentation for more details:
@@ -108,35 +162,36 @@ type ManagementApiReturnType = {
108
162
  * This function sets up the API client with the necessary authentication using client credentials.
109
163
  * It will automatically handle token retrieval and renewal as needed.
110
164
  *
111
- * @param tenantId The tenant ID for which to create the Management API client. For OSS deployments,
112
- * you can pass any string as the tenant ID, for example, 'default'.
113
- * @param options The options for creating the Management API client, including client ID and secret.
165
+ * @param tenantIdOrConfig A tenant ID, or object options containing a tenant ID or an explicit base
166
+ * URL and API indicator.
167
+ * @param options The client options when the first argument is a tenant ID.
114
168
  * @returns An object containing the API client and client credentials instance.
115
169
  * @example
116
170
  * ```ts
117
171
  * import { createManagementApi } from '@logto/api/management';
118
172
  *
119
173
  * // Logto Cloud example
120
- * const { apiClient, clientCredentials } = createManagementApi('my-tenant-id', {
174
+ * const { apiClient, clientCredentials } = createManagementApi({
175
+ * tenantId: 'my-tenant-id',
121
176
  * clientId: 'my-client-id',
122
177
  * clientSecret: 'my-client-secret',
123
178
  * });
124
179
  *
125
180
  * // Use apiClient to make requests to the Management API
126
- * const response = await apiClient.GET('/api/users');
181
+ * const response = await apiClient.get('/api/users');
127
182
  * console.log(response.data);
128
183
  * ```
129
184
  *
130
185
  * @example
131
186
  * ```ts
132
187
  * // OSS example
133
- * const { apiClient, clientCredentials } = createManagementApi('default', {
188
+ * const { apiClient, clientCredentials } = createManagementApi({
189
+ * tenantId: 'default',
134
190
  * clientId: 'my-client-id',
135
191
  * clientSecret: 'my-client-secret',
136
192
  * baseUrl: 'https://my-oss-logto-instance.com',
137
- * apiIndicator: 'https://default.logto.app/api',
138
193
  * });
139
194
  * ```
140
195
  */
196
+ export declare function createManagementApi(config: CreateManagementApiConfig): ManagementApiReturnType;
141
197
  export declare function createManagementApi(tenantId: string, options: CreateManagementApiOptions): ManagementApiReturnType;
142
- export {};
package/lib/management.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import createClient from 'openapi-fetch';
2
2
  import { ClientCredentials } from './client-credentials.js';
3
+ import { createPaginate } from './pagination.js';
4
+ import { normalizeTimeout, toTimeoutMilliseconds } from './timeout.js';
5
+ export { ManagementApiPaginationError } from './pagination.js';
3
6
  /**
4
7
  * Returns the base URL for the Management API based on the tenant ID.
5
8
  * @param tenantId The tenant ID to construct the base URL.
@@ -18,6 +21,29 @@ export const getManagementApiIndicator = (tenantId) => `${getBaseUrl(tenantId)}/
18
21
  * This is used when requesting an access token for the Management API.
19
22
  */
20
23
  export const allScope = 'all';
24
+ const bearerTokenPrefix = 'Bearer ';
25
+ const normalizeBaseUrl = (baseUrl) => baseUrl.replace(/\/+$/u, '');
26
+ const isWellKnownPath = (schemaPath) => schemaPath.includes('/.well-known/');
27
+ const createRequestTimeoutFetch = (requestTimeout) => async (request, requestInit) => {
28
+ const signal = AbortSignal.any([
29
+ request.signal,
30
+ AbortSignal.timeout(toTimeoutMilliseconds(requestTimeout)),
31
+ ]);
32
+ return fetch(request, { ...requestInit, signal });
33
+ };
34
+ const addClientMethods = (client) =>
35
+ // eslint-disable-next-line @silverhand/fp/no-mutating-assign -- Keep the original client identity while adding convenience methods.
36
+ Object.assign(client, {
37
+ get: client.GET,
38
+ put: client.PUT,
39
+ post: client.POST,
40
+ delete: client.DELETE,
41
+ options: client.OPTIONS,
42
+ head: client.HEAD,
43
+ patch: client.PATCH,
44
+ trace: client.TRACE,
45
+ paginate: createPaginate(client.GET),
46
+ });
21
47
  /**
22
48
  * Creates an API client with custom token authentication.
23
49
  *
@@ -37,71 +63,55 @@ export const allScope = 'all';
37
63
  * getToken: async () => getYourToken(),
38
64
  * });
39
65
  *
40
- * const response = await client.GET('/api/applications/{id}', {
66
+ * const response = await client.get('/api/applications/{id}', {
41
67
  * params: { path: { id: 'app-id' } },
42
68
  * });
43
69
  * ```
44
70
  */
45
71
  export function createApiClient(options) {
46
72
  const { baseUrl, getToken } = options;
47
- const client = createClient({ baseUrl });
73
+ const requestTimeout = normalizeTimeout(options.requestTimeout);
74
+ const client = createClient({
75
+ baseUrl: normalizeBaseUrl(baseUrl),
76
+ ...(requestTimeout > 0 && { fetch: createRequestTimeoutFetch(requestTimeout) }),
77
+ });
48
78
  client.use({
49
79
  async onRequest({ schemaPath, request }) {
50
- if (schemaPath.includes('/.well-known/')) {
80
+ if (isWellKnownPath(schemaPath)) {
51
81
  return;
52
82
  }
53
83
  const token = await getToken();
54
- request.headers.set('Authorization', `Bearer ${token}`);
84
+ request.headers.set('Authorization', `${bearerTokenPrefix}${token}`);
55
85
  return request;
56
86
  },
57
87
  });
58
- return client;
88
+ return addClientMethods(client);
59
89
  }
60
- /**
61
- * Creates a Management API client with the specified tenant ID and options.
62
- *
63
- * Before using this function, ensure that you have created a machine-to-machine application in
64
- * Logto and granted it access to the Management API. See the documentation for more details:
65
- *
66
- * https://a.logto.io/m2m-mapi
67
- *
68
- * This function sets up the API client with the necessary authentication using client credentials.
69
- * It will automatically handle token retrieval and renewal as needed.
70
- *
71
- * @param tenantId The tenant ID for which to create the Management API client. For OSS deployments,
72
- * you can pass any string as the tenant ID, for example, 'default'.
73
- * @param options The options for creating the Management API client, including client ID and secret.
74
- * @returns An object containing the API client and client credentials instance.
75
- * @example
76
- * ```ts
77
- * import { createManagementApi } from '@logto/api/management';
78
- *
79
- * // Logto Cloud example
80
- * const { apiClient, clientCredentials } = createManagementApi('my-tenant-id', {
81
- * clientId: 'my-client-id',
82
- * clientSecret: 'my-client-secret',
83
- * });
84
- *
85
- * // Use apiClient to make requests to the Management API
86
- * const response = await apiClient.GET('/api/users');
87
- * console.log(response.data);
88
- * ```
89
- *
90
- * @example
91
- * ```ts
92
- * // OSS example
93
- * const { apiClient, clientCredentials } = createManagementApi('default', {
94
- * clientId: 'my-client-id',
95
- * clientSecret: 'my-client-secret',
96
- * baseUrl: 'https://my-oss-logto-instance.com',
97
- * apiIndicator: 'https://default.logto.app/api',
98
- * });
99
- * ```
100
- */
101
- export function createManagementApi(tenantId, options) {
102
- const { clientId, clientSecret } = options;
103
- const baseUrl = options.baseUrl ?? getBaseUrl(tenantId);
104
- const apiIndicator = options.apiIndicator ?? getManagementApiIndicator(tenantId);
90
+ const resolveManagementApiArguments = (tenantIdOrConfig, options) => {
91
+ if (typeof tenantIdOrConfig === 'string') {
92
+ if (!options) {
93
+ throw new TypeError('Options are required when creating a Management API client by tenant ID');
94
+ }
95
+ return { tenantId: tenantIdOrConfig, options };
96
+ }
97
+ if (!tenantIdOrConfig) {
98
+ throw new TypeError('Provide a tenant ID or both baseUrl and apiIndicator');
99
+ }
100
+ return { tenantId: tenantIdOrConfig.tenantId, options: tenantIdOrConfig };
101
+ };
102
+ export function createManagementApi(tenantIdOrConfig, options) {
103
+ const { tenantId, options: resolvedOptions } = resolveManagementApiArguments(tenantIdOrConfig, options);
104
+ if (tenantId !== undefined && tenantId.trim().length === 0) {
105
+ throw new TypeError('Tenant ID must not be empty');
106
+ }
107
+ const { clientId, clientSecret, tokenRequestTimeout, getTokenRequestSignal, requestTimeout } = resolvedOptions;
108
+ const configuredBaseUrl = resolvedOptions.baseUrl ?? (tenantId === undefined ? undefined : getBaseUrl(tenantId));
109
+ const apiIndicator = resolvedOptions.apiIndicator ??
110
+ (tenantId === undefined ? undefined : getManagementApiIndicator(tenantId));
111
+ if (configuredBaseUrl === undefined || apiIndicator === undefined) {
112
+ throw new TypeError('Provide a tenant ID or both baseUrl and apiIndicator');
113
+ }
114
+ const baseUrl = normalizeBaseUrl(configuredBaseUrl);
105
115
  const clientCredentials = new ClientCredentials({
106
116
  clientId,
107
117
  clientSecret,
@@ -110,17 +120,41 @@ export function createManagementApi(tenantId, options) {
110
120
  resource: apiIndicator,
111
121
  scope: allScope,
112
122
  },
123
+ tokenRequestTimeout,
124
+ getTokenRequestSignal,
113
125
  });
126
+ const warnedScopes = new Set();
114
127
  const apiClient = createApiClient({
115
128
  baseUrl,
129
+ requestTimeout,
116
130
  getToken: async () => {
117
131
  const { value, scope } = await clientCredentials.getAccessToken();
118
- if (scope !== allScope) {
132
+ if (scope !== allScope && !warnedScopes.has(scope)) {
133
+ warnedScopes.add(scope);
119
134
  console.warn(`The scope "${scope}" is not equal to the expected value "${allScope}". This may cause issues with API access. See https://a.logto.io/m2m-mapi to learn more about configuring machine-to-machine access to the Management API.`);
120
135
  }
121
136
  return value;
122
137
  },
123
138
  });
139
+ apiClient.use({
140
+ onResponse({ schemaPath, request, response }) {
141
+ if (isWellKnownPath(schemaPath)) {
142
+ return;
143
+ }
144
+ const authorization = request.headers.get('Authorization');
145
+ if (!authorization?.startsWith(bearerTokenPrefix)) {
146
+ return;
147
+ }
148
+ const accessToken = authorization.slice(bearerTokenPrefix.length);
149
+ if (response.status === 401) {
150
+ // Do not retry here because the request body may have been consumed.
151
+ clientCredentials.invalidateAccessToken(accessToken);
152
+ }
153
+ else if (response.ok) {
154
+ clientCredentials.markAccessTokenAsValid(accessToken);
155
+ }
156
+ },
157
+ });
124
158
  return {
125
159
  apiClient,
126
160
  clientCredentials,
@@ -0,0 +1,37 @@
1
+ import type { Client, ClientPathsWithMethod, MaybeOptionalInit, MethodResponse } from 'openapi-fetch';
2
+ type GetOperation<Paths extends NonNullable<unknown>, Path extends keyof Paths> = Paths[Path] extends {
3
+ get: infer Operation;
4
+ } ? Operation : never;
5
+ type OperationQuery<Operation> = Operation extends {
6
+ parameters: {
7
+ query?: infer Query;
8
+ };
9
+ } ? NonNullable<Query> : never;
10
+ type SupportsPagination<Operation> = [OperationQuery<Operation>] extends [never] ? false : 'page' extends keyof OperationQuery<Operation> ? 'page_size' extends keyof OperationQuery<Operation> ? true : false : false;
11
+ type GetPath<Paths extends NonNullable<unknown>> = ClientPathsWithMethod<Client<Paths>, 'get'>;
12
+ type PaginatedGetPath<Paths extends NonNullable<unknown>> = {
13
+ [Path in GetPath<Paths>]: SupportsPagination<GetOperation<Paths, Path>> extends true ? MethodResponse<Client<Paths>, 'get', Path> extends readonly unknown[] ? Path : never : never;
14
+ }[GetPath<Paths>];
15
+ type PaginationItem<Paths extends NonNullable<unknown>, Path extends PaginatedGetPath<Paths>> = MethodResponse<Client<Paths>, 'get', Path> extends ReadonlyArray<infer Item> ? Item : never;
16
+ type GetInit<Paths extends NonNullable<unknown>, Path extends PaginatedGetPath<Paths>> = MaybeOptionalInit<{
17
+ get: GetOperation<Paths, Path>;
18
+ }, 'get'>;
19
+ type PaginationOptions<Paths extends NonNullable<unknown>, Path extends PaginatedGetPath<Paths>> = Omit<NonNullable<GetInit<Paths, Path>>, 'parseAs'> & {
20
+ parseAs?: never;
21
+ };
22
+ type PaginationInit<Paths extends NonNullable<unknown>, Path extends PaginatedGetPath<Paths>> = undefined extends GetInit<Paths, Path> ? [PaginationOptions<Paths, Path>?] : [PaginationOptions<Paths, Path>];
23
+ /** Iterates over items returned by a paginated Management API GET endpoint. */
24
+ export type PaginateMethod<Paths extends NonNullable<unknown>> = <Path extends PaginatedGetPath<Paths>>(path: Path, ...init: PaginationInit<Paths, Path>) => AsyncGenerator<PaginationItem<Paths, Path>, void, undefined>;
25
+ /** An error response returned while iterating over a paginated Management API endpoint. */
26
+ export declare class ManagementApiPaginationError extends Error {
27
+ /**
28
+ * The error response metadata and headers. Its body has already been read by openapi-fetch; the
29
+ * parsed error body is available as `cause`.
30
+ */
31
+ readonly response: Response;
32
+ readonly status: number;
33
+ constructor(response: Response, options?: ErrorOptions);
34
+ }
35
+ /** Creates a typed pagination method backed by an openapi-fetch GET method. */
36
+ export declare const createPaginate: <Paths extends NonNullable<unknown>>(get: Client<Paths>["GET"]) => PaginateMethod<Paths>;
37
+ export {};
@@ -0,0 +1,66 @@
1
+ const defaultPageSize = 100;
2
+ const hasNextLink = (headers) => headers
3
+ .get('Link')
4
+ ?.split(',')
5
+ .some((link) => /;\s*rel=(?:"next"|next)(?:\s*;|\s*$)/iu.test(link.trim())) ?? false;
6
+ const isTotalNumberCapped = (headers) => headers.get('Total-Number-Is-Capped')?.toLowerCase() === 'true';
7
+ /** An error response returned while iterating over a paginated Management API endpoint. */
8
+ export class ManagementApiPaginationError extends Error {
9
+ constructor(response, options) {
10
+ super(`Management API pagination request failed with status ${response.status}`, options);
11
+ this.name = 'ManagementApiPaginationError';
12
+ this.response = response;
13
+ this.status = response.status;
14
+ }
15
+ }
16
+ /** Creates a typed pagination method backed by an openapi-fetch GET method. */
17
+ export const createPaginate = (get) => {
18
+ // eslint-disable-next-line no-restricted-syntax -- The runtime adapter narrows openapi-fetch's generic GET method after the public type validates the path and options.
19
+ const getPage = get;
20
+ const paginate = (path, options = {}) => {
21
+ const { params = {}, parseAs, ...requestOptions } = options;
22
+ if (parseAs !== undefined) {
23
+ throw new TypeError('The paginate method only supports JSON array responses');
24
+ }
25
+ const query = params.query ?? {};
26
+ const firstPage = query.page === undefined ? 1 : query.page;
27
+ if (typeof firstPage !== 'number' || !Number.isSafeInteger(firstPage) || firstPage < 1) {
28
+ throw new RangeError('The page parameter must be a positive safe integer');
29
+ }
30
+ const pageSize = query.page_size ?? defaultPageSize;
31
+ const iterate = async function* () {
32
+ /* eslint-disable-next-line @silverhand/fp/no-let, @silverhand/fp/no-mutation -- Pagination advances one page at a time without retaining prior page responses. */
33
+ for (let page = firstPage; page <= Number.MAX_SAFE_INTEGER; page += 1) {
34
+ // eslint-disable-next-line no-await-in-loop -- Each request depends on the previous response's pagination headers.
35
+ const result = await getPage(path, {
36
+ ...requestOptions,
37
+ params: {
38
+ ...params,
39
+ query: {
40
+ ...query,
41
+ page,
42
+ page_size: pageSize,
43
+ },
44
+ },
45
+ });
46
+ if ('error' in result) {
47
+ throw new ManagementApiPaginationError(result.response, { cause: result.error });
48
+ }
49
+ if (!Array.isArray(result.data)) {
50
+ throw new TypeError('Expected a paginated Management API response to contain an array');
51
+ }
52
+ if (result.data.length === 0) {
53
+ return;
54
+ }
55
+ yield* result.data;
56
+ if (!isTotalNumberCapped(result.response.headers) &&
57
+ !hasNextLink(result.response.headers)) {
58
+ return;
59
+ }
60
+ }
61
+ };
62
+ return iterate();
63
+ };
64
+ // eslint-disable-next-line no-restricted-syntax -- The public generic type adds generated path and item types to the runtime iterator.
65
+ return paginate;
66
+ };
@@ -0,0 +1,7 @@
1
+ export declare const normalizeTimeout: (timeout?: number) => number;
2
+ export declare const getAbortReason: (signal: AbortSignal, fallbackMessage: string) => Error;
3
+ export declare const toTimeoutMilliseconds: (timeout: number) => number;
4
+ export declare const createTimeoutSignal: (timeout: number, message: string) => {
5
+ signal: AbortSignal;
6
+ clear: () => void;
7
+ };
package/lib/timeout.js ADDED
@@ -0,0 +1,29 @@
1
+ const defaultTimeout = 10;
2
+ // Browsers and Node.js reliably support timer delays up to a signed 32-bit integer.
3
+ const maximumTimeoutMilliseconds = 2_147_483_647;
4
+ export const normalizeTimeout = (timeout) => {
5
+ if (timeout === undefined || Number.isNaN(timeout)) {
6
+ return defaultTimeout;
7
+ }
8
+ return Number.isFinite(timeout) && timeout > 0 ? timeout : 0;
9
+ };
10
+ export const getAbortReason = (signal, fallbackMessage) => {
11
+ // eslint-disable-next-line prefer-destructuring -- Keep the DOM's any-typed reason narrowed to unknown.
12
+ const reason = signal.reason;
13
+ return reason instanceof Error ? reason : new Error(fallbackMessage, { cause: reason });
14
+ };
15
+ export const toTimeoutMilliseconds = (timeout) => Math.min(Math.max(Math.ceil(timeout * 1000), 1), maximumTimeoutMilliseconds);
16
+ export const createTimeoutSignal = (timeout, message) => {
17
+ const controller = new AbortController();
18
+ const timer = timeout > 0
19
+ ? setTimeout(() => {
20
+ controller.abort(new DOMException(message, 'TimeoutError'));
21
+ }, toTimeoutMilliseconds(timeout))
22
+ : undefined;
23
+ return {
24
+ signal: controller.signal,
25
+ clear: () => {
26
+ clearTimeout(timer);
27
+ },
28
+ };
29
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/api",
3
- "version": "1.43.0",
3
+ "version": "1.44.0-beta.1",
4
4
  "description": "Logto API types and clients.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -26,6 +26,18 @@
26
26
  "bugs": {
27
27
  "url": "https://github.com/logto-io/logto/issues"
28
28
  },
29
+ "scripts": {
30
+ "precommit": "lint-staged",
31
+ "prepublishOnly": "pnpm generate-types && pnpm build",
32
+ "generate-types": "./generate-types.sh",
33
+ "build": "./check-types.sh && rm -rf lib/ && tsc -p tsconfig.build.json",
34
+ "prepack": "pnpm build",
35
+ "lint": "eslint --ext .ts src",
36
+ "lint:report": "pnpm lint --format json --output-file report.json",
37
+ "test": "vitest run src",
38
+ "test:watch": "vitest src --watch",
39
+ "test:ci": "pnpm run test --silent --coverage"
40
+ },
29
41
  "engines": {
30
42
  "node": "^22.14.0 || ^24.0.0"
31
43
  },
@@ -49,15 +61,5 @@
49
61
  ],
50
62
  "extends": "@silverhand"
51
63
  },
52
- "prettier": "@silverhand/eslint-config/.prettierrc",
53
- "scripts": {
54
- "precommit": "lint-staged",
55
- "generate-types": "./generate-types.sh",
56
- "build": "./check-types.sh && rm -rf lib/ && tsc -p tsconfig.build.json",
57
- "lint": "eslint --ext .ts src",
58
- "lint:report": "pnpm lint --format json --output-file report.json",
59
- "test": "vitest run src",
60
- "test:watch": "vitest src --watch",
61
- "test:ci": "pnpm run test --silent --coverage"
62
- }
63
- }
64
+ "prettier": "@silverhand/eslint-config/.prettierrc"
65
+ }