unbound-cli 1.19.0 → 1.20.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 CHANGED
@@ -54,6 +54,9 @@ Only your API key is needed. Run with `sudo` to let the discovery step scan all
54
54
  | `unbound login` | Sign in via browser |
55
55
  | `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
56
56
  | `unbound login --domain <domain>` | Sign in via a custom domain |
57
+ | `unbound login --role <name>` | Browser sign-in with a token scoped to a console role (`admin`, `analytics-viewer`, `manager`, or a custom role) |
58
+ | `unbound login --permissions <list>` | Browser sign-in with a token scoped per area, e.g. `analytics=view,logs=view` |
59
+ | `unbound login --expires-in-days <n>` | Token lifetime for browser sign-in: 7, 30, 60 or 90 (default 90) |
57
60
  | `unbound logout` | Remove the CLI key from the OS keychain |
58
61
  | `unbound status` | Show CLI status, role, and connected tools |
59
62
  | `unbound doctor` | Diagnose per-tool health (config, hook, env) and API key |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/auth-pages.js CHANGED
@@ -169,12 +169,13 @@ function successPage({ email, orgName } = {}) {
169
169
  }
170
170
 
171
171
  /** Failure page for a callback that arrived without an api_key. */
172
- function failurePage() {
172
+ function failurePage(reason) {
173
+ const detail = reason ? `<p>${escapeHtml(reason)}</p>` : '';
173
174
  return renderPage({
174
175
  title: 'Authentication Failed | Unbound',
175
176
  icon: CROSS_ICON,
176
177
  heading: 'Authentication Failed',
177
- body: '<p>You can close this tab and try again from the terminal.</p>',
178
+ body: `${detail}<p>You can close this tab and try again from the terminal.</p>`,
178
179
  });
179
180
  }
180
181
 
package/src/auth.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Used by both `login` and `setup` commands.
4
4
  */
5
5
  const http = require('http');
6
+ const os = require('os');
6
7
  const { URL } = require('url');
7
8
  const authPages = require('./auth-pages');
8
9
  const config = require('./config');
@@ -32,7 +33,7 @@ function warnIfNotStored(stored) {
32
33
  * @param {string} frontendUrl - The frontend URL to open
33
34
  * @returns {Promise<{email: string, orgName: string}>}
34
35
  */
35
- async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
36
+ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {}) {
36
37
  const server = http.createServer();
37
38
  let callbackResolve;
38
39
  let callbackReject;
@@ -102,10 +103,14 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
102
103
  const apiKey = url.searchParams.get('api_key');
103
104
  const email = url.searchParams.get('email');
104
105
  const orgName = url.searchParams.get('org');
106
+ // The callback is reachable by any local page, so its text is stripped of control characters before display.
107
+ const reason = (url.searchParams.get('error') || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200);
108
+ // `scope` echoes what the dashboard minted; an older dashboard sends none.
109
+ const grantedScope = url.searchParams.get('scope');
105
110
 
106
111
  if (!apiKey) {
107
- respondAndShutdown(res, 400, authPages.failurePage());
108
- callbackReject(new Error('No API key received in callback'));
112
+ respondAndShutdown(res, 400, authPages.failurePage(reason));
113
+ callbackReject(new Error(reason || 'No API key received in callback'));
109
114
  return;
110
115
  }
111
116
 
@@ -116,7 +121,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
116
121
  config.writeConfig(cfg);
117
122
 
118
123
  respondAndShutdown(res, 200, authPages.successPage({ email, orgName }));
119
- callbackResolve({ email, orgName });
124
+ callbackResolve(grantedScope ? { email, orgName, scope: grantedScope } : { email, orgName });
120
125
  });
121
126
 
122
127
  await new Promise((resolve, reject) => {
@@ -126,7 +131,9 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
126
131
 
127
132
  const port = server.address().port;
128
133
  const callbackUrl = `http://localhost:${port}/callback`;
129
- const authUrl = `${frontendUrl}/automations/api-key-callback?callback_url=${encodeURIComponent(callbackUrl)}&app_type=cli`;
134
+ // `machine` names the login token after this host, so a re-login replaces that token instead of adding one.
135
+ const params = new URLSearchParams({ callback_url: callbackUrl, app_type: 'cli', machine: os.hostname(), ...scope });
136
+ const authUrl = `${frontendUrl}/automations/api-key-callback?${params.toString()}`;
130
137
 
131
138
  const open = openFn || (await import('open')).default;
132
139
 
@@ -4,6 +4,7 @@ const output = require('../output');
4
4
  const api = require('../api');
5
5
  const { loginWithBrowser, loginWithApiKey, fetchDeviceKey } = require('../auth');
6
6
  const { getDeviceSerial } = require('../device-serial');
7
+ const { parseLoginScope, isScoped, describeToken } = require('../login-scope');
7
8
 
8
9
  function register(program) {
9
10
  program
@@ -15,6 +16,9 @@ function register(program) {
15
16
  .option('--backend-url <url>', 'Tenant backend REST API URL (e.g. https://backend.acme.com). Persisted to config.')
16
17
  .addOption(new Option('--base-url <url>', 'Hidden alias of --backend-url, kept for back-compat.').hideHelp())
17
18
  .option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
19
+ .option('--role <name>', 'Browser login only: scope the token to a console role (admin, analytics-viewer, manager, or a custom role name)')
20
+ .option('--permissions <list>', 'Browser login only: scope the token per area, e.g. analytics=view,logs=view (unlisted areas get none)')
21
+ .option('--expires-in-days <days>', 'Browser login only: token lifetime, one of 7, 30, 60, 90 (default 90)')
18
22
  .addHelpText('after', `
19
23
  Authentication methods:
20
24
  Browser login (default):
@@ -35,9 +39,17 @@ Options:
35
39
  --domain sets a custom domain for organizations with self-hosted frontends.
36
40
  Bare hostnames (e.g. "api.acme.com") are auto-prefixed with https://.
37
41
 
42
+ Scoped browser login (--role / --permissions / --expires-in-days):
43
+ By default the login token has your full access for 90 days. Scope it to a
44
+ console role or to specific areas; the dashboard still caps it to your own
45
+ access. Re-running login on this machine replaces its previous token.
46
+
38
47
  Examples:
39
48
  $ unbound login # Login via default gateway
40
49
  $ unbound login --api-key sk-abc123 # Non-interactive login
50
+ $ unbound login --role analytics-viewer # View-only token
51
+ $ unbound login --role "Policy Auditor" --expires-in-days 30
52
+ $ unbound login --permissions analytics=view,logs=view
41
53
  $ unbound login --api-key sk-abc123 \\
42
54
  --gateway-url https://api.acme.com \\
43
55
  --frontend-url https://gateway.acme.com \\
@@ -60,8 +72,12 @@ Examples:
60
72
  // wrong tenant.
61
73
  const explicitBaseUrl = written.base_url;
62
74
  const explicitFrontendUrl = written.frontend_url;
75
+ const scope = parseLoginScope(opts);
63
76
 
64
77
  if (opts.apiKey) {
78
+ if (isScoped(scope)) {
79
+ output.warn('--role, --permissions and --expires-in-days apply to browser login only; the given key is used as is.');
80
+ }
65
81
  try {
66
82
  await loginWithApiKey(opts.apiKey, { baseUrl: explicitBaseUrl });
67
83
  } catch {
@@ -77,14 +93,20 @@ Examples:
77
93
  } else {
78
94
  frontendUrl = explicitFrontendUrl || config.getFrontendUrl();
79
95
  }
80
- await loginWithBrowser(frontendUrl);
96
+ const result = await loginWithBrowser(frontendUrl, { scope });
97
+ if (isScoped(scope) && !result.scope) {
98
+ output.warn('Dashboard ignored your login flags. Stored a default 90-day full-access token. '
99
+ + 'Revoke it under Settings › API Tokens if unwanted.');
100
+ }
81
101
  // Validate the just-stored key against the explicit backend if one
82
102
  // was just persisted; otherwise fall back to the env-var-aware
83
103
  // getter. api.get reads the stored key from config internally.
84
104
  const deviceSerial = await getDeviceSerial();
85
- await api.get('/api/v1/users/privileges/', {
105
+ const privileges = await api.get('/api/v1/users/privileges/', {
86
106
  baseUrl: explicitBaseUrl, query: { device_serial: deviceSerial },
87
107
  });
108
+ const token = describeToken(privileges);
109
+ if (token) output.info(`Token: ${token}`);
88
110
  // Replace whatever config.json held (an old CLI login, another org's key) with this login's device key.
89
111
  try {
90
112
  config.setDeviceKey(await fetchDeviceKey(config.getApiKey(), { baseUrl: explicitBaseUrl }));
@@ -4,9 +4,12 @@ const output = require('../output');
4
4
  const { getDeviceSerial } = require('../device-serial');
5
5
  const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
6
6
  const claudePaths = require('../claudePaths');
7
+ const { describeToken } = require('../login-scope');
7
8
 
8
9
  function roleFromPrivileges(p) {
9
10
  if (!p) return null;
11
+ // A scoped token reports its own cap, not the owner's role flags.
12
+ if (p.cli_token && p.cli_token.permissions) return 'Scoped token';
10
13
  if (p.is_admin) return 'Admin';
11
14
  if (p.is_analytics_viewer) return 'Analytics Viewer';
12
15
  if (p.is_manager) return 'Manager';
@@ -25,7 +28,8 @@ Output fields:
25
28
  Logged in - Whether credentials are stored (Yes/No)
26
29
  Email - The authenticated user's email (if logged in)
27
30
  Organization - The organization name (if logged in)
28
- Role - Admin / Analytics Viewer / Manager / Member (if logged in)
31
+ Role - Admin / Analytics Viewer / Manager / Member, or "Scoped token" (if logged in)
32
+ Token - Login token name, scope and expiry (if signed in with a CLI token)
29
33
  Backend URL - REST API host (configurable for tenant deployments)
30
34
  Frontend URL - Browser login host
31
35
  Gateway URL - AI gateway host (used by tool setup)
@@ -51,6 +55,7 @@ Examples:
51
55
 
52
56
  let connectivity = 'Not checked (not logged in)';
53
57
  let role = null;
58
+ let token = null;
54
59
  if (loggedIn) {
55
60
  const spin = output.spinner('Checking API connectivity...');
56
61
  try {
@@ -60,6 +65,7 @@ Examples:
60
65
  });
61
66
  config.backfillUserInfo(privileges);
62
67
  role = roleFromPrivileges(privileges);
68
+ token = describeToken(privileges);
63
69
  spin.stop();
64
70
  connectivity = 'Connected';
65
71
  } catch (err) {
@@ -71,6 +77,7 @@ Examples:
71
77
  pairs.push(['Email', cfg.email || '-']);
72
78
  pairs.push(['Organization', cfg.org_name || '-']);
73
79
  pairs.push(['Role', role || '-']);
80
+ if (token) pairs.push(['Token', token]);
74
81
  }
75
82
  pairs.push(['Backend URL', config.getBaseUrl()]);
76
83
  pairs.push(['Frontend URL', config.getFrontendUrl()]);
@@ -100,6 +107,7 @@ Examples:
100
107
  email: loggedIn ? (cfg.email || null) : null,
101
108
  organization: loggedIn ? (cfg.org_name || null) : null,
102
109
  role: role,
110
+ token: token,
103
111
  backend_url: config.getBaseUrl(),
104
112
  frontend_url: config.getFrontendUrl(),
105
113
  gateway_url: config.getGatewayUrl(),
package/src/index.js CHANGED
@@ -38,6 +38,7 @@ AUTHENTICATION
38
38
  $ unbound login Sign in via browser
39
39
  $ unbound login --api-key <key> Sign in with an API key (for CI/CD)
40
40
  $ unbound login --domain custom.co Sign in via custom domain
41
+ $ unbound login --role analytics-viewer Browser sign-in with a view-only token
41
42
  $ unbound logout Remove stored credentials
42
43
  $ unbound status Show CLI status, role, and connected tools
43
44
  $ unbound doctor Diagnose per-tool health and API key
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Scope flags for `unbound login`: what the minted login token may do and for how long.
3
+ * Parsed before the browser opens so a typo fails here, not after the round trip.
4
+ */
5
+ const AREAS = ['analytics', 'logs', 'policies', 'configuration'];
6
+ const LEVELS = ['none', 'view', 'edit'];
7
+ const EXPIRY_DAYS = [7, 30, 60, 90];
8
+ const ROLE_NAME_MAX_LENGTH = 64;
9
+
10
+ // Built-in roles by their shell-friendly slug; custom role names pass through verbatim.
11
+ const BUILTIN_ROLE_SLUGS = {
12
+ admin: 'Admin',
13
+ 'analytics-viewer': 'Analytics Viewer',
14
+ manager: 'Manager',
15
+ member: 'Member',
16
+ };
17
+
18
+ const AREA_LABELS = { analytics: 'Analytics', logs: 'Logs', policies: 'Policies', configuration: 'Configuration' };
19
+
20
+ function parsePermissions(raw) {
21
+ const permissions = Object.fromEntries(AREAS.map((area) => [area, 'none']));
22
+ for (const pair of String(raw).split(',')) {
23
+ const parts = pair.trim().split('=');
24
+ const [area, level] = parts;
25
+ if (parts.length !== 2 || !area || !level) throw new Error(`Invalid permissions entry "${pair.trim()}". Use area=level, e.g. analytics=view`);
26
+ if (!AREAS.includes(area)) throw new Error(`Unknown area "${area}". Areas: ${AREAS.join(', ')}`);
27
+ if (!LEVELS.includes(level)) throw new Error(`Unknown level "${level}" for ${area}. Levels: ${LEVELS.join(', ')}`);
28
+ if (area === 'logs' && level === 'edit') throw new Error('Logs has no edit level');
29
+ permissions[area] = level;
30
+ }
31
+ return permissions;
32
+ }
33
+
34
+ /**
35
+ * Turns the login flags into the query the dashboard reads. Returns {} when no flag was given.
36
+ * @param {{role?: string, permissions?: string, expiresInDays?: string|number}} opts
37
+ */
38
+ function parseLoginScope(opts = {}) {
39
+ const query = {};
40
+ const role = typeof opts.role === 'string' ? opts.role.trim() : '';
41
+ const permissions = typeof opts.permissions === 'string' ? opts.permissions.trim() : '';
42
+ if (opts.role !== undefined && !role) throw new Error('--role must not be empty');
43
+ if (opts.permissions !== undefined && !permissions) throw new Error('--permissions must not be empty');
44
+ if (role && permissions) throw new Error('Use --role or --permissions, not both');
45
+ if (opts.expiresInDays !== undefined) {
46
+ const days = Number(opts.expiresInDays);
47
+ if (!EXPIRY_DAYS.includes(days)) throw new Error(`--expires-in-days must be one of ${EXPIRY_DAYS.join(', ')}`);
48
+ query.expires_in_days = String(days);
49
+ }
50
+ if (role) {
51
+ const slug = role.toLowerCase();
52
+ const name = Object.hasOwn(BUILTIN_ROLE_SLUGS, slug) ? BUILTIN_ROLE_SLUGS[slug] : role;
53
+ if (name.length > ROLE_NAME_MAX_LENGTH) throw new Error(`Unknown role "${role}"`);
54
+ query.role = name;
55
+ } else if (permissions) {
56
+ const map = parsePermissions(permissions);
57
+ query.permissions = AREAS.filter((a) => map[a] !== 'none').map((a) => `${a}=${map[a]}`).join(',');
58
+ if (!query.permissions) throw new Error('--permissions grants no access');
59
+ }
60
+ return query;
61
+ }
62
+
63
+ /** True when any login flag was given, so a dashboard that echoes no `scope` must have ignored it. */
64
+ function isScoped(query) {
65
+ return Object.keys(query).length > 0;
66
+ }
67
+
68
+ /** "Analytics view, Logs view" from a permissions map, or "full access" for null. */
69
+ function formatPermissions(permissions) {
70
+ if (!permissions || typeof permissions !== 'object') return 'full access';
71
+ const granted = AREAS.filter((a) => permissions[a] && permissions[a] !== 'none')
72
+ .map((a) => `${AREA_LABELS[a]} ${permissions[a]}`);
73
+ return granted.length ? granted.join(', ') : 'no access';
74
+ }
75
+
76
+ /** One line describing the token the privileges call reports, or null when the caller is not a CLI token. */
77
+ function describeToken(privileges) {
78
+ const token = privileges && privileges.cli_token;
79
+ if (!token || typeof token !== 'object') return null;
80
+ const parts = [token.name, formatPermissions(token.permissions)];
81
+ if (token.expires_at) parts.push(`expires ${String(token.expires_at).slice(0, 10)}`);
82
+ return parts.filter(Boolean).join(' · ');
83
+ }
84
+
85
+ module.exports = { parseLoginScope, isScoped, formatPermissions, describeToken, BUILTIN_ROLE_SLUGS, EXPIRY_DAYS };
@@ -1,6 +1,7 @@
1
1
  const { test } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
3
  const http = require('http');
4
+ const os = require('os');
4
5
  const { URL } = require('url');
5
6
 
6
7
  // WEB-4941, root cause #1: loginWithBrowser's local callback server leaked a
@@ -105,8 +106,11 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
105
106
  let successHeaders;
106
107
  let successSocket;
107
108
 
109
+ let authParams;
110
+
108
111
  const fakeOpen = async (authUrl) => {
109
- port = Number(new URL(new URL(authUrl).searchParams.get('callback_url')).port);
112
+ authParams = new URL(authUrl).searchParams;
113
+ port = Number(new URL(authParams.get('callback_url')).port);
110
114
  const res = await getKeepAlive(agent, buildSuccessCallback(authUrl));
111
115
  successHeaders = res.headers;
112
116
  successSocket = res.socket;
@@ -120,6 +124,9 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
120
124
  );
121
125
 
122
126
  assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
127
+ // The dashboard names this machine's login token after `machine`, so a re-login replaces it.
128
+ assert.equal(authParams.get('app_type'), 'cli');
129
+ assert.equal(authParams.get('machine'), os.hostname());
123
130
  assert.equal(successHeaders.connection, 'close', 'success response must set Connection: close');
124
131
 
125
132
  // The leak-proof: the held keep-alive socket is actually closed by the
@@ -0,0 +1,79 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+ const { URL } = require('url');
5
+
6
+ // The browser round trip for a scoped login: the flags ride on the auth URL, the dashboard's
7
+ // `scope=` echo comes back on success, and its `error=` reason is what the user sees on failure.
8
+
9
+ const auth = require('../src/auth');
10
+ const config = require('../src/config');
11
+
12
+ function get(url) {
13
+ return new Promise((resolve, reject) => {
14
+ http.get(url, (res) => {
15
+ let body = '';
16
+ res.on('data', (chunk) => { body += chunk; });
17
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
18
+ }).on('error', reject);
19
+ });
20
+ }
21
+
22
+ function withStubbedConfig(fn) {
23
+ const saved = { setApiKey: config.setApiKey, writeConfig: config.writeConfig, readConfig: config.readConfig };
24
+ config.setApiKey = () => {};
25
+ config.writeConfig = () => {};
26
+ config.readConfig = () => ({});
27
+ return Promise.resolve().then(fn).finally(() => Object.assign(config, saved));
28
+ }
29
+
30
+ test('loginWithBrowser: scope flags ride on the auth URL and the echoed scope comes back', async () => {
31
+ await withStubbedConfig(async () => {
32
+ let authParams;
33
+ const fakeOpen = async (authUrl) => {
34
+ authParams = new URL(authUrl).searchParams;
35
+ const callbackUrl = authParams.get('callback_url');
36
+ setImmediate(() => get(`${callbackUrl}?api_key=sk-test&email=a@b.co&org=Acme&scope=role%3AAnalytics+Viewer`));
37
+ };
38
+
39
+ const result = await auth.loginWithBrowser('http://frontend.invalid', {
40
+ open: fakeOpen, scope: { role: 'Analytics Viewer', expires_in_days: '30' },
41
+ });
42
+
43
+ assert.equal(authParams.get('role'), 'Analytics Viewer');
44
+ assert.equal(authParams.get('expires_in_days'), '30');
45
+ assert.equal(authParams.get('app_type'), 'cli');
46
+ assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme', scope: 'role:Analytics Viewer' });
47
+ });
48
+ });
49
+
50
+ test('loginWithBrowser: control characters in the callback error never reach the terminal', async () => {
51
+ await withStubbedConfig(async () => {
52
+ const fakeOpen = async (authUrl) => {
53
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
54
+ setImmediate(() => get(`${callbackUrl}?error=${encodeURIComponent('bad\u001b[31m role\u0007')}`));
55
+ };
56
+ await assert.rejects(
57
+ () => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'x' } }),
58
+ (err) => err.message === 'bad[31m role',
59
+ );
60
+ });
61
+ });
62
+
63
+ test('loginWithBrowser: the dashboard\'s error reason is the rejection and shows on the failure page', async () => {
64
+ await withStubbedConfig(async () => {
65
+ let page;
66
+ const fakeOpen = async (authUrl) => {
67
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
68
+ setImmediate(async () => { page = await get(`${callbackUrl}?error=Unknown+role+%22Policy-Auditor%22`); });
69
+ };
70
+
71
+ await assert.rejects(
72
+ () => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'Policy-Auditor' } }),
73
+ /Unknown role "Policy-Auditor"/,
74
+ );
75
+ await new Promise((resolve) => setTimeout(resolve, 50));
76
+ assert.equal(page.statusCode, 400);
77
+ assert.match(page.body, /Unknown role &quot;Policy-Auditor&quot;/);
78
+ });
79
+ });
@@ -0,0 +1,51 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const { parseLoginScope, isScoped, formatPermissions, describeToken } = require('../src/login-scope');
5
+
6
+ test('parseLoginScope: no flags means a plain login', () => {
7
+ assert.deepEqual(parseLoginScope({}), {});
8
+ assert.equal(isScoped({}), false);
9
+ });
10
+
11
+ test('parseLoginScope: a flag given but empty is an error, not a plain login', () => {
12
+ assert.throws(() => parseLoginScope({ role: ' ' }), /--role must not be empty/);
13
+ assert.throws(() => parseLoginScope({ permissions: '' }), /--permissions must not be empty/);
14
+ assert.equal(isScoped(parseLoginScope({ expiresInDays: '7' })), true);
15
+ });
16
+
17
+ test('parseLoginScope: built-in slugs map to the role name, custom names pass verbatim', () => {
18
+ assert.deepEqual(parseLoginScope({ role: 'analytics-viewer' }), { role: 'Analytics Viewer' });
19
+ assert.deepEqual(parseLoginScope({ role: 'ADMIN' }), { role: 'Admin' });
20
+ assert.deepEqual(parseLoginScope({ role: ' Policy Auditor ' }), { role: 'Policy Auditor' });
21
+ assert.deepEqual(parseLoginScope({ role: 'constructor' }), { role: 'constructor' });
22
+ assert.deepEqual(parseLoginScope({ role: '__proto__' }), { role: '__proto__' });
23
+ });
24
+
25
+ test('parseLoginScope: permissions keep only granted areas, in a fixed order', () => {
26
+ assert.deepEqual(
27
+ parseLoginScope({ permissions: 'logs=view, analytics=view', expiresInDays: '30' }),
28
+ { expires_in_days: '30', permissions: 'analytics=view,logs=view' },
29
+ );
30
+ });
31
+
32
+ test('parseLoginScope: rejects bad input before the browser opens', () => {
33
+ assert.throws(() => parseLoginScope({ role: 'admin', permissions: 'analytics=view' }), /not both/);
34
+ assert.throws(() => parseLoginScope({ expiresInDays: '45' }), /7, 30, 60, 90/);
35
+ assert.throws(() => parseLoginScope({ permissions: 'foo=view' }), /Unknown area "foo"/);
36
+ assert.throws(() => parseLoginScope({ permissions: 'analytics=admin' }), /Unknown level "admin"/);
37
+ assert.throws(() => parseLoginScope({ permissions: 'logs=edit' }), /no edit level/);
38
+ assert.throws(() => parseLoginScope({ permissions: 'analytics' }), /area=level/);
39
+ assert.throws(() => parseLoginScope({ permissions: 'analytics=none' }), /grants no access/);
40
+ assert.throws(() => parseLoginScope({ role: 'x'.repeat(65) }), /Unknown role/);
41
+ });
42
+
43
+ test('formatPermissions and describeToken read the privileges payload', () => {
44
+ assert.equal(formatPermissions(null), 'full access');
45
+ assert.equal(formatPermissions({ analytics: 'view', logs: 'view', policies: 'none', configuration: 'none' }), 'Analytics view, Logs view');
46
+ assert.equal(describeToken({ is_admin: true }), null);
47
+ assert.equal(
48
+ describeToken({ cli_token: { name: 'cli-login:mbp', permissions: null, expires_at: '2027-02-01T00:00:00Z' } }),
49
+ 'cli-login:mbp · full access · expires 2027-02-01',
50
+ );
51
+ });