@ainote/mcp 1.1.1 → 1.2.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
@@ -11,6 +11,7 @@ A Model Context Protocol (MCP) server that connects AI assistants to your AI Not
11
11
 
12
12
  - [Access Methods](#access-methods)
13
13
  - [Features](#features)
14
+ - [Authentication](#authentication)
14
15
  - [Prerequisites](#prerequisites)
15
16
  - [Installation](#installation)
16
17
  - [Configuration](#configuration)
@@ -41,7 +42,7 @@ AI Note MCP server is available through three transport options to cover differe
41
42
  - **Best for**: Users wanting to expose AI Note tools to ChatGPT without deploying infrastructure
42
43
 
43
44
  ### 3. **Hosted HTTP Endpoint** - For Platform Integration
44
- - **URL**: `https://api.ainote.dev/mcp`
45
+ - **URL**: `https://api.ainote.dev/api/mcp`
45
46
  - **Protocol**: JSON-RPC 2.0 over HTTP
46
47
  - **Usage**: Platform integrations (Kakao PlayMCP, etc.)
47
48
  - **Setup**: No installation required, direct API access
@@ -53,7 +54,10 @@ All transports expose the same tool catalog. Choose the option that matches your
53
54
 
54
55
  - 📝 **Task Management**: Create, update, delete, and list tasks directly from Claude Desktop
55
56
  - 📄 **Dev Docs**: Manage CLAUDE.md, Cursor rules, Windsurf rules, and dev documents centrally via MCP
56
- - 🗂️ **Dev Category Hierarchy**: Auto-organized under `dev/` with subcategories (claude, cursor, windsurf, copilot, docs)
57
+ - 🗂️ **Dev Category Hierarchy**: Auto-organized under `dev/` with subcategories (claude, cursor, windsurf, copilot, docs, skills, agents, commands, hooks, mcp)
58
+ - 🔄 **Claude Environment Sync**: Push/pull `~/.claude/` skills, agents, commands, hooks across machines (macOS/WSL/Linux)
59
+ - 🔐 **End-to-End Encryption**: `mcp` category (mcpServers + API keys) auto-encrypted client-side with age; server never sees plaintext
60
+ - 🪪 **OS Keychain Integration**: Per-machine age identity stored in macOS Keychain / libsecret / Credential Manager (file fallback supported)
57
61
  - 🏷️ **Category Support**: Organize tasks with categories
58
62
  - 🔍 **Advanced Filtering**: 18+ filter options including date ranges, location, overdue tasks
59
63
  - ⭐ **Priority Management**: Mark tasks as important
@@ -128,6 +132,69 @@ These tools also work inside Claude (no API key needed):
128
132
  | `login_and_get_key` | Login + get MCP key (existing account) |
129
133
  | `get_setup_guide` | Setup instructions |
130
134
 
135
+ ## Authentication
136
+
137
+ The CLI supports browser-based login as a more secure alternative to manually pasting API keys into your MCP config. Tokens and the MCP key are stored in the OS keychain.
138
+
139
+ ### Quick start
140
+
141
+ ```bash
142
+ ainote-mcp login # browser-based, recommended
143
+ ainote-mcp whoami # verify current login
144
+ ainote-mcp logout # revoke + clear keychain
145
+ ```
146
+
147
+ ### How it works
148
+
149
+ `ainote-mcp login` runs an [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) OAuth 2.0 Device Authorization Grant. The CLI generates a PKCE pair locally, opens your browser to sign in via the AI Note web app, and polls the backend until you approve. On success it stores an access token, a refresh token, and an MCP key in the OS keychain. The default MCP stdio mode automatically uses the stored MCP key — no `AINOTE_API_KEY` env var needed.
150
+
151
+ ### Storage locations
152
+
153
+ | OS | Location |
154
+ |----------|---------------------------------------------------------------------------|
155
+ | macOS | Keychain Access — service `@ainote/cli` |
156
+ | Linux | libsecret (gnome-keyring / kwallet) — service `@ainote/cli` |
157
+ | Windows | Credential Manager — target `@ainote/cli` |
158
+ | Fallback | `${XDG_CONFIG_HOME:-~/.config}/ainote/credentials.json` (mode `0600`) |
159
+
160
+ The fallback file is only used when no system keychain is available (e.g. headless servers without `libsecret`).
161
+
162
+ ### CLI flags
163
+
164
+ - `--scope=mcp,read,write` — comma-separated scopes requested for the token (default: `mcp,read,write`)
165
+ - `--no-browser` — print the verification URL instead of auto-opening a browser (use this on SSH/CI)
166
+
167
+ ### Environment overrides
168
+
169
+ - `AINOTE_API_URL` — backend URL (default `https://ainote-5muq.onrender.com`)
170
+ - `AINOTE_API_KEY` — pre-provisioned MCP key. When set, the login flow is bypassed entirely and this key is used as-is.
171
+
172
+ ### Headless / CI usage
173
+
174
+ For SSH sessions, devcontainers, or CI runners that cannot open a browser, use `--no-browser`. The CLI will print a short user code and a verification URL; open the URL on any device (laptop, phone) signed in to your AI Note account, approve the request, then return to the terminal:
175
+
176
+ ```bash
177
+ $ ainote-mcp login --no-browser
178
+ Open this URL in any browser:
179
+ https://ainote.dev/oauth/cli/device?user_code=BCDF-GHJK
180
+ Waiting for approval...
181
+ ✓ Logged in as you@example.com
182
+ ```
183
+
184
+ After login, subsequent invocations (`ainote-mcp`, `ainote-mcp whoami`) work normally with no further interaction.
185
+
186
+ ### Troubleshooting
187
+
188
+ | Symptom | Fix |
189
+ |-----------------------------------------------|-------------------------------------------------------------------------------------------|
190
+ | `LOGIN_HINT` error on any subcommand | Run `ainote-mcp login` |
191
+ | macOS Keychain prompt every run | Open Keychain Access, find `@ainote/cli`, set access control to "Always allow" for `node` |
192
+ | Browser does not open | Re-run with `--no-browser` and copy the URL manually |
193
+ | Persistent 401 after login | Tokens were revoked server-side. Run `ainote-mcp login` again |
194
+ | `libsecret not available` on Linux | Install `gnome-keyring` / `libsecret-1-0`, or rely on the file fallback (still encrypted at-rest by the OS) |
195
+
196
+ See [`docs/architecture/CLI_AUTH.md`](../docs/architecture/CLI_AUTH.md) for the full protocol specification, sequence diagram, and security model.
197
+
131
198
  ## Prerequisites
132
199
 
133
200
  - Node.js >= 18.0.0
@@ -244,7 +311,7 @@ For platform integrations and services that prefer HTTP over stdio, AI Note prov
244
311
 
245
312
  ### Endpoint URL
246
313
  ```
247
- POST https://api.ainote.dev/mcp
314
+ POST https://api.ainote.dev/api/mcp
248
315
  Content-Type: application/json
249
316
  ```
250
317
 
@@ -287,7 +354,7 @@ Authorization: Bearer YOUR_API_KEY
287
354
  This HTTP endpoint provides identical functionality to the stdio version, making AI Note MCP accessible to a broader range of platforms and integration scenarios.
288
355
 
289
356
  ### 4. **Streaming API** - For Large Data Operations
290
- - **URL**: `POST https://api.ainote.dev/mcp/stream`
357
+ - **URL**: `POST https://api.ainote.dev/api/mcp/stream`
291
358
  - **Protocol**: Server-Sent Events (SSE)
292
359
  - **Usage**: Bulk operations, large task lists, real-time progress
293
360
  - **Best for**: Applications that need to handle large datasets or show progress indicators
package/index.js CHANGED
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * AI Note MCP Server - Claude Desktop Integration (stdio transport)
4
- * Also supports CLI subcommands: signup
3
+ * AI Note MCP Server — Claude Desktop integration (stdio transport).
4
+ *
5
+ * Also supports CLI subcommands:
6
+ * signup — interactive account creation
7
+ * login — device-authorization grant (RFC 8628)
8
+ * logout — revoke + clear local credentials
9
+ * whoami — show currently authenticated user
5
10
  */
6
11
 
7
12
  const subcommand = process.argv[2];
@@ -9,19 +14,39 @@ const subcommand = process.argv[2];
9
14
  if (subcommand === 'signup') {
10
15
  const { runSignup } = await import('./lib/cli/signup.js');
11
16
  await runSignup();
17
+ } else if (subcommand === 'login') {
18
+ const { run } = await import('./lib/cli/login.js');
19
+ await run();
20
+ } else if (subcommand === 'logout') {
21
+ const { run } = await import('./lib/cli/logout.js');
22
+ await run();
23
+ } else if (subcommand === 'whoami') {
24
+ const { run } = await import('./lib/cli/whoami.js');
25
+ await run();
12
26
  } else {
13
27
  // Default: start MCP stdio server
14
28
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
15
29
  const { resolveAuthContext } = await import('./lib/auth/strategy-factory.js');
16
30
  const { createClaudeServer } = await import('./lib/core/server-factory.js');
31
+ const { getConfigAsync } = await import('./lib/core/config-manager.js');
32
+
33
+ // Hydrate AINOTE_API_KEY from the token store BEFORE auth context resolution
34
+ // so existing sync `authenticateWithMcpKey()` paths see the stored credential.
35
+ if (!process.env.AINOTE_API_KEY) {
36
+ try {
37
+ const cfg = await getConfigAsync();
38
+ if (cfg.api.key) {
39
+ process.env.AINOTE_API_KEY = cfg.api.key;
40
+ }
41
+ } catch { /* anonymous fallback handled below */ }
42
+ }
17
43
 
18
44
  const transport = new StdioServerTransport();
19
45
  const defaultAuthContext = resolveAuthContext('stdio');
20
46
 
21
47
  if (defaultAuthContext.type === 'anonymous') {
22
48
  console.error('[ainote-mcp] No AINOTE_API_KEY found. Starting in onboarding mode.');
23
- console.error('[ainote-mcp] Use signup_and_get_key or login_and_get_key to get started.');
24
- console.error('[ainote-mcp] Or run: npx @ainote/mcp signup');
49
+ console.error('[ainote-mcp] Run `npx @ainote/mcp login` (browser flow) or `npx @ainote/mcp signup`.');
25
50
  }
26
51
 
27
52
  const { server } = createClaudeServer({ defaultAuthContext });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * RFC 8628 Device Authorization Grant — CLI client.
3
+ *
4
+ * Talks to the ainote backend endpoints:
5
+ * POST /api/auth/cli/device/start
6
+ * POST /api/auth/cli/device/poll
7
+ * POST /api/auth/cli/device/cancel
8
+ *
9
+ * Per the plan, the server returns 200 + JSON body with a `status` field for
10
+ * polling state (authorization_pending, slow_down, access_denied, expired_token,
11
+ * invalid_grant) and a token payload (no `status`) on success. We never use
12
+ * HTTP error codes for polling state.
13
+ */
14
+ import crypto from 'node:crypto';
15
+ import axios from 'axios';
16
+
17
+ export class DeviceFlowError extends Error {
18
+ constructor(message, code) {
19
+ super(message);
20
+ this.name = 'DeviceFlowError';
21
+ this.code = code;
22
+ }
23
+ }
24
+
25
+ export class DeviceAccessDeniedError extends DeviceFlowError {
26
+ constructor(message = 'Authorization was denied by the user.') {
27
+ super(message, 'access_denied');
28
+ this.name = 'DeviceAccessDeniedError';
29
+ }
30
+ }
31
+
32
+ export class DeviceExpiredError extends DeviceFlowError {
33
+ constructor(message = 'Device code expired before authorization completed.') {
34
+ super(message, 'expired_token');
35
+ this.name = 'DeviceExpiredError';
36
+ }
37
+ }
38
+
39
+ export class DevicePkceError extends DeviceFlowError {
40
+ constructor(message = 'PKCE verification failed (invalid_grant).') {
41
+ super(message, 'invalid_grant');
42
+ this.name = 'DevicePkceError';
43
+ }
44
+ }
45
+
46
+ export class DeviceNetworkError extends DeviceFlowError {
47
+ constructor(message, cause) {
48
+ super(message, 'network_error');
49
+ this.name = 'DeviceNetworkError';
50
+ this.cause = cause;
51
+ }
52
+ }
53
+
54
+ function generatePkce() {
55
+ const verifier = crypto.randomBytes(32).toString('base64url');
56
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
57
+ return { verifier, challenge };
58
+ }
59
+
60
+ function sleep(seconds) {
61
+ return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
62
+ }
63
+
64
+ /**
65
+ * Request a device + user code from the server.
66
+ *
67
+ * @param {Object} args
68
+ * @param {string} args.apiUrl - API base URL (no trailing slash).
69
+ * @param {string} args.clientName - Friendly client identifier (e.g. "ainote-mcp CLI").
70
+ * @param {string[]|string} [args.scopes] - Requested scopes.
71
+ * @returns {Promise<{device_code: string, user_code: string, verification_uri: string,
72
+ * verification_uri_complete?: string, expires_in: number, interval: number,
73
+ * code_verifier: string}>}
74
+ */
75
+ export async function requestDeviceCode({ apiUrl, clientName, scopes }) {
76
+ const { verifier, challenge } = generatePkce();
77
+
78
+ const body = {
79
+ client_name: clientName,
80
+ scopes,
81
+ code_challenge: challenge,
82
+ code_challenge_method: 'S256'
83
+ };
84
+
85
+ let response;
86
+ try {
87
+ response = await axios.post(`${apiUrl}/api/auth/cli/device/start`, body, {
88
+ headers: { 'Content-Type': 'application/json' },
89
+ timeout: 30000
90
+ });
91
+ } catch (error) {
92
+ if (error.response) {
93
+ const data = error.response.data;
94
+ const msg = (data && (data.error_description || data.error || data.message)) ||
95
+ `Device authorization request failed (HTTP ${error.response.status}).`;
96
+ throw new DeviceFlowError(msg, 'start_failed');
97
+ }
98
+ throw new DeviceNetworkError(`Could not reach ${apiUrl}: ${error.message}`, error);
99
+ }
100
+
101
+ // The ainote backend wraps API responses in a standard envelope
102
+ // ({ success, status:'success', data: <payload>, meta }) — see render_success.
103
+ // RFC 8628 device endpoints live behind that envelope, so unwrap before reading.
104
+ const raw = response.data || {};
105
+ const data = (raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object')
106
+ ? raw.data
107
+ : raw;
108
+
109
+ if (!data.device_code || !data.user_code || !data.verification_uri) {
110
+ throw new DeviceFlowError('Malformed response from device/start: missing required fields.', 'invalid_response');
111
+ }
112
+
113
+ return {
114
+ device_code: data.device_code,
115
+ user_code: data.user_code,
116
+ verification_uri: data.verification_uri,
117
+ verification_uri_complete: data.verification_uri_complete,
118
+ expires_in: Number(data.expires_in) || 900,
119
+ interval: Number(data.interval) || 5,
120
+ code_verifier: verifier
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Poll the device/poll endpoint until terminal state.
126
+ *
127
+ * The server returns HTTP 200 with a body whose shape signals state:
128
+ * - `{ error: 'authorization_pending' }` → keep waiting
129
+ * - `{ error: 'slow_down', interval?: n }` → bump interval by 5s and wait
130
+ * - `{ error: 'access_denied' }` → throw DeviceAccessDeniedError
131
+ * - `{ error: 'expired_token' }` → throw DeviceExpiredError
132
+ * - `{ error: 'invalid_grant' }` → throw DevicePkceError
133
+ * - `{ access_token, ... }` → success, resolve token bundle
134
+ *
135
+ * The ainote backend wraps these in `{ success, status:'success', data: <body>, meta }`,
136
+ * so we unwrap `response.data.data` when present.
137
+ *
138
+ * If the server ever does return non-2xx, we attempt to map the body shape
139
+ * the same way (RFC 8628 §3.5 permits 400 + error JSON, but our backend per
140
+ * the plan returns 200; we tolerate both).
141
+ *
142
+ * @param {Object} args
143
+ * @param {string} args.apiUrl
144
+ * @param {string} args.deviceCode
145
+ * @param {string} args.codeVerifier - PKCE verifier captured during start.
146
+ * @param {number} args.interval - Initial poll interval in seconds.
147
+ * @param {number} args.expiresIn - Total lifetime in seconds.
148
+ * @param {(info: {elapsed: number, interval: number}) => void} [args.onTick]
149
+ * @returns {Promise<{access_token: string, refresh_token?: string, mcp_key?: string,
150
+ * expires_in?: number, user?: Object, scope?: string}>}
151
+ */
152
+ export async function pollForToken({
153
+ apiUrl,
154
+ deviceCode,
155
+ codeVerifier,
156
+ interval,
157
+ expiresIn,
158
+ onTick
159
+ }) {
160
+ let currentInterval = Math.max(1, Number(interval) || 5);
161
+ const totalBudget = Math.max(1, Number(expiresIn) || 900);
162
+ const startedAt = Date.now();
163
+
164
+ while (true) {
165
+ const elapsedBeforeWait = Math.floor((Date.now() - startedAt) / 1000);
166
+
167
+ if (elapsedBeforeWait >= totalBudget) {
168
+ throw new DeviceExpiredError();
169
+ }
170
+
171
+ if (typeof onTick === 'function') {
172
+ try {
173
+ onTick({ elapsed: elapsedBeforeWait, interval: currentInterval });
174
+ } catch { /* swallow UX callback errors */ }
175
+ }
176
+
177
+ await sleep(currentInterval);
178
+
179
+ const elapsedAfterWait = Math.floor((Date.now() - startedAt) / 1000);
180
+ if (elapsedAfterWait >= totalBudget) {
181
+ throw new DeviceExpiredError();
182
+ }
183
+
184
+ let body;
185
+ try {
186
+ const response = await axios.post(
187
+ `${apiUrl}/api/auth/cli/device/poll`,
188
+ { device_code: deviceCode, code_verifier: codeVerifier },
189
+ {
190
+ headers: { 'Content-Type': 'application/json' },
191
+ timeout: 30000,
192
+ // Per the plan the server returns 200 with status. Don't throw on
193
+ // 4xx so we can read the body uniformly.
194
+ validateStatus: (s) => s >= 200 && s < 500
195
+ }
196
+ );
197
+ const raw = response.data || {};
198
+ // Unwrap ainote API envelope ({ success, data: <payload>, meta }) if present.
199
+ body = (raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object')
200
+ ? raw.data
201
+ : raw;
202
+ } catch (error) {
203
+ // Network/5xx — transient. Wait and retry rather than fail the whole flow.
204
+ // But if we're out of budget, give up.
205
+ if (Math.floor((Date.now() - startedAt) / 1000) >= totalBudget) {
206
+ throw new DeviceExpiredError();
207
+ }
208
+ continue;
209
+ }
210
+
211
+ // RFC 8628 §3.5 + ainote service: polling state lives in `error`, success has no `error`.
212
+ // Also accept legacy `status` field for forward-compat with any wrapper changes.
213
+ const pollState = body.error || body.status;
214
+
215
+ if (!pollState && body.access_token) {
216
+ return {
217
+ access_token: body.access_token,
218
+ refresh_token: body.refresh_token,
219
+ mcp_key: body.mcp_key,
220
+ expires_in: body.expires_in,
221
+ scope: body.scope,
222
+ user: body.user
223
+ };
224
+ }
225
+
226
+ switch (pollState) {
227
+ case 'authorization_pending':
228
+ continue;
229
+ case 'slow_down': {
230
+ const serverInterval = Number(body.interval);
231
+ if (Number.isFinite(serverInterval) && serverInterval > currentInterval) {
232
+ currentInterval = serverInterval;
233
+ } else {
234
+ currentInterval += 5;
235
+ }
236
+ continue;
237
+ }
238
+ case 'access_denied':
239
+ throw new DeviceAccessDeniedError(body.error_description);
240
+ case 'expired_token':
241
+ throw new DeviceExpiredError(body.error_description);
242
+ case 'invalid_grant':
243
+ throw new DevicePkceError(body.error_description);
244
+ default: {
245
+ const message = body.error_description ||
246
+ body.error ||
247
+ body.message ||
248
+ `Unexpected poll response: ${JSON.stringify(body).slice(0, 200)}`;
249
+ throw new DeviceFlowError(message, pollState || 'unknown');
250
+ }
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Best-effort cancellation. Failures are swallowed — the server will GC
257
+ * the pending request via TTL anyway.
258
+ */
259
+ export async function cancel({ apiUrl, deviceCode }) {
260
+ if (!deviceCode) return;
261
+ try {
262
+ await axios.post(
263
+ `${apiUrl}/api/auth/cli/device/cancel`,
264
+ { device_code: deviceCode },
265
+ {
266
+ headers: { 'Content-Type': 'application/json' },
267
+ timeout: 5000,
268
+ validateStatus: () => true
269
+ }
270
+ );
271
+ } catch {
272
+ // intentionally ignored
273
+ }
274
+ }
@@ -1,10 +1,14 @@
1
1
  import { getConfig } from '../core/config.js';
2
2
 
3
+ /**
4
+ * Synchronous variant: only consults env var. Kept for callers that cannot
5
+ * await (e.g. the stdio bootstrap path that hasn't loaded the async config).
6
+ */
3
7
  export function authenticateWithMcpKey() {
4
8
  const config = getConfig();
5
9
 
6
10
  if (!config.api.key) {
7
- throw new Error('AINOTE_API_KEY is required to authenticate MCP requests');
11
+ throw new Error('AINOTE_API_KEY is required to authenticate MCP requests. Run `ainote-mcp login` or set AINOTE_API_KEY.');
8
12
  }
9
13
 
10
14
  return {
@@ -12,3 +16,25 @@ export function authenticateWithMcpKey() {
12
16
  apiKey: config.api.key
13
17
  };
14
18
  }
19
+
20
+ /**
21
+ * Async variant: falls back to the local CLI token store (populated by
22
+ * `ainote-mcp login`) when no env var is set.
23
+ */
24
+ export async function authenticateWithMcpKeyAsync() {
25
+ const envKey = process.env.AINOTE_API_KEY;
26
+ if (envKey) {
27
+ return { type: 'mcpKey', apiKey: envKey };
28
+ }
29
+
30
+ try {
31
+ const { getCredentials } = await import('./token-store.js');
32
+ const baseUrl = process.env.AINOTE_API_URL || 'https://ainote-5muq.onrender.com';
33
+ const creds = await getCredentials(baseUrl);
34
+ if (creds && creds.mcpKey) {
35
+ return { type: 'mcpKey', apiKey: creds.mcpKey };
36
+ }
37
+ } catch { /* fall through */ }
38
+
39
+ throw new Error('AINOTE_API_KEY is required to authenticate MCP requests. Run `ainote-mcp login` or set AINOTE_API_KEY.');
40
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Access token rotation against the ainote backend.
3
+ *
4
+ * Endpoint: POST /api/auth/token/refresh
5
+ * Body: { refresh_token: "rt_..." }
6
+ * Response: { access_token, refresh_token, expires_in }
7
+ *
8
+ * Concurrency: an in-process mutex (module-level Promise) ensures that
9
+ * parallel callers wait on a single in-flight refresh instead of issuing
10
+ * duplicate requests (which would otherwise burn through refresh-token
11
+ * rotation and invalidate the family).
12
+ */
13
+ import axios from 'axios';
14
+
15
+ let inFlight = null;
16
+
17
+ export class RefreshError extends Error {
18
+ constructor(message, { status, body } = {}) {
19
+ super(message);
20
+ this.name = 'RefreshError';
21
+ this.status = status;
22
+ this.body = body;
23
+ }
24
+ }
25
+
26
+ async function performRefresh({ apiUrl, refreshToken }) {
27
+ let response;
28
+ try {
29
+ response = await axios.post(
30
+ `${apiUrl}/api/auth/token/refresh`,
31
+ { refresh_token: refreshToken },
32
+ {
33
+ headers: { 'Content-Type': 'application/json' },
34
+ timeout: 30000,
35
+ validateStatus: (s) => s >= 200 && s < 500
36
+ }
37
+ );
38
+ } catch (err) {
39
+ throw new RefreshError(`Refresh request failed: ${err.message}`);
40
+ }
41
+
42
+ if (response.status < 200 || response.status >= 300) {
43
+ const body = response.data || {};
44
+ const msg = body.error_description || body.error || body.message ||
45
+ `Refresh failed with HTTP ${response.status}`;
46
+ throw new RefreshError(msg, { status: response.status, body });
47
+ }
48
+
49
+ const body = response.data || {};
50
+ if (!body.access_token) {
51
+ throw new RefreshError('Refresh response missing access_token', { body });
52
+ }
53
+ return {
54
+ access_token: body.access_token,
55
+ refresh_token: body.refresh_token || refreshToken,
56
+ expires_in: Number(body.expires_in) || undefined
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Rotate the access token. Concurrent callers share one in-flight request.
62
+ *
63
+ * @param {Object} args
64
+ * @param {string} args.apiUrl
65
+ * @param {string} args.refreshToken
66
+ * @returns {Promise<{access_token: string, refresh_token: string, expires_in?: number}>}
67
+ */
68
+ export async function refreshAccessToken({ apiUrl, refreshToken }) {
69
+ if (!refreshToken) {
70
+ throw new RefreshError('No refresh token available — login required.');
71
+ }
72
+ if (inFlight) return inFlight;
73
+ inFlight = performRefresh({ apiUrl, refreshToken })
74
+ .finally(() => {
75
+ inFlight = null;
76
+ });
77
+ return inFlight;
78
+ }