@jacebenson/jsn 4.3.13-dev.71.1 → 4.3.13-dev.73.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.71.1",
3
+ "version": "4.3.13-dev.73.1",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@ compatibility: |
14
14
  Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
15
  metadata:
16
16
  author: jacebenson
17
- version: "4.3.6"
17
+ version: "4.3.9"
18
18
  repository: https://github.com/jacebenson/jsn
19
19
  ---
20
20
 
@@ -43,10 +43,9 @@ All `list` subcommands use a paginated interactive picker in TTY mode:
43
43
  1. **Output modes** — `--json` when parsing data; text when presenting to humans
44
44
  2. **Use sys_id for updates** — All update/delete operations require sys_id
45
45
  3. **Check auth first** — Run `jsn auth status` before operations
46
- 4. **NEVER logout** — Only run `jsn auth logout` if the user explicitly asks
47
- 5. **Use `--profile <name>`** to target a specific instance, or `jsn auth switch <name>` to change the default
48
- 6. **Before using `eval` or `rest`** — Ask: *"Is there a more specific jsn command?"* Prefer specific over generic over escape hatch over eval.
49
- 7. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
46
+ 4. **Verify the intended instance** — Use `--profile <name>` for explicit targeting; never assume the default is right. `jsn auth switch <name>` changes the default.
47
+ 5. **Eval safety** — Prefer purpose-built or `records` commands. Never use `eval` to bypass a blocked business rule, ACL, or other ServiceNow protection. If eval is genuinely needed, explain why and obtain explicit user approval.
48
+ 6. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
50
49
 
51
50
  ## Command Hierarchy
52
51
 
@@ -113,6 +112,12 @@ excludes that history row. Use raw values, not display labels or other skip reas
113
112
 
114
113
  ### Complete an update set
115
114
 
115
+ Before development changes, identify the intended application scope and update
116
+ set. Keep related changes together, use the appropriate update-set or application
117
+ deployment mechanism, and report where changes were captured. Not all changes
118
+ are update-set tracked. Only complete a set when the human explicitly asks,
119
+ including sets you created; completing the current set returns selection to Default.
120
+
116
121
  ```bash
117
122
  jsn updatesets complete "My Feature" --notes "Fixed validation" --force --json
118
123
  ```
@@ -127,18 +132,10 @@ child sets when the parent completes; their descriptions are not replaced.
127
132
  Both selected-set Description and state are read back before success. On
128
133
  verification failure, inspect the set before retrying.
129
134
 
130
- ### Table and raw REST examples
131
-
132
- ```bash
133
- # Query a table with the normal records command
134
- jsn records list --table incident --query "active=true" --limit 5 --json
135
+ ### Table and raw REST discovery
135
136
 
136
- # Get a single record
137
- jsn records get --table incident --sys-id abc123... --json
138
-
139
- # Raw endpoint access when no specific command fits
140
- jsn rest "/api/now/table/incident?sysparm_limit=3" --json
141
- ```
137
+ Use `jsn records list --help`, `jsn records get --help`, and `jsn rest --help`
138
+ for syntax and examples. REST is blocked on read-only connections, including GET.
142
139
 
143
140
  ### `jsn docs` — offline documentation search
144
141
 
package/src/app.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // command handlers that already call them.
9
9
 
10
10
  import { AuthManager } from './auth.js';
11
+ import { createAuthRecovery } from './auth-recovery.js';
11
12
  import { SDKClient } from './sdk.js';
12
13
  import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled, hyperlink } from './output.js';
13
14
  import { saveConfig } from './config.js';
@@ -53,8 +54,11 @@ export class App {
53
54
 
54
55
  /** Build (or rebuild) the SDK client for an instance + session. */
55
56
  _buildSdk(instance, session = this.session) {
56
- this.sdk = new SDKClient(instance, this.auth, {
57
+ this.sdk = new SDKClient(instance, this.auth.createProfileProvider(instance, {
58
+ username: session?.username, authMethod: session?.profile?.auth_method,
59
+ }), {
57
60
  domain: session?.domain || '',
61
+ recovery: createAuthRecovery({ app: this, session }),
58
62
  });
59
63
  }
60
64
 
@@ -0,0 +1,74 @@
1
+ import { errAPI } from './errors.js';
2
+ import { canUsePicker, quoteShellArg } from './helpers.js';
3
+ import { confirm } from './prompts.js';
4
+
5
+ // Recovery is scoped to the SDK's resolved identity, never the mutable default.
6
+ // error supplies the same safe guidance if the single retry is also rejected.
7
+ export function createAuthRecovery({ app, session, argv = () => app._authArgv || {},
8
+ canInteract = canUsePicker, prompt = confirm,
9
+ renewBrowser = async (request, targetApp) => {
10
+ const { refreshProfile } = await import('./commands/auth.js');
11
+ return refreshProfile(request, targetApp);
12
+ },
13
+ }) {
14
+ const { instance, profileName, username, profile } = session;
15
+ const target = profileName ? `--profile ${quoteShellArg(profileName)}` : quoteShellArg(instance);
16
+ const label = profileName ? `profile ${quoteShellArg(profileName)}` : 'the active profile';
17
+ function failure(method = profile?.auth_method) {
18
+ const error = errAPI(401, '');
19
+ const login = `jsn auth login ${target}`;
20
+ error.message = `Authentication rejected for ${label} (HTTP 401).`;
21
+ error.profile = profileName || null;
22
+ error.recovery = method === 'basic'
23
+ ? `jsn auth login --basic ${quoteShellArg(instance)}${profileName ? ` --profile ${quoteShellArg(profileName)}` : ''}`
24
+ : method === 'gck' ? `jsn auth refresh ${target} --browser`
25
+ : method === 'oauth' ? `jsn auth refresh ${target}` : login;
26
+ error.hint = method === 'basic'
27
+ ? `Set SN_USERNAME/SN_PASSWORD to the correct credentials, then run: ${error.recovery}`
28
+ : method === 'oauth'
29
+ ? `Run interactively: ${error.recovery}. If refresh fails, run: ${login}`
30
+ : `Run: ${error.recovery}`;
31
+ return error;
32
+ }
33
+ // Keep the settled promise too: this provider gets only one recovery attempt.
34
+ let recoveryAttempt;
35
+ const provider = {
36
+ error: failure(),
37
+ async recover({ status }) {
38
+ if (status !== 401) return false;
39
+ return recoveryAttempt ??= recoverOnce();
40
+ },
41
+ };
42
+ async function recoverOnce() {
43
+ let creds;
44
+ try {
45
+ creds = await app.auth.getCredentialsFor(instance, username, {
46
+ username, authMethod: profile?.auth_method, refresh: false,
47
+ });
48
+ } catch { throw provider.error; }
49
+ const method = profile?.auth_method || creds.auth_method;
50
+ provider.error = failure(method);
51
+ if (method === 'oauth' && creds.refresh_token) {
52
+ try {
53
+ await app.auth.refreshToken(instance, creds, username ?? null);
54
+ return true;
55
+ } catch { throw provider.error; }
56
+ }
57
+ if (method === 'gck' && profileName && ['auto', 'confirm'].includes(profile?.reauth ?? 'confirm')
58
+ && canInteract(app, argv())) {
59
+ try {
60
+ if ((profile?.reauth ?? 'confirm') === 'confirm'
61
+ && !await prompt({ message: `Sign in again in a browser for ${label}?`, default: false })) throw provider.error;
62
+ // Reuse the canonical GCK refresh/login operation without emitting a
63
+ // second success envelope or replacing the in-flight command's SDK.
64
+ const targetApp = Object.create(app);
65
+ targetApp.ok = () => {};
66
+ targetApp.sdk = app.getSDKForProfile(instance, { profile, authMethod: 'gck', username });
67
+ await renewBrowser({ profile: profileName, browser: true }, targetApp);
68
+ return true;
69
+ } catch { throw provider.error; }
70
+ }
71
+ throw provider.error;
72
+ }
73
+ return provider;
74
+ }
package/src/auth.js CHANGED
@@ -612,7 +612,9 @@ export class AuthManager {
612
612
  if (!instance) {
613
613
  throw errAuth('No instance specified');
614
614
  }
615
+ const existed = !!this.credentialStore.load(instance, username);
615
616
  this.credentialStore.delete(instance, username);
617
+ return existed && !this.credentialStore.load(instance, username);
616
618
  }
617
619
 
618
620
  /**
package/src/cli.js CHANGED
@@ -213,6 +213,7 @@ export function buildCLI() {
213
213
  hint: 'Run "jsn auth status" to list profiles.',
214
214
  });
215
215
  }
216
+ app._authArgv = argv;
216
217
  applySession(app, session);
217
218
  } catch (err) {
218
219
  guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
@@ -1,5 +1,5 @@
1
1
  import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile } from '../config.js';
2
- import { errUsage, errUsageHint } from '../errors.js';
2
+ import { errUsage, errUsageHint, errConfirmationRequired } from '../errors.js';
3
3
  import { canUsePicker, formatCommandList, quoteShellArg } from '../helpers.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
5
  import process from 'node:process';
@@ -382,13 +382,18 @@ export async function modifyProfile(app, argv = {}) {
382
382
  const name = argv.name || argv.profile || await pickProfile(app, 'Modify which profile?', argv);
383
383
  const profile = app.config.profiles?.[name];
384
384
  if (!profile) throw new Error(`Profile not found: ${name}`);
385
- const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts'].some(flag => argv[flag] !== undefined);
385
+ if (argv.reauth !== undefined && !['auto', 'confirm', 'never'].includes(argv.reauth)) {
386
+ throw errUsage('--reauth must be auto, confirm, or never.');
387
+ }
388
+ const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts', 'reauth'].some(flag => argv[flag] !== undefined);
386
389
  if (explicitSettings) {
387
390
  if (argv.domain !== undefined) throw errUsage('Set profile preferences and domain separately.');
388
391
  const updated = await configureProfileOptions({ ...profile }, argv);
392
+ if (argv.reauth !== undefined) updated.reauth = argv.reauth;
389
393
  app.config.profiles[name] = updated;
390
394
  await saveConfig(app.config);
391
- app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false },
395
+ app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false,
396
+ ...(argv.reauth !== undefined ? { reauth: updated.reauth } : {}) },
392
397
  { summary: `Settings saved for ${name}` });
393
398
  return;
394
399
  }
@@ -803,10 +808,33 @@ Examples:
803
808
  }
804
809
  const target = resolveTargetProfile(argv, app.config, instanceURL);
805
810
  const username = target.profile?.username ?? null;
806
- if (username || canLogoutBareProfile(app, instanceURL, target.profile, !!argv.profile)) {
807
- app.auth.logout(instanceURL, username);
811
+ if (!username && !canLogoutBareProfile(app, instanceURL, target.profile, !!target.name)) {
812
+ throw errUsageHint('Cannot identify which stored credentials to clear.', 'Choose an unambiguous identity with --profile <name>.');
808
813
  }
809
- app.ok({ logged_out: true, instance: instanceURL }, { summary: `✓ Logged out from ${instanceURL}` });
814
+ const selector = target.name ? `--profile ${quoteShellArg(target.name)}` : quoteShellArg(instanceURL);
815
+ const rerun = `jsn auth logout ${selector}`;
816
+ const warning = `Clear locally stored credentials for ${target.name ? `profile "${target.name}" at ` : ''}${instanceURL}${username ? ` (user ${username})` : ''}? You will need to authenticate again. The profile is retained; environment credentials and ServiceNow/Chrome sessions are unaffected.`;
817
+ if (!argv.force) {
818
+ if (!canUsePicker(app, argv)) throw errConfirmationRequired(warning, rerun);
819
+ const { confirm } = await import('../prompts.js');
820
+ if (!await confirm({ message: warning, default: false })) {
821
+ app.ok({ logged_out: false, credentials_cleared: false, profile: target.name, instance: instanceURL },
822
+ { summary: 'Logout cancelled; stored credentials were retained.' });
823
+ return;
824
+ }
825
+ }
826
+ const cleared = app.auth.logout(instanceURL, username) === true;
827
+ const nextActions = cleared ? [
828
+ { action: 'Authenticate again', cmd: `jsn auth login ${selector}`, description: 'Restore locally stored authentication' },
829
+ ...(target.name ? [{ action: 'Remove retained profile', cmd: `jsn auth remove ${quoteShellArg(target.name)}`, description: 'Remove this connection if no longer needed' }] : []),
830
+ ] : [];
831
+ app.ok({ logged_out: cleared, credentials_cleared: cleared, profile: target.name, instance: instanceURL,
832
+ profile_retained: !!target.name, environment_credentials_affected: false, remote_session_terminated: false,
833
+ next_actions: nextActions }, {
834
+ summary: cleared ? `✓ Cleared local credentials for ${target.name || instanceURL}. Profile retained; environment credentials and ServiceNow/Chrome sessions are unaffected.`
835
+ : `No stored credentials were cleared for ${target.name || instanceURL}. Environment credentials and ServiceNow/Chrome sessions are unaffected.`,
836
+ breadcrumbs: nextActions,
837
+ });
810
838
  }
811
839
 
812
840
  export async function showAuthStatus(argv, app) {
@@ -1094,7 +1122,8 @@ export function authCmd(wrap) {
1094
1122
  })
1095
1123
  .command({
1096
1124
  command: 'logout [instance]',
1097
- describe: 'Clear stored credentials (keeps the instance entry — use "auth remove" to delete it)',
1125
+ describe: 'Clear local credentials after approval; retains the profile. Unnecessary after ordinary operations; does not sign out of ServiceNow/Chrome or clear environment credentials.',
1126
+ builder: sub => sub.option('force', { type: 'boolean', describe: 'Clear credentials only after explicit approval for this exact logout' }),
1098
1127
  handler: wrap(logoutProfile),
1099
1128
  })
1100
1129
  .command({
@@ -1134,6 +1163,7 @@ export function authCmd(wrap) {
1134
1163
  .option('read-only', { type: 'boolean', describe: 'Set read-only access (use --no-read-only to allow writes)' })
1135
1164
  .option('include-counts', { type: 'boolean', describe: 'Set result counts (use --no-include-counts to disable)' })
1136
1165
  .option('confirm-deletes', { type: 'boolean', describe: 'Set delete confirmations (use --no-confirm-deletes to disable)' })
1166
+ .option('reauth', { type: 'string', choices: ['auto', 'confirm', 'never'], describe: 'Browser recovery policy: auto, confirm (default), or never; interactive only' })
1137
1167
  .option('skip-confirmations', { type: 'boolean', describe: 'Compatibility flag: disable delete confirmations' })
1138
1168
  .positional('name', {
1139
1169
  describe: 'Profile name to modify',
@@ -83,7 +83,15 @@ export function recordsCmd(wrap) {
83
83
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
84
84
  .option('limit', { type: 'number', default: 20, describe: 'Max records' })
85
85
  .option('offset', { type: 'number', default: 0, describe: 'Offset' })
86
- .option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' }),
86
+ .option('count', { type: 'boolean', default: true, describe: 'Include the total-matching count (use --no-count to opt out)' })
87
+ .epilogue([
88
+ 'In an interactive terminal with automatic output, browse a picker (10 visible items).',
89
+ 'Use arrow keys to browse, type to search sys_id on the server, scroll to load more,',
90
+ 'and press Enter to select or Esc to cancel. --sys-id skips the picker.',
91
+ 'Use --json for noninteractive structured output; pipes and explicit formats also skip the picker.',
92
+ 'Example: jsn records list --table incident --query "active=true" --limit 5 --json',
93
+ 'Discover fields with: jsn columns --table incident',
94
+ ].join('\n')),
87
95
  handler: wrap(async (argv, app) => {
88
96
  const table = argv.table;
89
97
  const columns = argv.columns ? argv.columns.split(',') : getDefaultColumns(table);
@@ -187,7 +195,8 @@ export function recordsCmd(wrap) {
187
195
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
188
196
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
189
197
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
190
- .option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' }),
198
+ .option('attachments', { type: 'boolean', default: false, describe: 'Also list the record\'s attachments' })
199
+ .epilogue('Example: jsn records get --table incident --sys-id <sys_id> --json'),
191
200
  handler: wrap(async (argv, app) => {
192
201
  assertSafeExactMatch(argv['sys-id']);
193
202
  const params = new URLSearchParams();
@@ -10,6 +10,10 @@ export function restCmd(wrap) {
10
10
  describe: 'Make raw REST API calls',
11
11
  builder: (yargs) => {
12
12
  return yargs
13
+ .positional('endpoint', {
14
+ type: 'string',
15
+ describe: 'API path, optionally including query parameters; with --table, a record sys_id',
16
+ })
13
17
  .option('method', {
14
18
  alias: 'X',
15
19
  type: 'string',
@@ -34,7 +38,16 @@ export function restCmd(wrap) {
34
38
  .option('query', {
35
39
  type: 'string',
36
40
  describe: 'Query parameters string',
37
- });
41
+ })
42
+ .epilogue([
43
+ 'Prefer a specific jsn command or records for normal table operations.',
44
+ 'Raw REST is blocked on read-only profiles for every method, including GET.',
45
+ 'Examples:',
46
+ ' jsn rest "/api/now/table/incident?sysparm_limit=3" --json',
47
+ ' jsn rest --table incident --query "sysparm_limit=3" --json',
48
+ ' jsn rest /stats.do --raw',
49
+ 'Use either query parameters in the endpoint or --query, rather than both.',
50
+ ].join('\n'));
38
51
  },
39
52
  handler: wrap(async (argv, app) => {
40
53
  const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
@@ -119,9 +119,8 @@ function saveSkillConfig(updates) {
119
119
  fs.writeFileSync(globalConfigPath(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
120
120
  }
121
121
 
122
- function recordSkillInstall(targetPath) {
123
- const bundled = readBundledSkill();
124
- const version = extractVersion(bundled) || 'unknown';
122
+ function recordSkillInstall(targetPath, content) {
123
+ const version = extractVersion(content) || 'unknown';
125
124
  saveSkillConfig({
126
125
  skillLocation: targetPath,
127
126
  skillVersion: version,
@@ -260,7 +259,7 @@ export function skillCmd(wrap) {
260
259
  })
261
260
  .command({
262
261
  command: 'install [dir]',
263
- describe: 'Download and save the latest skill file',
262
+ describe: 'Install the bundled skill matched to this CLI version',
264
263
  builder: (y) => y
265
264
  .positional('dir', {
266
265
  type: 'string',
@@ -271,9 +270,10 @@ export function skillCmd(wrap) {
271
270
  describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit for interactive picker.',
272
271
  }),
273
272
  handler: wrap(async (argv, app) => {
274
- const res = await fetch(SKILL_RAW_URL);
275
- if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
276
- const content = await res.text();
273
+ const content = readBundledSkill();
274
+ if (!content) {
275
+ throw new Error('Skill file not found in package (expected at skills/servicenow/SKILL.md). Reinstall jsn to restore it.');
276
+ }
277
277
 
278
278
  // Resolve target directories
279
279
  let targets = [];
@@ -389,7 +389,7 @@ export function skillCmd(wrap) {
389
389
  }
390
390
 
391
391
  // Record install location so checkSkill knows where to look
392
- recordSkillInstall(targets[0].path);
392
+ recordSkillInstall(targets[0].path, content);
393
393
 
394
394
  const installed = targets.reduce((acc, t) => { acc[t.name] = t.path; return acc; }, {});
395
395
  const summary = targets.length === 1
@@ -399,7 +399,7 @@ export function skillCmd(wrap) {
399
399
  const okOpts = {
400
400
  summary,
401
401
  breadcrumbs: [
402
- { action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall to default target' },
402
+ { action: 'reinstall', cmd: 'jsn skill install', description: 'Reinstall the bundled skill to default target' },
403
403
  ],
404
404
  };
405
405
 
@@ -407,7 +407,7 @@ export function skillCmd(wrap) {
407
407
  okOpts.notice = 'Installed to multiple agents. Restart or reload your agent to pick up the skill.';
408
408
  }
409
409
 
410
- app.ok({ installed, from: SKILL_RAW_URL }, okOpts);
410
+ app.ok({ installed, from: SKILL_REPO_PATH }, okOpts);
411
411
  }),
412
412
  });
413
413
  },
package/src/errors.js CHANGED
@@ -77,6 +77,16 @@ export function errNetwork(cause) {
77
77
  }
78
78
 
79
79
  export function errAPI(status, msg) {
80
+ // Authentication/authorization bodies can echo credentials. Never render them.
81
+ if (status === 401) {
82
+ const error = new AppError(CodeAuth, 'Authentication rejected for the active profile (HTTP 401).', 'Run: jsn auth login', 401);
83
+ error.recovery = 'jsn auth login';
84
+ return error;
85
+ }
86
+ if (status === 403) {
87
+ return new AppError(CodeForbidden, 'Access forbidden (HTTP 403).',
88
+ 'This is an authorization/ACL/role problem, not a login problem. Check access to this endpoint.', 403);
89
+ }
80
90
  let hint = 'Check the API documentation for this endpoint.';
81
91
  if (status >= 500) {
82
92
  hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
@@ -229,6 +239,11 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
229
239
  return { stream: 'stderr', text, exitCode: 1 };
230
240
  }
231
241
 
242
+ if (code === CodeForbidden) {
243
+ if (resolveJsonMode(app, argv) && app) return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
244
+ return { stream: 'stderr', text: `Error (forbidden): ${err.message}\n\nHint: ${err.hint}\n`, exitCode: 1 };
245
+ }
246
+
232
247
  if (code === CodeConfirmationRequired) {
233
248
  // Structured error for AI agents / piped consumers: the JSON envelope
234
249
  // goes to stdout (where tooling reads) so the agent can surface the
package/src/output.js CHANGED
@@ -160,6 +160,11 @@ export class OutputWriter {
160
160
  const format = this.effectiveFormat();
161
161
  const e = error.code ? error : { code: 'unknown', message: String(error), hint: '' };
162
162
  const resp = { ok: false, error: e.message, code: e.code, hint: e.hint || '' };
163
+ if (e.status === 401 || e.status === 403) resp.status = e.status;
164
+ if (e.recovery) {
165
+ resp.recovery = e.recovery;
166
+ if (Object.hasOwn(e, 'profile')) resp.profile = e.profile;
167
+ }
163
168
  switch (format) {
164
169
  case FormatJSON:
165
170
  case FormatQuiet:
package/src/sdk.js CHANGED
@@ -47,6 +47,7 @@ export class SDKClient {
47
47
  constructor(baseURL, authProvider, opts = {}) {
48
48
  this.baseURL = baseURL.replace(/\/$/, '');
49
49
  this.authProvider = authProvider;
50
+ this.recovery = opts.recovery;
50
51
  this.timeout = opts.timeout || DEFAULT_TIMEOUT;
51
52
  this.domain = opts.domain || '';
52
53
  this.oauthCookies = null;
@@ -100,23 +101,42 @@ export class SDKClient {
100
101
  async _withAuthenticatedRequest(endpoint, opts, {
101
102
  timeout = this.timeout, prepare, skipOAuthSession = false, normalizeErrors = false,
102
103
  }, consume = (response) => response) {
103
- const controller = new AbortController();
104
- const timer = setTimeout(() => controller.abort(), timeout);
105
- try {
106
- const req = new Request(endpoint, { ...opts, signal: controller.signal });
107
- if (prepare) prepare(req);
108
- await this._setAuth(req, { skipOAuthSession });
109
- return await consume(await fetch(req));
110
- } catch (err) {
111
- if (normalizeErrors) {
112
- if (err.name === 'AbortError') throw errNetwork(new Error('Request timed out'));
113
- if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
114
- throw errNetwork(err);
104
+ for (let attempt = 0; attempt < 2; attempt++) {
105
+ const controller = new AbortController();
106
+ const timer = setTimeout(() => controller.abort(), timeout);
107
+ let rejection;
108
+ try {
109
+ const req = new Request(endpoint, { ...opts, signal: controller.signal });
110
+ if (prepare) prepare(req);
111
+ await this._setAuth(req, { skipOAuthSession });
112
+ const response = await fetch(req);
113
+ if (!skipOAuthSession && response.status === 401 && this.recovery) {
114
+ // Only a definitive HTTP rejection permits replay, including POST.
115
+ // Do not consume/echo the provider's potentially sensitive body.
116
+ rejection = { status: response.status, method: req.method, url: req.url };
117
+ await response.body?.cancel();
118
+ } else {
119
+ return await consume(response);
115
120
  }
121
+ } catch (err) {
122
+ if (normalizeErrors) {
123
+ if (err.name === 'AbortError') throw errNetwork(new Error('Request timed out'));
124
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
125
+ throw errNetwork(err);
126
+ }
127
+ }
128
+ throw err;
129
+ } finally {
130
+ clearTimeout(timer);
116
131
  }
117
- throw err;
118
- } finally {
119
- clearTimeout(timer);
132
+ // Browser renewal has its own existing hard timeout. Never leave a
133
+ // request timeout running while waiting for the human to authenticate.
134
+ if (attempt === 0 && await this.recovery.recover(rejection)) {
135
+ this.oauthCookies = null;
136
+ this.sessionUserToken = '';
137
+ continue;
138
+ }
139
+ throw this.recovery.error || errAPI(401, '');
120
140
  }
121
141
  }
122
142