@motorical/mcp 1.1.3 → 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
@@ -25,6 +25,39 @@ A **Motorical SMTP Motor Block** is an isolated sending stream (similar to a per
25
25
 
26
26
  Safety: real sends require `dryRun: false` **and** `confirmRealSend: true`. Sandbox outbound is allowlist-locked until convert. Optional `fromName` sets the inbox display name (same as HTTP `/v1/send` / CLI `--from-name`); do not put `From` in custom headers.
27
27
 
28
+ ## Authorization (recommended)
29
+
30
+ Sign in once; no keys to paste, copy, or rotate by hand:
31
+
32
+ ```bash
33
+ npx @motorical/mcp login
34
+ ```
35
+
36
+ This opens your browser, you approve the scopes on Motorical's consent screen,
37
+ and the grant is stored at `~/.motorical/mcp-credentials.json` (owner-only,
38
+ `0600`). Access tokens last an hour and refresh silently.
39
+
40
+ - `motorical-mcp status` — show the current connection
41
+
42
+ An authorization covers **every Motor Block you own**, so block-scoped calls
43
+ must say which one they mean. Set `MOTORICAL_MOTOR_BLOCK_ID` (or pass
44
+ `motorBlockId` per call); the tools fail with a clear message rather than a bare
45
+ API error if it is missing.
46
+ - `motorical-mcp logout` — remove the local credentials
47
+
48
+ Logging out only deletes the local file. To cut the agent's access off at the
49
+ server, revoke the connection in **Settings → API Access → Connected AI
50
+ Agents**; that takes effect immediately, on every device, and does not sign you
51
+ out of your own dashboard.
52
+
53
+ Under the hood this is OAuth 2.1: PKCE `S256`, a loopback redirect, RFC 8707
54
+ audience-bound tokens, and RFC 9207 issuer checking on the callback. The client
55
+ is public, so refresh tokens rotate on every use.
56
+
57
+ The environment variables below still work and are the right choice for CI or a
58
+ headless server, where no browser is available. When an OAuth session is present
59
+ it takes precedence over them.
60
+
28
61
  ## Environment
29
62
 
30
63
  | Variable | Required | Description |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@motorical/mcp",
3
- "version": "1.1.3",
4
- "description": "MCP server for Motorical transactional email API dry-run/send, mint public tokens, list Motor Blocks, inspect delivery events",
3
+ "version": "1.2.0",
4
+ "description": "MCP server for Motorical transactional email API \u2014 dry-run/send, mint public tokens, list Motor Blocks, inspect delivery events",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "motorical-mcp": "./src/index.js"
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "homepage": "https://docs.motorical.com/ai-mcp",
44
44
  "bugs": {
45
- "url": "https://github.com/motorical-smtp/motorical-packages/issues"
45
+ "url": "https://github.com/motorical-smtp/motorical-backend/issues"
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
package/src/client.js CHANGED
@@ -3,6 +3,10 @@
3
3
  * Auth rules match docs.motorical.com (mk_live_ → /v1/send; ak_live_ → mint bearer).
4
4
  */
5
5
 
6
+ import {
7
+ loadCredentials, saveCredentials, isExpired, discover, refreshTokens,
8
+ } from './oauth.js';
9
+
6
10
  const DEFAULT_API_BASE = 'https://api.motorical.com';
7
11
  const DEFAULT_DOCS_BASE = 'https://docs.motorical.com';
8
12
 
@@ -15,7 +19,9 @@ export function loadConfig(env = process.env) {
15
19
  bearerToken: env.MOTORICAL_BEARER_TOKEN || '',
16
20
  dashboardJwt: env.MOTORICAL_JWT || '',
17
21
  motorBlockId: env.MOTORICAL_MOTOR_BLOCK_ID || '',
18
- defaultFrom: env.MOTORICAL_DEFAULT_FROM || ''
22
+ defaultFrom: env.MOTORICAL_DEFAULT_FROM || '',
23
+ // A grant from `motorical-mcp login`, when one exists.
24
+ oauthCredentials: loadCredentials()
19
25
  };
20
26
  }
21
27
 
@@ -26,12 +32,56 @@ export class MotoricalClient {
26
32
  this._cachedBearer = config.bearerToken || null;
27
33
  /** @type {string|null} */
28
34
  this._cachedMkKey = null;
35
+ /**
36
+ * An OAuth grant from `motorical-mcp login`. When present it is preferred
37
+ * over pasted keys: it is scoped, revocable from the dashboard, and expires.
38
+ */
39
+ // Read ONLY from config. An implicit loadCredentials() here would make the
40
+ // client behave differently on a machine that happens to have logged in —
41
+ // including inside tests. loadConfig() is the one place the disk is read.
42
+ this._oauth = config.oauthCredentials || null;
43
+ this._oauthMeta = null;
44
+ }
45
+
46
+ hasOAuthSession() {
47
+ return !!(this._oauth && this._oauth.accessToken);
48
+ }
49
+
50
+ /** Returns a live OAuth access token, refreshing (and rotating) if needed. */
51
+ async oauthAccessToken({ fetchImpl } = {}) {
52
+ if (!this.hasOAuthSession()) return null;
53
+ if (!isExpired(this._oauth)) return this._oauth.accessToken;
54
+
55
+ if (!this._oauth.refreshToken) {
56
+ throw new Error('Motorical session expired and has no refresh token. Run: motorical-mcp login');
57
+ }
58
+ if (!this._oauthMeta) {
59
+ this._oauthMeta = await discover(this._oauth.issuer, fetchImpl || fetch);
60
+ }
61
+ let tokens;
62
+ try {
63
+ tokens = await refreshTokens(this._oauthMeta, { refreshToken: this._oauth.refreshToken }, fetchImpl || fetch);
64
+ } catch (err) {
65
+ throw new Error(`Motorical session could not be refreshed (${err.message}). Run: motorical-mcp login`);
66
+ }
67
+ this._oauth = {
68
+ ...this._oauth,
69
+ accessToken: tokens.access_token,
70
+ // Rotation is mandatory for public clients: the old refresh token is dead.
71
+ refreshToken: tokens.refresh_token || this._oauth.refreshToken,
72
+ scope: tokens.scope || this._oauth.scope,
73
+ expiresAt: Date.now() + (Number(tokens.expires_in || 3600) * 1000),
74
+ };
75
+ try { saveCredentials(this._oauth); } catch { /* keep working in-memory */ }
76
+ return this._oauth.accessToken;
29
77
  }
30
78
 
31
79
  requireMk() {
32
80
  if (this._cachedMkKey) return this._cachedMkKey;
33
81
  if (!this.config.mkApiKey) {
34
- throw new Error('MOTORICAL_MK_API_KEY is required (mk_live_... Motor Block API key for POST /v1/send)');
82
+ throw new Error(
83
+ 'No credentials for sending. Run `motorical-mcp login`, or set MOTORICAL_MK_API_KEY (mk_live_...).'
84
+ );
35
85
  }
36
86
  return this.config.mkApiKey;
37
87
  }
@@ -96,7 +146,32 @@ export class MotoricalClient {
96
146
  return data;
97
147
  }
98
148
 
149
+ /**
150
+ * An OAuth grant covers every Motor Block the user owned at consent time, so
151
+ * a block-scoped request must say which one it means. (A minted public token
152
+ * carried a single block inside the token, so this never came up before.)
153
+ * Fails here with an actionable message rather than letting the API answer
154
+ * with a bare 400.
155
+ */
156
+ _scoped(path, motorBlockId) {
157
+ if (!this.hasOAuthSession()) return path;
158
+ const id = motorBlockId || this.config.motorBlockId;
159
+ if (!id) {
160
+ throw new Error(
161
+ 'motorBlockId is required: your Motorical authorization covers multiple Motor Blocks. '
162
+ + 'Pass motorBlockId, or set MOTORICAL_MOTOR_BLOCK_ID.'
163
+ );
164
+ }
165
+ const sep = path.includes('?') ? '&' : '?';
166
+ return `${path}${sep}motorBlockId=${encodeURIComponent(id)}`;
167
+ }
168
+
99
169
  async getBearer({ motorBlockId, forceRefresh = false } = {}) {
170
+ // An OAuth grant supersedes minted public tokens entirely — no ak_live_ key
171
+ // and no dashboard JWT needed.
172
+ const oauthToken = await this.oauthAccessToken();
173
+ if (oauthToken) return oauthToken;
174
+
100
175
  if (this._cachedBearer && !forceRefresh) return this._cachedBearer;
101
176
  const minted = await this.mintPublicToken({ motorBlockId });
102
177
  const token = minted?.data?.token || minted?.token || minted?.access_token;
@@ -107,7 +182,7 @@ export class MotoricalClient {
107
182
 
108
183
  async listMotorBlocks({ motorBlockId } = {}) {
109
184
  const bearer = await this.getBearer({ motorBlockId });
110
- return this.request('GET', '/api/public/v1/motor-blocks', { bearer });
185
+ return this.request('GET', this._scoped('/api/public/v1/motor-blocks', motorBlockId), { bearer });
111
186
  }
112
187
 
113
188
  async sendEmail(payload) {
@@ -140,8 +215,25 @@ export class MotoricalClient {
140
215
  const headers = {};
141
216
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
142
217
 
143
- return this.request('POST', '/v1/send', {
144
- apiKey: this.requireMk(),
218
+ const oauthToken = await this.oauthAccessToken();
219
+ if (oauthToken && !this.config.motorBlockId && !payload.motorBlockId) {
220
+ throw new Error(
221
+ 'motorBlockId is required to send with an OAuth authorization: it covers every Motor Block '
222
+ + 'you own. Set MOTORICAL_MOTOR_BLOCK_ID, or pass motorBlockId.'
223
+ );
224
+ }
225
+ const auth = oauthToken ? { bearer: oauthToken } : { apiKey: this.requireMk() };
226
+
227
+ // The block goes in the query string, not the body: /v1/send validates its
228
+ // body strictly and rejects unknown keys, and an api-key client's block
229
+ // comes from the key itself — a body field that disagreed would be a
230
+ // silent footgun.
231
+ const sendPath = oauthToken
232
+ ? `/v1/send?motorBlockId=${encodeURIComponent(payload.motorBlockId || this.config.motorBlockId)}`
233
+ : '/v1/send';
234
+
235
+ return this.request('POST', sendPath, {
236
+ ...auth,
145
237
  headers,
146
238
  body: {
147
239
  from: fromAddr,
@@ -161,14 +253,14 @@ export class MotoricalClient {
161
253
  if (!messageId) throw new Error('messageId is required');
162
254
  const bearer = await this.getBearer({ motorBlockId });
163
255
  const q = includePII ? '?includePII=true' : '';
164
- return this.request('GET', `/api/public/v1/messages/${encodeURIComponent(messageId)}${q}`, { bearer });
256
+ return this.request('GET', this._scoped(`/api/public/v1/messages/${encodeURIComponent(messageId)}${q}`, motorBlockId), { bearer });
165
257
  }
166
258
 
167
259
  async getMessageEvents(messageId, { includePII = false, motorBlockId } = {}) {
168
260
  if (!messageId) throw new Error('messageId is required');
169
261
  const bearer = await this.getBearer({ motorBlockId });
170
262
  const q = includePII ? '?includePII=true' : '';
171
- return this.request('GET', `/api/public/v1/messages/${encodeURIComponent(messageId)}/events${q}`, { bearer });
263
+ return this.request('GET', this._scoped(`/api/public/v1/messages/${encodeURIComponent(messageId)}/events${q}`, motorBlockId), { bearer });
172
264
  }
173
265
 
174
266
  async getSendApiStatus() {
@@ -219,6 +311,16 @@ export class MotoricalClient {
219
311
  }
220
312
 
221
313
  async domainList() {
314
+ // An OAuth grant reads the scoped public endpoint. It cannot use
315
+ // /api/domains: that route is behind the dashboard-session middleware,
316
+ // which also guards billing and account settings.
317
+ const oauthToken = await this.oauthAccessToken();
318
+ if (oauthToken) {
319
+ // Domains are account-wide, but authenticatePublic keeps MCP grants
320
+ // strictly block-bound rather than silently choosing one, so the
321
+ // selector travels here too. The endpoint scopes by user, not by block.
322
+ return this.request('GET', this._scoped('/api/public/v1/domains'), { bearer: oauthToken });
323
+ }
222
324
  return this.request('GET', '/api/domains', {
223
325
  bearer: this.requireDashboardJwt()
224
326
  });
package/src/index.js CHANGED
@@ -1,13 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Motorical MCP server — stdio transport for Cursor / Claude Desktop / etc.
4
- * Credentials via env (see README). Log only to stderr.
4
+ * Auth: `motorical-mcp login` (OAuth 2.1), or env credentials (see README).
5
+ * Log only to stderr — stdout is the MCP stream.
5
6
  */
6
7
 
7
8
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
9
  import { createMotoricalMcpServer } from './server.js';
9
10
 
11
+ const USAGE = `motorical-mcp — Motorical MCP server
12
+
13
+ motorical-mcp start the MCP server on stdio (default)
14
+ motorical-mcp login authorize with Motorical in your browser
15
+ motorical-mcp logout revoke the authorization and remove local credentials
16
+ (--local-only to forget it without revoking)
17
+ motorical-mcp status show the current connection
18
+ `;
19
+
10
20
  async function main() {
21
+ const cmd = process.argv[2];
22
+
23
+ if (cmd === 'login' || cmd === 'logout' || cmd === 'status') {
24
+ const mod = await import('./login.js');
25
+ if (cmd === 'login') await mod.login();
26
+ if (cmd === 'logout') await mod.logout({ revoke: !process.argv.includes('--local-only') });
27
+ if (cmd === 'status') mod.status();
28
+ return;
29
+ }
30
+
31
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
32
+ console.error(USAGE);
33
+ return;
34
+ }
35
+
11
36
  const { server } = createMotoricalMcpServer();
12
37
  const transport = new StdioServerTransport();
13
38
  await server.connect(transport);
@@ -15,6 +40,6 @@ async function main() {
15
40
  }
16
41
 
17
42
  main().catch((err) => {
18
- console.error('[motorical-mcp] fatal:', err);
43
+ console.error('[motorical-mcp] fatal:', err.message || err);
19
44
  process.exit(1);
20
45
  });
package/src/login.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `motorical-mcp login` — interactive OAuth 2.1 authorization.
3
+ * Everything it prints goes to stderr so it never corrupts an stdio MCP stream.
4
+ */
5
+
6
+ import crypto from 'node:crypto';
7
+ import { spawn } from 'node:child_process';
8
+ import {
9
+ DEFAULT_ISSUER, DEFAULT_RESOURCE,
10
+ discover, generatePkce, buildAuthorizeUrl, startCallbackServer,
11
+ exchangeCode, toStoredCredentials, saveCredentials, clearCredentials, revokeToken,
12
+ credentialsPath, loadCredentials,
13
+ } from './oauth.js';
14
+
15
+ const DEFAULT_SCOPES = ['send:transactional', 'read:analytics', 'manage:domains'];
16
+
17
+ function openBrowser(url) {
18
+ const cmd = process.platform === 'darwin' ? 'open'
19
+ : process.platform === 'win32' ? 'start' : 'xdg-open';
20
+ try {
21
+ spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ export async function login({
29
+ issuer = process.env.MOTORICAL_ISSUER || DEFAULT_ISSUER,
30
+ resource = process.env.MOTORICAL_RESOURCE || DEFAULT_RESOURCE,
31
+ scopes = DEFAULT_SCOPES,
32
+ open = true,
33
+ log = (m) => console.error(m),
34
+ } = {}) {
35
+ const meta = await discover(issuer);
36
+ const { verifier, challenge } = generatePkce();
37
+ const state = crypto.randomBytes(16).toString('base64url');
38
+
39
+ const server = await startCallbackServer({ expectedState: state, expectedIssuer: issuer });
40
+ const authorizeUrl = buildAuthorizeUrl(meta, {
41
+ redirectUri: server.redirectUri, challenge, state, scopes, resource,
42
+ });
43
+
44
+ log('');
45
+ log('Authorize Motorical in your browser:');
46
+ log(` ${authorizeUrl}`);
47
+ log('');
48
+ if (open && !openBrowser(authorizeUrl)) {
49
+ log('(could not open a browser automatically — copy the link above)');
50
+ }
51
+
52
+ let code;
53
+ try {
54
+ code = await server.waitForCode;
55
+ } catch (err) {
56
+ server.close();
57
+ throw err;
58
+ }
59
+
60
+ const tokens = await exchangeCode(meta, {
61
+ code, verifier, redirectUri: server.redirectUri, resource,
62
+ });
63
+ const creds = toStoredCredentials(tokens, { issuer, resource });
64
+ const file = saveCredentials(creds);
65
+
66
+ log(`Connected. Credentials stored at ${file} (owner-only).`);
67
+ log(`Scopes granted: ${creds.scope || scopes.join(' ')}`);
68
+ return creds;
69
+ }
70
+
71
+ export async function logout({ log = (m) => console.error(m), revoke = true } = {}) {
72
+ const creds = loadCredentials();
73
+ if (!creds) { log('No stored credentials to remove.'); return false; }
74
+
75
+ if (revoke) {
76
+ try {
77
+ const meta = await discover(creds.issuer);
78
+ const ok = await revokeToken(meta, {
79
+ token: creds.refreshToken || creds.accessToken,
80
+ tokenTypeHint: creds.refreshToken ? 'refresh_token' : 'access_token',
81
+ });
82
+ log(ok
83
+ ? 'Revoked the authorization on the server.'
84
+ : 'Could not revoke on the server — revoke it in Settings → API Access.');
85
+ } catch (err) {
86
+ log(`Could not reach the server to revoke (${err.message}).`);
87
+ log('The grant is still live — revoke it in Settings → API Access.');
88
+ }
89
+ }
90
+
91
+ const removed = clearCredentials();
92
+ log(removed ? `Removed ${credentialsPath()}.` : 'No local credentials file to remove.');
93
+ return removed;
94
+ }
95
+
96
+ export function status({ log = (m) => console.error(m) } = {}) {
97
+ const creds = loadCredentials();
98
+ if (!creds) { log('Not connected. Run: motorical-mcp login'); return null; }
99
+ log(`Connected to ${creds.issuer}`);
100
+ log(` resource: ${creds.resource}`);
101
+ log(` scopes: ${creds.scope || '(unknown)'}`);
102
+ log(` expires: ${new Date(creds.expiresAt).toISOString()}`);
103
+ log(` file: ${credentialsPath()}`);
104
+ return creds;
105
+ }
package/src/oauth.js ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * OAuth 2.1 + PKCE login for the Motorical MCP server.
3
+ *
4
+ * Replaces pasted mk_live_/ak_live_ keys: the user authorizes once in a
5
+ * browser and the resulting grant is stored locally and refreshed silently.
6
+ * The client is public (no secret), so PKCE S256 and refresh rotation are
7
+ * mandatory rather than optional.
8
+ */
9
+
10
+ import http from 'node:http';
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+
16
+ export const DEFAULT_ISSUER = 'https://motorical.com';
17
+ export const DEFAULT_RESOURCE = 'https://api.motorical.com';
18
+ export const CLIENT_ID = 'https://motorical.com/.well-known/motorical-mcp-client.json';
19
+
20
+ const CREDENTIALS_DIR = path.join(os.homedir(), '.motorical');
21
+ const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'mcp-credentials.json');
22
+
23
+ /** Tokens are user credentials: owner-only, never world-readable. */
24
+ export function saveCredentials(creds, file = CREDENTIALS_FILE) {
25
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
26
+ fs.writeFileSync(file, JSON.stringify(creds, null, 2), { mode: 0o600 });
27
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort on odd filesystems */ }
28
+ return file;
29
+ }
30
+
31
+ export function loadCredentials(file = CREDENTIALS_FILE) {
32
+ try {
33
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function clearCredentials(file = CREDENTIALS_FILE) {
40
+ try { fs.unlinkSync(file); return true; } catch { return false; }
41
+ }
42
+
43
+ export function credentialsPath() { return CREDENTIALS_FILE; }
44
+
45
+ export function generatePkce() {
46
+ const verifier = crypto.randomBytes(32).toString('base64url');
47
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
48
+ return { verifier, challenge };
49
+ }
50
+
51
+ /** RFC 8414 discovery, so endpoints are never hardcoded past the issuer. */
52
+ export async function discover(issuer = DEFAULT_ISSUER, fetchImpl = fetch) {
53
+ const res = await fetchImpl(`${issuer}/.well-known/oauth-authorization-server`, {
54
+ headers: { Accept: 'application/json' },
55
+ });
56
+ if (!res.ok) throw new Error(`Discovery failed: HTTP ${res.status}`);
57
+ const meta = await res.json();
58
+ if (meta.issuer !== issuer) {
59
+ throw new Error(`Issuer mismatch: document says ${meta.issuer}, expected ${issuer}`);
60
+ }
61
+ // A client MUST refuse to continue without PKCE S256 support.
62
+ if (!Array.isArray(meta.code_challenge_methods_supported)
63
+ || !meta.code_challenge_methods_supported.includes('S256')) {
64
+ throw new Error('Authorization server does not advertise PKCE S256 — refusing to continue');
65
+ }
66
+ return meta;
67
+ }
68
+
69
+ export function buildAuthorizeUrl(meta, { redirectUri, challenge, state, scopes, resource }) {
70
+ const u = new URL(meta.authorization_endpoint);
71
+ u.searchParams.set('client_id', CLIENT_ID);
72
+ u.searchParams.set('response_type', 'code');
73
+ u.searchParams.set('redirect_uri', redirectUri);
74
+ u.searchParams.set('scope', scopes.join(' '));
75
+ u.searchParams.set('state', state);
76
+ u.searchParams.set('code_challenge', challenge);
77
+ u.searchParams.set('code_challenge_method', 'S256');
78
+ u.searchParams.set('resource', resource);
79
+ return u.toString();
80
+ }
81
+
82
+ /**
83
+ * RFC 9207: the callback carries `iss`. Comparing it to the issuer we
84
+ * discovered is what stops a mix-up attack, where a malicious AS relays the
85
+ * user to a different one and harvests the code.
86
+ */
87
+ export function validateCallback({ params, expectedState, expectedIssuer }) {
88
+ if (params.get('error')) {
89
+ throw new Error(params.get('error_description') || params.get('error'));
90
+ }
91
+ if (params.get('state') !== expectedState) {
92
+ throw new Error('State mismatch — discarding this callback');
93
+ }
94
+ const iss = params.get('iss');
95
+ if (iss && iss !== expectedIssuer) {
96
+ throw new Error(`Issuer mismatch in callback: ${iss}`);
97
+ }
98
+ const code = params.get('code');
99
+ if (!code) throw new Error('Authorization callback carried no code');
100
+ return code;
101
+ }
102
+
103
+ export async function exchangeCode(meta, { code, verifier, redirectUri, resource }, fetchImpl = fetch) {
104
+ const body = new URLSearchParams({
105
+ grant_type: 'authorization_code',
106
+ code,
107
+ client_id: CLIENT_ID,
108
+ redirect_uri: redirectUri,
109
+ code_verifier: verifier,
110
+ resource,
111
+ });
112
+ const res = await fetchImpl(meta.token_endpoint, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
115
+ body: body.toString(),
116
+ });
117
+ const data = await res.json().catch(() => ({}));
118
+ if (!res.ok) throw new Error(data.error_description || data.error || `Token exchange failed: HTTP ${res.status}`);
119
+ return data;
120
+ }
121
+
122
+ export async function refreshTokens(meta, { refreshToken }, fetchImpl = fetch) {
123
+ const body = new URLSearchParams({
124
+ grant_type: 'refresh_token',
125
+ refresh_token: refreshToken,
126
+ client_id: CLIENT_ID,
127
+ });
128
+ const res = await fetchImpl(meta.token_endpoint, {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
131
+ body: body.toString(),
132
+ });
133
+ const data = await res.json().catch(() => ({}));
134
+ if (!res.ok) throw new Error(data.error_description || data.error || `Refresh failed: HTTP ${res.status}`);
135
+ return data;
136
+ }
137
+
138
+ /**
139
+ * RFC 7009. Deleting the local file leaves the grant alive on the server —
140
+ * still valid, still refreshable, belonging to nobody. "Log out" should mean
141
+ * the access is gone, so revoke before forgetting.
142
+ */
143
+ export async function revokeToken(meta, { token, tokenTypeHint = 'refresh_token' }, fetchImpl = fetch) {
144
+ if (!meta?.revocation_endpoint) return false;
145
+ const body = new URLSearchParams({
146
+ token, token_type_hint: tokenTypeHint, client_id: CLIENT_ID,
147
+ });
148
+ const res = await fetchImpl(meta.revocation_endpoint, {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
151
+ body: body.toString(),
152
+ });
153
+ return res.ok;
154
+ }
155
+
156
+ /** Shape stored on disk; `expiresAt` is absolute so a stale clock is obvious. */
157
+ export function toStoredCredentials(tokenResponse, { issuer, resource }) {
158
+ return {
159
+ version: 1,
160
+ issuer,
161
+ resource,
162
+ accessToken: tokenResponse.access_token,
163
+ refreshToken: tokenResponse.refresh_token || null,
164
+ scope: tokenResponse.scope || '',
165
+ expiresAt: Date.now() + (Number(tokenResponse.expires_in || 3600) * 1000),
166
+ obtainedAt: Date.now(),
167
+ };
168
+ }
169
+
170
+ /** 60s skew so a token is never used in the instant before it expires. */
171
+ export function isExpired(creds, now = Date.now()) {
172
+ if (!creds?.expiresAt) return true;
173
+ return now >= (creds.expiresAt - 60_000);
174
+ }
175
+
176
+ /**
177
+ * Starts the loopback listener and returns the chosen port immediately, since
178
+ * the port has to go into the authorize URL before any code can arrive.
179
+ */
180
+ export function startCallbackServer({ expectedState, expectedIssuer, timeoutMs = 300_000 }) {
181
+ return new Promise((resolveStart, rejectStart) => {
182
+ let settle;
183
+ const waitForCode = new Promise((resolve, reject) => { settle = { resolve, reject }; });
184
+
185
+ const server = http.createServer((req, res) => {
186
+ const url = new URL(req.url, 'http://127.0.0.1');
187
+ if (url.pathname !== '/callback') { res.writeHead(404).end('Not found'); return; }
188
+ try {
189
+ const code = validateCallback({ params: url.searchParams, expectedState, expectedIssuer });
190
+ res.writeHead(200, { 'Content-Type': 'text/html' }).end(page(
191
+ 'Motorical is connected',
192
+ 'You can close this tab and return to your terminal.'
193
+ ));
194
+ finish(); settle.resolve(code);
195
+ } catch (err) {
196
+ res.writeHead(400, { 'Content-Type': 'text/html' }).end(page(
197
+ 'Authorization failed', escapeHtml(err.message)
198
+ ));
199
+ finish(); settle.reject(err);
200
+ }
201
+ });
202
+
203
+ const timer = setTimeout(() => {
204
+ finish();
205
+ settle.reject(new Error('Timed out waiting for the browser callback'));
206
+ }, timeoutMs);
207
+
208
+ function finish() {
209
+ clearTimeout(timer);
210
+ setImmediate(() => server.close(() => {}));
211
+ }
212
+
213
+ server.on('error', (err) => { clearTimeout(timer); rejectStart(err); });
214
+ // Port 0 = whatever the OS gives us; the AS matches loopback ignoring port.
215
+ server.listen(0, '127.0.0.1', () => {
216
+ const { port } = server.address();
217
+ resolveStart({
218
+ port,
219
+ redirectUri: `http://127.0.0.1:${port}/callback`,
220
+ waitForCode,
221
+ close: finish,
222
+ });
223
+ });
224
+ });
225
+ }
226
+
227
+ function page(title, body) {
228
+ return `<html><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>${title}</h2><p>${body}</p></body></html>`;
229
+ }
230
+
231
+ function escapeHtml(s) {
232
+ return String(s).replace(/[&<>"']/g, (c) => (
233
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
234
+ ));
235
+ }