@apex-actions/cli 0.75.0 → 0.76.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 +22 -4
- package/dist/cli/grant/main.js +141 -0
- package/dist/cli/login/device.js +43 -0
- package/dist/cli/login/main.js +104 -31
- package/dist/cli/logout/main.js +13 -7
- package/dist/cli/program.js +2 -0
- package/dist/cli/reference.js +14 -1
- package/dist/cli/tenant/main.js +38 -2
- package/dist/cli/token/main.js +40 -19
- package/dist/src/application/authenticate.js +82 -1
- package/dist/src/application/manage-grants.js +21 -0
- package/dist/src/application/manage-tokens.js +3 -3
- package/dist/src/domain/credentials.js +70 -1
- package/dist/src/domain/grants.js +111 -0
- package/dist/src/infrastructure/control-plane-http.js +18 -2
- package/dist/src/infrastructure/credentials.js +23 -0
- package/dist/src/infrastructure/device-login.js +82 -0
- package/dist/src/infrastructure/loopback-login.js +33 -26
- package/package.json +1 -1
package/dist/cli/token/main.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { createToken, editToken, listTokens, revokeTokens } from '../../src/application/manage-tokens.js';
|
|
4
|
-
import { MAX_TOKEN_DAYS, TOKENS_PAGE, TOKEN_RESOURCES, describeGrants, webBaseFor } from '../../src/domain/credentials.js';
|
|
4
|
+
import { MAX_TOKEN_DAYS, TOKENS_PAGE, TOKEN_RESOURCES, describeGrants, loopbackTimeoutHint, webBaseFor } from '../../src/domain/credentials.js';
|
|
5
5
|
import { TOKEN_ENV, WEB_URL_ENV, apiUrlFrom } from '../../src/infrastructure/credentials.js';
|
|
6
6
|
import { HttpLoginExchange, listenForCallback, newPkcePair, openInBrowser } from '../../src/infrastructure/loopback-login.js';
|
|
7
7
|
import { api, collect, usage } from '../secret/shared.js';
|
|
8
|
+
import { approvalFlow, withFlowOptions } from '../login/device.js';
|
|
8
9
|
/**
|
|
9
10
|
* `apex token`: the person's API tokens from the terminal (control-plane
|
|
10
11
|
* ADR-0064, amended 2026-09-27).
|
|
@@ -22,38 +23,49 @@ import { api, collect, usage } from '../secret/shared.js';
|
|
|
22
23
|
* Exit codes follow §7: 0 done · 1 refused or declined · 2 usage · 3 no such token.
|
|
23
24
|
*/
|
|
24
25
|
const PERMISSION_HELP = `resource:read or resource:write (write implies read); resource is one of ${TOKEN_RESOURCES.join(', ')}`;
|
|
25
|
-
/**
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The approval half `create` and a widening `edit` share — the loopback on this machine, or a device code
|
|
28
|
+
* (control-plane ADR-0075). Everything a person reads goes to stderr: stdout is the token's alone.
|
|
29
|
+
*/
|
|
30
|
+
const approvalDeps = (flags, what) => {
|
|
27
31
|
const apiUrl = apiUrlFrom();
|
|
32
|
+
const chosen = approvalFlow(flags, apiUrl, (line) => console.error(line));
|
|
33
|
+
if ('problem' in chosen)
|
|
34
|
+
return chosen;
|
|
28
35
|
return {
|
|
29
36
|
pkce: newPkcePair,
|
|
30
37
|
listen: (state) => listenForCallback(state),
|
|
31
|
-
openBrowser:
|
|
38
|
+
openBrowser: (url) => openInBrowser(url),
|
|
32
39
|
announce: (url) => {
|
|
33
|
-
console.error(
|
|
40
|
+
console.error(`Opening the browser to approve ${what}. If it does not open, visit:`);
|
|
34
41
|
console.error(` ${url}`);
|
|
35
42
|
console.error(chalk.dim('Waiting for the approval (5 minutes)…'));
|
|
36
43
|
},
|
|
37
44
|
exchange: new HttpLoginExchange(apiUrl),
|
|
38
45
|
webBase: webBaseFor(apiUrl, process.env[WEB_URL_ENV]),
|
|
46
|
+
flow: chosen.flow,
|
|
47
|
+
device: chosen.device,
|
|
39
48
|
};
|
|
40
49
|
};
|
|
41
50
|
const refused = (message) => {
|
|
42
51
|
console.error(chalk.red(message));
|
|
43
52
|
process.exitCode = 1;
|
|
44
53
|
};
|
|
45
|
-
const createCommand = new Command('create')
|
|
46
|
-
.description('Create an API token for a script or CI system, approved in the browser; the token is printed once and not saved')
|
|
54
|
+
const createCommand = withFlowOptions(new Command('create')
|
|
55
|
+
.description('Create an API token for a script or CI system, approved in the browser — here, or on any device with a code; the token is printed once and not saved')
|
|
47
56
|
.argument('<name>', 'what the token is for, as the token list will show it (1–100 characters)')
|
|
48
57
|
.option('--permission <permission>', `what the token may do, repeatable and at least one: ${PERMISSION_HELP}`, collect, [])
|
|
49
58
|
.option('--account <id>', 'the Account the token belongs to — needed when you belong to more than one')
|
|
50
59
|
.option('--expires-in-days <days>', `days until the token expires, 1–${MAX_TOKEN_DAYS} (default 30)`)
|
|
51
60
|
.option('--repo <owner/repo>', 'hold the token to this repository (repeatable, up to 100); omit for every repository you reach in the Account', collect, [])
|
|
52
|
-
.option('--
|
|
53
|
-
.option('--json', 'print the token and its details as JSON on stdout')
|
|
54
|
-
.action(async (name, opts) => {
|
|
61
|
+
.option('--json', 'print the token and its details as JSON on stdout')).action(async (name, opts) => {
|
|
55
62
|
const days = opts.expiresInDays === undefined ? undefined : /^[0-9]+$/.test(opts.expiresInDays) ? Number(opts.expiresInDays) : Number.NaN;
|
|
56
|
-
const
|
|
63
|
+
const deps = approvalDeps(opts, 'this token');
|
|
64
|
+
if ('problem' in deps) {
|
|
65
|
+
usage(deps.problem);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const outcome = await createToken(deps, {
|
|
57
69
|
name,
|
|
58
70
|
days,
|
|
59
71
|
repos: opts.repo,
|
|
@@ -96,7 +108,10 @@ const createCommand = new Command('create')
|
|
|
96
108
|
refused('the browser came back without a code; run apex token create again');
|
|
97
109
|
return;
|
|
98
110
|
case 'timeout':
|
|
99
|
-
refused('
|
|
111
|
+
refused(loopbackTimeoutHint('apex token create'));
|
|
112
|
+
return;
|
|
113
|
+
case 'expired':
|
|
114
|
+
refused('the code expired before anyone approved it; run apex token create again for a new one');
|
|
100
115
|
return;
|
|
101
116
|
case 'refused':
|
|
102
117
|
refused(outcome.detail);
|
|
@@ -105,25 +120,28 @@ const createCommand = new Command('create')
|
|
|
105
120
|
});
|
|
106
121
|
const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all' : t.repos.join(', '));
|
|
107
122
|
const day = (iso) => (iso === undefined ? '—' : iso.slice(0, 10));
|
|
108
|
-
const editCommand = new Command('edit')
|
|
109
|
-
.description('Change what a token may do or which repositories it reaches; narrowing applies at once, widening is approved in the browser')
|
|
123
|
+
const editCommand = withFlowOptions(new Command('edit')
|
|
124
|
+
.description('Change what a token may do or which repositories it reaches; narrowing applies at once, widening is approved in the browser — here, or on any device with a code')
|
|
110
125
|
.argument('<id>', 'the token id, from apex token list')
|
|
111
126
|
.option('--permission <permission>', `the token's whole new set of permissions, repeatable: ${PERMISSION_HELP}`, collect, [])
|
|
112
127
|
.option('--add-permission <permission>', 'add one permission to what the token holds (repeatable)', collect, [])
|
|
113
128
|
.option('--remove-permission <permission>', 'take one permission away (repeatable)', collect, [])
|
|
114
129
|
.option('--repo <owner/repo>', "the token's new list of repositories (repeatable, up to 100)", collect, [])
|
|
115
130
|
.option('--all-repos', 'let the token reach every repository you reach in its Account')
|
|
116
|
-
.option('--
|
|
117
|
-
.option('--json', 'print the edited token as JSON')
|
|
118
|
-
.action(async (id, opts) => {
|
|
131
|
+
.option('--json', 'print the edited token as JSON')).action(async (id, opts) => {
|
|
119
132
|
if (opts.allRepos === true && opts.repo.length > 0) {
|
|
120
133
|
usage('pass --repo or --all-repos, not both');
|
|
121
134
|
return;
|
|
122
135
|
}
|
|
136
|
+
const deps = approvalDeps(opts, 'widening this token');
|
|
137
|
+
if ('problem' in deps) {
|
|
138
|
+
usage(deps.problem);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
123
141
|
const client = api();
|
|
124
142
|
if (!client)
|
|
125
143
|
return;
|
|
126
|
-
const outcome = await editToken(client,
|
|
144
|
+
const outcome = await editToken(client, deps, id, {
|
|
127
145
|
...(opts.permission.length > 0 ? { permissions: opts.permission } : {}),
|
|
128
146
|
add: opts.addPermission,
|
|
129
147
|
remove: opts.removePermission,
|
|
@@ -164,7 +182,10 @@ const editCommand = new Command('edit')
|
|
|
164
182
|
refused('the browser came back without a code; run apex token edit again');
|
|
165
183
|
return;
|
|
166
184
|
case 'timeout':
|
|
167
|
-
refused('
|
|
185
|
+
refused(loopbackTimeoutHint('apex token edit'));
|
|
186
|
+
return;
|
|
187
|
+
case 'expired':
|
|
188
|
+
refused('the code expired before anyone approved the widening; run apex token edit again for a new one');
|
|
168
189
|
return;
|
|
169
190
|
case 'refused':
|
|
170
191
|
refused(outcome.detail);
|
|
@@ -32,8 +32,71 @@ export const approveInBrowser = async (deps, token, accountId) => {
|
|
|
32
32
|
await listener.close();
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
|
+
/** RFC 8628 §3.2's default when a server names no interval. */
|
|
36
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
37
|
+
/**
|
|
38
|
+
* The device flow (control-plane ADR-0075, RFC 8628 run by Apex): ask for a request, show the code, and
|
|
39
|
+
* poll with the device code — which never leaves this process — every `interval` seconds, slowing down
|
|
40
|
+
* when told, until the person approves or denies on any device, or the code's ten minutes pass.
|
|
41
|
+
*/
|
|
42
|
+
export const approveOnDevice = async (deps, token, accountId) => {
|
|
43
|
+
const { device } = deps;
|
|
44
|
+
const purpose = token === undefined ? 'login' : (token.purpose ?? 'token');
|
|
45
|
+
let start;
|
|
46
|
+
try {
|
|
47
|
+
start = await device.exchange.start({
|
|
48
|
+
name: deps.name,
|
|
49
|
+
purpose,
|
|
50
|
+
...(token?.days === undefined ? {} : { days: token.days }),
|
|
51
|
+
...(token?.allRepos === true ? { allRepositories: true } : token?.repos !== undefined && token.repos.length > 0 ? { repos: token.repos } : {}),
|
|
52
|
+
...(token?.permissions === undefined ? {} : { permissions: token.permissions }),
|
|
53
|
+
...((token?.accountId ?? accountId) === undefined ? {} : { accountId: token?.accountId ?? accountId }),
|
|
54
|
+
...(token?.tokenId === undefined ? {} : { tokenId: token.tokenId }),
|
|
55
|
+
...(device.os === undefined ? {} : { os: device.os }),
|
|
56
|
+
...(device.hostname === undefined ? {} : { hostname: device.hostname }),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
61
|
+
}
|
|
62
|
+
device.announceCode(start, device.why);
|
|
63
|
+
let interval = Number.isFinite(start.interval) && start.interval >= 0 ? start.interval : DEFAULT_INTERVAL_SECONDS;
|
|
64
|
+
const deadline = device.now() + start.expiresIn * 1000;
|
|
65
|
+
for (;;) {
|
|
66
|
+
await device.sleep(interval * 1000);
|
|
67
|
+
let answer;
|
|
68
|
+
try {
|
|
69
|
+
answer = await device.exchange.poll(start.deviceCode);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
73
|
+
}
|
|
74
|
+
switch (answer.kind) {
|
|
75
|
+
case 'minted':
|
|
76
|
+
return { kind: 'minted', minted: answer.minted };
|
|
77
|
+
case 'slow_down':
|
|
78
|
+
// RFC 8628 §3.5: add five seconds — or keep to what the server now says.
|
|
79
|
+
interval = answer.interval ?? interval + 5;
|
|
80
|
+
break;
|
|
81
|
+
case 'authorization_pending':
|
|
82
|
+
if (answer.interval !== undefined)
|
|
83
|
+
interval = answer.interval;
|
|
84
|
+
break;
|
|
85
|
+
case 'access_denied':
|
|
86
|
+
return { kind: 'denied', detail: 'denied on the approval page' };
|
|
87
|
+
case 'expired_token':
|
|
88
|
+
return { kind: 'expired' };
|
|
89
|
+
case 'invalid_grant':
|
|
90
|
+
return { kind: 'refused', detail: 'the control plane does not know this device request — run the command again' };
|
|
91
|
+
}
|
|
92
|
+
if (device.now() >= deadline)
|
|
93
|
+
return { kind: 'expired' };
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/** The approval, by whichever flow was chosen: the loopback unless `flow` is `device`. */
|
|
97
|
+
export const approve = async (deps, token, accountId) => deps.flow === 'device' && deps.device !== undefined ? await approveOnDevice({ ...deps, device: deps.device }, token, accountId) : await approveInBrowser(deps, token, accountId);
|
|
35
98
|
export const login = async (deps) => {
|
|
36
|
-
const approval = await
|
|
99
|
+
const approval = await approve(deps, undefined, deps.accountId);
|
|
37
100
|
if (approval.kind !== 'minted')
|
|
38
101
|
return approval;
|
|
39
102
|
const { minted } = approval;
|
|
@@ -49,6 +112,24 @@ export const login = async (deps) => {
|
|
|
49
112
|
accountId: minted.accountId,
|
|
50
113
|
};
|
|
51
114
|
};
|
|
115
|
+
/**
|
|
116
|
+
* `apex login --console` (control-plane ADR-0079): the same loopback + PKCE approval, opened on the Admin
|
|
117
|
+
* Console's bare page rather than the app's — `deps.webBase` is the Console's — and redeemed at the same
|
|
118
|
+
* control plane, for an **operator token**. Only the loopback: an operator approves in the Console's own
|
|
119
|
+
* browser session. What comes back must be an operator token, or nothing is saved.
|
|
120
|
+
*/
|
|
121
|
+
export const consoleLogin = async (deps) => {
|
|
122
|
+
const approval = await approveInBrowser({ ...deps, flow: 'loopback' });
|
|
123
|
+
if (approval.kind !== 'minted')
|
|
124
|
+
return approval;
|
|
125
|
+
const { minted } = approval;
|
|
126
|
+
if (minted.token === undefined)
|
|
127
|
+
return { kind: 'refused', detail: 'the control plane answered the login without a token' };
|
|
128
|
+
if (!minted.token.startsWith(deps.operatorPrefix))
|
|
129
|
+
return { kind: 'refused', detail: 'the control plane answered with an Account token, not an operator token — nothing was saved' };
|
|
130
|
+
await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
|
|
131
|
+
return { kind: 'logged-in', login: minted.login, expiresAt: minted.expiresAt, location: deps.store.location };
|
|
132
|
+
};
|
|
52
133
|
/**
|
|
53
134
|
* Revoke the saved token at the control plane that minted it, then forget it
|
|
54
135
|
* (control-plane ADR-0064: a token may revoke itself and nothing else). The file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { withOperatorHint } from '../domain/grants.js';
|
|
2
|
+
const attempt = async (fn) => {
|
|
3
|
+
try {
|
|
4
|
+
return { kind: 'ok', value: await fn() };
|
|
5
|
+
}
|
|
6
|
+
catch (err) {
|
|
7
|
+
return { kind: 'refused', message: withOperatorHint(err instanceof Error ? err.message : String(err)) };
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
/** Every grant ever made, newest first. The control plane decides who may read them. */
|
|
11
|
+
export const listGrants = async (api) => await attempt(() => api.listGrants());
|
|
12
|
+
/**
|
|
13
|
+
* Grant an installation complimentary access, replacing any open grant it holds. Only a person may: the
|
|
14
|
+
* control plane records the operator the token belongs to, and refuses the machine token.
|
|
15
|
+
*/
|
|
16
|
+
export const createGrant = async (api, body) => await attempt(() => api.createGrant(body));
|
|
17
|
+
export const updateGrant = async (api, id, body) => await attempt(() => api.updateGrant(id, body));
|
|
18
|
+
/** End a grant now; its queued free work is cancelled with a reason on each check run. */
|
|
19
|
+
export const revokeGrant = async (api, id) => await attempt(() => api.revokeGrant(id));
|
|
20
|
+
/** Every installation GitHub lists for the App — `fresh` keeps only those the tenants list never shows. */
|
|
21
|
+
export const listInstallations = async (api, fresh) => await attempt(async () => (await api.listInstallations()).filter((i) => !fresh || !i.listedAsTenant));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// application layer — see ../../../../docs/conventions.md §1
|
|
2
2
|
import { editedPermissions, tokenEditProblem, tokenRequestProblem } from '../domain/credentials.js';
|
|
3
|
-
import {
|
|
3
|
+
import { approve } from './authenticate.js';
|
|
4
4
|
const detail = (err) => (err instanceof Error ? err.message : String(err));
|
|
5
5
|
/**
|
|
6
6
|
* Mint a token through the browser. The flags are checked before a browser
|
|
@@ -11,7 +11,7 @@ export const createToken = async (deps, input) => {
|
|
|
11
11
|
const problem = tokenRequestProblem(name, input.days, input.repos, input.permissions);
|
|
12
12
|
if (problem !== undefined)
|
|
13
13
|
return { kind: 'invalid', detail: problem };
|
|
14
|
-
const approval = await
|
|
14
|
+
const approval = await approve({ ...deps, name }, {
|
|
15
15
|
purpose: 'token',
|
|
16
16
|
permissions: [...new Set(input.permissions)],
|
|
17
17
|
...(input.days === undefined ? {} : { days: input.days }),
|
|
@@ -81,7 +81,7 @@ export const editToken = async (api, approval, id, edit) => {
|
|
|
81
81
|
if (answer.status !== 403)
|
|
82
82
|
return { kind: 'refused', detail: answer.detail };
|
|
83
83
|
// A widening: a person approves it in the browser, within the Account's policy.
|
|
84
|
-
const approved = await
|
|
84
|
+
const approved = await approve({ ...approval, name: target.name }, {
|
|
85
85
|
purpose: 'edit',
|
|
86
86
|
tokenId: id,
|
|
87
87
|
permissions,
|
|
@@ -19,7 +19,30 @@ export const describeSource = (source) => {
|
|
|
19
19
|
case 'login':
|
|
20
20
|
return 'the token `apex login` saved';
|
|
21
21
|
case 'operator':
|
|
22
|
-
return 'APEX_ADMIN_TOKEN, the
|
|
22
|
+
return 'APEX_ADMIN_TOKEN, the machine token';
|
|
23
|
+
case 'console':
|
|
24
|
+
return 'the operator token `apex login --console` saved';
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/** An operator token's prefix (control-plane ADR-0079); an Account token's is `apex_pat_`. */
|
|
28
|
+
export const OPERATOR_TOKEN_PREFIX = 'apex_opt_';
|
|
29
|
+
/**
|
|
30
|
+
* The Admin Console's base URL, where `apex login --console` is approved (control-plane ADR-0079):
|
|
31
|
+
* `APEX_CONSOLE_URL` when set, else the API's host with `app.` read as `admin.` — the hosted service's
|
|
32
|
+
* pair — else `undefined`, and the command asks for `APEX_CONSOLE_URL`.
|
|
33
|
+
*/
|
|
34
|
+
export const consoleBaseFor = (apiUrl, consoleUrl) => {
|
|
35
|
+
if (consoleUrl !== undefined && consoleUrl !== '')
|
|
36
|
+
return normaliseUrl(consoleUrl);
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(apiUrl);
|
|
39
|
+
if (!url.hostname.startsWith('app.'))
|
|
40
|
+
return undefined;
|
|
41
|
+
url.hostname = `admin.${url.hostname.slice('app.'.length)}`;
|
|
42
|
+
return normaliseUrl(url.origin);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
23
46
|
}
|
|
24
47
|
};
|
|
25
48
|
/** A URL without its trailing slash, so two spellings of one origin compare equal. */
|
|
@@ -209,3 +232,49 @@ export const judgeCallback = (params, expectedState) => {
|
|
|
209
232
|
return { kind: 'malformed' };
|
|
210
233
|
return { kind: 'code', code };
|
|
211
234
|
};
|
|
235
|
+
const set = (v) => v !== undefined && v !== '';
|
|
236
|
+
/**
|
|
237
|
+
* Why no browser can open on this machine, in words for the terminal — or `undefined` when one can.
|
|
238
|
+
* ADR-0075's list: an SSH session, a Codespace, a dev container, a container, or Linux with no display.
|
|
239
|
+
*/
|
|
240
|
+
export const headlessReason = ({ env, platform, dockerenv }) => {
|
|
241
|
+
if (set(env['SSH_CONNECTION']) || set(env['SSH_TTY']))
|
|
242
|
+
return 'this is an SSH session';
|
|
243
|
+
if (set(env['CODESPACES']))
|
|
244
|
+
return 'this is a Codespace';
|
|
245
|
+
if (set(env['REMOTE_CONTAINERS']))
|
|
246
|
+
return 'this is a dev container';
|
|
247
|
+
if (dockerenv)
|
|
248
|
+
return 'this is a container';
|
|
249
|
+
if (platform === 'linux' && !set(env['DISPLAY']) && !set(env['WAYLAND_DISPLAY']))
|
|
250
|
+
return 'this Linux machine has no display';
|
|
251
|
+
return undefined;
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* Which flow to use. `--device` and `--no-browser` ask for the device flow (`--no-browser` once printed a
|
|
255
|
+
* loopback URL, which only ever worked on this same machine); `--browser` insists on the loopback;
|
|
256
|
+
* otherwise a headless environment chooses the device flow and says why.
|
|
257
|
+
*/
|
|
258
|
+
export const chooseFlow = (flags, headless) => {
|
|
259
|
+
if (flags.device === true && flags.browser === true)
|
|
260
|
+
return { problem: 'pass --device or --browser, not both' };
|
|
261
|
+
if (flags.device === true || flags.browser === false)
|
|
262
|
+
return { flow: 'device' };
|
|
263
|
+
if (flags.browser === true)
|
|
264
|
+
return { flow: 'loopback' };
|
|
265
|
+
return headless === undefined ? { flow: 'loopback' } : { flow: 'device', why: headless };
|
|
266
|
+
};
|
|
267
|
+
/** The lines the terminal shows for a device approval — the code, where to enter it, and for how long. */
|
|
268
|
+
export const deviceInstructions = (start, why) => {
|
|
269
|
+
const minutes = Math.max(1, Math.round(start.expiresIn / 60));
|
|
270
|
+
return [
|
|
271
|
+
...(why === undefined ? [] : [`No browser can open here (${why}), so approve with a code instead.`]),
|
|
272
|
+
`Open ${start.verificationUri} on any device and enter ${start.userCode}`,
|
|
273
|
+
`The code expires in ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}. Enter it only on that page — Apex never asks for it anywhere else.`,
|
|
274
|
+
];
|
|
275
|
+
};
|
|
276
|
+
/** RFC 8628 §3.5's answers to a poll that minted nothing, plus `invalid_grant` for a code never issued. */
|
|
277
|
+
export const DEVICE_POLL_ERRORS = ['authorization_pending', 'slow_down', 'access_denied', 'expired_token', 'invalid_grant'];
|
|
278
|
+
export const isDevicePollError = (v) => typeof v === 'string' && DEVICE_POLL_ERRORS.includes(v);
|
|
279
|
+
/** What a loopback timeout says: most likely the browser is on another machine, and there is a flow for that. */
|
|
280
|
+
export const loopbackTimeoutHint = (command) => `No approval arrived. If your browser is on another machine, run \`${command} --device\`.`;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
/**
|
|
3
|
+
* Complimentary access from the terminal (control-plane ADR-0056 as amended by
|
|
4
|
+
* ADR-0079, TASK-0199): the grant and installation shapes the admin API
|
|
5
|
+
* answers with, the checks `apex grant` makes before a round trip — the
|
|
6
|
+
* control plane makes them again — and how each reads as a line.
|
|
7
|
+
*/
|
|
8
|
+
/** The admin API's limits on a grant (control-plane `domain/grant.ts`). */
|
|
9
|
+
export const MAX_GRANT_MINUTES = 120_000;
|
|
10
|
+
export const MAX_GRANT_CONCURRENCY = 50;
|
|
11
|
+
export const MAX_GRANT_REASON = 500;
|
|
12
|
+
const whole = (raw, min, max) => {
|
|
13
|
+
if (raw === undefined || !/^[0-9]+$/.test(raw.trim()))
|
|
14
|
+
return undefined;
|
|
15
|
+
const n = Number(raw.trim());
|
|
16
|
+
return Number.isSafeInteger(n) && n >= min && n <= max ? n : undefined;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* `--until YYYY-MM-DD`: the grant ends at the end of that UTC day, as the Console's form has it — so the
|
|
20
|
+
* instant sent is the next day's midnight. A day that is not a real date, or not after `now`, is refused.
|
|
21
|
+
*/
|
|
22
|
+
export const endOfDay = (day, now) => {
|
|
23
|
+
const value = day.trim();
|
|
24
|
+
const start = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00.000Z`) : new Date(Number.NaN);
|
|
25
|
+
if (Number.isNaN(start.getTime()) || start.toISOString().slice(0, 10) !== value)
|
|
26
|
+
return { ok: false, problem: `--until must be a date, YYYY-MM-DD, got ${JSON.stringify(day)}` };
|
|
27
|
+
const end = new Date(start.getTime() + 86_400_000);
|
|
28
|
+
if (end.getTime() <= now.getTime())
|
|
29
|
+
return { ok: false, problem: '--until must be today or later — the grant ends at the end of that UTC day' };
|
|
30
|
+
return { ok: true, value: end.toISOString() };
|
|
31
|
+
};
|
|
32
|
+
const minutesProblem = `--minutes must be a whole number from 1 to ${MAX_GRANT_MINUTES.toLocaleString('en-US')}`;
|
|
33
|
+
const concurrencyProblem = `--concurrency must be a whole number from 1 to ${MAX_GRANT_CONCURRENCY}`;
|
|
34
|
+
/** `apex grant create`'s arguments, checked. */
|
|
35
|
+
export const createGrantBody = (installation, flags, now) => {
|
|
36
|
+
const installationId = whole(installation, 1, Number.MAX_SAFE_INTEGER);
|
|
37
|
+
if (installationId === undefined)
|
|
38
|
+
return { ok: false, problem: `expected a positive installation id, got ${JSON.stringify(installation)}` };
|
|
39
|
+
const minutesPerMonth = whole(flags.minutes, 1, MAX_GRANT_MINUTES);
|
|
40
|
+
if (minutesPerMonth === undefined)
|
|
41
|
+
return { ok: false, problem: minutesProblem };
|
|
42
|
+
const concurrency = whole(flags.concurrency, 1, MAX_GRANT_CONCURRENCY);
|
|
43
|
+
if (concurrency === undefined)
|
|
44
|
+
return { ok: false, problem: concurrencyProblem };
|
|
45
|
+
const reason = flags.reason?.trim() ?? '';
|
|
46
|
+
if (reason === '' || reason.length > MAX_GRANT_REASON)
|
|
47
|
+
return { ok: false, problem: `--reason is required, up to ${MAX_GRANT_REASON} characters — who this is for, and why they run free` };
|
|
48
|
+
if (flags.until === undefined)
|
|
49
|
+
return { ok: true, value: { installationId, minutesPerMonth, concurrency, reason } };
|
|
50
|
+
const until = endOfDay(flags.until, now);
|
|
51
|
+
return until.ok ? { ok: true, value: { installationId, minutesPerMonth, concurrency, reason, expiresAt: until.value } } : until;
|
|
52
|
+
};
|
|
53
|
+
/** `apex grant update`'s flags, checked: at least one change, each well-formed, and not both an end date and none. */
|
|
54
|
+
export const updateGrantBody = (flags, now) => {
|
|
55
|
+
const body = {};
|
|
56
|
+
if (flags.minutes !== undefined) {
|
|
57
|
+
const n = whole(flags.minutes, 1, MAX_GRANT_MINUTES);
|
|
58
|
+
if (n === undefined)
|
|
59
|
+
return { ok: false, problem: minutesProblem };
|
|
60
|
+
body.minutesPerMonth = n;
|
|
61
|
+
}
|
|
62
|
+
if (flags.concurrency !== undefined) {
|
|
63
|
+
const n = whole(flags.concurrency, 1, MAX_GRANT_CONCURRENCY);
|
|
64
|
+
if (n === undefined)
|
|
65
|
+
return { ok: false, problem: concurrencyProblem };
|
|
66
|
+
body.concurrency = n;
|
|
67
|
+
}
|
|
68
|
+
if (flags.until !== undefined && flags.noEnd === true)
|
|
69
|
+
return { ok: false, problem: 'pass --until or --no-end, not both' };
|
|
70
|
+
if (flags.noEnd === true)
|
|
71
|
+
body.expiresAt = null;
|
|
72
|
+
if (flags.until !== undefined) {
|
|
73
|
+
const until = endOfDay(flags.until, now);
|
|
74
|
+
if (!until.ok)
|
|
75
|
+
return until;
|
|
76
|
+
body.expiresAt = until.value;
|
|
77
|
+
}
|
|
78
|
+
if (Object.keys(body).length === 0)
|
|
79
|
+
return { ok: false, problem: 'nothing to change: pass --minutes, --concurrency, --until or --no-end' };
|
|
80
|
+
return { ok: true, value: body };
|
|
81
|
+
};
|
|
82
|
+
const day = (iso) => iso?.slice(0, 10);
|
|
83
|
+
/** One grant as a line: whose, how it stands, the month so far, and who granted it and why. */
|
|
84
|
+
export const grantLine = (g) => {
|
|
85
|
+
const used = g.usedMinutes === undefined ? '' : `${Math.floor(g.usedMinutes).toLocaleString('en-US')} of `;
|
|
86
|
+
const state = g.state === 'ended' ? `ended ${day(g.endedAt ?? g.revokedAt) ?? ''}${g.revokedBy === undefined ? '' : ` by ${g.revokedBy}`}`.trim() : g.dormant ? `${g.state}, dormant — they pay for a plan` : g.state;
|
|
87
|
+
const until = g.expiresAt === undefined ? '' : `, until ${day(g.expiresAt) ?? ''}`;
|
|
88
|
+
return `${g.id} ${g.owner} (installation ${g.installationId}) — ${state}; ${used}${g.minutesPerMonth.toLocaleString('en-US')} minutes a month, ${g.concurrency} at once${until}; granted by ${g.grantedBy}: ${g.reason}`;
|
|
89
|
+
};
|
|
90
|
+
/** What a create means, in the Console's words: open, a replacement, or dormant behind a subscription. */
|
|
91
|
+
export const createdLine = (c) => c.dormant
|
|
92
|
+
? `Granted to ${c.grant.owner}. They pay for a plan, so the grant is dormant until they stop.`
|
|
93
|
+
: `Granted to ${c.grant.owner}.${c.superseded === undefined ? '' : ' It replaces the grant they held.'}`;
|
|
94
|
+
export const revokedLine = (r) => `Revoked the grant for ${r.grant.owner}.${r.cancelledJobs > 0 ? ` ${r.cancelledJobs} queued job${r.cancelledJobs === 1 ? ' was' : 's were'} cancelled.` : ''}`;
|
|
95
|
+
/** One installation as a line: whose, and what Apex knows of it — or that it is new. */
|
|
96
|
+
export const installationLine = (i) => {
|
|
97
|
+
const known = [
|
|
98
|
+
i.accountId === undefined ? 'no Account' : `Account ${i.accountId}`,
|
|
99
|
+
...(i.subscription === undefined ? [] : [`subscription ${i.subscription}`]),
|
|
100
|
+
...(i.grant === undefined ? [] : [`grant ${i.grant}`]),
|
|
101
|
+
...(i.recentUsage ? ['ran in the last 30 days'] : []),
|
|
102
|
+
];
|
|
103
|
+
return `${i.installationId} ${i.account.login} (${i.account.type === 'User' ? 'personal account' : 'organization'})${i.listedAsTenant ? '' : ' — new'} · ${known.join(', ')}`;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* The control plane refuses a grant write made with the machine token, and every operator route to an Account
|
|
107
|
+
* token. Either way the remedy is the operator token: say so.
|
|
108
|
+
*/
|
|
109
|
+
export const withOperatorHint = (message) => /machine token|no token reaches|operator’s|only the grant routes|not signed in/.test(message)
|
|
110
|
+
? `${message} — sign in as an operator with: apex login --console`
|
|
111
|
+
: message;
|
|
@@ -3,7 +3,7 @@ import { writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
import { withPermissionHint } from '../domain/credentials.js';
|
|
6
|
-
import { NotConfiguredError, apiUrlFrom, resolveCredential } from './credentials.js';
|
|
6
|
+
import { NotConfiguredError, apiUrlFrom, resolveCredential, resolveOperatorCredential } from './credentials.js';
|
|
7
7
|
export { NotConfiguredError } from './credentials.js';
|
|
8
8
|
/** The API refused, with its status, so a use case can tell a 403 from a 500. */
|
|
9
9
|
export class HttpError extends Error {
|
|
@@ -48,7 +48,7 @@ export class HttpControlPlane {
|
|
|
48
48
|
this.credentialsAlsoPresent = [];
|
|
49
49
|
}
|
|
50
50
|
else {
|
|
51
|
-
const resolved = resolveCredential();
|
|
51
|
+
const resolved = config.operator === true ? resolveOperatorCredential() : resolveCredential();
|
|
52
52
|
this.#token = resolved.token;
|
|
53
53
|
this.#baseUrl = (config.baseUrl ?? resolved.apiUrl).replace(/\/$/, '');
|
|
54
54
|
this.credentialSource = resolved.source;
|
|
@@ -247,6 +247,22 @@ export class HttpControlPlane {
|
|
|
247
247
|
async accountsBackfill(input, apply) {
|
|
248
248
|
return await this.#json('POST', `/api/v1/admin/accounts/backfill${apply ? '?apply=true' : ''}`, input);
|
|
249
249
|
}
|
|
250
|
+
// ── complimentary access (control-plane ADR-0056, ADR-0079): the admin API's shapes, unchanged ──
|
|
251
|
+
async listGrants() {
|
|
252
|
+
return (await this.#json('GET', '/api/v1/admin/grants')).grants;
|
|
253
|
+
}
|
|
254
|
+
async createGrant(body) {
|
|
255
|
+
return await this.#json('POST', '/api/v1/admin/grants', body);
|
|
256
|
+
}
|
|
257
|
+
async updateGrant(id, body) {
|
|
258
|
+
return (await this.#json('PATCH', `/api/v1/admin/grants/${encodeURIComponent(id)}`, body)).grant;
|
|
259
|
+
}
|
|
260
|
+
async revokeGrant(id) {
|
|
261
|
+
return await this.#json('DELETE', `/api/v1/admin/grants/${encodeURIComponent(id)}`);
|
|
262
|
+
}
|
|
263
|
+
async listInstallations() {
|
|
264
|
+
return (await this.#json('GET', '/api/v1/admin/installations')).installations;
|
|
265
|
+
}
|
|
250
266
|
async accountsUnconnected() {
|
|
251
267
|
return (await this.#json('GET', '/api/v1/admin/accounts/unconnected')).installations;
|
|
252
268
|
}
|
|
@@ -9,6 +9,7 @@ export const URL_ENV = 'APEX_API_URL';
|
|
|
9
9
|
export const WEB_URL_ENV = 'APEX_WEB_URL';
|
|
10
10
|
export const TOKEN_ENV = 'APEX_TOKEN';
|
|
11
11
|
export const ADMIN_TOKEN_ENV = 'APEX_ADMIN_TOKEN';
|
|
12
|
+
export const CONSOLE_URL_ENV = 'APEX_CONSOLE_URL';
|
|
12
13
|
const set = (value) => value !== undefined && value !== '';
|
|
13
14
|
/** The control plane: `APEX_API_URL`, or the hosted service. */
|
|
14
15
|
export const apiUrlFrom = (env = process.env) => normaliseUrl(set(env[URL_ENV]) ? env[URL_ENV] : DEFAULT_API_URL);
|
|
@@ -35,6 +36,11 @@ export class InsecureCredentialsError extends NotConfiguredError {
|
|
|
35
36
|
}
|
|
36
37
|
/** `$XDG_CONFIG_HOME/apex/credentials`, defaulting to `~/.config`. */
|
|
37
38
|
export const credentialsPath = (env = process.env) => join(set(env['XDG_CONFIG_HOME']) ? env['XDG_CONFIG_HOME'] : join(homedir(), '.config'), 'apex', 'credentials');
|
|
39
|
+
/**
|
|
40
|
+
* `$XDG_CONFIG_HOME/apex/console-credentials`: where `apex login --console` keeps the operator token
|
|
41
|
+
* (control-plane ADR-0079) — beside the Account login, never in place of it.
|
|
42
|
+
*/
|
|
43
|
+
export const consoleCredentialsPath = (env = process.env) => join(dirname(credentialsPath(env)), 'console-credentials');
|
|
38
44
|
/**
|
|
39
45
|
* Adapter: the credentials file `apex login` writes.
|
|
40
46
|
*
|
|
@@ -154,3 +160,20 @@ export const resolveCredential = (env = process.env, store = new FileCredentialS
|
|
|
154
160
|
}
|
|
155
161
|
throw new NotConfiguredError();
|
|
156
162
|
};
|
|
163
|
+
/**
|
|
164
|
+
* Which credential an operator command — `apex grant`, `apex tenant installations` — uses (control-plane
|
|
165
|
+
* ADR-0079): the operator token `apex login --console` saved, when it is private, unexpired and was minted by
|
|
166
|
+
* the control plane `APEX_API_URL` names; otherwise the ordinary order ({@link resolveCredential}), in which
|
|
167
|
+
* the machine token can read grants and is refused every write. An expired operator token is skipped, and
|
|
168
|
+
* the refusal that follows says to sign in again.
|
|
169
|
+
*/
|
|
170
|
+
export const resolveOperatorCredential = (env = process.env, consoleStore = new FileCredentialStore(consoleCredentialsPath(env)), fallback = () => resolveCredential(env), now = new Date()) => {
|
|
171
|
+
const explicitUrl = set(env[URL_ENV]) ? normaliseUrl(env[URL_ENV]) : undefined;
|
|
172
|
+
const saved = consoleStore.read();
|
|
173
|
+
if (saved !== undefined && !hasExpired(saved, now)) {
|
|
174
|
+
const mintedBy = normaliseUrl(saved.apiUrl);
|
|
175
|
+
if (explicitUrl === undefined || explicitUrl === mintedBy)
|
|
176
|
+
return { token: saved.token, apiUrl: mintedBy, source: 'console' };
|
|
177
|
+
}
|
|
178
|
+
return fallback();
|
|
179
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// infrastructure layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { hostname, platform, release, type } from 'node:os';
|
|
4
|
+
import { headlessReason, isDevicePollError, normaliseUrl } from '../domain/credentials.js';
|
|
5
|
+
import { redeemedFrom } from './loopback-login.js';
|
|
6
|
+
/**
|
|
7
|
+
* The device flow's adapters (control-plane ADR-0075): the two calls, what this machine says about
|
|
8
|
+
* itself, and whether a browser can open here at all.
|
|
9
|
+
*/
|
|
10
|
+
/** Why no browser can open here, read from this process's environment; `undefined` when one can. */
|
|
11
|
+
export const detectHeadless = () => headlessReason({ env: process.env, platform: process.platform, dockerenv: existsSync('/.dockerenv') });
|
|
12
|
+
/** What the approval page shows as "reported by the terminal": this machine's name and system. */
|
|
13
|
+
export const machineReport = () => ({
|
|
14
|
+
hostname: hostname().slice(0, 100),
|
|
15
|
+
os: `${type()} ${release()} (${platform()})`.slice(0, 100),
|
|
16
|
+
});
|
|
17
|
+
export const sleep = async (ms) => await new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
/** Adapter: `POST /api/v1/cli/device` and the `{ deviceCode }` poll of `POST /api/v1/cli/token`. No credential either way. */
|
|
19
|
+
export class HttpDeviceExchange {
|
|
20
|
+
#baseUrl;
|
|
21
|
+
#fetch;
|
|
22
|
+
constructor(baseUrl, fetchImpl = fetch) {
|
|
23
|
+
this.#baseUrl = normaliseUrl(baseUrl);
|
|
24
|
+
this.#fetch = fetchImpl;
|
|
25
|
+
}
|
|
26
|
+
async start(request) {
|
|
27
|
+
const body = await this.#post('/api/v1/cli/device', {
|
|
28
|
+
name: request.name,
|
|
29
|
+
purpose: request.purpose,
|
|
30
|
+
...(request.days === undefined ? {} : { expiresInDays: request.days }),
|
|
31
|
+
...(request.repos === undefined ? {} : { repos: request.repos }),
|
|
32
|
+
...(request.permissions === undefined ? {} : { permissions: request.permissions }),
|
|
33
|
+
...(request.accountId === undefined ? {} : { accountId: request.accountId }),
|
|
34
|
+
...(request.tokenId === undefined ? {} : { tokenId: request.tokenId }),
|
|
35
|
+
...(request.allRepositories === true ? { allRepositories: true } : {}),
|
|
36
|
+
...(request.os === undefined ? {} : { os: request.os }),
|
|
37
|
+
...(request.hostname === undefined ? {} : { hostname: request.hostname }),
|
|
38
|
+
});
|
|
39
|
+
if (!body.ok) {
|
|
40
|
+
if (body.status === 429)
|
|
41
|
+
throw new Error('too many device logins from this address this hour — wait, or approve in a browser on this machine with --browser');
|
|
42
|
+
throw new Error(typeof body.json['error'] === 'string' ? body.json['error'] : `starting the device login failed with ${body.status}`);
|
|
43
|
+
}
|
|
44
|
+
const j = body.json;
|
|
45
|
+
if (typeof j['deviceCode'] !== 'string' || typeof j['userCode'] !== 'string' || typeof j['verificationUri'] !== 'string') {
|
|
46
|
+
throw new Error('the control plane answered the device login without a code — is it older than this CLI?');
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
deviceCode: j['deviceCode'],
|
|
50
|
+
userCode: j['userCode'],
|
|
51
|
+
verificationUri: j['verificationUri'],
|
|
52
|
+
expiresIn: typeof j['expiresIn'] === 'number' ? j['expiresIn'] : 600,
|
|
53
|
+
interval: typeof j['interval'] === 'number' ? j['interval'] : 5,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async poll(deviceCode) {
|
|
57
|
+
const body = await this.#post('/api/v1/cli/token', { deviceCode });
|
|
58
|
+
if (body.ok)
|
|
59
|
+
return { kind: 'minted', minted: redeemedFrom(body.json) };
|
|
60
|
+
const error = body.json['error'];
|
|
61
|
+
const interval = body.json['interval'];
|
|
62
|
+
if (body.status === 400 && isDevicePollError(error))
|
|
63
|
+
return { kind: error, ...(typeof interval === 'number' ? { interval } : {}) };
|
|
64
|
+
throw new Error(typeof error === 'string' ? error : `polling the device login failed with ${body.status}`);
|
|
65
|
+
}
|
|
66
|
+
async #post(path, payload) {
|
|
67
|
+
let response;
|
|
68
|
+
try {
|
|
69
|
+
response = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
72
|
+
body: JSON.stringify(payload),
|
|
73
|
+
signal: AbortSignal.timeout(30_000),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw new Error(`${this.#baseUrl} is unreachable: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
78
|
+
}
|
|
79
|
+
const json = (await response.json().catch(() => ({})));
|
|
80
|
+
return { ok: response.ok, status: response.status, json };
|
|
81
|
+
}
|
|
82
|
+
}
|