adapty 0.8.5 → 0.8.6
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 +6 -1
- package/dist/cli/base/adapty/adapty-command.d.ts +21 -0
- package/dist/cli/base/adapty/adapty-command.js +47 -0
- package/dist/cli/base/adapty/build.d.ts +11 -0
- package/dist/cli/base/adapty/build.js +13 -0
- package/dist/cli/base/adapty/index.d.ts +5 -0
- package/dist/cli/base/adapty/index.js +3 -0
- package/dist/cli/base/adapty/openSession.d.ts +22 -0
- package/dist/cli/base/adapty/openSession.js +25 -0
- package/dist/cli/base/base-command.d.ts +30 -0
- package/dist/cli/base/base-command.js +64 -0
- package/dist/cli/commands/apps/create.d.ts +19 -0
- package/dist/cli/commands/apps/create.js +68 -0
- package/dist/cli/commands/apps/get.d.ts +10 -0
- package/dist/cli/commands/apps/get.js +14 -0
- package/dist/cli/commands/apps/list.d.ts +11 -0
- package/dist/cli/commands/apps/list.js +14 -0
- package/dist/cli/commands/apps/update.d.ts +15 -0
- package/dist/cli/commands/apps/update.js +30 -0
- package/dist/cli/commands/auth/login.d.ts +11 -0
- package/dist/cli/commands/auth/login.js +68 -0
- package/dist/cli/commands/auth/logout.d.ts +12 -0
- package/dist/cli/commands/auth/logout.js +28 -0
- package/dist/cli/commands/auth/revoke.d.ts +13 -0
- package/dist/cli/commands/auth/revoke.js +32 -0
- package/dist/cli/commands/auth/status/index.d.ts +7 -0
- package/dist/cli/commands/auth/status/index.js +22 -0
- package/dist/cli/commands/auth/status/lib/render.d.ts +3 -0
- package/dist/cli/commands/auth/status/lib/render.js +14 -0
- package/dist/cli/commands/auth/status/lib/result.d.ts +15 -0
- package/dist/cli/commands/auth/status/lib/result.js +1 -0
- package/dist/cli/commands/auth/whoami.d.ts +10 -0
- package/dist/cli/commands/auth/whoami.js +16 -0
- package/dist/cli/errors.d.ts +39 -0
- package/dist/cli/errors.js +92 -0
- package/dist/cli/flags.d.ts +16 -0
- package/dist/cli/flags.js +27 -0
- package/dist/cli/views/list.d.ts +6 -0
- package/dist/cli/views/list.js +10 -0
- package/dist/cli/views/record.d.ts +9 -0
- package/dist/cli/views/record.js +14 -0
- package/dist/commands/apps/create.d.ts +1 -14
- package/dist/commands/apps/create.js +2 -57
- package/dist/commands/apps/get.d.ts +1 -11
- package/dist/commands/apps/get.js +2 -22
- package/dist/commands/apps/list.d.ts +1 -13
- package/dist/commands/apps/list.js +2 -19
- package/dist/commands/apps/update.d.ts +1 -16
- package/dist/commands/apps/update.js +2 -41
- package/dist/commands/asa/whoami.js +6 -2
- package/dist/commands/auth/login.d.ts +1 -8
- package/dist/commands/auth/login.js +2 -122
- package/dist/commands/auth/logout.d.ts +1 -9
- package/dist/commands/auth/logout.js +2 -18
- package/dist/commands/auth/revoke.d.ts +1 -9
- package/dist/commands/auth/revoke.js +2 -21
- package/dist/commands/auth/status.d.ts +1 -7
- package/dist/commands/auth/status.js +2 -26
- package/dist/commands/auth/whoami.d.ts +1 -7
- package/dist/commands/auth/whoami.js +2 -15
- package/dist/lib/api-schemas.d.ts +7 -3
- package/dist/lib/asa-schemas.d.ts +1 -1
- package/dist/sdk/adapty/access-levels.d.ts +15 -0
- package/dist/sdk/adapty/access-levels.js +4 -0
- package/dist/sdk/adapty/apps/create.d.ts +26 -0
- package/dist/sdk/adapty/apps/create.js +36 -0
- package/dist/sdk/adapty/apps/index.d.ts +13 -0
- package/dist/sdk/adapty/apps/index.js +9 -0
- package/dist/sdk/adapty/apps/model.d.ts +24 -0
- package/dist/sdk/adapty/apps/model.js +10 -0
- package/dist/sdk/adapty/apps/resource.d.ts +19 -0
- package/dist/sdk/adapty/apps/resource.js +23 -0
- package/dist/sdk/adapty/apps/update.d.ts +16 -0
- package/dist/sdk/adapty/apps/update.js +25 -0
- package/dist/sdk/adapty/auth/device-code.d.ts +8 -0
- package/dist/sdk/adapty/auth/device-code.js +19 -0
- package/dist/sdk/adapty/auth/index.d.ts +7 -0
- package/dist/sdk/adapty/auth/index.js +5 -0
- package/dist/sdk/adapty/auth/model.d.ts +15 -0
- package/dist/sdk/adapty/auth/model.js +6 -0
- package/dist/sdk/adapty/auth/poll-token.d.ts +8 -0
- package/dist/sdk/adapty/auth/poll-token.js +58 -0
- package/dist/sdk/adapty/auth/resource.d.ts +17 -0
- package/dist/sdk/adapty/auth/resource.js +21 -0
- package/dist/sdk/adapty/errors.d.ts +10 -0
- package/dist/sdk/adapty/errors.js +38 -0
- package/dist/sdk/adapty/index.d.ts +36 -0
- package/dist/sdk/adapty/index.js +27 -0
- package/dist/sdk/adapty/pagination.d.ts +23 -0
- package/dist/sdk/adapty/pagination.js +1 -0
- package/dist/sdk/core/auth/device-flow.d.ts +46 -0
- package/dist/sdk/core/auth/device-flow.js +62 -0
- package/dist/sdk/core/clock.d.ts +7 -0
- package/dist/sdk/core/clock.js +21 -0
- package/dist/sdk/core/errors.d.ts +81 -0
- package/dist/sdk/core/errors.js +84 -0
- package/dist/sdk/core/http/client.d.ts +46 -0
- package/dist/sdk/core/http/client.js +127 -0
- package/dist/sdk/core/http/index.d.ts +11 -0
- package/dist/sdk/core/http/index.js +7 -0
- package/dist/sdk/core/http/policies.d.ts +14 -0
- package/dist/sdk/core/http/policies.js +29 -0
- package/dist/sdk/core/http/retry.d.ts +27 -0
- package/dist/sdk/core/http/retry.js +22 -0
- package/dist/sdk/core/http/url.d.ts +3 -0
- package/dist/sdk/core/http/url.js +19 -0
- package/dist/sdk/core/session.d.ts +20 -0
- package/dist/sdk/core/session.js +69 -0
- package/dist/sdk/core/testing.d.ts +25 -0
- package/dist/sdk/core/testing.js +46 -0
- package/dist/sdk/core/validation.d.ts +10 -0
- package/dist/sdk/core/validation.js +14 -0
- package/oclif.manifest.json +813 -813
- package/package.json +10 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ApiError } from '../../core/errors.js';
|
|
2
|
+
import { CLIENT_ID } from './model.js';
|
|
3
|
+
/** A 400 carrying one of these codes is a state of the protocol, not a failure. */
|
|
4
|
+
const pollResultByCode = {
|
|
5
|
+
access_denied: { status: 'denied' },
|
|
6
|
+
authorization_pending: { status: 'pending' },
|
|
7
|
+
expired_token: { status: 'expired' },
|
|
8
|
+
slow_down: { status: 'slow_down' },
|
|
9
|
+
};
|
|
10
|
+
/** A protocol code as a state, or a real error when the code is not one of the protocol's. */
|
|
11
|
+
const toPollResult = (code, body) => {
|
|
12
|
+
const result = pollResultByCode[code];
|
|
13
|
+
if (result === undefined) {
|
|
14
|
+
throw new ApiError({
|
|
15
|
+
code,
|
|
16
|
+
details: body,
|
|
17
|
+
message: `Unexpected device flow response: ${code}`,
|
|
18
|
+
status: 200,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Step two, asked over and over until the user is done: the answer is a token, a state of the
|
|
25
|
+
* protocol, or a failure — and telling the three apart is the whole of this operation.
|
|
26
|
+
*/
|
|
27
|
+
export const pollToken = async (http, deviceCode) => {
|
|
28
|
+
try {
|
|
29
|
+
const response = await http.post('/auth/token', {
|
|
30
|
+
client_id: CLIENT_ID,
|
|
31
|
+
device_code: deviceCode,
|
|
32
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
33
|
+
});
|
|
34
|
+
// A protocol state normally arrives as a 400 and lands in the catch below, but the
|
|
35
|
+
// shipped CLI's types also anticipate a 200 whose body is { error: <code> }, and
|
|
36
|
+
// reading only the thrown case would call a pending login authorized.
|
|
37
|
+
if (!('access_token' in response)) {
|
|
38
|
+
return toPollResult(response.error, response);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
status: 'authorized',
|
|
42
|
+
token: {
|
|
43
|
+
accessToken: response.access_token,
|
|
44
|
+
expiresInSec: response.expires_in,
|
|
45
|
+
user: response.user,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error instanceof ApiError && error.code !== undefined) {
|
|
51
|
+
const result = pollResultByCode[error.code];
|
|
52
|
+
if (result) {
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IssuedToken } from './model.js';
|
|
2
|
+
import type { Http } from '../../core/http/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* The auth resource: core's DeviceAuthApi port filled in by the two device flow steps, plus the
|
|
5
|
+
* endpoints that only need a path. A missing or stale token surfaces as AuthRequiredError.
|
|
6
|
+
*
|
|
7
|
+
* Unlike apps, not every path is listed here: a device flow step is its path and the reading of
|
|
8
|
+
* its answer together, so each keeps its own. What is left in this file is the shape of the
|
|
9
|
+
* resource — which port it satisfies, and what else it answers.
|
|
10
|
+
*/
|
|
11
|
+
export declare const auth: (http: Http) => {
|
|
12
|
+
me: () => Promise<Record<string, unknown>>;
|
|
13
|
+
revokeToken: (token: string) => Promise<undefined>;
|
|
14
|
+
pollToken(deviceCode: string): Promise<import("../../core/auth/device-flow.js").PollResult<IssuedToken>>;
|
|
15
|
+
requestDeviceCode(): Promise<import("../../core/auth/device-flow.js").DeviceCode>;
|
|
16
|
+
};
|
|
17
|
+
export type AuthApi = ReturnType<typeof auth>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { requestDeviceCode } from './device-code.js';
|
|
2
|
+
import { pollToken } from './poll-token.js';
|
|
3
|
+
/**
|
|
4
|
+
* The auth resource: core's DeviceAuthApi port filled in by the two device flow steps, plus the
|
|
5
|
+
* endpoints that only need a path. A missing or stale token surfaces as AuthRequiredError.
|
|
6
|
+
*
|
|
7
|
+
* Unlike apps, not every path is listed here: a device flow step is its path and the reading of
|
|
8
|
+
* its answer together, so each keeps its own. What is left in this file is the shape of the
|
|
9
|
+
* resource — which port it satisfies, and what else it answers.
|
|
10
|
+
*/
|
|
11
|
+
export const auth = (http) => {
|
|
12
|
+
const deviceAuth = {
|
|
13
|
+
pollToken: (deviceCode) => pollToken(http, deviceCode),
|
|
14
|
+
requestDeviceCode: () => requestDeviceCode(http),
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
...deviceAuth,
|
|
18
|
+
me: () => http.get('/me'),
|
|
19
|
+
revokeToken: (token) => http.post('/auth/tokens/revoke', { token }),
|
|
20
|
+
};
|
|
21
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ErrorParser } from '../core/http/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* How this service words a rejection: `{ error_code, errors: { field: [message] } }`, sometimes
|
|
4
|
+
* just `{ error: 'code' }`. Ported from src/lib/errors.ts so a migrated command keeps saying
|
|
5
|
+
* "apple_bundle_id: already used" instead of "POST /apps failed with HTTP 400".
|
|
6
|
+
*
|
|
7
|
+
* Wired once in createAdapty: which shapes a service speaks is product knowledge, and ASA speaks
|
|
8
|
+
* another — which is why the transport takes the parser as a parameter.
|
|
9
|
+
*/
|
|
10
|
+
export declare const developerErrorParser: ErrorParser;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this service words a rejection: `{ error_code, errors: { field: [message] } }`, sometimes
|
|
3
|
+
* just `{ error: 'code' }`. Ported from src/lib/errors.ts so a migrated command keeps saying
|
|
4
|
+
* "apple_bundle_id: already used" instead of "POST /apps failed with HTTP 400".
|
|
5
|
+
*
|
|
6
|
+
* Wired once in createAdapty: which shapes a service speaks is product knowledge, and ASA speaks
|
|
7
|
+
* another — which is why the transport takes the parser as a parameter.
|
|
8
|
+
*/
|
|
9
|
+
export const developerErrorParser = (_status, body) => {
|
|
10
|
+
if (typeof body !== 'object' || body === null) {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
const { error, error_code: errorCode, errors } = body;
|
|
14
|
+
if (typeof errorCode === 'string' && errorCode !== '') {
|
|
15
|
+
// The bare code as the fallback message: what the published CLI prints, and still better
|
|
16
|
+
// than an HTTP status when the server sends no field errors
|
|
17
|
+
return { code: errorCode, message: fieldMessages(errors) ?? errorCode };
|
|
18
|
+
}
|
|
19
|
+
if (typeof error === 'string' && error !== '') {
|
|
20
|
+
return { code: error, message: error };
|
|
21
|
+
}
|
|
22
|
+
return {};
|
|
23
|
+
};
|
|
24
|
+
/** `non_field_errors` is the server's name for "about the request as a whole": printed bare. */
|
|
25
|
+
const fieldMessages = (errors) => {
|
|
26
|
+
if (typeof errors !== 'object' || errors === null) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const parts = [];
|
|
30
|
+
for (const [field, messages] of Object.entries(errors)) {
|
|
31
|
+
for (const message of Array.isArray(messages) ? messages : [messages]) {
|
|
32
|
+
if (typeof message === 'string') {
|
|
33
|
+
parts.push(field === 'non_field_errors' ? message : `${field}: ${message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parts.length > 0 ? parts.join('; ') : undefined;
|
|
38
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AccessLevelsApi } from './access-levels.js';
|
|
2
|
+
import type { AppsApi } from './apps/index.js';
|
|
3
|
+
import type { AuthApi } from './auth/index.js';
|
|
4
|
+
import type { Clock } from '../core/clock.js';
|
|
5
|
+
import type { RetryAttempt } from '../core/http/index.js';
|
|
6
|
+
export { platforms } from './apps/index.js';
|
|
7
|
+
export { developerErrorParser } from './errors.js';
|
|
8
|
+
export type { AccessLevel, AccessLevelList, AccessLevelsApi } from './access-levels.js';
|
|
9
|
+
export type { AppDetail, AppsApi, AppSummary, CreateAppInput, UpdateAppInput } from './apps/index.js';
|
|
10
|
+
export type { AuthApi, AuthUser, IssuedToken } from './auth/index.js';
|
|
11
|
+
export type { PageParams, Paginated, Pagination } from './pagination.js';
|
|
12
|
+
/** Exported because the adapter compares the resolved URL with it and warns when they differ. */
|
|
13
|
+
export declare const DEFAULT_ADAPTY_API_URL = "https://api-admin.adapty.io/api/v1/developer";
|
|
14
|
+
/**
|
|
15
|
+
* Narrower than HttpOptions on purpose: the transport seams (parseError, shouldRetry, the retry
|
|
16
|
+
* policy, trailingSlash) are product knowledge, and two adapters overriding them would read the
|
|
17
|
+
* same answers differently. What is left is what a consumer really owns.
|
|
18
|
+
*/
|
|
19
|
+
export type AdaptyOptions = {
|
|
20
|
+
baseUrl?: string | undefined;
|
|
21
|
+
clock?: Clock | undefined;
|
|
22
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
23
|
+
onRetry?: ((info: RetryAttempt) => void) | undefined;
|
|
24
|
+
signal?: AbortSignal | undefined;
|
|
25
|
+
/** Without a token only auth is usable — that is how login builds the sdk. */
|
|
26
|
+
token?: string | undefined;
|
|
27
|
+
/** Comes from the adapter, so server logs can tell the CLI from an MCP server. */
|
|
28
|
+
userAgent?: string | undefined;
|
|
29
|
+
};
|
|
30
|
+
export type Adapty = {
|
|
31
|
+
accessLevels: AccessLevelsApi;
|
|
32
|
+
apps: AppsApi;
|
|
33
|
+
auth: AuthApi;
|
|
34
|
+
};
|
|
35
|
+
/** The assembly point of the developer API: one transport, resources on top of it. */
|
|
36
|
+
export declare const createAdapty: (options?: AdaptyOptions) => Adapty;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHttp } from '../core/http/index.js';
|
|
2
|
+
import { accessLevels } from './access-levels.js';
|
|
3
|
+
import { apps } from './apps/index.js';
|
|
4
|
+
import { auth } from './auth/index.js';
|
|
5
|
+
import { developerErrorParser } from './errors.js';
|
|
6
|
+
export { platforms } from './apps/index.js';
|
|
7
|
+
export { developerErrorParser } from './errors.js';
|
|
8
|
+
/** Exported because the adapter compares the resolved URL with it and warns when they differ. */
|
|
9
|
+
export const DEFAULT_ADAPTY_API_URL = 'https://api-admin.adapty.io/api/v1/developer';
|
|
10
|
+
/** The assembly point of the developer API: one transport, resources on top of it. */
|
|
11
|
+
export const createAdapty = (options = {}) => {
|
|
12
|
+
const http = createHttp({
|
|
13
|
+
baseUrl: options.baseUrl ?? DEFAULT_ADAPTY_API_URL,
|
|
14
|
+
clock: options.clock,
|
|
15
|
+
fetch: options.fetch,
|
|
16
|
+
headers: options.userAgent === undefined ? undefined : { 'user-agent': options.userAgent },
|
|
17
|
+
onRetry: options.onRetry,
|
|
18
|
+
parseError: developerErrorParser,
|
|
19
|
+
signal: options.signal,
|
|
20
|
+
token: options.token,
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
accessLevels: accessLevels(http),
|
|
24
|
+
apps: apps(http),
|
|
25
|
+
auth: auth(http),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { QueryParams } from '../core/http/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The paginated answer, and the query that asks for a page. Product and not core: `meta.pagination`
|
|
4
|
+
* and the bracketed `page[...]` names are this API's convention, not the transport's business. ASA
|
|
5
|
+
* follows the same convention today, so sdk/asa can import this until the two actually diverge.
|
|
6
|
+
*/
|
|
7
|
+
export type Pagination = {
|
|
8
|
+
count: number;
|
|
9
|
+
page: number;
|
|
10
|
+
pages: number;
|
|
11
|
+
};
|
|
12
|
+
export type Paginated<T> = {
|
|
13
|
+
data: T[];
|
|
14
|
+
meta: {
|
|
15
|
+
pagination: Pagination;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/** Page selection as we take it: camelCase in, the server's names on the wire. */
|
|
19
|
+
export type PageParams = {
|
|
20
|
+
page?: number | undefined;
|
|
21
|
+
pageSize?: number | undefined;
|
|
22
|
+
};
|
|
23
|
+
export declare const pageQuery: (params: PageParams | undefined) => QueryParams;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const pageQuery = (params) => ({ 'page[number]': params?.page, 'page[size]': params?.pageSize });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Clock } from '../clock.js';
|
|
2
|
+
export type DeviceCode = {
|
|
3
|
+
deviceCode: string;
|
|
4
|
+
expiresInSec: number;
|
|
5
|
+
intervalSec: number;
|
|
6
|
+
userCode: string;
|
|
7
|
+
verificationUri: string;
|
|
8
|
+
verificationUriComplete: string | undefined;
|
|
9
|
+
};
|
|
10
|
+
export type PollResult<TToken> = {
|
|
11
|
+
status: 'authorized';
|
|
12
|
+
token: TToken;
|
|
13
|
+
} | {
|
|
14
|
+
status: 'denied';
|
|
15
|
+
} | {
|
|
16
|
+
status: 'expired';
|
|
17
|
+
} | {
|
|
18
|
+
status: 'pending';
|
|
19
|
+
} | {
|
|
20
|
+
status: 'slow_down';
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The device flow port (RFC 8628) — all the orchestration below knows. What sits inside a token
|
|
24
|
+
* (for Adapty, an access_token plus the user) is the port's business.
|
|
25
|
+
*/
|
|
26
|
+
export type DeviceAuthApi<TToken> = {
|
|
27
|
+
pollToken(deviceCode: string): Promise<PollResult<TToken>>;
|
|
28
|
+
requestDeviceCode(): Promise<DeviceCode>;
|
|
29
|
+
};
|
|
30
|
+
export type DeviceFlowDeps = {
|
|
31
|
+
clock: Clock;
|
|
32
|
+
maxConsecutiveErrors?: number | undefined;
|
|
33
|
+
/** Do not poll faster than this, even if the server allows it. */
|
|
34
|
+
minIntervalMs?: number | undefined;
|
|
35
|
+
/** Show the code and the link to the user. Sdk prints nothing itself. */
|
|
36
|
+
onCode: (code: DeviceCode) => void;
|
|
37
|
+
/** One poll failed (network, odd answer). The flow goes on up to maxConsecutiveErrors. */
|
|
38
|
+
onTransientError?: ((error: unknown, consecutive: number) => void) | undefined;
|
|
39
|
+
signal?: AbortSignal | undefined;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Device flow orchestration with every effect kept outside: the network behind the api port, time
|
|
43
|
+
* behind clock, cancellation behind signal. That is what makes expiry, slow_down, poll failures
|
|
44
|
+
* and Ctrl+C unit-testable.
|
|
45
|
+
*/
|
|
46
|
+
export declare const runDeviceFlow: <TToken>(api: DeviceAuthApi<TToken>, deps: DeviceFlowDeps) => Promise<TToken>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { throwIfAborted } from '../clock.js';
|
|
2
|
+
import { CancelledError, DeviceFlowDeniedError, DeviceFlowExpiredError } from '../errors.js';
|
|
3
|
+
/** RFC 8628, 3.5: on slow_down the interval grows by 5 seconds. */
|
|
4
|
+
const SLOW_DOWN_STEP_MS = 5000;
|
|
5
|
+
const DEFAULT_MIN_INTERVAL_MS = 5000;
|
|
6
|
+
const DEFAULT_MAX_CONSECUTIVE_ERRORS = 10;
|
|
7
|
+
/**
|
|
8
|
+
* Device flow orchestration with every effect kept outside: the network behind the api port, time
|
|
9
|
+
* behind clock, cancellation behind signal. That is what makes expiry, slow_down, poll failures
|
|
10
|
+
* and Ctrl+C unit-testable.
|
|
11
|
+
*/
|
|
12
|
+
export const runDeviceFlow = async (api, deps) => {
|
|
13
|
+
const { clock, signal } = deps;
|
|
14
|
+
const maxConsecutiveErrors = deps.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS;
|
|
15
|
+
throwIfAborted(signal);
|
|
16
|
+
const code = await api.requestDeviceCode();
|
|
17
|
+
deps.onCode(code);
|
|
18
|
+
const deadline = clock.now() + code.expiresInSec * 1000;
|
|
19
|
+
let intervalMs = Math.max(code.intervalSec * 1000, deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS);
|
|
20
|
+
let consecutiveErrors = 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (clock.now() >= deadline) {
|
|
23
|
+
throw new DeviceFlowExpiredError();
|
|
24
|
+
}
|
|
25
|
+
await clock.sleep(intervalMs, signal);
|
|
26
|
+
throwIfAborted(signal);
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await api.pollToken(code.deviceCode);
|
|
30
|
+
consecutiveErrors = 0;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof CancelledError) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
consecutiveErrors += 1;
|
|
37
|
+
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
deps.onTransientError?.(error, consecutiveErrors);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
switch (result.status) {
|
|
44
|
+
case 'authorized': {
|
|
45
|
+
return result.token;
|
|
46
|
+
}
|
|
47
|
+
case 'pending': {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'slow_down': {
|
|
51
|
+
intervalMs += SLOW_DOWN_STEP_MS;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'expired': {
|
|
55
|
+
throw new DeviceFlowExpiredError();
|
|
56
|
+
}
|
|
57
|
+
case 'denied': {
|
|
58
|
+
throw new DeviceFlowDeniedError();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Time as a dependency: device flow and retry take a Clock, so tests never wait for real. */
|
|
2
|
+
export type Clock = {
|
|
3
|
+
now(): number;
|
|
4
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
export declare const systemClock: Clock;
|
|
7
|
+
export declare const throwIfAborted: (signal: AbortSignal | undefined) => void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { CancelledError, isAbortError } from './errors.js';
|
|
3
|
+
export const systemClock = {
|
|
4
|
+
now: () => Date.now(),
|
|
5
|
+
sleep: async (ms, signal) => {
|
|
6
|
+
try {
|
|
7
|
+
await delay(ms, undefined, signal ? { signal } : {});
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (isAbortError(error)) {
|
|
11
|
+
throw new CancelledError();
|
|
12
|
+
}
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
export const throwIfAborted = (signal) => {
|
|
18
|
+
if (signal?.aborted === true) {
|
|
19
|
+
throw new CancelledError();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sdk errors carry no user-facing text and no exit code: the adapter assigns both
|
|
3
|
+
* (src/cli/errors.ts, and a future MCP server).
|
|
4
|
+
*
|
|
5
|
+
* Every error has a stable `kind`, and the adapter switches over it. An instanceof chain would
|
|
6
|
+
* let a new error class fall through to the fallback and become exit 1 with no readable text.
|
|
7
|
+
*/
|
|
8
|
+
export type SdkErrorKind = 'api' | 'auth_required' | 'cancelled' | 'device_flow_denied' | 'device_flow_expired' | 'network' | 'storage' | 'validation';
|
|
9
|
+
/** Abstract on purpose: `new SdkError(...)` would be an error without a kind, unrecognisable. */
|
|
10
|
+
export declare abstract class SdkError extends Error {
|
|
11
|
+
abstract readonly kind: SdkErrorKind;
|
|
12
|
+
constructor(message: string, options?: ErrorOptions);
|
|
13
|
+
}
|
|
14
|
+
/** No response at all: DNS, refused connection, a dropped socket. */
|
|
15
|
+
export declare class NetworkError extends SdkError {
|
|
16
|
+
readonly kind = "network";
|
|
17
|
+
readonly url: string;
|
|
18
|
+
constructor(url: string, cause: unknown);
|
|
19
|
+
}
|
|
20
|
+
/** The server answered with an error. `code` and `details` follow the service's own format. */
|
|
21
|
+
export declare class ApiError extends SdkError {
|
|
22
|
+
readonly kind = "api";
|
|
23
|
+
readonly code: string | undefined;
|
|
24
|
+
readonly details: unknown;
|
|
25
|
+
readonly retryAfterMs: number | undefined;
|
|
26
|
+
readonly status: number;
|
|
27
|
+
constructor(init: {
|
|
28
|
+
code?: string | undefined;
|
|
29
|
+
details?: unknown;
|
|
30
|
+
message: string;
|
|
31
|
+
retryAfterMs?: number | undefined;
|
|
32
|
+
status: number;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** missing: no token stored locally. rejected: the server answered 401. */
|
|
36
|
+
export type AuthRequiredReason = 'missing' | 'rejected';
|
|
37
|
+
export declare class AuthRequiredError extends SdkError {
|
|
38
|
+
readonly kind = "auth_required";
|
|
39
|
+
readonly reason: AuthRequiredReason;
|
|
40
|
+
constructor(reason: AuthRequiredReason);
|
|
41
|
+
}
|
|
42
|
+
export declare class CancelledError extends SdkError {
|
|
43
|
+
readonly kind = "cancelled";
|
|
44
|
+
constructor();
|
|
45
|
+
}
|
|
46
|
+
export type Issue = {
|
|
47
|
+
message: string;
|
|
48
|
+
/** The input field the problem belongs to. No path: the input as a whole is wrong. */
|
|
49
|
+
path?: string | undefined;
|
|
50
|
+
};
|
|
51
|
+
/** A business rule broken before any network call. */
|
|
52
|
+
export declare class ValidationError extends SdkError {
|
|
53
|
+
readonly kind = "validation";
|
|
54
|
+
readonly issues: readonly Issue[];
|
|
55
|
+
constructor(issues: readonly Issue[]);
|
|
56
|
+
}
|
|
57
|
+
export declare class DeviceFlowExpiredError extends SdkError {
|
|
58
|
+
readonly kind = "device_flow_expired";
|
|
59
|
+
constructor();
|
|
60
|
+
}
|
|
61
|
+
export declare class DeviceFlowDeniedError extends SdkError {
|
|
62
|
+
readonly kind = "device_flow_denied";
|
|
63
|
+
constructor();
|
|
64
|
+
}
|
|
65
|
+
/** The session file is there but unusable: corrupted JSON, or something else in its place. */
|
|
66
|
+
export declare class StorageError extends SdkError {
|
|
67
|
+
readonly kind = "storage";
|
|
68
|
+
readonly path: string;
|
|
69
|
+
constructor(message: string, path: string, options?: ErrorOptions);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Every concrete error: a switch over `kind` narrows to the class. instanceof on the abstract
|
|
73
|
+
* base gives the base, without .status, .url or .issues.
|
|
74
|
+
*/
|
|
75
|
+
export type AnySdkError = ApiError | AuthRequiredError | CancelledError | DeviceFlowDeniedError | DeviceFlowExpiredError | NetworkError | StorageError | ValidationError;
|
|
76
|
+
export declare const isSdkError: (error: unknown) => error is AnySdkError;
|
|
77
|
+
type Assert<T extends true> = T;
|
|
78
|
+
/** Safety net: a kind whose class never made it into AnySdkError breaks the build here. */
|
|
79
|
+
export type AllKindsCovered = Assert<SdkErrorKind extends AnySdkError['kind'] ? true : false>;
|
|
80
|
+
export declare const isAbortError: (error: unknown) => boolean;
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sdk errors carry no user-facing text and no exit code: the adapter assigns both
|
|
3
|
+
* (src/cli/errors.ts, and a future MCP server).
|
|
4
|
+
*
|
|
5
|
+
* Every error has a stable `kind`, and the adapter switches over it. An instanceof chain would
|
|
6
|
+
* let a new error class fall through to the fallback and become exit 1 with no readable text.
|
|
7
|
+
*/
|
|
8
|
+
/** Abstract on purpose: `new SdkError(...)` would be an error without a kind, unrecognisable. */
|
|
9
|
+
export class SdkError extends Error {
|
|
10
|
+
constructor(message, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.name = new.target.name;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** No response at all: DNS, refused connection, a dropped socket. */
|
|
16
|
+
export class NetworkError extends SdkError {
|
|
17
|
+
kind = 'network';
|
|
18
|
+
url;
|
|
19
|
+
constructor(url, cause) {
|
|
20
|
+
super(`Request to ${url} failed`, { cause });
|
|
21
|
+
this.url = url;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The server answered with an error. `code` and `details` follow the service's own format. */
|
|
25
|
+
export class ApiError extends SdkError {
|
|
26
|
+
kind = 'api';
|
|
27
|
+
code;
|
|
28
|
+
details;
|
|
29
|
+
retryAfterMs;
|
|
30
|
+
status;
|
|
31
|
+
constructor(init) {
|
|
32
|
+
super(init.message);
|
|
33
|
+
this.status = init.status;
|
|
34
|
+
this.code = init.code;
|
|
35
|
+
this.details = init.details;
|
|
36
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class AuthRequiredError extends SdkError {
|
|
40
|
+
kind = 'auth_required';
|
|
41
|
+
reason;
|
|
42
|
+
constructor(reason) {
|
|
43
|
+
super(reason === 'missing' ? 'Not authenticated' : 'Token expired or invalid');
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class CancelledError extends SdkError {
|
|
48
|
+
kind = 'cancelled';
|
|
49
|
+
constructor() {
|
|
50
|
+
super('Operation cancelled');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** A business rule broken before any network call. */
|
|
54
|
+
export class ValidationError extends SdkError {
|
|
55
|
+
kind = 'validation';
|
|
56
|
+
issues;
|
|
57
|
+
constructor(issues) {
|
|
58
|
+
super(issues.map(issue => (issue.path === undefined ? issue.message : `${issue.path}: ${issue.message}`)).join('; '));
|
|
59
|
+
this.issues = issues;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class DeviceFlowExpiredError extends SdkError {
|
|
63
|
+
kind = 'device_flow_expired';
|
|
64
|
+
constructor() {
|
|
65
|
+
super('Device code expired before authorization');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class DeviceFlowDeniedError extends SdkError {
|
|
69
|
+
kind = 'device_flow_denied';
|
|
70
|
+
constructor() {
|
|
71
|
+
super('Authorization denied');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The session file is there but unusable: corrupted JSON, or something else in its place. */
|
|
75
|
+
export class StorageError extends SdkError {
|
|
76
|
+
kind = 'storage';
|
|
77
|
+
path;
|
|
78
|
+
constructor(message, path, options) {
|
|
79
|
+
super(message, options);
|
|
80
|
+
this.path = path;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export const isSdkError = (error) => error instanceof SdkError;
|
|
84
|
+
export const isAbortError = (error) => error instanceof Error && error.name === 'AbortError';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Clock } from '../clock.js';
|
|
2
|
+
import type { ErrorParser, ShouldRetry } from './policies.js';
|
|
3
|
+
import type { RetryAttempt, RetryPolicy } from './retry.js';
|
|
4
|
+
import type { QueryParams } from './url.js';
|
|
5
|
+
export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
|
|
6
|
+
export type RequestOptions = {
|
|
7
|
+
/** A JSON-serializable value, or FormData for file uploads. */
|
|
8
|
+
body?: unknown;
|
|
9
|
+
headers?: Record<string, string> | undefined;
|
|
10
|
+
/** Retry on 429, 5xx and network errors. On by default for GET only. */
|
|
11
|
+
idempotent?: boolean | undefined;
|
|
12
|
+
/** Response headers, e.g. an ETag for optimistic locking. */
|
|
13
|
+
onResponse?: ((headers: Headers) => void) | undefined;
|
|
14
|
+
query?: QueryParams | undefined;
|
|
15
|
+
signal?: AbortSignal | undefined;
|
|
16
|
+
};
|
|
17
|
+
type BodylessOptions = Omit<RequestOptions, 'body'>;
|
|
18
|
+
export type Http = {
|
|
19
|
+
delete<T>(path: string, options?: BodylessOptions): Promise<T>;
|
|
20
|
+
get<T>(path: string, options?: BodylessOptions): Promise<T>;
|
|
21
|
+
patch<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
22
|
+
post<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
23
|
+
put<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
24
|
+
request<T>(method: HttpMethod, path: string, options?: RequestOptions): Promise<T>;
|
|
25
|
+
};
|
|
26
|
+
export type HttpOptions = {
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
clock?: Clock | undefined;
|
|
29
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
30
|
+
headers?: Record<string, string> | undefined;
|
|
31
|
+
onRetry?: ((info: RetryAttempt) => void) | undefined;
|
|
32
|
+
parseError?: ErrorParser | undefined;
|
|
33
|
+
retry?: RetryPolicy | undefined;
|
|
34
|
+
/** When to retry an idempotent request. */
|
|
35
|
+
shouldRetry?: ShouldRetry | undefined;
|
|
36
|
+
signal?: AbortSignal | undefined;
|
|
37
|
+
token?: string | undefined;
|
|
38
|
+
/** Trailing slash in paths (Django style). On by default. */
|
|
39
|
+
trailingSlash?: boolean | undefined;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The transport: base URL, bearer token, JSON both ways, responses mapped to sdk errors,
|
|
43
|
+
* retry for idempotent requests. It knows nothing about resources or about the CLI.
|
|
44
|
+
*/
|
|
45
|
+
export declare const createHttp: (options: HttpOptions) => Http;
|
|
46
|
+
export {};
|