nworks 1.2.1 → 1.2.2

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/dist/index.js CHANGED
@@ -38,13 +38,14 @@ var ApiError = class extends Error {
38
38
  function hasServiceAccountCreds(creds) {
39
39
  return !!(creds.serviceAccount && creds.privateKeyPath && creds.botId);
40
40
  }
41
+ var IS_UNIX = process.platform !== "win32";
41
42
  var CONFIG_DIR = join(homedir(), ".config", "nworks");
42
43
  var CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
43
44
  var TOKEN_PATH = join(CONFIG_DIR, "token.json");
44
45
  var USER_TOKEN_PATH = join(CONFIG_DIR, "user-token.json");
45
46
  async function ensureConfigDir() {
46
47
  if (!existsSync(CONFIG_DIR)) {
47
- await mkdir(CONFIG_DIR, { recursive: true });
48
+ await mkdir(CONFIG_DIR, { recursive: true, ...IS_UNIX && { mode: 448 } });
48
49
  }
49
50
  }
50
51
  function getCredentialsFromEnv() {
@@ -84,7 +85,11 @@ async function saveCredentials(creds, profile = "default") {
84
85
  profiles = JSON.parse(raw);
85
86
  }
86
87
  profiles[profile] = creds;
87
- await writeFile(CREDENTIALS_PATH, JSON.stringify(profiles, null, 2), "utf-8");
88
+ await writeFile(
89
+ CREDENTIALS_PATH,
90
+ JSON.stringify(profiles, null, 2),
91
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
92
+ );
88
93
  }
89
94
  async function loadToken(profile = "default") {
90
95
  if (!existsSync(TOKEN_PATH)) return null;
@@ -105,7 +110,11 @@ async function saveToken(token, profile = "default") {
105
110
  tokens = JSON.parse(raw);
106
111
  }
107
112
  tokens[profile] = token;
108
- await writeFile(TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
113
+ await writeFile(
114
+ TOKEN_PATH,
115
+ JSON.stringify(tokens, null, 2),
116
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
117
+ );
109
118
  }
110
119
  async function loadUserToken(profile = "default") {
111
120
  if (!existsSync(USER_TOKEN_PATH)) return null;
@@ -128,7 +137,11 @@ async function saveUserToken(token, profile = "default") {
128
137
  tokens = JSON.parse(raw);
129
138
  }
130
139
  tokens[profile] = token;
131
- await writeFile(USER_TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
140
+ await writeFile(
141
+ USER_TOKEN_PATH,
142
+ JSON.stringify(tokens, null, 2),
143
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
144
+ );
132
145
  }
133
146
  async function clearCredentials(profile = "default") {
134
147
  if (existsSync(CREDENTIALS_PATH)) {
@@ -138,25 +151,33 @@ async function clearCredentials(profile = "default") {
138
151
  await writeFile(
139
152
  CREDENTIALS_PATH,
140
153
  JSON.stringify(profiles, null, 2),
141
- "utf-8"
154
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
142
155
  );
143
156
  }
144
157
  if (existsSync(TOKEN_PATH)) {
145
158
  const raw = await readFile(TOKEN_PATH, "utf-8");
146
159
  const tokens = JSON.parse(raw);
147
160
  delete tokens[profile];
148
- await writeFile(TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
161
+ await writeFile(
162
+ TOKEN_PATH,
163
+ JSON.stringify(tokens, null, 2),
164
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
165
+ );
149
166
  }
150
167
  if (existsSync(USER_TOKEN_PATH)) {
151
168
  const raw = await readFile(USER_TOKEN_PATH, "utf-8");
152
169
  const tokens = JSON.parse(raw);
153
170
  delete tokens[profile];
154
- await writeFile(USER_TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
171
+ await writeFile(
172
+ USER_TOKEN_PATH,
173
+ JSON.stringify(tokens, null, 2),
174
+ { encoding: "utf-8", ...IS_UNIX && { mode: 384 } }
175
+ );
155
176
  }
156
177
  }
157
178
 
158
179
  // src/auth/jwt.ts
159
- import { readFile as readFile2 } from "fs/promises";
180
+ import { readFile as readFile2, stat } from "fs/promises";
160
181
  import jwt from "jsonwebtoken";
161
182
  async function createJWT(creds) {
162
183
  if (!creds.serviceAccount || !creds.privateKeyPath) {
@@ -165,6 +186,15 @@ async function createJWT(creds) {
165
186
  );
166
187
  }
167
188
  const privateKey = await readFile2(creds.privateKeyPath, "utf-8");
189
+ if (process.platform !== "win32") {
190
+ const fileStat = await stat(creds.privateKeyPath);
191
+ const mode = fileStat.mode & 511;
192
+ if (mode & 63) {
193
+ console.error(
194
+ `[nworks] Warning: Private key file has permissions ${mode.toString(8)}. Recommended: 600`
195
+ );
196
+ }
197
+ }
168
198
  const now = Math.floor(Date.now() / 1e3);
169
199
  const payload = {
170
200
  iss: creds.clientId,
@@ -201,7 +231,8 @@ async function refreshToken(profile = "default") {
201
231
  });
202
232
  if (!res.ok) {
203
233
  const text = await res.text();
204
- throw new AuthError(`Token exchange failed (${res.status}): ${text}`);
234
+ const truncated = text.length > 200 ? text.substring(0, 200) + "..." : text;
235
+ throw new AuthError(`Token exchange failed (${res.status}): ${truncated}`);
205
236
  }
206
237
  const data = await res.json();
207
238
  const expiresIn = Number(data.expires_in);
@@ -215,7 +246,8 @@ async function refreshToken(profile = "default") {
215
246
 
216
247
  // src/auth/oauth-user.ts
217
248
  import { createServer } from "http";
218
- import { URL } from "url";
249
+ import { randomBytes } from "crypto";
250
+ import { URL as URL2 } from "url";
219
251
  var AUTH_URL2 = "https://auth.worksmobile.com/oauth2/v2.0/authorize";
220
252
  var TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
221
253
  var REDIRECT_PORT = 9876;
@@ -230,19 +262,19 @@ function buildAuthorizeUrl(clientId, scope, state) {
230
262
  });
231
263
  return `${AUTH_URL2}?${params.toString()}`;
232
264
  }
233
- async function startUserOAuthFlow(_scope, profile = "default") {
265
+ async function startUserOAuthFlow(_scope, profile = "default", expectedState) {
234
266
  const creds = await loadCredentials(profile);
235
- const code = await waitForAuthCode();
267
+ const code = await waitForAuthCode(expectedState ?? randomBytes(16).toString("hex"));
236
268
  return exchangeCodeForToken(code, creds.clientId, creds.clientSecret);
237
269
  }
238
- function waitForAuthCode() {
239
- return new Promise((resolve, reject) => {
270
+ function waitForAuthCode(expectedState) {
271
+ return new Promise((resolve2, reject) => {
240
272
  const timeout = setTimeout(() => {
241
273
  server.close();
242
274
  reject(new AuthError("OAuth login timed out (120s). Try again."));
243
275
  }, 12e4);
244
276
  const server = createServer((req, res) => {
245
- const url = new URL(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
277
+ const url = new URL2(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
246
278
  if (url.pathname !== "/callback") {
247
279
  res.writeHead(404);
248
280
  res.end("Not found");
@@ -250,6 +282,15 @@ function waitForAuthCode() {
250
282
  }
251
283
  const code = url.searchParams.get("code");
252
284
  const error = url.searchParams.get("error");
285
+ const state = url.searchParams.get("state");
286
+ if (state !== expectedState) {
287
+ res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
288
+ res.end("<h2>\uBCF4\uC548 \uC624\uB958</h2><p>state \uBD88\uC77C\uCE58. \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.</p>");
289
+ clearTimeout(timeout);
290
+ server.close();
291
+ reject(new AuthError("OAuth state mismatch \u2014 possible CSRF attack."));
292
+ return;
293
+ }
253
294
  if (error) {
254
295
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
255
296
  res.end("<h2>\uB85C\uADF8\uC778 \uC2E4\uD328</h2><p>\uC774 \uCC3D\uC744 \uB2EB\uC544\uB3C4 \uB429\uB2C8\uB2E4.</p>");
@@ -270,9 +311,9 @@ function waitForAuthCode() {
270
311
  res.end("<h2>\uB85C\uADF8\uC778 \uC131\uACF5!</h2><p>\uC774 \uCC3D\uC744 \uB2EB\uACE0 \uD130\uBBF8\uB110\uB85C \uB3CC\uC544\uAC00\uC138\uC694.</p>");
271
312
  clearTimeout(timeout);
272
313
  server.close();
273
- resolve(code);
314
+ resolve2(code);
274
315
  });
275
- server.listen(REDIRECT_PORT, () => {
316
+ server.listen(REDIRECT_PORT, "127.0.0.1", () => {
276
317
  });
277
318
  server.on("error", (err) => {
278
319
  clearTimeout(timeout);
@@ -295,7 +336,8 @@ async function exchangeCodeForToken(code, clientId, clientSecret) {
295
336
  });
296
337
  if (!res.ok) {
297
338
  const text = await res.text();
298
- throw new AuthError(`Token exchange failed (${res.status}): ${text}`);
339
+ const truncated = text.length > 200 ? text.substring(0, 200) + "..." : text;
340
+ throw new AuthError(`Token exchange failed (${res.status}): ${truncated}`);
299
341
  }
300
342
  const data = await res.json();
301
343
  return {
@@ -320,7 +362,8 @@ async function refreshUserToken(refreshToken2, profile = "default") {
320
362
  });
321
363
  if (!res.ok) {
322
364
  const text = await res.text();
323
- throw new AuthError(`Token refresh failed (${res.status}): ${text}`);
365
+ const truncated = text.length > 200 ? text.substring(0, 200) + "..." : text;
366
+ throw new AuthError(`Token refresh failed (${res.status}): ${truncated}`);
324
367
  }
325
368
  const data = await res.json();
326
369
  return {
@@ -330,11 +373,28 @@ async function refreshUserToken(refreshToken2, profile = "default") {
330
373
  scope: data.scope
331
374
  };
332
375
  }
333
- function startOAuthCallbackServer(clientId, clientSecret) {
334
- return waitForAuthCode().then(
376
+ function startOAuthCallbackServer(clientId, clientSecret, expectedState) {
377
+ return waitForAuthCode(expectedState).then(
335
378
  (code) => exchangeCodeForToken(code, clientId, clientSecret)
336
379
  );
337
380
  }
381
+ async function revokeToken(token, clientId, clientSecret) {
382
+ try {
383
+ const res = await fetch("https://auth.worksmobile.com/oauth2/v2.0/revoke", {
384
+ method: "POST",
385
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
386
+ body: new URLSearchParams({
387
+ token,
388
+ client_id: clientId,
389
+ client_secret: clientSecret
390
+ }).toString()
391
+ });
392
+ if (!res.ok && process.env["NWORKS_VERBOSE"] === "1") {
393
+ console.error(`[nworks] Token revoke returned ${res.status}`);
394
+ }
395
+ } catch {
396
+ }
397
+ }
338
398
 
339
399
  // src/output/format.ts
340
400
  function output(data, opts = {}) {
@@ -409,7 +469,7 @@ function errorOutput(error, opts = {}) {
409
469
  }
410
470
 
411
471
  // src/commands/login.ts
412
- import { randomBytes } from "crypto";
472
+ import { randomBytes as randomBytes2 } from "crypto";
413
473
  async function prompt(question) {
414
474
  const rl = createInterface({
415
475
  input: process.stdin,
@@ -460,7 +520,7 @@ async function handleUserLogin(scope, profile, opts) {
460
520
  } catch {
461
521
  await saveCredentials({ clientId, clientSecret }, profile);
462
522
  }
463
- const state = randomBytes(16).toString("hex");
523
+ const state = randomBytes2(16).toString("hex");
464
524
  const authorizeUrl = buildAuthorizeUrl(clientId, scope, state);
465
525
  console.error(`
466
526
  Opening browser for NAVER WORKS login...`);
@@ -475,7 +535,7 @@ Opening browser for NAVER WORKS login...`);
475
535
  } else {
476
536
  exec(`${openCmd} "${authorizeUrl}"`);
477
537
  }
478
- const tokenData = await startUserOAuthFlow(scope, profile);
538
+ const tokenData = await startUserOAuthFlow(scope, profile, state);
479
539
  await saveUserToken(tokenData, profile);
480
540
  output(
481
541
  {
@@ -652,6 +712,18 @@ function cliError(err, opts = {}, area) {
652
712
  var logoutCommand = new Command2("logout").description("Remove stored credentials and tokens").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
653
713
  try {
654
714
  const profile = opts.profile;
715
+ try {
716
+ const creds = await loadCredentials(profile);
717
+ const token = await loadToken(profile);
718
+ const userToken = await loadUserToken(profile);
719
+ if (token?.accessToken) {
720
+ await revokeToken(token.accessToken, creds.clientId, creds.clientSecret);
721
+ }
722
+ if (userToken?.refreshToken) {
723
+ await revokeToken(userToken.refreshToken, creds.clientId, creds.clientSecret);
724
+ }
725
+ } catch {
726
+ }
655
727
  await clearCredentials(profile);
656
728
  output({ success: true, message: `Logged out (profile: ${profile})` }, opts);
657
729
  } catch (err) {
@@ -696,7 +768,7 @@ import { Command as Command4 } from "commander";
696
768
  var BASE_URL = "https://www.worksapis.com/v1.0";
697
769
  var MAX_RETRIES = 3;
698
770
  function sleep(ms) {
699
- return new Promise((resolve) => setTimeout(resolve, ms));
771
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
700
772
  }
701
773
  async function request(opts, _retryCount = 0) {
702
774
  const { method, path, body, profile = "default" } = opts;
@@ -718,7 +790,9 @@ async function request(opts, _retryCount = 0) {
718
790
  return request(opts, _retryCount + 1);
719
791
  }
720
792
  if (res.status === 429 && _retryCount < MAX_RETRIES) {
721
- const retryAfter = parseInt(res.headers.get("Retry-After") ?? "5", 10);
793
+ const MAX_RETRY_AFTER = 60;
794
+ const rawRetry = parseInt(res.headers.get("Retry-After") ?? "5", 10);
795
+ const retryAfter = Math.min(Number.isNaN(rawRetry) ? 5 : rawRetry, MAX_RETRY_AFTER);
722
796
  await sleep(retryAfter * 1e3);
723
797
  return request(opts, _retryCount + 1);
724
798
  }
@@ -743,6 +817,58 @@ async function request(opts, _retryCount = 0) {
743
817
  return JSON.parse(text);
744
818
  }
745
819
 
820
+ // src/utils/sanitize.ts
821
+ import { basename, resolve, sep } from "path";
822
+ import { randomBytes as randomBytes3 } from "crypto";
823
+ function sanitizePathSegment(value) {
824
+ if (!value || typeof value !== "string") {
825
+ throw new Error("Path segment must be a non-empty string");
826
+ }
827
+ if (value === "me") return value;
828
+ if (/[/\\]/.test(value) || value.includes("..")) {
829
+ throw new Error(`Invalid path segment: "${value}"`);
830
+ }
831
+ return encodeURIComponent(value);
832
+ }
833
+ function sanitizeFileName(name) {
834
+ if (!name || typeof name !== "string") {
835
+ throw new Error("File name must be a non-empty string");
836
+ }
837
+ const base = basename(name);
838
+ return base.replace(/[\r\n"\\]/g, "_");
839
+ }
840
+ function validateLocalPath(filePath, allowedBase) {
841
+ const resolved = resolve(filePath);
842
+ if (allowedBase) {
843
+ const resolvedBase = resolve(allowedBase);
844
+ if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + sep)) {
845
+ throw new Error(`Path "${filePath}" escapes the allowed directory "${allowedBase}"`);
846
+ }
847
+ }
848
+ return resolved;
849
+ }
850
+ function validateRedirectUrl(location, allowedHosts) {
851
+ let parsed;
852
+ try {
853
+ parsed = new URL(location);
854
+ } catch {
855
+ throw new Error(`Invalid redirect URL: "${location}"`);
856
+ }
857
+ if (parsed.protocol !== "https:") {
858
+ throw new Error(`Redirect URL must use HTTPS: "${location}"`);
859
+ }
860
+ const isAllowed = allowedHosts.some(
861
+ (host) => parsed.hostname === host || parsed.hostname.endsWith("." + host)
862
+ );
863
+ if (!isAllowed) {
864
+ throw new Error(`Redirect to untrusted host: "${parsed.hostname}"`);
865
+ }
866
+ return location;
867
+ }
868
+ function generateSecureState() {
869
+ return randomBytes3(32).toString("hex");
870
+ }
871
+
746
872
  // src/api/message.ts
747
873
  function buildContent(opts) {
748
874
  const type = opts.type ?? "text";
@@ -750,7 +876,11 @@ function buildContent(opts) {
750
876
  return { type: "text", text: opts.text };
751
877
  }
752
878
  if (type === "button") {
753
- const actions = opts.actions ? JSON.parse(opts.actions) : [];
879
+ const actions = opts.actions ? (() => {
880
+ const parsed = JSON.parse(opts.actions);
881
+ if (!Array.isArray(parsed)) throw new Error("actions must be a JSON array");
882
+ return parsed;
883
+ })() : [];
754
884
  return {
755
885
  type: "button_template",
756
886
  contentText: opts.text,
@@ -758,7 +888,11 @@ function buildContent(opts) {
758
888
  };
759
889
  }
760
890
  if (type === "list") {
761
- const elements = opts.elements ? JSON.parse(opts.elements) : [];
891
+ const elements = opts.elements ? (() => {
892
+ const parsed = JSON.parse(opts.elements);
893
+ if (!Array.isArray(parsed)) throw new Error("elements must be a JSON array");
894
+ return parsed;
895
+ })() : [];
762
896
  return {
763
897
  type: "list_template",
764
898
  coverData: { text: opts.text },
@@ -780,7 +914,7 @@ async function send(opts) {
780
914
  if (opts.to) {
781
915
  const result = await request({
782
916
  method: "POST",
783
- path: `/bots/${creds.botId}/users/${opts.to}/messages`,
917
+ path: `/bots/${sanitizePathSegment(creds.botId)}/users/${sanitizePathSegment(opts.to)}/messages`,
784
918
  body,
785
919
  profile
786
920
  });
@@ -789,7 +923,7 @@ async function send(opts) {
789
923
  if (opts.channel) {
790
924
  const result = await request({
791
925
  method: "POST",
792
- path: `/bots/${creds.botId}/channels/${opts.channel}/messages`,
926
+ path: `/bots/${sanitizePathSegment(creds.botId)}/channels/${sanitizePathSegment(opts.channel)}/messages`,
793
927
  body,
794
928
  profile
795
929
  });
@@ -806,7 +940,7 @@ async function listMembers(channelId, profile = "default") {
806
940
  }
807
941
  const result = await request({
808
942
  method: "GET",
809
- path: `/bots/${creds.botId}/channels/${channelId}/members`,
943
+ path: `/bots/${sanitizePathSegment(creds.botId)}/channels/${sanitizePathSegment(channelId)}/members`,
810
944
  profile
811
945
  });
812
946
  return { members: result.members ?? [], responseMetaData: result.responseMetaData };
@@ -940,7 +1074,7 @@ function normalizeDateTime(dt) {
940
1074
  async function listEvents(fromDateTime, untilDateTime, userId = "me", profile = "default") {
941
1075
  const from = encodeURIComponent(fromDateTime);
942
1076
  const until = encodeURIComponent(untilDateTime);
943
- const url = `${BASE_URL2}/users/${userId}/calendar/events?fromDateTime=${from}&untilDateTime=${until}`;
1077
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events?fromDateTime=${from}&untilDateTime=${until}`;
944
1078
  if (process.env["NWORKS_VERBOSE"] === "1") {
945
1079
  console.error(`[nworks] GET ${url}`);
946
1080
  }
@@ -977,10 +1111,10 @@ async function createEvent(opts) {
977
1111
  eventComponents: [eventComponent],
978
1112
  sendNotification: opts.sendNotification ?? false
979
1113
  };
980
- const url = `${BASE_URL2}/users/${userId}/calendar/events`;
1114
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events`;
981
1115
  if (process.env["NWORKS_VERBOSE"] === "1") {
982
1116
  console.error(`[nworks] POST ${url}`);
983
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1117
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
984
1118
  }
985
1119
  const res = await authedFetch(
986
1120
  url,
@@ -998,7 +1132,7 @@ async function createEvent(opts) {
998
1132
  return await res.json();
999
1133
  }
1000
1134
  async function getEvent(eventId, userId = "me", profile = "default") {
1001
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${eventId}`;
1135
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(eventId)}`;
1002
1136
  if (process.env["NWORKS_VERBOSE"] === "1") {
1003
1137
  console.error(`[nworks] GET ${url}`);
1004
1138
  }
@@ -1041,10 +1175,10 @@ async function updateEvent(opts) {
1041
1175
  eventComponents: [eventComponent],
1042
1176
  sendNotification: opts.sendNotification ?? false
1043
1177
  };
1044
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${opts.eventId}`;
1178
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(opts.eventId)}`;
1045
1179
  if (process.env["NWORKS_VERBOSE"] === "1") {
1046
1180
  console.error(`[nworks] PUT ${url}`);
1047
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1181
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1048
1182
  }
1049
1183
  const res = await authedFetch(
1050
1184
  url,
@@ -1060,7 +1194,7 @@ async function updateEvent(opts) {
1060
1194
  async function deleteEvent(eventId, userId = "me", sendNotification = false, profile = "default") {
1061
1195
  const params = new URLSearchParams();
1062
1196
  params.set("sendNotification", String(sendNotification));
1063
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${eventId}?${params.toString()}`;
1197
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(eventId)}?${params.toString()}`;
1064
1198
  if (process.env["NWORKS_VERBOSE"] === "1") {
1065
1199
  console.error(`[nworks] DELETE ${url}`);
1066
1200
  }
@@ -1186,9 +1320,15 @@ import { join as join2 } from "path";
1186
1320
  import { Command as Command7 } from "commander";
1187
1321
 
1188
1322
  // src/api/drive.ts
1189
- import { readFile as readFile4, stat } from "fs/promises";
1190
- import { basename } from "path";
1323
+ import { readFile as readFile4, stat as stat2 } from "fs/promises";
1324
+ import { basename as basename2 } from "path";
1191
1325
  var BASE_URL3 = "https://www.worksapis.com/v1.0";
1326
+ var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
1327
+ var ALLOWED_HOSTS = [
1328
+ "storage.worksmobile.com",
1329
+ "www.worksapis.com",
1330
+ "worksapis.com"
1331
+ ];
1192
1332
  async function authedFetch2(url, init, profile) {
1193
1333
  const token = await getValidUserToken(profile);
1194
1334
  const headers = new Headers(init.headers);
@@ -1210,8 +1350,8 @@ async function handleError2(res) {
1210
1350
  throw new ApiError(code, description, res.status);
1211
1351
  }
1212
1352
  async function listFiles(userId = "me", folderId, count = 20, cursor, profile = "default") {
1213
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1214
- const path = folderId ? `${base}/${folderId}/children` : base;
1353
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
1354
+ const path = folderId ? `${base}/${sanitizePathSegment(folderId)}/children` : base;
1215
1355
  const params = new URLSearchParams();
1216
1356
  params.set("count", String(count));
1217
1357
  if (cursor) params.set("cursor", cursor);
@@ -1225,11 +1365,15 @@ async function listFiles(userId = "me", folderId, count = 20, cursor, profile =
1225
1365
  return { files: data.files ?? [], responseMetaData: data.responseMetaData };
1226
1366
  }
1227
1367
  async function uploadFile(localPath, userId = "me", folderId, overwrite = false, profile = "default") {
1228
- const fileName = basename(localPath);
1229
- const fileStat = await stat(localPath);
1368
+ const fileName = basename2(localPath);
1369
+ const safeName = sanitizeFileName(fileName);
1370
+ const fileStat = await stat2(localPath);
1230
1371
  const fileSize = fileStat.size;
1231
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1232
- const createUrl = folderId ? `${base}/${folderId}` : base;
1372
+ if (fileSize > MAX_UPLOAD_SIZE) {
1373
+ throw new ApiError("FILE_TOO_LARGE", `File size (${fileSize} bytes) exceeds maximum allowed (${MAX_UPLOAD_SIZE} bytes)`, 413);
1374
+ }
1375
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
1376
+ const createUrl = folderId ? `${base}/${sanitizePathSegment(folderId)}` : base;
1233
1377
  if (process.env["NWORKS_VERBOSE"] === "1") {
1234
1378
  console.error(`[nworks] POST ${createUrl} (create upload URL)`);
1235
1379
  }
@@ -1244,11 +1388,12 @@ async function uploadFile(localPath, userId = "me", folderId, overwrite = false,
1244
1388
  );
1245
1389
  if (!createRes.ok) return handleError2(createRes);
1246
1390
  const { uploadUrl } = await createRes.json();
1391
+ validateRedirectUrl(uploadUrl, ALLOWED_HOSTS);
1247
1392
  const fileBuffer = await readFile4(localPath);
1248
1393
  const boundary = `----nworks${Date.now()}`;
1249
1394
  const header = Buffer.from(
1250
1395
  `--${boundary}\r
1251
- Content-Disposition: form-data; name="Filedata"; filename="${fileName}"\r
1396
+ Content-Disposition: form-data; name="Filedata"; filename="${safeName}"\r
1252
1397
  Content-Type: application/octet-stream\r
1253
1398
  \r
1254
1399
  `
@@ -1274,8 +1419,11 @@ Content-Type: application/octet-stream\r
1274
1419
  }
1275
1420
  async function uploadBuffer(fileBuffer, fileName, userId = "me", folderId, overwrite = false, profile = "default") {
1276
1421
  const fileSize = fileBuffer.length;
1277
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1278
- const createUrl = folderId ? `${base}/${folderId}` : base;
1422
+ if (fileSize > MAX_UPLOAD_SIZE) {
1423
+ throw new ApiError("FILE_TOO_LARGE", `File size (${fileSize} bytes) exceeds maximum allowed (${MAX_UPLOAD_SIZE} bytes)`, 413);
1424
+ }
1425
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
1426
+ const createUrl = folderId ? `${base}/${sanitizePathSegment(folderId)}` : base;
1279
1427
  if (process.env["NWORKS_VERBOSE"] === "1") {
1280
1428
  console.error(`[nworks] POST ${createUrl} (create upload URL for buffer)`);
1281
1429
  }
@@ -1290,10 +1438,11 @@ async function uploadBuffer(fileBuffer, fileName, userId = "me", folderId, overw
1290
1438
  );
1291
1439
  if (!createRes.ok) return handleError2(createRes);
1292
1440
  const { uploadUrl } = await createRes.json();
1441
+ validateRedirectUrl(uploadUrl, ALLOWED_HOSTS);
1293
1442
  const boundary = `----nworks${Date.now()}`;
1294
1443
  const header = Buffer.from(
1295
1444
  `--${boundary}\r
1296
- Content-Disposition: form-data; name="Filedata"; filename="${fileName}"\r
1445
+ Content-Disposition: form-data; name="Filedata"; filename="${sanitizeFileName(fileName)}"\r
1297
1446
  Content-Type: application/octet-stream\r
1298
1447
  \r
1299
1448
  `
@@ -1318,7 +1467,7 @@ Content-Type: application/octet-stream\r
1318
1467
  return await uploadRes.json();
1319
1468
  }
1320
1469
  async function downloadFile(fileId, userId = "me", profile = "default") {
1321
- const url = `${BASE_URL3}/users/${userId}/drive/files/${fileId}/download`;
1470
+ const url = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files/${sanitizePathSegment(fileId)}/download`;
1322
1471
  if (process.env["NWORKS_VERBOSE"] === "1") {
1323
1472
  console.error(`[nworks] GET ${url} (get download URL)`);
1324
1473
  }
@@ -1335,10 +1484,11 @@ async function downloadFile(fileId, userId = "me", profile = "default") {
1335
1484
  if (!redirectRes.ok) return handleError2(redirectRes);
1336
1485
  throw new ApiError("NO_REDIRECT", "No download URL returned", redirectRes.status);
1337
1486
  }
1487
+ const safeLocation = validateRedirectUrl(location, ALLOWED_HOSTS);
1338
1488
  if (process.env["NWORKS_VERBOSE"] === "1") {
1339
- console.error(`[nworks] GET ${location} (download content)`);
1489
+ console.error(`[nworks] GET ${safeLocation} (download content)`);
1340
1490
  }
1341
- const downloadRes = await authedFetch2(location, { method: "GET" }, profile);
1491
+ const downloadRes = await fetch(safeLocation, { method: "GET" });
1342
1492
  if (!downloadRes.ok) return handleError2(downloadRes);
1343
1493
  const arrayBuffer = await downloadRes.arrayBuffer();
1344
1494
  const buffer = Buffer.from(arrayBuffer);
@@ -1475,7 +1625,7 @@ async function handleError3(res) {
1475
1625
  async function sendMail(opts) {
1476
1626
  const userId = opts.userId ?? "me";
1477
1627
  const profile = opts.profile ?? "default";
1478
- const url = `${BASE_URL4}/users/${userId}/mail`;
1628
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail`;
1479
1629
  if (process.env["NWORKS_VERBOSE"] === "1") {
1480
1630
  console.error(`[nworks] POST ${url}`);
1481
1631
  }
@@ -1504,7 +1654,7 @@ async function listMails(folderId = 0, userId = "me", count = 30, cursor, isUnre
1504
1654
  params.set("count", String(count));
1505
1655
  if (cursor) params.set("cursor", cursor);
1506
1656
  if (isUnread) params.set("isUnread", "true");
1507
- const url = `${BASE_URL4}/users/${userId}/mail/mailfolders/${folderId}/children?${params.toString()}`;
1657
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail/mailfolders/${sanitizePathSegment(String(folderId))}/children?${params.toString()}`;
1508
1658
  if (process.env["NWORKS_VERBOSE"] === "1") {
1509
1659
  console.error(`[nworks] GET ${url}`);
1510
1660
  }
@@ -1520,7 +1670,7 @@ async function listMails(folderId = 0, userId = "me", count = 30, cursor, isUnre
1520
1670
  };
1521
1671
  }
1522
1672
  async function readMail(mailId, userId = "me", profile = "default") {
1523
- const url = `${BASE_URL4}/users/${userId}/mail/${mailId}`;
1673
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail/${sanitizePathSegment(String(mailId))}`;
1524
1674
  if (process.env["NWORKS_VERBOSE"] === "1") {
1525
1675
  console.error(`[nworks] GET ${url}`);
1526
1676
  }
@@ -1644,7 +1794,7 @@ async function handleError4(res) {
1644
1794
  }
1645
1795
  async function resolveUserId(userId, profile) {
1646
1796
  if (userId !== "me") return userId;
1647
- const url = `${BASE_URL5}/users/me`;
1797
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}`;
1648
1798
  const res = await authedFetch4(url, { method: "GET" }, profile);
1649
1799
  if (!res.ok) return handleError4(res);
1650
1800
  const data = await res.json();
@@ -1659,7 +1809,7 @@ async function listTasks(categoryId = "default", userId = "me", count = 50, curs
1659
1809
  params.set("count", String(count));
1660
1810
  params.set("status", status);
1661
1811
  if (cursor) params.set("cursor", cursor);
1662
- const url = `${BASE_URL5}/users/${userId}/tasks?${params.toString()}`;
1812
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}/tasks?${params.toString()}`;
1663
1813
  if (process.env["NWORKS_VERBOSE"] === "1") {
1664
1814
  console.error(`[nworks] GET ${url}`);
1665
1815
  }
@@ -1683,10 +1833,10 @@ async function createTask(opts) {
1683
1833
  };
1684
1834
  if (opts.dueDate) body.dueDate = opts.dueDate;
1685
1835
  if (opts.categoryId) body.categoryId = opts.categoryId;
1686
- const url = `${BASE_URL5}/users/${userId}/tasks`;
1836
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}/tasks`;
1687
1837
  if (process.env["NWORKS_VERBOSE"] === "1") {
1688
1838
  console.error(`[nworks] POST ${url}`);
1689
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1839
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1690
1840
  }
1691
1841
  const res = await authedFetch4(
1692
1842
  url,
@@ -1709,7 +1859,7 @@ async function updateTask(opts) {
1709
1859
  if (opts.title !== void 0) body.title = opts.title;
1710
1860
  if (opts.content !== void 0) body.content = opts.content;
1711
1861
  if (opts.dueDate !== void 0) body.dueDate = opts.dueDate;
1712
- const url = `${BASE_URL5}/tasks/${opts.taskId}`;
1862
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(opts.taskId)}`;
1713
1863
  if (process.env["NWORKS_VERBOSE"] === "1") {
1714
1864
  console.error(`[nworks] PATCH ${url}`);
1715
1865
  }
@@ -1726,7 +1876,7 @@ async function updateTask(opts) {
1726
1876
  return await res.json();
1727
1877
  }
1728
1878
  async function completeTask(taskId, profile = "default") {
1729
- const url = `${BASE_URL5}/tasks/${taskId}/complete`;
1879
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}/complete`;
1730
1880
  if (process.env["NWORKS_VERBOSE"] === "1") {
1731
1881
  console.error(`[nworks] POST ${url}`);
1732
1882
  }
@@ -1739,7 +1889,7 @@ async function completeTask(taskId, profile = "default") {
1739
1889
  if (!res.ok) return handleError4(res);
1740
1890
  }
1741
1891
  async function incompleteTask(taskId, profile = "default") {
1742
- const url = `${BASE_URL5}/tasks/${taskId}/incomplete`;
1892
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}/incomplete`;
1743
1893
  if (process.env["NWORKS_VERBOSE"] === "1") {
1744
1894
  console.error(`[nworks] POST ${url}`);
1745
1895
  }
@@ -1752,7 +1902,7 @@ async function incompleteTask(taskId, profile = "default") {
1752
1902
  if (!res.ok) return handleError4(res);
1753
1903
  }
1754
1904
  async function deleteTask(taskId, profile = "default") {
1755
- const url = `${BASE_URL5}/tasks/${taskId}`;
1905
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}`;
1756
1906
  if (process.env["NWORKS_VERBOSE"] === "1") {
1757
1907
  console.error(`[nworks] DELETE ${url}`);
1758
1908
  }
@@ -1924,7 +2074,7 @@ async function listBoards(count = 20, cursor, profile = "default") {
1924
2074
  if (!res.ok) return handleError5(res);
1925
2075
  const text = await res.text();
1926
2076
  if (process.env["NWORKS_VERBOSE"] === "1") {
1927
- console.error(`[nworks] Response: ${text}`);
2077
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1928
2078
  }
1929
2079
  const data = safeParseJson(text);
1930
2080
  return { boards: data.boards ?? [], responseMetaData: data.responseMetaData };
@@ -1933,7 +2083,7 @@ async function listPosts(boardId, count = 20, cursor, profile = "default") {
1933
2083
  const params = new URLSearchParams();
1934
2084
  params.set("count", String(count));
1935
2085
  if (cursor) params.set("cursor", cursor);
1936
- const url = `${BASE_URL6}/boards/${boardId}/posts?${params.toString()}`;
2086
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(boardId)}/posts?${params.toString()}`;
1937
2087
  if (process.env["NWORKS_VERBOSE"] === "1") {
1938
2088
  console.error(`[nworks] GET ${url}`);
1939
2089
  }
@@ -1941,13 +2091,13 @@ async function listPosts(boardId, count = 20, cursor, profile = "default") {
1941
2091
  if (!res.ok) return handleError5(res);
1942
2092
  const text = await res.text();
1943
2093
  if (process.env["NWORKS_VERBOSE"] === "1") {
1944
- console.error(`[nworks] Response: ${text}`);
2094
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1945
2095
  }
1946
2096
  const data = safeParseJson(text);
1947
2097
  return { posts: data.posts ?? [], responseMetaData: data.responseMetaData };
1948
2098
  }
1949
2099
  async function readPost(boardId, postId, profile = "default") {
1950
- const url = `${BASE_URL6}/boards/${boardId}/posts/${postId}`;
2100
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(boardId)}/posts/${sanitizePathSegment(postId)}`;
1951
2101
  if (process.env["NWORKS_VERBOSE"] === "1") {
1952
2102
  console.error(`[nworks] GET ${url}`);
1953
2103
  }
@@ -1955,7 +2105,7 @@ async function readPost(boardId, postId, profile = "default") {
1955
2105
  if (!res.ok) return handleError5(res);
1956
2106
  const text = await res.text();
1957
2107
  if (process.env["NWORKS_VERBOSE"] === "1") {
1958
- console.error(`[nworks] Response: ${text}`);
2108
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1959
2109
  }
1960
2110
  return safeParseJson(text);
1961
2111
  }
@@ -1967,10 +2117,10 @@ async function createPost(opts) {
1967
2117
  };
1968
2118
  if (opts.enableComment !== void 0) body.enableComment = opts.enableComment;
1969
2119
  if (opts.sendNotifications !== void 0) body.sendNotifications = opts.sendNotifications;
1970
- const url = `${BASE_URL6}/boards/${opts.boardId}/posts`;
2120
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(opts.boardId)}/posts`;
1971
2121
  if (process.env["NWORKS_VERBOSE"] === "1") {
1972
2122
  console.error(`[nworks] POST ${url}`);
1973
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
2123
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1974
2124
  }
1975
2125
  const res = await authedFetch5(
1976
2126
  url,
@@ -1984,7 +2134,7 @@ async function createPost(opts) {
1984
2134
  if (res.status === 201 || res.ok) {
1985
2135
  const text = await res.text();
1986
2136
  if (process.env["NWORKS_VERBOSE"] === "1") {
1987
- console.error(`[nworks] Response: ${text}`);
2137
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1988
2138
  }
1989
2139
  return safeParseJson(text);
1990
2140
  }
@@ -2280,7 +2430,7 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2280
2430
  success: true,
2281
2431
  message: "\uC778\uC99D \uC815\uBCF4\uAC00 \uC800\uC7A5\uB418\uC5C8\uC2B5\uB2C8\uB2E4.",
2282
2432
  nextSteps,
2283
- clientId,
2433
+ clientId: mask(clientId),
2284
2434
  clientSecret: `${mask(resolvedSecret)} (\uD658\uACBD\uBCC0\uC218)`,
2285
2435
  serviceAccount: serviceAccount ?? null,
2286
2436
  privateKeyPath: resolvedPrivateKeyPath ? `${mask(resolvedPrivateKeyPath)} (\uD658\uACBD\uBCC0\uC218)` : null,
@@ -2416,7 +2566,17 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2416
2566
  sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
2417
2567
  userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2418
2568
  },
2419
- async ({ summary, start, end, timeZone, description, location, attendees, sendNotification, userId }) => {
2569
+ async ({
2570
+ summary,
2571
+ start,
2572
+ end,
2573
+ timeZone,
2574
+ description,
2575
+ location,
2576
+ attendees,
2577
+ sendNotification,
2578
+ userId
2579
+ }) => {
2420
2580
  try {
2421
2581
  const result = await createEvent({
2422
2582
  summary,
@@ -2455,7 +2615,17 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2455
2615
  sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
2456
2616
  userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2457
2617
  },
2458
- async ({ eventId, summary, start, end, timeZone, description, location, sendNotification, userId }) => {
2618
+ async ({
2619
+ eventId,
2620
+ summary,
2621
+ start,
2622
+ end,
2623
+ timeZone,
2624
+ description,
2625
+ location,
2626
+ sendNotification,
2627
+ userId
2628
+ }) => {
2459
2629
  try {
2460
2630
  await updateEvent({
2461
2631
  eventId,
@@ -2568,11 +2738,12 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2568
2738
  overwrite ?? false
2569
2739
  );
2570
2740
  } else if (filePath) {
2741
+ const safePath = validateLocalPath(filePath);
2571
2742
  if (process.env["NWORKS_VERBOSE"] === "1") {
2572
- console.error(`[nworks] MCP upload: filePath=${filePath}`);
2743
+ console.error(`[nworks] MCP upload: filePath=${safePath}`);
2573
2744
  }
2574
2745
  result = await uploadFile(
2575
- filePath,
2746
+ safePath,
2576
2747
  userId ?? "me",
2577
2748
  folderId,
2578
2749
  overwrite ?? false
@@ -2587,10 +2758,11 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2587
2758
  content: [{ type: "text", text: JSON.stringify({ success: true, ...result }) }]
2588
2759
  };
2589
2760
  } catch (err) {
2590
- const error = err;
2591
- const detail = process.env["NWORKS_VERBOSE"] === "1" ? ` | stack: ${error.stack}` : "";
2761
+ if (process.env["NWORKS_VERBOSE"] === "1") {
2762
+ console.error(`[nworks] drive upload error: ${err.stack}`);
2763
+ }
2592
2764
  return {
2593
- content: [{ type: "text", text: `${mcpErrorHint(err, "drive.upload")}${detail}` }],
2765
+ content: [{ type: "text", text: mcpErrorHint(err, "drive.upload") }],
2594
2766
  isError: true
2595
2767
  };
2596
2768
  }
@@ -2615,7 +2787,10 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2615
2787
  if (outputDir) {
2616
2788
  const { writeFile: writeFile3 } = await import("fs/promises");
2617
2789
  const { join: join3 } = await import("path");
2618
- const outPath = join3(outputDir, fileName);
2790
+ const safeDir = validateLocalPath(outputDir);
2791
+ const safeName = sanitizeFileName(fileName);
2792
+ const outPath = join3(safeDir, safeName);
2793
+ validateLocalPath(outPath, safeDir);
2619
2794
  await writeFile3(outPath, result.buffer);
2620
2795
  return {
2621
2796
  content: [{ type: "text", text: JSON.stringify({ success: true, fileName, path: outPath, size: result.buffer.length }) }]
@@ -3036,9 +3211,9 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3036
3211
  const existingScopes = existingToken?.scope?.split(" ").filter(Boolean) ?? [];
3037
3212
  const requestedScopes = expandScopes((scope ?? DEFAULT_SCOPE).split(" ").filter(Boolean));
3038
3213
  const mergedScopes = [.../* @__PURE__ */ new Set([...existingScopes, ...requestedScopes])].join(" ");
3039
- const state = Math.random().toString(36).substring(2);
3214
+ const state = generateSecureState();
3040
3215
  const authorizeUrl = buildAuthorizeUrl(creds.clientId, mergedScopes, state);
3041
- startOAuthCallbackServer(creds.clientId, creds.clientSecret).then(
3216
+ startOAuthCallbackServer(creds.clientId, creds.clientSecret, state).then(
3042
3217
  (token) => saveUserToken({
3043
3218
  accessToken: token.accessToken,
3044
3219
  refreshToken: token.refreshToken,
@@ -3083,9 +3258,10 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3083
3258
  const userToken = await loadUserToken();
3084
3259
  const isValid = token ? token.expiresAt > Date.now() / 1e3 : false;
3085
3260
  const userTokenValid = userToken ? userToken.expiresAt > Date.now() / 1e3 : false;
3261
+ const mask = (s) => s.length <= 4 ? "****" : `****${s.slice(-4)}`;
3086
3262
  const info = {
3087
3263
  serviceAccount: creds.serviceAccount ?? null,
3088
- clientId: creds.clientId,
3264
+ clientId: mask(creds.clientId),
3089
3265
  botId: creds.botId ?? null,
3090
3266
  tokenValid: isValid,
3091
3267
  userOAuth: userToken ? { valid: userTokenValid, scope: userToken.scope } : null
@@ -3107,6 +3283,18 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3107
3283
  {},
3108
3284
  async () => {
3109
3285
  try {
3286
+ try {
3287
+ const creds = await loadCredentials();
3288
+ const token = await loadToken();
3289
+ const userToken = await loadUserToken();
3290
+ if (token?.accessToken) {
3291
+ await revokeToken(token.accessToken, creds.clientId, creds.clientSecret);
3292
+ }
3293
+ if (userToken?.refreshToken) {
3294
+ await revokeToken(userToken.refreshToken, creds.clientId, creds.clientSecret);
3295
+ }
3296
+ } catch {
3297
+ }
3110
3298
  await clearCredentials();
3111
3299
  return {
3112
3300
  content: [
@@ -3134,8 +3322,21 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3134
3322
  async () => {
3135
3323
  try {
3136
3324
  const results = await runChecks("default");
3325
+ const maskedResults = results.map((r) => {
3326
+ if (r.check === "credentials" && r.status === "OK") {
3327
+ return { ...r, detail: r.detail.replace(/clientId: .+/, "clientId: ****") };
3328
+ }
3329
+ if (r.check === "privateKey" && r.status === "OK") {
3330
+ return { ...r, detail: "OK (path hidden)" };
3331
+ }
3332
+ if (r.check === "serviceAccount" && r.status === "OK") {
3333
+ const masked = r.detail.length <= 4 ? "****" : `****${r.detail.slice(-4)}`;
3334
+ return { ...r, detail: masked };
3335
+ }
3336
+ return r;
3337
+ });
3137
3338
  return {
3138
- content: [{ type: "text", text: JSON.stringify(results) }]
3339
+ content: [{ type: "text", text: JSON.stringify(maskedResults) }]
3139
3340
  };
3140
3341
  } catch (err) {
3141
3342
  return {