@gmag11/nodered-mcp-server 1.0.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.
Files changed (89) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +162 -0
  3. package/index.js +133 -0
  4. package/package.json +58 -0
  5. package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
  6. package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
  7. package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
  8. package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
  9. package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
  10. package/resources/skills/nodered-mustache/SKILL.md +588 -0
  11. package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
  12. package/resources/skills/nodered-node-reference/examples/common.json +113 -0
  13. package/resources/skills/nodered-node-reference/examples/network.json +107 -0
  14. package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
  15. package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
  16. package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
  17. package/resources/skills/nodered-patterns/SKILL.md +414 -0
  18. package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
  19. package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
  20. package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
  21. package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
  22. package/resources/skills/nodered-subflows/SKILL.md +261 -0
  23. package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
  24. package/src/auth/api-key-verifier.js +36 -0
  25. package/src/auth/composite-verifier.js +59 -0
  26. package/src/auth/config.js +106 -0
  27. package/src/auth/oauth-clients-store.js +107 -0
  28. package/src/auth/oauth-provider.js +149 -0
  29. package/src/auth/oauth-token-store.js +312 -0
  30. package/src/nodered/auth.js +158 -0
  31. package/src/nodered/client.js +199 -0
  32. package/src/nodered/comms-client.js +500 -0
  33. package/src/renderer/colors.js +161 -0
  34. package/src/renderer/geometry.js +115 -0
  35. package/src/renderer/html-builder.js +571 -0
  36. package/src/renderer/index.js +51 -0
  37. package/src/renderer/ir-builder.js +161 -0
  38. package/src/renderer/layout.js +126 -0
  39. package/src/renderer/mermaid-builder.js +109 -0
  40. package/src/renderer/svg-builder.js +228 -0
  41. package/src/schemas/responses.js +283 -0
  42. package/src/server.js +844 -0
  43. package/src/skills/loader.js +84 -0
  44. package/src/staging-store.js +258 -0
  45. package/src/tools/add-nodes-to-group.js +216 -0
  46. package/src/tools/connect-nodes.js +115 -0
  47. package/src/tools/constants.js +45 -0
  48. package/src/tools/create-flow.js +87 -0
  49. package/src/tools/create-node.js +126 -0
  50. package/src/tools/create-subflow-instance.js +123 -0
  51. package/src/tools/create-subflow.js +101 -0
  52. package/src/tools/delete-context.js +60 -0
  53. package/src/tools/delete-flow.js +81 -0
  54. package/src/tools/delete-group.js +116 -0
  55. package/src/tools/delete-node.js +73 -0
  56. package/src/tools/delete-subflow.js +103 -0
  57. package/src/tools/deploy.js +94 -0
  58. package/src/tools/disconnect-nodes.js +158 -0
  59. package/src/tools/export-flow.js +161 -0
  60. package/src/tools/export-subflow.js +78 -0
  61. package/src/tools/flow-utils.js +376 -0
  62. package/src/tools/get-config-nodes.js +86 -0
  63. package/src/tools/get-context.js +76 -0
  64. package/src/tools/get-flow-diagram.js +99 -0
  65. package/src/tools/get-flow-nodes.js +116 -0
  66. package/src/tools/get-flows.js +74 -0
  67. package/src/tools/get-node-detail.js +77 -0
  68. package/src/tools/get-node-type-detail.js +92 -0
  69. package/src/tools/get-palette-nodes.js +63 -0
  70. package/src/tools/get-staging-status.js +34 -0
  71. package/src/tools/get-subflow-detail.js +110 -0
  72. package/src/tools/get-subflows.js +105 -0
  73. package/src/tools/import-flow.js +310 -0
  74. package/src/tools/inject-message.js +117 -0
  75. package/src/tools/install-node.js +31 -0
  76. package/src/tools/read-debug-messages.js +155 -0
  77. package/src/tools/refresh-staging.js +62 -0
  78. package/src/tools/remove-nodes-from-group.js +162 -0
  79. package/src/tools/render-staging.js +69 -0
  80. package/src/tools/response-utils.js +42 -0
  81. package/src/tools/search-nodes.js +134 -0
  82. package/src/tools/uninstall-node.js +31 -0
  83. package/src/tools/update-flow.js +95 -0
  84. package/src/tools/update-group.js +77 -0
  85. package/src/tools/update-node.js +132 -0
  86. package/src/tools/update-subflow.js +84 -0
  87. package/src/transport/http.js +252 -0
  88. package/src/transport/stdio.js +16 -0
  89. package/src/transport/ws-server.js +223 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * JSON file-backed OAuth client registry.
3
+ *
4
+ * Implements the SDK's client store interface:
5
+ * - getClient(clientId): retrieves a registered client
6
+ * - registerClient(clientData): registers a new OAuth client
7
+ *
8
+ * Uses atomic writes (temp file + rename) to prevent corruption.
9
+ */
10
+
11
+ import { readFile, writeFile, rename } from 'node:fs/promises';
12
+ import { randomUUID, randomBytes } from 'node:crypto';
13
+ import path from 'node:path';
14
+
15
+ /** Default clients store path when MCP_OAUTH_CLIENTS_FILE is not set. */
16
+ const DEFAULT_CLIENTS_PATH = 'oauth-clients.json';
17
+
18
+ /**
19
+ * Creates a JSON file-backed OAuth client registry.
20
+ *
21
+ * @param {string|null} filePath - Path to the JSON clients file (null = use default)
22
+ * @returns {Promise<ClientsStore>}
23
+ */
24
+ export async function createClientsStore(filePath) {
25
+ const resolvedPath = path.resolve(filePath || DEFAULT_CLIENTS_PATH);
26
+ const clients = await loadClients(resolvedPath);
27
+
28
+ return {
29
+ /**
30
+ * Retrieve a registered OAuth client by ID.
31
+ *
32
+ * @param {string} clientId
33
+ * @returns {Promise<object|null>} The client object or null if not found
34
+ */
35
+ async getClient(clientId) {
36
+ return clients[clientId] || null;
37
+ },
38
+
39
+ /**
40
+ * Register a new OAuth client (Dynamic Client Registration).
41
+ *
42
+ * Generates client_id and client_secret cryptographically.
43
+ *
44
+ * @param {object} clientData
45
+ * @param {string} clientData.client_name
46
+ * @param {string[]} clientData.redirect_uris
47
+ * @returns {Promise<object>} The registered client
48
+ */
49
+ async registerClient(clientData) {
50
+ const clientId = randomUUID();
51
+ const clientSecret = randomBytes(32).toString('hex');
52
+
53
+ const client = {
54
+ client_id: clientId,
55
+ client_secret: clientSecret,
56
+ client_name: clientData.client_name,
57
+ redirect_uris: clientData.redirect_uris,
58
+ client_id_issued_at: Math.floor(Date.now() / 1000),
59
+ };
60
+
61
+ clients[clientId] = client;
62
+ await saveClients(resolvedPath, clients);
63
+
64
+ return client;
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Load clients from the JSON file, or return empty object if the file
71
+ * does not exist.
72
+ *
73
+ * @param {string} filePath
74
+ * @returns {Promise<Record<string, object>>}
75
+ */
76
+ async function loadClients(filePath) {
77
+ try {
78
+ const data = await readFile(filePath, 'utf-8');
79
+ return JSON.parse(data);
80
+ } catch (err) {
81
+ if (err.code === 'ENOENT') {
82
+ return {};
83
+ }
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Atomically save clients to the JSON file.
90
+ *
91
+ * Writes to a temp file first, then renames it over the target.
92
+ *
93
+ * @param {string} filePath
94
+ * @param {Record<string, object>} clients
95
+ * @returns {Promise<void>}
96
+ */
97
+ async function saveClients(filePath, clients) {
98
+ const tmpPath = filePath + '.tmp';
99
+ await writeFile(tmpPath, JSON.stringify(clients, null, 2), 'utf-8');
100
+ await rename(tmpPath, filePath);
101
+ }
102
+
103
+ /**
104
+ * @typedef {object} ClientsStore
105
+ * @property {(clientId: string) => Promise<object|null>} getClient
106
+ * @property {(clientData: object) => Promise<object>} registerClient
107
+ */
@@ -0,0 +1,149 @@
1
+ /**
2
+ * OAuth 2.0 server provider implementing the MCP SDK's OAuthServerProvider interface.
3
+ *
4
+ * Coordinates between:
5
+ * - ClientsStore: registered OAuth clients
6
+ * - TokenStore: authorization codes, access/refresh tokens
7
+ *
8
+ * Supports authorization code flow with PKCE (S256).
9
+ */
10
+
11
+ import { InvalidGrantError, InvalidClientError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
12
+ import { randomBytes } from 'node:crypto';
13
+
14
+ /**
15
+ * Creates an OAuth server provider compatible with the SDK's mcpAuthRouter().
16
+ *
17
+ * @param {object} opts
18
+ * @param {import('./oauth-clients-store.js').ClientsStore} opts.clientsStore
19
+ * @param {import('./oauth-token-store.js').OAuthTokenStore} opts.tokenStore
20
+ * @returns {import('@modelcontextprotocol/sdk/server/auth/provider.js').OAuthServerProvider}
21
+ */
22
+ export function createOAuthProvider({ clientsStore, tokenStore }) {
23
+ return {
24
+ // Expose clientsStore as required by the interface
25
+ get clientsStore() {
26
+ return clientsStore;
27
+ },
28
+
29
+ /**
30
+ * Begin the authorization flow.
31
+ *
32
+ * Generates an authorization code bound to the PKCE challenge,
33
+ * then redirects the client with the code and state.
34
+ *
35
+ * This is a programmatic authorization — no consent screen.
36
+ * For production, you may want to add a consent UI here.
37
+ */
38
+ async authorize(client, params, res) {
39
+ // Validate client
40
+ if (!client) {
41
+ throw new InvalidClientError('Unknown client');
42
+ }
43
+
44
+ const state = params.state;
45
+ const scopes = params.scopes || ['*'];
46
+ const codeChallenge = params.codeChallenge;
47
+ const redirectUri = params.redirectUri;
48
+
49
+ // Generate authorization code
50
+ const code = randomBytes(32).toString('hex');
51
+
52
+ // Store code with challenge for later verification
53
+ await tokenStore.saveAuthorizationCode(
54
+ code,
55
+ client.client_id,
56
+ codeChallenge,
57
+ redirectUri,
58
+ scopes,
59
+ );
60
+
61
+ // Build redirect URL with authorization code
62
+ const redirectUrl = new URL(redirectUri);
63
+ redirectUrl.searchParams.set('code', code);
64
+ if (state) {
65
+ redirectUrl.searchParams.set('state', state);
66
+ }
67
+
68
+ res.redirect(302, redirectUrl.href);
69
+ },
70
+
71
+ /**
72
+ * Retrieve the PKCE code challenge for an authorization code.
73
+ * Does NOT consume the code — the SDK calls this before exchangeAuthorizationCode.
74
+ */
75
+ async challengeForAuthorizationCode(_client, authorizationCode) {
76
+ const challenge = await tokenStore.getCodeChallenge(authorizationCode);
77
+ if (!challenge) {
78
+ throw new InvalidGrantError('Authorization code is invalid or expired');
79
+ }
80
+ return challenge;
81
+ },
82
+
83
+ /**
84
+ * Exchange an authorization code for access and refresh tokens.
85
+ */
86
+ async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri) {
87
+ // Consume the authorization code
88
+ const entry = await tokenStore.consumeAuthorizationCode(authorizationCode);
89
+ if (!entry) {
90
+ throw new InvalidGrantError('Authorization code is invalid or expired');
91
+ }
92
+
93
+ // Validate client matches
94
+ if (entry.clientId !== client.client_id) {
95
+ throw new InvalidGrantError('Authorization code was issued to a different client');
96
+ }
97
+
98
+ // Validate redirect URI if provided
99
+ if (redirectUri && entry.redirectUri !== redirectUri) {
100
+ throw new InvalidGrantError('Redirect URI mismatch');
101
+ }
102
+
103
+ // Issue tokens
104
+ const tokens = await tokenStore.issueTokens(client.client_id, entry.scopes);
105
+
106
+ return {
107
+ access_token: tokens.accessToken,
108
+ token_type: 'bearer',
109
+ expires_in: tokens.expiresIn,
110
+ refresh_token: tokens.refreshToken,
111
+ scope: entry.scopes.join(' '),
112
+ };
113
+ },
114
+
115
+ /**
116
+ * Exchange a refresh token for new tokens.
117
+ */
118
+ async exchangeRefreshToken(client, refreshToken, scopes) {
119
+ const result = await tokenStore.exchangeRefreshToken(refreshToken);
120
+ if (!result) {
121
+ throw new InvalidGrantError('Refresh token is invalid or expired');
122
+ }
123
+
124
+ const effectiveScopes = scopes && scopes.length > 0 ? scopes : ['*'];
125
+
126
+ return {
127
+ access_token: result.accessToken,
128
+ token_type: 'bearer',
129
+ expires_in: result.expiresIn,
130
+ refresh_token: result.refreshToken,
131
+ scope: effectiveScopes.join(' '),
132
+ };
133
+ },
134
+
135
+ /**
136
+ * Verify an access token. Delegates to token store.
137
+ */
138
+ async verifyAccessToken(token) {
139
+ return tokenStore.verifyAccessToken(token);
140
+ },
141
+
142
+ /**
143
+ * Revoke an access or refresh token.
144
+ */
145
+ async revokeToken(_client, request) {
146
+ await tokenStore.revokeToken(request.token);
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * JSON file-backed OAuth token and authorization code store.
3
+ *
4
+ * Handles:
5
+ * - Authorization codes (temporary, with PKCE challenge)
6
+ * - Access tokens (with clientId, scopes, expiry)
7
+ * - Refresh tokens
8
+ * - Token revocation
9
+ *
10
+ * Uses atomic writes (temp file + rename) to prevent corruption.
11
+ */
12
+
13
+ import { readFile, writeFile, rename } from 'node:fs/promises';
14
+ import { randomBytes } from 'node:crypto';
15
+ import path from 'node:path';
16
+ import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
17
+
18
+ /** Default token store path when MCP_OAUTH_TOKENS_FILE is not set. */
19
+ const DEFAULT_TOKENS_PATH = 'oauth-tokens.json';
20
+
21
+ /** Access token lifetime: 1 hour */
22
+ const ACCESS_TOKEN_TTL_SEC = 3600;
23
+
24
+ /** Refresh token lifetime: 30 days */
25
+ const REFRESH_TOKEN_TTL_SEC = 30 * 24 * 3600;
26
+
27
+ /** Authorization code lifetime: 10 minutes */
28
+ const AUTH_CODE_TTL_SEC = 600;
29
+
30
+ /**
31
+ * Creates a JSON file-backed OAuth token store.
32
+ *
33
+ * @param {string|null} filePath - Path to the JSON tokens file (null = use default)
34
+ * @returns {Promise<OAuthTokenStore>}
35
+ */
36
+ export async function createTokenStore(filePath) {
37
+ const resolvedPath = path.resolve(filePath || DEFAULT_TOKENS_PATH);
38
+ const data = await loadTokens(resolvedPath);
39
+
40
+ return {
41
+ // ── Authorization codes ──────────────────────────────────────
42
+
43
+ /**
44
+ * Store an authorization code with its PKCE challenge.
45
+ *
46
+ * @param {string} code
47
+ * @param {string} clientId
48
+ * @param {string} codeChallenge
49
+ * @param {string} redirectUri
50
+ * @param {string[]} scopes
51
+ */
52
+ async saveAuthorizationCode(code, clientId, codeChallenge, redirectUri, scopes) {
53
+ data.codes = data.codes || {};
54
+ data.codes[code] = {
55
+ clientId,
56
+ codeChallenge,
57
+ redirectUri,
58
+ scopes,
59
+ expiresAt: Math.floor(Date.now() / 1000) + AUTH_CODE_TTL_SEC,
60
+ };
61
+ await saveTokens(resolvedPath, data);
62
+ },
63
+
64
+ /**
65
+ * Look up the PKCE challenge for an authorization code WITHOUT consuming it.
66
+ * Returns null if the code is unknown or expired.
67
+ *
68
+ * @param {string} code
69
+ * @returns {Promise<string|null>}
70
+ */
71
+ async getCodeChallenge(code) {
72
+ data.codes = data.codes || {};
73
+ const entry = data.codes[code];
74
+ if (!entry) return null;
75
+ if (entry.expiresAt < Math.floor(Date.now() / 1000)) {
76
+ delete data.codes[code];
77
+ await saveTokens(resolvedPath, data);
78
+ return null;
79
+ }
80
+ return entry.codeChallenge;
81
+ },
82
+
83
+ /**
84
+ * Retrieve and consume an authorization code.
85
+ * Returns null if the code is unknown or expired.
86
+ *
87
+ * @param {string} code
88
+ * @returns {Promise<object|null>}
89
+ */
90
+ async consumeAuthorizationCode(code) {
91
+ data.codes = data.codes || {};
92
+ const entry = data.codes[code];
93
+ if (!entry) return null;
94
+
95
+ // Check expiry
96
+ if (entry.expiresAt < Math.floor(Date.now() / 1000)) {
97
+ delete data.codes[code];
98
+ await saveTokens(resolvedPath, data);
99
+ return null;
100
+ }
101
+
102
+ // Consume the code (single-use)
103
+ delete data.codes[code];
104
+ await saveTokens(resolvedPath, data);
105
+
106
+ return entry;
107
+ },
108
+
109
+ // ── Access tokens ─────────────────────────────────────────────
110
+
111
+ /**
112
+ * Issue a new access token (and optional refresh token).
113
+ *
114
+ * @param {string} clientId
115
+ * @param {string[]} scopes
116
+ * @returns {Promise<{ accessToken: string, refreshToken?: string, expiresIn: number }>}
117
+ */
118
+ async issueTokens(clientId, scopes) {
119
+ data.tokens = data.tokens || {};
120
+
121
+ const accessToken = randomBytes(32).toString('hex');
122
+ const refreshToken = randomBytes(32).toString('hex');
123
+ const nowSec = Math.floor(Date.now() / 1000);
124
+
125
+ data.tokens[accessToken] = {
126
+ clientId,
127
+ scopes,
128
+ expiresAt: nowSec + ACCESS_TOKEN_TTL_SEC,
129
+ };
130
+
131
+ // Store refresh token mapping
132
+ data.refreshTokens = data.refreshTokens || {};
133
+ data.refreshTokens[refreshToken] = {
134
+ accessToken,
135
+ clientId,
136
+ scopes,
137
+ expiresAt: nowSec + REFRESH_TOKEN_TTL_SEC,
138
+ };
139
+
140
+ await saveTokens(resolvedPath, data);
141
+
142
+ return {
143
+ accessToken,
144
+ refreshToken,
145
+ expiresIn: ACCESS_TOKEN_TTL_SEC,
146
+ };
147
+ },
148
+
149
+ /**
150
+ * Verify an access token. Returns the AuthInfo on success.
151
+ *
152
+ * @param {string} token
153
+ * @returns {Promise<import('./types.js').AuthInfo>}
154
+ * @throws {import('@modelcontextprotocol/sdk/server/auth/errors.js').InvalidTokenError}
155
+ */
156
+ async verifyAccessToken(token) {
157
+ data.tokens = data.tokens || {};
158
+ const entry = data.tokens[token];
159
+
160
+ if (!entry) {
161
+ throw new InvalidTokenError('Token not found');
162
+ }
163
+
164
+ if (entry.expiresAt < Math.floor(Date.now() / 1000)) {
165
+ throw new InvalidTokenError('Token has expired');
166
+ }
167
+
168
+ return {
169
+ token,
170
+ clientId: entry.clientId,
171
+ scopes: entry.scopes,
172
+ expiresAt: entry.expiresAt,
173
+ };
174
+ },
175
+
176
+ // ── Refresh tokens ────────────────────────────────────────────
177
+
178
+ /**
179
+ * Exchange a refresh token for a new access token.
180
+ * The old access token is revoked and the refresh token is rotated.
181
+ *
182
+ * @param {string} refreshToken
183
+ * @returns {Promise<{ accessToken: string, refreshToken: string, expiresIn: number }|null>}
184
+ */
185
+ async exchangeRefreshToken(refreshToken) {
186
+ data.refreshTokens = data.refreshTokens || {};
187
+ data.tokens = data.tokens || {};
188
+
189
+ const rtEntry = data.refreshTokens[refreshToken];
190
+ if (!rtEntry) return null;
191
+
192
+ // Check refresh token expiry
193
+ if (rtEntry.expiresAt < Math.floor(Date.now() / 1000)) {
194
+ delete data.refreshTokens[refreshToken];
195
+ await saveTokens(resolvedPath, data);
196
+ return null;
197
+ }
198
+
199
+ // Revoke old access token
200
+ const oldAccessToken = rtEntry.accessToken;
201
+ if (oldAccessToken && data.tokens[oldAccessToken]) {
202
+ delete data.tokens[oldAccessToken];
203
+ }
204
+
205
+ // Issue new tokens
206
+ const newAccessToken = randomBytes(32).toString('hex');
207
+ const newRefreshToken = randomBytes(32).toString('hex');
208
+ const nowSec = Math.floor(Date.now() / 1000);
209
+
210
+ data.tokens[newAccessToken] = {
211
+ clientId: rtEntry.clientId,
212
+ scopes: rtEntry.scopes,
213
+ expiresAt: nowSec + ACCESS_TOKEN_TTL_SEC,
214
+ };
215
+
216
+ // Rotate refresh token
217
+ delete data.refreshTokens[refreshToken];
218
+ data.refreshTokens[newRefreshToken] = {
219
+ accessToken: newAccessToken,
220
+ clientId: rtEntry.clientId,
221
+ scopes: rtEntry.scopes,
222
+ expiresAt: nowSec + REFRESH_TOKEN_TTL_SEC,
223
+ };
224
+
225
+ await saveTokens(resolvedPath, data);
226
+
227
+ return {
228
+ accessToken: newAccessToken,
229
+ refreshToken: newRefreshToken,
230
+ expiresIn: ACCESS_TOKEN_TTL_SEC,
231
+ };
232
+ },
233
+
234
+ // ── Revocation ────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Revoke an access token or refresh token.
238
+ * Does nothing if the token is already unknown.
239
+ *
240
+ * @param {string} token
241
+ * @returns {Promise<void>}
242
+ */
243
+ async revokeToken(token) {
244
+ let changed = false;
245
+
246
+ // Check access tokens
247
+ data.tokens = data.tokens || {};
248
+ if (data.tokens[token]) {
249
+ delete data.tokens[token];
250
+ changed = true;
251
+ }
252
+
253
+ // Check refresh tokens
254
+ data.refreshTokens = data.refreshTokens || {};
255
+ if (data.refreshTokens[token]) {
256
+ const rtEntry = data.refreshTokens[token];
257
+ // Also revoke the associated access token
258
+ if (rtEntry.accessToken && data.tokens[rtEntry.accessToken]) {
259
+ delete data.tokens[rtEntry.accessToken];
260
+ }
261
+ delete data.refreshTokens[token];
262
+ changed = true;
263
+ }
264
+
265
+ if (changed) {
266
+ await saveTokens(resolvedPath, data);
267
+ }
268
+ },
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Load token data from the JSON file, or return empty structure
274
+ * if the file does not exist.
275
+ *
276
+ * @param {string} filePath
277
+ * @returns {Promise<object>}
278
+ */
279
+ async function loadTokens(filePath) {
280
+ try {
281
+ const raw = await readFile(filePath, 'utf-8');
282
+ return JSON.parse(raw);
283
+ } catch (err) {
284
+ if (err.code === 'ENOENT') {
285
+ return {};
286
+ }
287
+ throw err;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Atomically save token data to the JSON file.
293
+ *
294
+ * @param {string} filePath
295
+ * @param {object} data
296
+ * @returns {Promise<void>}
297
+ */
298
+ async function saveTokens(filePath, data) {
299
+ const tmpPath = filePath + '.tmp';
300
+ await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
301
+ await rename(tmpPath, filePath);
302
+ }
303
+
304
+ /**
305
+ * @typedef {object} OAuthTokenStore
306
+ * @property {(code: string, clientId: string, codeChallenge: string, redirectUri: string, scopes: string[]) => Promise<void>} saveAuthorizationCode
307
+ * @property {(code: string) => Promise<object|null>} consumeAuthorizationCode
308
+ * @property {(clientId: string, scopes: string[]) => Promise<{ accessToken: string, refreshToken?: string, expiresIn: number }>} issueTokens
309
+ * @property {(token: string) => Promise<import('./types.js').AuthInfo>} verifyAccessToken
310
+ * @property {(refreshToken: string) => Promise<{ accessToken: string, refreshToken: string, expiresIn: number }|null>} exchangeRefreshToken
311
+ * @property {(token: string) => Promise<void>} revokeToken
312
+ */