faceless-cli 1.1.7 → 1.1.9

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/src/mcp/stdio.mjs CHANGED
@@ -2,112 +2,103 @@ import fs from "node:fs";
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { resolveApiKey, resolveBaseUrl } from "../config.mjs";
5
+ import { loadConfig, resolveApiKey, resolveBaseUrl } from "../config.mjs";
6
6
  import { CliError, request } from "../client.mjs";
7
7
 
8
8
  const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
9
9
  let spec;
10
10
  try {
11
- spec = JSON.parse(
12
- fs.readFileSync(new URL("../generated/operations.json", import.meta.url), "utf8")
13
- );
11
+ spec = JSON.parse(fs.readFileSync(new URL("../generated/operations.json", import.meta.url), "utf8"));
14
12
  } catch {
15
- throw new Error(
16
- 'cli/src/generated/operations.json is missing. Run "npm run generate:agents" in the repo root to generate it.'
17
- );
13
+ throw new Error('cli/src/generated/operations.json is missing. Run "npm run generate:agents" in the repo root to generate it.');
18
14
  }
19
15
  const operations = spec.operations.filter((op) => op.mcp && op.mcp.enabled);
20
16
 
21
17
  function toolInputSchema(op) {
22
- const properties = {
23
- ...((op.querySchema && op.querySchema.properties) || {}),
24
- ...((op.requestSchema && op.requestSchema.properties) || {}),
25
- };
26
- const required = [
27
- ...new Set([
28
- ...((op.querySchema && op.querySchema.required) || []),
29
- ...((op.requestSchema && op.requestSchema.required) || []),
30
- ]),
31
- ];
32
- const schema = { type: "object", properties, additionalProperties: false };
33
- if (required.length) schema.required = required;
34
- return schema;
18
+ const properties = {
19
+ ...((op.querySchema && op.querySchema.properties) || {}),
20
+ ...((op.requestSchema && op.requestSchema.properties) || {}),
21
+ };
22
+ const required = [...new Set([...((op.querySchema && op.querySchema.required) || []), ...((op.requestSchema && op.requestSchema.required) || [])])];
23
+ const schema = { type: "object", properties, additionalProperties: false };
24
+ if (required.length) schema.required = required;
25
+ return schema;
35
26
  }
36
27
 
37
28
  function errorResult(type, message) {
38
- return {
39
- isError: true,
40
- content: [
41
- {
42
- type: "text",
43
- text: JSON.stringify({ success: false, error: { type, message } }),
44
- },
45
- ],
46
- };
29
+ return {
30
+ isError: true,
31
+ content: [
32
+ {
33
+ type: "text",
34
+ text: JSON.stringify({ success: false, error: { type, message } }),
35
+ },
36
+ ],
37
+ };
47
38
  }
48
39
 
49
40
  export async function runMcpServer() {
50
- const server = new Server(
51
- { name: "faceless", version: pkg.version },
52
- { capabilities: { tools: {} } }
53
- );
41
+ const server = new Server({ name: "faceless", version: pkg.version }, { capabilities: { tools: {} } });
54
42
 
55
- const tools = operations.map((op) => ({
56
- name: op.mcp.name,
57
- description: `${op.summary}. ${op.description}`,
58
- inputSchema: toolInputSchema(op),
59
- }));
43
+ const tools = operations.map((op) => ({
44
+ name: op.mcp.name,
45
+ description: `${op.summary}. ${op.description}`,
46
+ inputSchema: toolInputSchema(op),
47
+ }));
60
48
 
61
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
49
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
62
50
 
63
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
64
- const name = req.params.name;
65
- const args = { ...(req.params.arguments || {}) };
66
- const op = operations.find((o) => o.mcp.name === name);
67
- if (!op) return errorResult("invalid_input", `Unknown tool: ${name}`);
51
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
52
+ const name = req.params.name;
53
+ const args = { ...(req.params.arguments || {}) };
54
+ const op = operations.find((o) => o.mcp.name === name);
55
+ if (!op) return errorResult("invalid_input", `Unknown tool: ${name}`);
68
56
 
69
- const apiKey = resolveApiKey({});
70
- if (!apiKey) {
71
- return errorResult(
72
- "unauthorized",
73
- 'No API key configured. Set the FACELESS_API_KEY environment variable or run "faceless login". Create keys in your team settings at https://faceless.so/team.'
74
- );
75
- }
57
+ // A null key is fine when `faceless login` stored an OAuth session: request() resolves and
58
+ // refreshes it. Only fail fast when NEITHER credential exists, and say the browser flow first -
59
+ // it is the path an agent can actually complete without a human pasting a key.
60
+ const apiKey = resolveApiKey({});
61
+ if (!apiKey && !loadConfig().oauth?.refreshToken) {
62
+ return errorResult(
63
+ "unauthorized",
64
+ 'Not authenticated. Run "faceless login" (opens a browser for OAuth - no key to paste), or set the FACELESS_API_KEY environment variable with a key from https://faceless.so/team.'
65
+ );
66
+ }
76
67
 
77
- let path = op.path;
78
- for (const match of op.path.matchAll(/\{(\w+)\}/g)) {
79
- const param = match[1];
80
- if (args[param] === undefined || args[param] === null) {
81
- return errorResult("invalid_input", `Missing required parameter: ${param}`);
82
- }
83
- path = path.replace(`{${param}}`, encodeURIComponent(String(args[param])));
84
- delete args[param];
85
- }
68
+ let path = op.path;
69
+ for (const match of op.path.matchAll(/\{(\w+)\}/g)) {
70
+ const param = match[1];
71
+ if (args[param] === undefined || args[param] === null) {
72
+ return errorResult("invalid_input", `Missing required parameter: ${param}`);
73
+ }
74
+ path = path.replace(`{${param}}`, encodeURIComponent(String(args[param])));
75
+ delete args[param];
76
+ }
86
77
 
87
- try {
88
- const method = op.method.toLowerCase();
89
- const hasBody = method !== "get" && method !== "delete";
90
- // Transport-level replay key (same semantics as the HTTP Idempotency-Key
91
- // header and the remote MCP's idempotencyKey argument), not operation input.
92
- const { idempotencyKey, ...opArgs } = args;
93
- const result = await request({
94
- method: op.method,
95
- path,
96
- query: hasBody ? undefined : opArgs,
97
- body: hasBody ? opArgs : undefined,
98
- apiKey,
99
- baseUrl: resolveBaseUrl({}),
100
- idempotencyKey,
101
- });
102
- return {
103
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
104
- };
105
- } catch (err) {
106
- if (err instanceof CliError) return errorResult(err.type, err.message);
107
- return errorResult("internal_error", err.message || "Unknown error");
108
- }
109
- });
78
+ try {
79
+ const method = op.method.toLowerCase();
80
+ const hasBody = method !== "get" && method !== "delete";
81
+ // Transport-level replay key (same semantics as the HTTP Idempotency-Key
82
+ // header and the remote MCP's idempotencyKey argument), not operation input.
83
+ const { idempotencyKey, ...opArgs } = args;
84
+ const result = await request({
85
+ method: op.method,
86
+ path,
87
+ query: hasBody ? undefined : opArgs,
88
+ body: hasBody ? opArgs : undefined,
89
+ apiKey,
90
+ baseUrl: resolveBaseUrl({}),
91
+ idempotencyKey,
92
+ });
93
+ return {
94
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
95
+ };
96
+ } catch (err) {
97
+ if (err instanceof CliError) return errorResult(err.type, err.message);
98
+ return errorResult("internal_error", err.message || "Unknown error");
99
+ }
100
+ });
110
101
 
111
- const transport = new StdioServerTransport();
112
- await server.connect(transport);
102
+ const transport = new StdioServerTransport();
103
+ await server.connect(transport);
113
104
  }
package/src/oauth.mjs ADDED
@@ -0,0 +1,306 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import { execFile } from "node:child_process";
4
+ import { loadConfig, saveConfig } from "./config.mjs";
5
+ import { CliError } from "./client.mjs";
6
+
7
+ // OAuth 2.0 login for the CLI and the MCP stdio server.
8
+ //
9
+ // This is what makes `faceless login` work for an AGENT, not just a human with a key to paste:
10
+ // dynamic client registration (RFC 7591), the authorization-code + PKCE flow with the redirect
11
+ // caught on a loopback listener (RFC 8252), and refresh-token rotation handled transparently on
12
+ // every subsequent request. The only human moment is approving the consent screen.
13
+ //
14
+ // The server side of all of this lives in the main app (src/pages/api/oauth2/*); this file is a
15
+ // client of the same documents any third-party agent would read.
16
+
17
+ const LOOPBACK_PORT = 8976;
18
+ const REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
19
+ /** Refresh this many ms before expiry, so a token cannot die mid-request. */
20
+ const EXPIRY_SKEW_MS = 60 * 1000;
21
+
22
+ /** https://faceless.so/api/v1 -> https://faceless.so (where the oauth2 + well-known routes live). */
23
+ export const siteBaseFrom = (apiBaseUrl) =>
24
+ String(apiBaseUrl || "")
25
+ .replace(/\/+$/, "")
26
+ .replace(/\/api\/v1$/, "");
27
+
28
+ export function generatePkce() {
29
+ const verifier = crypto.randomBytes(32).toString("base64url");
30
+ const challenge = crypto.createHash("sha256").update(verifier, "ascii").digest("base64url");
31
+ return { verifier, challenge };
32
+ }
33
+
34
+ async function postJson(url, body) {
35
+ const response = await fetch(url, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+ let payload = null;
41
+ try {
42
+ payload = await response.json();
43
+ } catch {
44
+ payload = null;
45
+ }
46
+ return { status: response.status, payload };
47
+ }
48
+
49
+ /** Register (or reuse) this machine's public client. One client per config file is plenty. */
50
+ async function ensureClient(site) {
51
+ const config = loadConfig();
52
+ if (config.oauthClientId) return config.oauthClientId;
53
+ const { status, payload } = await postJson(`${site}/api/oauth2/register`, {
54
+ client_name: `faceless-cli (${process.env.USER || process.env.USERNAME || "user"})`,
55
+ redirect_uris: [REDIRECT_URI],
56
+ });
57
+ if (status !== 201 || !payload?.client_id) {
58
+ throw new CliError("server_error", `OAuth client registration failed (${status}): ${payload?.error_description || "unknown error"}`);
59
+ }
60
+ saveConfig({ oauthClientId: payload.client_id });
61
+ return payload.client_id;
62
+ }
63
+
64
+ /** Every scope the server publishes; the consent screen is where the user narrows it. */
65
+ async function allScopes(site) {
66
+ try {
67
+ const response = await fetch(`${site}/.well-known/oauth-protected-resource`);
68
+ const metadata = await response.json();
69
+ if (Array.isArray(metadata?.scopes_supported) && metadata.scopes_supported.length) return metadata.scopes_supported.join(" ");
70
+ } catch {
71
+ /* fall through */
72
+ }
73
+ return ""; // absent scope -> server's read-only default
74
+ }
75
+
76
+ /**
77
+ * The page the loopback listener serves after the consent redirect.
78
+ *
79
+ * Three rules, each learned from the first version being wrong:
80
+ * - It must not claim success before it is true. "Logged in" was sent before the error/state
81
+ * params were even read, so DENYING consent still showed "Logged in", and even a real approval
82
+ * has only produced a code at this point - the exchange can still fail in the terminal.
83
+ * - It must scrub itself from history. The redirect lands with ?code=... in the URL, and the
84
+ * browser stores that in history/autocomplete; history.replaceState drops the query so a
85
+ * credential-bearing URL is never retained. The code is never echoed into the HTML either.
86
+ * - It should look like ours. A bare unstyled heading at a raw IP reads as broken or phishy.
87
+ */
88
+ export const callbackHtml = ({ ok, detail = "", site = "https://faceless.so" }) => `<!doctype html>
89
+ <html lang="en">
90
+ <head>
91
+ <meta charset="utf-8" />
92
+ <title>Faceless CLI</title>
93
+ <meta name="robots" content="noindex" />
94
+ <script>history.replaceState(null, "", "/callback");</script>
95
+ <style>
96
+ /* The consent page's visual shell (src/pages/oauth2/authorize.jsx, which itself mirrors /login),
97
+ replicated with the app's real tokens because this page is served from the CLI's loopback
98
+ listener where Tailwind and React do not exist: #060607 canvas, orbiting-star canvas at half
99
+ opacity, two blurred glow orbs (primary-600 / info-500), the .fa-grain noise overlay, the
100
+ top-left logo, and the #0A0A0D hairline card. This page renders one second after the consent
101
+ screen; any style break between them reads as leaving the product. */
102
+ * { box-sizing: border-box; }
103
+ body { margin: 0; min-height: 100vh; background: #060607; color: #fff;
104
+ font: 16px/1.6 Inter, -apple-system, "Segoe UI", Roboto, sans-serif; }
105
+ .ambient { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
106
+ .stars { position: absolute; inset: 0; opacity: 0.5; }
107
+ .orb-a { position: absolute; top: -160px; right: 8%; width: 480px; height: 480px;
108
+ border-radius: 50%; background: rgba(127, 20, 255, 0.08); filter: blur(160px); }
109
+ .orb-b { position: absolute; bottom: -10%; left: -6%; width: 420px; height: 420px;
110
+ border-radius: 50%; background: rgba(48, 180, 255, 0.04); filter: blur(130px); }
111
+ .grain { position: absolute; inset: 0; opacity: 0.04;
112
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"); }
113
+ .logo { position: absolute; left: 2.5rem; top: 2rem; z-index: 10; }
114
+ .logo img { display: block; width: 120px; height: auto; }
115
+ .shell { position: relative; z-index: 10; min-height: 100vh; display: flex;
116
+ align-items: center; justify-content: center; padding: 2.5rem 1rem; }
117
+ .card { position: relative; overflow: hidden; width: 100%; max-width: 420px; padding: 1.5rem 1.25rem;
118
+ background: #0A0A0D; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 1.5rem; }
119
+ .hairline { pointer-events: none; position: absolute; left: 2.5rem; right: 2.5rem; top: 0; height: 1px;
120
+ background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2), transparent); }
121
+ h1 { margin: 0 0 0.35rem; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; color: #fff; }
122
+ .fail h1 { color: #f87171; }
123
+ p { margin: 0; font-size: 13px; line-height: 1.55; color: #AEAEB2; }
124
+ .hint { margin-top: 1rem; font-size: 12px; color: #8E8E93; }
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <div class="ambient" aria-hidden="true">
129
+ <canvas class="stars" id="stars"></canvas>
130
+ <div class="orb-a"></div>
131
+ <div class="orb-b"></div>
132
+ <div class="grain"></div>
133
+ </div>
134
+ <div class="logo"><img src="${site}/faceless-logo.png" alt="Faceless" /></div>
135
+ <div class="shell">
136
+ <main class="card ${ok ? "" : "fail"}">
137
+ <div class="hairline"></div>
138
+ <h1>${ok ? "Authorization received" : "Authorization failed"}</h1>
139
+ <p>${ok ? "You can close this tab. The login finishes in your terminal." : `${detail} See your terminal for details.`}</p>
140
+ <p class="hint">${ok ? "This window holds no credentials and is safe to close." : "You can retry with `faceless login`."}</p>
141
+ </main>
142
+ </div>
143
+ <script>
144
+ // Compact port of the consent page's StarryBackground: stars slowly orbiting a centre point.
145
+ (function () {
146
+ var canvas = document.getElementById("stars");
147
+ var ctx = canvas.getContext("2d");
148
+ canvas.width = window.innerWidth;
149
+ canvas.height = window.innerHeight;
150
+ var cx = canvas.width / 2, cy = canvas.height / 2;
151
+ var stars = [];
152
+ for (var i = 0; i < 140; i++) {
153
+ stars.push({
154
+ radius: Math.random() * 1.5,
155
+ angle: Math.random() * Math.PI * 2,
156
+ orbit: Math.random() * (canvas.width / 2) + 100,
157
+ speed: Math.random() * 0.001 + 0.0005,
158
+ });
159
+ }
160
+ (function animate() {
161
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
162
+ ctx.fillStyle = "#fff";
163
+ for (var j = 0; j < stars.length; j++) {
164
+ var s = stars[j];
165
+ s.angle += s.speed;
166
+ var x = cx + Math.cos(s.angle) * s.orbit;
167
+ var y = cy + Math.sin(s.angle) * s.orbit;
168
+ ctx.globalAlpha = 0.9 - Math.min(0.7, s.orbit / canvas.width);
169
+ ctx.beginPath();
170
+ ctx.arc(x, y, s.radius, 0, Math.PI * 2);
171
+ ctx.fill();
172
+ }
173
+ ctx.globalAlpha = 1;
174
+ requestAnimationFrame(animate);
175
+ })();
176
+ })();
177
+ </script>
178
+ </body>
179
+ </html>`;
180
+
181
+ const openBrowser = (url) => {
182
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
183
+ execFile(command, [url], () => {});
184
+ };
185
+
186
+ /**
187
+ * The interactive login: registers a client, opens the consent screen, catches the redirect on a
188
+ * loopback listener, exchanges the code, and returns the token set. Writes nothing itself beyond
189
+ * the client id - the caller decides how to store the tokens.
190
+ */
191
+ export async function loginWithOAuth({ baseUrl, log = () => {} }) {
192
+ const site = siteBaseFrom(baseUrl);
193
+ const clientId = await ensureClient(site);
194
+ const { verifier, challenge } = generatePkce();
195
+ const state = crypto.randomBytes(8).toString("base64url");
196
+ const scope = await allScopes(site);
197
+
198
+ const authorizeUrl =
199
+ `${site}/oauth2/authorize?response_type=code&client_id=${clientId}` +
200
+ `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}${scope ? `&scope=${encodeURIComponent(scope)}` : ""}` +
201
+ `&state=${state}&code_challenge=${challenge}&code_challenge_method=S256`;
202
+
203
+ log(`Opening your browser to authorize this machine...\n ${authorizeUrl}\n`);
204
+ openBrowser(authorizeUrl);
205
+
206
+ const code = await new Promise((resolve, reject) => {
207
+ const timer = setTimeout(() => {
208
+ server.close();
209
+ reject(new CliError("timeout", "Timed out after 5 minutes waiting for browser approval."));
210
+ }, 300000);
211
+ const server = http.createServer((req, res) => {
212
+ const url = new URL(req.url, `http://127.0.0.1:${LOOPBACK_PORT}`);
213
+ if (url.pathname !== "/callback") {
214
+ res.writeHead(404).end();
215
+ return;
216
+ }
217
+ // Validate BEFORE responding, so the page tells the truth: the first version sent
218
+ // "Logged in" unconditionally, including when the user had just clicked Deny.
219
+ const errorParam = url.searchParams.get("error");
220
+ const stateOk = url.searchParams.get("state") === state;
221
+ const code = url.searchParams.get("code");
222
+ const ok = stateOk && !errorParam && Boolean(code);
223
+ const detail = !stateOk
224
+ ? "The redirect did not match this login attempt."
225
+ : errorParam === "access_denied"
226
+ ? "You denied the request."
227
+ : errorParam
228
+ ? `The server rejected the request (${errorParam}).`
229
+ : "No authorization code arrived.";
230
+ res.writeHead(ok ? 200 : 400, {
231
+ "Content-Type": "text/html; charset=utf-8",
232
+ // The redirect URL carried a credential (the code); nothing about it may be cached.
233
+ "Cache-Control": "no-store",
234
+ "Referrer-Policy": "no-referrer",
235
+ });
236
+ res.end(callbackHtml({ ok, detail, site }));
237
+ clearTimeout(timer);
238
+ server.close();
239
+ if (!stateOk) return reject(new CliError("unauthorized", "State mismatch in OAuth redirect."));
240
+ if (errorParam)
241
+ return reject(new CliError("unauthorized", `Authorization was ${errorParam === "access_denied" ? "denied" : `rejected: ${errorParam}`}.`));
242
+ if (!code) return reject(new CliError("unauthorized", "Redirect arrived without an authorization code."));
243
+ resolve(code);
244
+ });
245
+ server.on("error", (err) => {
246
+ clearTimeout(timer);
247
+ reject(
248
+ err?.code === "EADDRINUSE"
249
+ ? new CliError("server_error", `Port ${LOOPBACK_PORT} is in use; free it and retry.`)
250
+ : new CliError("server_error", `Loopback listener failed: ${err.message}`)
251
+ );
252
+ });
253
+ server.listen(LOOPBACK_PORT, "127.0.0.1");
254
+ });
255
+
256
+ const { status, payload } = await postJson(`${site}/api/oauth2/token`, {
257
+ grant_type: "authorization_code",
258
+ code,
259
+ redirect_uri: REDIRECT_URI,
260
+ client_id: clientId,
261
+ code_verifier: verifier,
262
+ });
263
+ if (status !== 200 || !payload?.access_token) {
264
+ throw new CliError("server_error", `Token exchange failed (${status}): ${payload?.error_description || payload?.error || "unknown"}`);
265
+ }
266
+ return tokenSetFrom(payload, site, clientId);
267
+ }
268
+
269
+ const tokenSetFrom = (payload, site, clientId) => ({
270
+ site,
271
+ clientId,
272
+ accessToken: payload.access_token,
273
+ refreshToken: payload.refresh_token,
274
+ scope: payload.scope,
275
+ expiresAt: Date.now() + (Number(payload.expires_in) || 3600) * 1000,
276
+ });
277
+
278
+ /**
279
+ * A valid access token from the stored OAuth session, refreshing (and persisting the rotated pair)
280
+ * when it is near expiry. Returns null when there is no OAuth session at all; throws when the
281
+ * session existed but is dead (family revoked, refresh expired) so the caller can say "log in
282
+ * again" instead of a bare 401.
283
+ */
284
+ export async function ensureOauthAccessToken() {
285
+ const config = loadConfig();
286
+ const session = config.oauth;
287
+ if (!session?.refreshToken) return null;
288
+
289
+ if (session.accessToken && Date.now() < Number(session.expiresAt || 0) - EXPIRY_SKEW_MS) {
290
+ return session.accessToken;
291
+ }
292
+
293
+ const { status, payload } = await postJson(`${session.site}/api/oauth2/token`, {
294
+ grant_type: "refresh_token",
295
+ refresh_token: session.refreshToken,
296
+ client_id: session.clientId,
297
+ });
298
+ if (status !== 200 || !payload?.access_token) {
299
+ // Rotation means a dead refresh token is unrecoverable; clear it so the next error is honest.
300
+ saveConfig({ oauth: undefined });
301
+ throw new CliError("unauthorized", 'Your OAuth session has expired or was revoked. Run "faceless login" again.');
302
+ }
303
+ const rotated = tokenSetFrom(payload, session.site, session.clientId);
304
+ saveConfig({ oauth: rotated });
305
+ return rotated.accessToken;
306
+ }