@apex-actions/cli 0.74.0 → 0.75.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.
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { createToken, listTokens, revokeTokens } from '../../src/application/manage-tokens.js';
4
- import { MAX_TOKEN_DAYS, TOKENS_PAGE, webBaseFor } from '../../src/domain/credentials.js';
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';
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';
@@ -13,10 +13,31 @@ import { api, collect, usage } from '../secret/shared.js';
13
13
  * login` does, and prints the token once — the token alone on stdout, so
14
14
  * `$(apex token create …)` captures it, and everything a person reads on
15
15
  * stderr. It is never saved, and the CLI's own login is left as it was.
16
+ * A token carries the permissions asked for with `--permission` and belongs
17
+ * to one Account (control-plane ADR-0071).
18
+ * `edit` narrows a token at once with whichever credential the CLI has, and
19
+ * widens one only through the same browser approval.
16
20
  * `list` and `revoke` use whichever credential the CLI already has.
17
21
  *
18
22
  * Exit codes follow §7: 0 done · 1 refused or declined · 2 usage · 3 no such token.
19
23
  */
24
+ const PERMISSION_HELP = `resource:read or resource:write (write implies read); resource is one of ${TOKEN_RESOURCES.join(', ')}`;
25
+ /** The browser half `create` and a widening `edit` share. */
26
+ const approvalDeps = (browser, what) => {
27
+ const apiUrl = apiUrlFrom();
28
+ return {
29
+ pkce: newPkcePair,
30
+ listen: (state) => listenForCallback(state),
31
+ openBrowser: browser ? (url) => openInBrowser(url) : undefined,
32
+ announce: (url) => {
33
+ console.error(browser ? `Opening the browser to approve ${what}. If it does not open, visit:` : `Open this URL to approve ${what}:`);
34
+ console.error(` ${url}`);
35
+ console.error(chalk.dim('Waiting for the approval (5 minutes)…'));
36
+ },
37
+ exchange: new HttpLoginExchange(apiUrl),
38
+ webBase: webBaseFor(apiUrl, process.env[WEB_URL_ENV]),
39
+ };
40
+ };
20
41
  const refused = (message) => {
21
42
  console.error(chalk.red(message));
22
43
  process.exitCode = 1;
@@ -24,35 +45,41 @@ const refused = (message) => {
24
45
  const createCommand = new Command('create')
25
46
  .description('Create an API token for a script or CI system, approved in the browser; the token is printed once and not saved')
26
47
  .argument('<name>', 'what the token is for, as the token list will show it (1–100 characters)')
48
+ .option('--permission <permission>', `what the token may do, repeatable and at least one: ${PERMISSION_HELP}`, collect, [])
49
+ .option('--account <id>', 'the Account the token belongs to — needed when you belong to more than one')
27
50
  .option('--expires-in-days <days>', `days until the token expires, 1–${MAX_TOKEN_DAYS} (default 30)`)
28
- .option('--repo <owner/repo>', 'hold the token to this repository (repeatable, up to 100); omit for every repository you administer', collect, [])
51
+ .option('--repo <owner/repo>', 'hold the token to this repository (repeatable, up to 100); omit for every repository you reach in the Account', collect, [])
29
52
  .option('--no-browser', 'print the approval URL instead of opening a browser')
30
53
  .option('--json', 'print the token and its details as JSON on stdout')
31
54
  .action(async (name, opts) => {
32
55
  const days = opts.expiresInDays === undefined ? undefined : /^[0-9]+$/.test(opts.expiresInDays) ? Number(opts.expiresInDays) : Number.NaN;
33
- const apiUrl = apiUrlFrom();
34
- const outcome = await createToken({
35
- pkce: newPkcePair,
36
- listen: (state) => listenForCallback(state),
37
- openBrowser: opts.browser ? (url) => openInBrowser(url) : undefined,
38
- announce: (url) => {
39
- console.error(opts.browser ? 'Opening the browser to approve this token. If it does not open, visit:' : 'Open this URL to approve this token:');
40
- console.error(` ${url}`);
41
- console.error(chalk.dim('Waiting for the approval (5 minutes)…'));
42
- },
43
- exchange: new HttpLoginExchange(apiUrl),
44
- webBase: webBaseFor(apiUrl, process.env[WEB_URL_ENV]),
45
- }, { name, days, repos: opts.repo });
56
+ const outcome = await createToken(approvalDeps(opts.browser, 'this token'), {
57
+ name,
58
+ days,
59
+ repos: opts.repo,
60
+ permissions: opts.permission,
61
+ accountId: opts.account,
62
+ });
46
63
  switch (outcome.kind) {
47
64
  case 'created': {
48
65
  if (opts.json === true) {
49
- console.log(JSON.stringify({ token: outcome.token, id: outcome.id, name: outcome.name, login: outcome.login, expiresAt: outcome.expiresAt, repos: outcome.repos ?? null }, null, 2));
66
+ console.log(JSON.stringify({
67
+ token: outcome.token,
68
+ id: outcome.id,
69
+ name: outcome.name,
70
+ login: outcome.login,
71
+ expiresAt: outcome.expiresAt,
72
+ repos: outcome.repos ?? null,
73
+ permissions: outcome.permissions ?? [],
74
+ accountId: outcome.accountId ?? null,
75
+ }, null, 2));
50
76
  }
51
77
  else {
52
78
  console.log(outcome.token);
53
79
  }
54
- const reach = outcome.repos === undefined || outcome.repos.length === 0 ? 'every repository you administer' : outcome.repos.join(', ');
80
+ const reach = outcome.repos === undefined || outcome.repos.length === 0 ? 'every repository you reach' : outcome.repos.join(', ');
55
81
  console.error(`${chalk.green('✔')} created "${outcome.name}" for ${outcome.login} — ${reach}; expires ${outcome.expiresAt}`);
82
+ console.error(` permissions: ${describeGrants(outcome.permissions)}${outcome.accountId === undefined ? '' : ` · Account ${outcome.accountId}`}`);
56
83
  console.error(chalk.yellow('This is the only time the token is shown. Store it now — in your CI system as a secret named APEX_TOKEN.'));
57
84
  return;
58
85
  }
@@ -69,17 +96,83 @@ const createCommand = new Command('create')
69
96
  refused('the browser came back without a code; run apex token create again');
70
97
  return;
71
98
  case 'timeout':
72
- refused('no approval arrived within 5 minutes; run apex token create again');
99
+ refused('no approval arrived within 5 minutes; the approval page must be opened in a browser on this machine — run apex token create again');
73
100
  return;
74
101
  case 'refused':
75
102
  refused(outcome.detail);
76
103
  return;
77
104
  }
78
105
  });
79
- const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all administered' : t.repos.join(', '));
106
+ const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all' : t.repos.join(', '));
80
107
  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')
110
+ .argument('<id>', 'the token id, from apex token list')
111
+ .option('--permission <permission>', `the token's whole new set of permissions, repeatable: ${PERMISSION_HELP}`, collect, [])
112
+ .option('--add-permission <permission>', 'add one permission to what the token holds (repeatable)', collect, [])
113
+ .option('--remove-permission <permission>', 'take one permission away (repeatable)', collect, [])
114
+ .option('--repo <owner/repo>', "the token's new list of repositories (repeatable, up to 100)", collect, [])
115
+ .option('--all-repos', 'let the token reach every repository you reach in its Account')
116
+ .option('--no-browser', 'if the edit widens the token, print the approval URL instead of opening a browser')
117
+ .option('--json', 'print the edited token as JSON')
118
+ .action(async (id, opts) => {
119
+ if (opts.allRepos === true && opts.repo.length > 0) {
120
+ usage('pass --repo or --all-repos, not both');
121
+ return;
122
+ }
123
+ const client = api();
124
+ if (!client)
125
+ return;
126
+ const outcome = await editToken(client, approvalDeps(opts.browser, 'widening this token'), id, {
127
+ ...(opts.permission.length > 0 ? { permissions: opts.permission } : {}),
128
+ add: opts.addPermission,
129
+ remove: opts.removePermission,
130
+ ...(opts.allRepos === true ? { repos: 'all' } : opts.repo.length > 0 ? { repos: opts.repo } : {}),
131
+ });
132
+ switch (outcome.kind) {
133
+ case 'edited':
134
+ case 'unchanged': {
135
+ const t = outcome.token;
136
+ if (opts.json === true) {
137
+ console.log(JSON.stringify({ token: t, changed: outcome.kind === 'edited', ...(outcome.kind === 'edited' ? { via: outcome.via } : {}) }, null, 2));
138
+ return;
139
+ }
140
+ if (outcome.kind === 'unchanged')
141
+ console.log(chalk.yellow(`"${t.name}" already holds that — nothing changed`));
142
+ else
143
+ console.log(`${chalk.green('✔')} edited "${t.name}"${outcome.via === 'browser' ? ' — the widening was approved in the browser' : ''}`);
144
+ console.log(` permissions ${describeGrants(t.permissions)}`);
145
+ console.log(` repositories ${reachOf(t)}`);
146
+ if (t.accountId !== undefined)
147
+ console.log(` account ${t.accountId}`);
148
+ return;
149
+ }
150
+ case 'invalid':
151
+ usage(outcome.detail);
152
+ return;
153
+ case 'not-found':
154
+ console.error(chalk.red(`no active token with id ${outcome.id} — apex token list shows yours`));
155
+ process.exitCode = 3;
156
+ return;
157
+ case 'denied':
158
+ refused(`declined in the browser: ${outcome.detail}`);
159
+ return;
160
+ case 'state-mismatch':
161
+ refused('a callback arrived that did not belong to this request, so it was refused; run apex token edit again');
162
+ return;
163
+ case 'malformed':
164
+ refused('the browser came back without a code; run apex token edit again');
165
+ return;
166
+ case 'timeout':
167
+ refused('no approval arrived within 5 minutes; the approval page must be opened in a browser on this machine — run apex token edit again');
168
+ return;
169
+ case 'refused':
170
+ refused(outcome.detail);
171
+ return;
172
+ }
173
+ });
81
174
  const listCommand = new Command('list')
82
- .description('Your API tokens — names and last four characters, never the tokens')
175
+ .description('Your API tokens by Account, with what each may do — names and last four characters, never the tokens')
83
176
  .option('--all', 'include expired and revoked tokens')
84
177
  .option('--json', 'machine-readable output')
85
178
  .action(async (opts) => {
@@ -99,23 +192,32 @@ const listCommand = new Command('list')
99
192
  console.log(chalk.yellow(outcome.hidden > 0 ? `No active tokens (${outcome.hidden} expired or revoked; --all shows them).` : 'You hold no tokens.'));
100
193
  return;
101
194
  }
102
- const rows = outcome.tokens.map((t) => [
195
+ const header = [' NAME', 'ID', 'LAST 4', 'SOURCE', 'PERMISSIONS', 'REPOSITORIES', 'EXPIRES', 'LAST USED', 'STATE'];
196
+ const rowOf = (t) => [
103
197
  `${t.current === true ? '* ' : ' '}${t.name}`,
104
198
  t.id,
105
199
  `…${t.last4}`,
106
200
  t.source,
201
+ describeGrants(t.permissions),
107
202
  reachOf(t),
108
- day(t.createdAt),
109
203
  day(t.expiresAt),
110
204
  day(t.lastUsedAt),
111
205
  t.state,
112
- ]);
113
- const header = [' NAME', 'ID', 'LAST 4', 'SOURCE', 'REPOSITORIES', 'CREATED', 'EXPIRES', 'LAST USED', 'STATE'];
206
+ ];
207
+ const rows = outcome.tokens.map(rowOf);
114
208
  const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
115
209
  const line = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd();
116
- console.log(chalk.dim(line(header)));
117
- for (const r of rows)
118
- console.log(line(r));
210
+ // One section per Account: a token belongs to one, and reaches only that Account's repositories.
211
+ const accounts = [...new Set(outcome.tokens.map((t) => t.accountId ?? ''))].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)));
212
+ accounts.forEach((account, i) => {
213
+ if (i > 0)
214
+ console.log('');
215
+ console.log(chalk.bold(account === '' ? 'No Account — these tokens reach nothing; create a new one' : `Account ${account}`));
216
+ console.log(chalk.dim(line(header)));
217
+ for (const t of outcome.tokens)
218
+ if ((t.accountId ?? '') === account)
219
+ console.log(line(rowOf(t)));
220
+ });
119
221
  if (outcome.tokens.some((t) => t.current === true))
120
222
  console.log(chalk.dim('* the token this CLI is using'));
121
223
  if (outcome.hidden > 0)
@@ -151,7 +253,8 @@ const revokeCommand = new Command('revoke')
151
253
  }
152
254
  });
153
255
  export default new Command('token')
154
- .description(`API tokens for scripts and CI: create one (approved in the browser), list yours, revoke one. Also on the dashboard at ${TOKENS_PAGE}`)
256
+ .description(`API tokens for scripts and CI: create one with the permissions it needs (approved in the browser), edit, list or revoke yours. Also on the dashboard at ${TOKENS_PAGE}`)
155
257
  .addCommand(createCommand)
258
+ .addCommand(editCommand)
156
259
  .addCommand(listCommand)
157
260
  .addCommand(revokeCommand);
@@ -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 { describeSource } from '../../src/domain/credentials.js';
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,37 +1,53 @@
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
- return { kind: 'minted', minted: await deps.exchange.redeem(callback.code, pair.verifier) };
23
+ outcome = { kind: 'minted', minted: await deps.exchange.redeem(callback.code, pair.verifier) };
19
24
  }
20
25
  catch (err) {
21
- return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
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
  };
28
35
  export const login = async (deps) => {
29
- const approval = await approveInBrowser(deps);
36
+ const approval = await approveInBrowser(deps, undefined, deps.accountId);
30
37
  if (approval.kind !== 'minted')
31
38
  return approval;
32
39
  const { minted } = approval;
40
+ if (minted.token === undefined)
41
+ return { kind: 'refused', detail: 'the control plane answered the login without a token' };
33
42
  await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
34
- return { kind: 'logged-in', login: minted.login, expiresAt: minted.expiresAt, location: deps.store.location };
43
+ return {
44
+ kind: 'logged-in',
45
+ login: minted.login,
46
+ expiresAt: minted.expiresAt,
47
+ location: deps.store.location,
48
+ permissions: minted.permissions,
49
+ accountId: minted.accountId,
50
+ };
35
51
  };
36
52
  /**
37
53
  * Revoke the saved token at the control plane that minted it, then forget it
@@ -58,7 +74,7 @@ export const logout = async (store, revoke) => {
58
74
  };
59
75
  export const whoami = async (api) => {
60
76
  try {
61
- return { kind: 'identified', identity: await api.whoami(), source: api.credentialSource };
77
+ return { kind: 'identified', identity: await api.whoami(), source: api.credentialSource, alsoPresent: api.credentialsAlsoPresent ?? [] };
62
78
  }
63
79
  catch (err) {
64
80
  return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
@@ -1,5 +1,5 @@
1
1
  // application layer — see ../../../../docs/conventions.md §1
2
- import { tokenRequestProblem } from '../domain/credentials.js';
2
+ import { editedPermissions, tokenEditProblem, tokenRequestProblem } from '../domain/credentials.js';
3
3
  import { approveInBrowser } from './authenticate.js';
4
4
  const detail = (err) => (err instanceof Error ? err.message : String(err));
5
5
  /**
@@ -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 approveInBrowser({ ...deps, name }, { ...(input.days === undefined ? {} : { days: input.days }), ...(input.repos.length === 0 ? {} : { repos: input.repos }) });
14
+ const approval = await approveInBrowser({ ...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 approveInBrowser({ ...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`. */