@apex-actions/cli 0.74.0 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -12
- package/dist/cli/grant/main.js +141 -0
- package/dist/cli/login/device.js +43 -0
- package/dist/cli/login/main.js +110 -25
- package/dist/cli/logout/main.js +16 -8
- package/dist/cli/program.js +2 -0
- package/dist/cli/reference.js +211 -39
- package/dist/cli/spend/main.js +1 -1
- package/dist/cli/tenant/main.js +38 -2
- package/dist/cli/token/main.js +156 -32
- package/dist/cli/whoami/main.js +44 -2
- package/dist/src/application/authenticate.js +104 -7
- package/dist/src/application/manage-grants.js +21 -0
- package/dist/src/application/manage-tokens.js +84 -4
- package/dist/src/domain/credentials.js +185 -17
- package/dist/src/domain/grants.js +111 -0
- package/dist/src/infrastructure/control-plane-http.js +91 -18
- package/dist/src/infrastructure/credentials.js +53 -8
- package/dist/src/infrastructure/device-login.js +82 -0
- package/dist/src/infrastructure/loopback-login.js +54 -15
- package/package.json +1 -1
package/dist/cli/whoami/main.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { whoami } from '../../src/application/authenticate.js';
|
|
4
|
-
import {
|
|
4
|
+
import { listTokens } from '../../src/application/manage-tokens.js';
|
|
5
|
+
import { describeGrants, describeSource } from '../../src/domain/credentials.js';
|
|
5
6
|
import { credentialsPath } from '../../src/infrastructure/credentials.js';
|
|
6
7
|
import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
|
|
7
8
|
const credentialLine = (credential) => {
|
|
@@ -22,9 +23,21 @@ const credentialLine = (credential) => {
|
|
|
22
23
|
* `apex whoami`: who the control plane thinks this CLI is, and which
|
|
23
24
|
* credential got it there. The answer is the server's (`GET /api/v1/me`), not
|
|
24
25
|
* a guess from the local file; the credential itself is never printed.
|
|
26
|
+
*
|
|
27
|
+
* It also names any credential that was present and lost (ADR-0005: the
|
|
28
|
+
* environment outranks the saved login), because the surprise this command
|
|
29
|
+
* exists to prevent is acting as somebody other than the one you meant.
|
|
30
|
+
*
|
|
31
|
+
* When the credential is a token it also says what the token may do and which
|
|
32
|
+
* Account it belongs to — the list of the person's tokens marks it `current`.
|
|
25
33
|
*/
|
|
26
34
|
export default new Command('whoami')
|
|
27
35
|
.description('Who the CLI is acting as, with which credential, and where it found it')
|
|
36
|
+
.addHelpText('after', '\nWhich credential is used, first match wins:\n' +
|
|
37
|
+
' 1. APEX_TOKEN in the environment\n' +
|
|
38
|
+
' 2. APEX_ADMIN_TOKEN in the environment (operators)\n' +
|
|
39
|
+
' 3. the token `apex login` saved\n' +
|
|
40
|
+
'A variable you exported outranks a saved login. Unset it to use the login.')
|
|
28
41
|
.option('--json', 'machine-readable output')
|
|
29
42
|
.action(async (opts) => {
|
|
30
43
|
let client;
|
|
@@ -42,7 +55,14 @@ export default new Command('whoami')
|
|
|
42
55
|
process.exitCode = 1;
|
|
43
56
|
return;
|
|
44
57
|
}
|
|
45
|
-
const { identity, source } = outcome;
|
|
58
|
+
const { identity, source, alsoPresent } = outcome;
|
|
59
|
+
// The token's grants and Account: best effort, since the identity above is the answer this command owes.
|
|
60
|
+
let token;
|
|
61
|
+
if (identity.credential?.kind === 'token') {
|
|
62
|
+
const listed = await listTokens(client, false);
|
|
63
|
+
if (listed.kind === 'listed')
|
|
64
|
+
token = listed.tokens.find((t) => t.current === true);
|
|
65
|
+
}
|
|
46
66
|
const login = identity.actor.kind === 'user' ? identity.actor.login : undefined;
|
|
47
67
|
const foundIn = source === 'login' ? `${describeSource(source)} (${credentialsPath()})` : describeSource(source);
|
|
48
68
|
if (opts.json === true) {
|
|
@@ -57,13 +77,35 @@ export default new Command('whoami')
|
|
|
57
77
|
...(identity.credential.source === undefined ? {} : { source: identity.credential.source }),
|
|
58
78
|
...(identity.credential.expiresAt === undefined ? {} : { expiresAt: identity.credential.expiresAt }),
|
|
59
79
|
},
|
|
80
|
+
...(token === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: {
|
|
83
|
+
token: {
|
|
84
|
+
id: token.id,
|
|
85
|
+
name: token.name,
|
|
86
|
+
permissions: token.permissions ?? [],
|
|
87
|
+
repos: token.repos ?? null,
|
|
88
|
+
accountId: token.accountId ?? null,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
60
91
|
foundIn: source,
|
|
92
|
+
notUsed: alsoPresent,
|
|
61
93
|
apiUrl: client.baseUrl,
|
|
62
94
|
}, null, 2));
|
|
63
95
|
return;
|
|
64
96
|
}
|
|
65
97
|
console.log(`${'acting as'.padEnd(14)}${login ?? 'the machine principal'}`);
|
|
66
98
|
console.log(`${'credential'.padEnd(14)}${credentialLine(identity.credential)}`);
|
|
99
|
+
if (token !== undefined) {
|
|
100
|
+
console.log(`${'token'.padEnd(14)}${token.name} (${token.id})`);
|
|
101
|
+
console.log(`${'permissions'.padEnd(14)}${describeGrants(token.permissions)}`);
|
|
102
|
+
console.log(`${'repositories'.padEnd(14)}${token.repos === undefined || token.repos.length === 0 ? 'every repository you reach in its Account' : token.repos.join(', ')}`);
|
|
103
|
+
console.log(`${'account'.padEnd(14)}${token.accountId ?? 'none — this token reaches nothing; create a new one'}`);
|
|
104
|
+
}
|
|
67
105
|
console.log(`${'found in'.padEnd(14)}${foundIn}`);
|
|
106
|
+
for (const other of alsoPresent) {
|
|
107
|
+
const where = other === 'login' ? `${describeSource(other)} (${credentialsPath()})` : describeSource(other);
|
|
108
|
+
console.log(`${'not used'.padEnd(14)}${where} — outranked by the credential above`);
|
|
109
|
+
}
|
|
68
110
|
console.log(`${'control plane'.padEnd(14)}${client.baseUrl}`);
|
|
69
111
|
});
|
|
@@ -1,35 +1,132 @@
|
|
|
1
|
-
import { authorizeUrl } from '../domain/credentials.js';
|
|
1
|
+
import { authorizeUrl, doneUrl } from '../domain/credentials.js';
|
|
2
2
|
/**
|
|
3
3
|
* The loopback flow itself: open the consent page, wait for the one callback,
|
|
4
4
|
* redeem its code with the verifier. Shared by `apex login`, which saves what
|
|
5
5
|
* it gets, and `apex token create`, which hands it back to be printed once.
|
|
6
6
|
*/
|
|
7
|
-
export const approveInBrowser = async (deps, token) => {
|
|
7
|
+
export const approveInBrowser = async (deps, token, accountId) => {
|
|
8
8
|
const pair = deps.pkce();
|
|
9
9
|
const listener = await deps.listen(pair.state);
|
|
10
10
|
try {
|
|
11
|
-
const url = authorizeUrl(deps.webBase, { port: listener.port, state: pair.state, codeChallenge: pair.challenge, name: deps.name, token });
|
|
11
|
+
const url = authorizeUrl(deps.webBase, { port: listener.port, state: pair.state, codeChallenge: pair.challenge, name: deps.name, token, accountId });
|
|
12
12
|
deps.announce(url);
|
|
13
13
|
deps.openBrowser?.(url);
|
|
14
14
|
const callback = await listener.wait();
|
|
15
|
+
const purpose = token === undefined ? 'login' : (token.purpose ?? 'token');
|
|
16
|
+
if (callback.kind === 'denied')
|
|
17
|
+
await listener.conclude(doneUrl(deps.webBase, 'denied', purpose));
|
|
15
18
|
if (callback.kind !== 'code')
|
|
16
19
|
return callback;
|
|
20
|
+
// Redeem first, then hand the tab to the done page: its words must match what actually happened.
|
|
21
|
+
let outcome;
|
|
17
22
|
try {
|
|
18
|
-
|
|
23
|
+
outcome = { kind: 'minted', minted: await deps.exchange.redeem(callback.code, pair.verifier) };
|
|
19
24
|
}
|
|
20
25
|
catch (err) {
|
|
21
|
-
|
|
26
|
+
outcome = { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
22
27
|
}
|
|
28
|
+
await listener.conclude(doneUrl(deps.webBase, outcome.kind === 'minted' ? 'approved' : 'failed', purpose));
|
|
29
|
+
return outcome;
|
|
23
30
|
}
|
|
24
31
|
finally {
|
|
25
32
|
await listener.close();
|
|
26
33
|
}
|
|
27
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);
|
|
28
98
|
export const login = async (deps) => {
|
|
29
|
-
const approval = await
|
|
99
|
+
const approval = await approve(deps, undefined, deps.accountId);
|
|
100
|
+
if (approval.kind !== 'minted')
|
|
101
|
+
return approval;
|
|
102
|
+
const { minted } = approval;
|
|
103
|
+
if (minted.token === undefined)
|
|
104
|
+
return { kind: 'refused', detail: 'the control plane answered the login without a token' };
|
|
105
|
+
await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
|
|
106
|
+
return {
|
|
107
|
+
kind: 'logged-in',
|
|
108
|
+
login: minted.login,
|
|
109
|
+
expiresAt: minted.expiresAt,
|
|
110
|
+
location: deps.store.location,
|
|
111
|
+
permissions: minted.permissions,
|
|
112
|
+
accountId: minted.accountId,
|
|
113
|
+
};
|
|
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' });
|
|
30
123
|
if (approval.kind !== 'minted')
|
|
31
124
|
return approval;
|
|
32
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' };
|
|
33
130
|
await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
|
|
34
131
|
return { kind: 'logged-in', login: minted.login, expiresAt: minted.expiresAt, location: deps.store.location };
|
|
35
132
|
};
|
|
@@ -58,7 +155,7 @@ export const logout = async (store, revoke) => {
|
|
|
58
155
|
};
|
|
59
156
|
export const whoami = async (api) => {
|
|
60
157
|
try {
|
|
61
|
-
return { kind: 'identified', identity: await api.whoami(), source: api.credentialSource };
|
|
158
|
+
return { kind: 'identified', identity: await api.whoami(), source: api.credentialSource, alsoPresent: api.credentialsAlsoPresent ?? [] };
|
|
62
159
|
}
|
|
63
160
|
catch (err) {
|
|
64
161
|
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
@@ -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
|
-
import { tokenRequestProblem } from '../domain/credentials.js';
|
|
3
|
-
import {
|
|
2
|
+
import { editedPermissions, tokenEditProblem, tokenRequestProblem } from '../domain/credentials.js';
|
|
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
|
|
@@ -8,13 +8,21 @@ const detail = (err) => (err instanceof Error ? err.message : String(err));
|
|
|
8
8
|
*/
|
|
9
9
|
export const createToken = async (deps, input) => {
|
|
10
10
|
const name = input.name.trim();
|
|
11
|
-
const problem = tokenRequestProblem(name, input.days, input.repos);
|
|
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
|
+
purpose: 'token',
|
|
16
|
+
permissions: [...new Set(input.permissions)],
|
|
17
|
+
...(input.days === undefined ? {} : { days: input.days }),
|
|
18
|
+
...(input.repos.length === 0 ? {} : { repos: input.repos }),
|
|
19
|
+
...(input.accountId === undefined ? {} : { accountId: input.accountId }),
|
|
20
|
+
});
|
|
15
21
|
if (approval.kind !== 'minted')
|
|
16
22
|
return approval;
|
|
17
23
|
const { minted } = approval;
|
|
24
|
+
if (minted.token === undefined)
|
|
25
|
+
return { kind: 'refused', detail: 'the control plane answered without a token' };
|
|
18
26
|
return {
|
|
19
27
|
kind: 'created',
|
|
20
28
|
token: minted.token,
|
|
@@ -23,6 +31,78 @@ export const createToken = async (deps, input) => {
|
|
|
23
31
|
id: minted.id,
|
|
24
32
|
name: minted.name ?? name,
|
|
25
33
|
repos: minted.repos,
|
|
34
|
+
permissions: minted.permissions,
|
|
35
|
+
accountId: minted.accountId,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const sameSet = (a, b) => (a ?? []).length === (b ?? []).length && (a ?? []).every((x) => (b ?? []).includes(x));
|
|
39
|
+
/**
|
|
40
|
+
* Edit a token's grants or repositories. The token is read first — `--add-permission` needs its current grants, and a
|
|
41
|
+
* browser approval must carry the repositories unchanged when only the grants change, because an approval that names
|
|
42
|
+
* none means every repository. Then the edit is asked for directly: a narrowing applies at once. The control plane
|
|
43
|
+
* refuses a widening to anything but a signed-in person (403), so that one goes through the approval page, whose
|
|
44
|
+
* redeemed code applies the edit and mints nothing.
|
|
45
|
+
*/
|
|
46
|
+
export const editToken = async (api, approval, id, edit) => {
|
|
47
|
+
const problem = tokenEditProblem(edit);
|
|
48
|
+
if (problem !== undefined)
|
|
49
|
+
return { kind: 'invalid', detail: problem };
|
|
50
|
+
let target;
|
|
51
|
+
try {
|
|
52
|
+
target = (await api.listTokens()).find((t) => t.id === id && t.state === 'active');
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return { kind: 'refused', detail: detail(err) };
|
|
56
|
+
}
|
|
57
|
+
if (target === undefined)
|
|
58
|
+
return { kind: 'not-found', id };
|
|
59
|
+
const permissions = editedPermissions(target.permissions ?? [], edit);
|
|
60
|
+
if (permissions.length === 0)
|
|
61
|
+
return { kind: 'invalid', detail: 'that would leave the token no permission — revoke it instead' };
|
|
62
|
+
const repos = edit.repos === 'all' ? undefined : (edit.repos ?? target.repos);
|
|
63
|
+
const permissionsChange = !sameSet(permissions, target.permissions);
|
|
64
|
+
const reposChange = edit.repos !== undefined && !(edit.repos === 'all' ? (target.repos ?? []).length === 0 : sameSet(edit.repos, target.repos));
|
|
65
|
+
if (!permissionsChange && !reposChange)
|
|
66
|
+
return { kind: 'unchanged', token: target };
|
|
67
|
+
let answer;
|
|
68
|
+
try {
|
|
69
|
+
answer = await api.editToken(id, {
|
|
70
|
+
...(permissionsChange ? { permissions } : {}),
|
|
71
|
+
...(reposChange ? { repos: repos === undefined || repos.length === 0 ? null : repos } : {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return { kind: 'refused', detail: detail(err) };
|
|
76
|
+
}
|
|
77
|
+
if (answer.kind === 'edited')
|
|
78
|
+
return { kind: 'edited', token: answer.token, via: 'direct' };
|
|
79
|
+
if (answer.kind === 'not-found')
|
|
80
|
+
return { kind: 'not-found', id };
|
|
81
|
+
if (answer.status !== 403)
|
|
82
|
+
return { kind: 'refused', detail: answer.detail };
|
|
83
|
+
// A widening: a person approves it in the browser, within the Account's policy.
|
|
84
|
+
const approved = await approve({ ...approval, name: target.name }, {
|
|
85
|
+
purpose: 'edit',
|
|
86
|
+
tokenId: id,
|
|
87
|
+
permissions,
|
|
88
|
+
// No repositories keeps the token's own; every repository has to be asked for outright.
|
|
89
|
+
...(edit.repos === 'all' ? { allRepos: true } : repos === undefined || repos.length === 0 ? {} : { repos }),
|
|
90
|
+
});
|
|
91
|
+
if (approved.kind !== 'minted')
|
|
92
|
+
return approved;
|
|
93
|
+
const m = approved.minted;
|
|
94
|
+
return {
|
|
95
|
+
kind: 'edited',
|
|
96
|
+
via: 'browser',
|
|
97
|
+
token: {
|
|
98
|
+
...target,
|
|
99
|
+
...(m.name === undefined ? {} : { name: m.name }),
|
|
100
|
+
expiresAt: m.expiresAt,
|
|
101
|
+
permissions: m.permissions ?? permissions,
|
|
102
|
+
// The control plane's view is the answer: no repositories there means every repository.
|
|
103
|
+
repos: m.repos,
|
|
104
|
+
...(m.accountId === undefined ? {} : { accountId: m.accountId }),
|
|
105
|
+
},
|
|
26
106
|
};
|
|
27
107
|
};
|
|
28
108
|
/** The person's tokens, newest first; only the active ones unless `all`. */
|
|
@@ -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. */
|
|
@@ -51,9 +74,18 @@ export const parseSavedCredential = (raw) => {
|
|
|
51
74
|
return { token: r['token'], login: r['login'], apiUrl: r['apiUrl'], expiresAt: r['expiresAt'] };
|
|
52
75
|
};
|
|
53
76
|
/**
|
|
54
|
-
* The consent page `apex login
|
|
55
|
-
* loopback, RFC 7636 S256).
|
|
56
|
-
*
|
|
77
|
+
* The consent page `apex login`, `apex token create` and a widening `apex token
|
|
78
|
+
* edit` open (RFC 8252 loopback, RFC 7636 S256). The query is the contract with
|
|
79
|
+
* the web app's `/cli/authorize` page:
|
|
80
|
+
*
|
|
81
|
+
* - `port`, `state`, `code_challenge`, `name` — always;
|
|
82
|
+
* - `purpose` — `token` or `edit` (absent for a login);
|
|
83
|
+
* - `days`, `repos` (comma-separated `owner/repo`) — a token's lifetime and scope; for an edit, `repos` absent
|
|
84
|
+
* keeps the token's own repositories;
|
|
85
|
+
* - `all_repos=1` — an edit that widens to every repository (never sent with `repos`);
|
|
86
|
+
* - `permission` — repeated, once per grant (`actions:read`, `secrets:write`, …);
|
|
87
|
+
* - `account` — the Account id, for a login or a token;
|
|
88
|
+
* - `token` — the token id an edit widens.
|
|
57
89
|
*/
|
|
58
90
|
export const authorizeUrl = (webBase, request) => {
|
|
59
91
|
const query = new URLSearchParams({
|
|
@@ -62,27 +94,55 @@ export const authorizeUrl = (webBase, request) => {
|
|
|
62
94
|
code_challenge: request.codeChallenge,
|
|
63
95
|
name: request.name,
|
|
64
96
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
97
|
+
const t = request.token;
|
|
98
|
+
if (t !== undefined) {
|
|
99
|
+
query.set('purpose', t.purpose ?? 'token');
|
|
100
|
+
if (t.days !== undefined)
|
|
101
|
+
query.set('days', String(t.days));
|
|
102
|
+
if (t.allRepos === true)
|
|
103
|
+
query.set('all_repos', '1');
|
|
104
|
+
else if (t.repos !== undefined && t.repos.length > 0)
|
|
105
|
+
query.set('repos', t.repos.join(','));
|
|
106
|
+
for (const p of t.permissions ?? [])
|
|
107
|
+
query.append('permission', p);
|
|
108
|
+
if (t.accountId !== undefined)
|
|
109
|
+
query.set('account', t.accountId);
|
|
110
|
+
if (t.tokenId !== undefined)
|
|
111
|
+
query.set('token', t.tokenId);
|
|
112
|
+
}
|
|
113
|
+
else if (request.accountId !== undefined) {
|
|
114
|
+
query.set('account', request.accountId);
|
|
71
115
|
}
|
|
72
116
|
return `${normaliseUrl(webBase)}/cli/authorize?${query.toString()}`;
|
|
73
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* Where the loopback callback sends the browser once the code is redeemed (control-plane ADR-0075): the
|
|
120
|
+
* web app's bare `/cli/done`, so the tab ends on our domain rather than on this process's `127.0.0.1`.
|
|
121
|
+
* The page is copy only and confirms nothing server-side, so it carries only the outcome and the purpose
|
|
122
|
+
* — never the code, the state or anything else the callback held.
|
|
123
|
+
*/
|
|
124
|
+
export const doneUrl = (webBase, outcome, purpose) => `${normaliseUrl(webBase)}/cli/done?${new URLSearchParams({ outcome, purpose }).toString()}`;
|
|
74
125
|
/** A token's lifetime bounds (control-plane ADR-0064): every token expires, 90 days at most. */
|
|
75
126
|
export const MAX_TOKEN_DAYS = 90;
|
|
76
127
|
/** Repositories one token may be held to. */
|
|
77
128
|
export const MAX_TOKEN_REPOS = 100;
|
|
78
129
|
const REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
130
|
+
/**
|
|
131
|
+
* What a token may be granted (context ADR-0038): `resource:read|write`, write implying read. The control plane's
|
|
132
|
+
* `RepoPermission`, in the order a person reads them.
|
|
133
|
+
*/
|
|
134
|
+
export const TOKEN_RESOURCES = ['actions', 'approvals', 'deployments', 'secrets', 'variables', 'environments', 'notifications', 'administration'];
|
|
135
|
+
export const TOKEN_PERMISSIONS = TOKEN_RESOURCES.flatMap((r) => [`${r}:read`, `${r}:write`]);
|
|
136
|
+
/** `--permission`'s values: at least one when `required`, each one a known grant. */
|
|
137
|
+
export const permissionsProblem = (permissions, required) => {
|
|
138
|
+
if (required && permissions.length === 0)
|
|
139
|
+
return 'a token needs at least one --permission, e.g. --permission actions:read';
|
|
140
|
+
const bad = permissions.filter((p) => !TOKEN_PERMISSIONS.includes(p));
|
|
141
|
+
if (bad.length > 0)
|
|
142
|
+
return `not a permission: ${bad.join(', ')} — each is resource:read or resource:write, resource one of ${TOKEN_RESOURCES.join(', ')}`;
|
|
143
|
+
return undefined;
|
|
144
|
+
};
|
|
145
|
+
const reposProblem = (repos) => {
|
|
86
146
|
if (repos.length > MAX_TOKEN_REPOS)
|
|
87
147
|
return `a token may name at most ${MAX_TOKEN_REPOS} repositories`;
|
|
88
148
|
const bad = repos.filter((r) => !REPO.test(r));
|
|
@@ -90,6 +150,68 @@ export const tokenRequestProblem = (name, days, repos) => {
|
|
|
90
150
|
return `not owner/repo: ${bad.join(', ')}`;
|
|
91
151
|
return undefined;
|
|
92
152
|
};
|
|
153
|
+
/**
|
|
154
|
+
* `apex token create`'s flags, checked before a browser opens — the control plane checks them again. `permissions`,
|
|
155
|
+
* when given, must name at least one grant.
|
|
156
|
+
*/
|
|
157
|
+
export const tokenRequestProblem = (name, days, repos, permissions) => {
|
|
158
|
+
if (name.trim() === '' || name.trim().length > 100)
|
|
159
|
+
return 'a token needs a name of 1 to 100 characters';
|
|
160
|
+
if (days !== undefined && !(Number.isInteger(days) && days >= 1 && days <= MAX_TOKEN_DAYS)) {
|
|
161
|
+
return `--expires-in-days must be a whole number from 1 to ${MAX_TOKEN_DAYS}`;
|
|
162
|
+
}
|
|
163
|
+
return reposProblem(repos) ?? (permissions === undefined ? undefined : permissionsProblem(permissions, true));
|
|
164
|
+
};
|
|
165
|
+
/** `apex token edit`'s flags: something to change, and each change well-formed. */
|
|
166
|
+
export const tokenEditProblem = (edit) => {
|
|
167
|
+
if (edit.permissions === undefined && edit.add.length === 0 && edit.remove.length === 0 && edit.repos === undefined) {
|
|
168
|
+
return 'nothing to change: pass --permission, --add-permission, --remove-permission, --repo or --all-repos';
|
|
169
|
+
}
|
|
170
|
+
if (edit.permissions !== undefined && (edit.add.length > 0 || edit.remove.length > 0))
|
|
171
|
+
return 'pass --permission for the whole set, or --add-permission/--remove-permission, not both';
|
|
172
|
+
return ((edit.permissions === undefined ? undefined : permissionsProblem(edit.permissions, true)) ??
|
|
173
|
+
permissionsProblem([...edit.add, ...edit.remove], false) ??
|
|
174
|
+
(edit.repos === undefined || edit.repos === 'all' ? undefined : reposProblem(edit.repos)));
|
|
175
|
+
};
|
|
176
|
+
/** Whether `current` already grants `asked` — itself, or its write when a read is asked (write implies read). */
|
|
177
|
+
export const covers = (current, asked) => {
|
|
178
|
+
if (current.includes(asked))
|
|
179
|
+
return true;
|
|
180
|
+
const [resource, level] = asked.split(':');
|
|
181
|
+
return level === 'read' && current.includes(`${resource ?? ''}:write`);
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* The grants an edit asks for: the whole new set, or the current set with additions (skipping any already covered)
|
|
185
|
+
* and removals. Removing a write keeps nothing of it — ask for the read to keep reading.
|
|
186
|
+
*/
|
|
187
|
+
export const editedPermissions = (current, edit) => {
|
|
188
|
+
if (edit.permissions !== undefined)
|
|
189
|
+
return [...new Set(edit.permissions)];
|
|
190
|
+
const out = current.filter((p) => !edit.remove.includes(p));
|
|
191
|
+
for (const p of edit.add)
|
|
192
|
+
if (!covers(out, p))
|
|
193
|
+
out.push(p);
|
|
194
|
+
return out;
|
|
195
|
+
};
|
|
196
|
+
/** A grant set as one short phrase: `every permission` for all of them, otherwise the grants as given. */
|
|
197
|
+
export const describeGrants = (permissions) => {
|
|
198
|
+
if (permissions === undefined || permissions.length === 0)
|
|
199
|
+
return 'none';
|
|
200
|
+
if (TOKEN_RESOURCES.every((r) => permissions.includes(`${r}:write`)))
|
|
201
|
+
return 'every permission';
|
|
202
|
+
return [...permissions].sort((a, b) => TOKEN_PERMISSIONS.indexOf(a) - TOKEN_PERMISSIONS.indexOf(b)).join(', ');
|
|
203
|
+
};
|
|
204
|
+
const NEEDS = /^this token needs ([a-z]+:(?:read|write))$/;
|
|
205
|
+
/**
|
|
206
|
+
* The control plane refuses a token a route its grants do not cover with `this token needs <permission>`. Say how to
|
|
207
|
+
* get it — an edit that adds the grant, which a browser approves — naming the token when the CLI knows which it is.
|
|
208
|
+
*/
|
|
209
|
+
export const withPermissionHint = (message, tokenId) => {
|
|
210
|
+
const m = NEEDS.exec(message);
|
|
211
|
+
if (m === null)
|
|
212
|
+
return message;
|
|
213
|
+
return `${message} — add it with: apex token edit ${tokenId ?? '<id>'} --add-permission ${m[1] ?? ''}${tokenId === undefined ? ' (apex token list shows the id)' : ''}`;
|
|
214
|
+
};
|
|
93
215
|
/**
|
|
94
216
|
* Judge what arrived at the loopback callback.
|
|
95
217
|
*
|
|
@@ -110,3 +232,49 @@ export const judgeCallback = (params, expectedState) => {
|
|
|
110
232
|
return { kind: 'malformed' };
|
|
111
233
|
return { kind: 'code', code };
|
|
112
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\`.`;
|