nworks 1.2.0 → 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...`);
@@ -470,8 +530,12 @@ Opening browser for NAVER WORKS login...`);
470
530
  `);
471
531
  const { exec } = await import("child_process");
472
532
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
473
- exec(`${openCmd} "${authorizeUrl}"`);
474
- const tokenData = await startUserOAuthFlow(scope, profile);
533
+ if (process.platform === "win32") {
534
+ exec(`start "" "${authorizeUrl}"`);
535
+ } else {
536
+ exec(`${openCmd} "${authorizeUrl}"`);
537
+ }
538
+ const tokenData = await startUserOAuthFlow(scope, profile, state);
475
539
  await saveUserToken(tokenData, profile);
476
540
  output(
477
541
  {
@@ -569,7 +633,7 @@ var ERROR_HINTS_MCP = {
569
633
  FORBIDDEN: "\uAD8C\uD55C\uC774 \uBD80\uC871\uD569\uB2C8\uB2E4. Developer Console\uC5D0\uC11C OAuth Scope\uB97C \uD655\uC778\uD558\uC138\uC694.",
570
634
  ACCESS_DENIED: "\uC811\uADFC\uC774 \uAC70\uBD80\uB410\uC2B5\uB2C8\uB2E4. Admin\uC5D0\uC11C Bot\uC744 \uCD94\uAC00\uD588\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694.",
571
635
  SERVICE_ACCOUNT_NOT_ALLOWED: "\uC11C\uBE44\uC2A4 \uACC4\uC815\uC73C\uB85C\uB294 \uC774 API\uB97C \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. nworks_login_user tool\uB85C User OAuth \uB85C\uADF8\uC778\uC744 \uBA3C\uC800 \uD574\uC8FC\uC138\uC694.",
572
- UNAUTHORIZED: "\uC778\uC99D\uC774 \uB9CC\uB8CC\uB410\uC2B5\uB2C8\uB2E4. nworks_setup tool\uB85C \uC7AC\uC124\uC815\uD558\uC138\uC694."
636
+ UNAUTHORIZED: "\uC778\uC99D\uC774 \uB9CC\uB8CC\uB410\uC2B5\uB2C8\uB2E4. nworks_login_user tool\uB85C \uB2E4\uC2DC \uBE0C\uB77C\uC6B0\uC800 \uB85C\uADF8\uC778\uD558\uC138\uC694. \uADF8\uB798\uB3C4 \uC548 \uB418\uBA74 nworks_setup tool\uB85C \uC7AC\uC124\uC815 \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694."
573
637
  };
574
638
  function cliErrorHint(err, area) {
575
639
  if (err instanceof ApiError) {
@@ -601,7 +665,7 @@ function mcpErrorHint(err, area) {
601
665
  if (err instanceof AuthError) {
602
666
  return `Error: ${err.message}
603
667
 
604
- [\uC548\uB0B4] \uC778\uC99D \uC815\uBCF4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. nworks_setup tool\uB85C Client ID/Secret\uC744 \uBA3C\uC800 \uC124\uC815\uD574\uC8FC\uC138\uC694.`;
668
+ [\uC548\uB0B4] \uC778\uC99D \uC815\uBCF4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. nworks_setup tool\uC744 \uBA3C\uC800 \uD638\uCD9C\uD558\uC138\uC694 (Client ID \uD544\uC694, Client Secret\uC740 \uD658\uACBD\uBCC0\uC218 NWORKS_CLIENT_SECRET\uC5D0\uC11C \uC77D\uC74C). \uD658\uACBD\uBCC0\uC218\uAC00 \uC5C6\uC73C\uBA74 \uC0AC\uC6A9\uC790\uC5D0\uAC8C MCP \uC124\uC815 \uD30C\uC77C\uC758 env \uD544\uB4DC\uC5D0 NWORKS_CLIENT_SECRET \uCD94\uAC00\uB97C \uC548\uB0B4\uD558\uC138\uC694.`;
605
669
  }
606
670
  return `Error: ${err.message}`;
607
671
  }
@@ -648,6 +712,18 @@ function cliError(err, opts = {}, area) {
648
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) => {
649
713
  try {
650
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
+ }
651
727
  await clearCredentials(profile);
652
728
  output({ success: true, message: `Logged out (profile: ${profile})` }, opts);
653
729
  } catch (err) {
@@ -692,7 +768,7 @@ import { Command as Command4 } from "commander";
692
768
  var BASE_URL = "https://www.worksapis.com/v1.0";
693
769
  var MAX_RETRIES = 3;
694
770
  function sleep(ms) {
695
- return new Promise((resolve) => setTimeout(resolve, ms));
771
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
696
772
  }
697
773
  async function request(opts, _retryCount = 0) {
698
774
  const { method, path, body, profile = "default" } = opts;
@@ -714,7 +790,9 @@ async function request(opts, _retryCount = 0) {
714
790
  return request(opts, _retryCount + 1);
715
791
  }
716
792
  if (res.status === 429 && _retryCount < MAX_RETRIES) {
717
- 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);
718
796
  await sleep(retryAfter * 1e3);
719
797
  return request(opts, _retryCount + 1);
720
798
  }
@@ -739,6 +817,58 @@ async function request(opts, _retryCount = 0) {
739
817
  return JSON.parse(text);
740
818
  }
741
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
+
742
872
  // src/api/message.ts
743
873
  function buildContent(opts) {
744
874
  const type = opts.type ?? "text";
@@ -746,7 +876,11 @@ function buildContent(opts) {
746
876
  return { type: "text", text: opts.text };
747
877
  }
748
878
  if (type === "button") {
749
- 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
+ })() : [];
750
884
  return {
751
885
  type: "button_template",
752
886
  contentText: opts.text,
@@ -754,7 +888,11 @@ function buildContent(opts) {
754
888
  };
755
889
  }
756
890
  if (type === "list") {
757
- 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
+ })() : [];
758
896
  return {
759
897
  type: "list_template",
760
898
  coverData: { text: opts.text },
@@ -776,7 +914,7 @@ async function send(opts) {
776
914
  if (opts.to) {
777
915
  const result = await request({
778
916
  method: "POST",
779
- path: `/bots/${creds.botId}/users/${opts.to}/messages`,
917
+ path: `/bots/${sanitizePathSegment(creds.botId)}/users/${sanitizePathSegment(opts.to)}/messages`,
780
918
  body,
781
919
  profile
782
920
  });
@@ -785,7 +923,7 @@ async function send(opts) {
785
923
  if (opts.channel) {
786
924
  const result = await request({
787
925
  method: "POST",
788
- path: `/bots/${creds.botId}/channels/${opts.channel}/messages`,
926
+ path: `/bots/${sanitizePathSegment(creds.botId)}/channels/${sanitizePathSegment(opts.channel)}/messages`,
789
927
  body,
790
928
  profile
791
929
  });
@@ -802,7 +940,7 @@ async function listMembers(channelId, profile = "default") {
802
940
  }
803
941
  const result = await request({
804
942
  method: "GET",
805
- path: `/bots/${creds.botId}/channels/${channelId}/members`,
943
+ path: `/bots/${sanitizePathSegment(creds.botId)}/channels/${sanitizePathSegment(channelId)}/members`,
806
944
  profile
807
945
  });
808
946
  return { members: result.members ?? [], responseMetaData: result.responseMetaData };
@@ -936,7 +1074,7 @@ function normalizeDateTime(dt) {
936
1074
  async function listEvents(fromDateTime, untilDateTime, userId = "me", profile = "default") {
937
1075
  const from = encodeURIComponent(fromDateTime);
938
1076
  const until = encodeURIComponent(untilDateTime);
939
- 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}`;
940
1078
  if (process.env["NWORKS_VERBOSE"] === "1") {
941
1079
  console.error(`[nworks] GET ${url}`);
942
1080
  }
@@ -973,10 +1111,10 @@ async function createEvent(opts) {
973
1111
  eventComponents: [eventComponent],
974
1112
  sendNotification: opts.sendNotification ?? false
975
1113
  };
976
- const url = `${BASE_URL2}/users/${userId}/calendar/events`;
1114
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events`;
977
1115
  if (process.env["NWORKS_VERBOSE"] === "1") {
978
1116
  console.error(`[nworks] POST ${url}`);
979
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1117
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
980
1118
  }
981
1119
  const res = await authedFetch(
982
1120
  url,
@@ -994,7 +1132,7 @@ async function createEvent(opts) {
994
1132
  return await res.json();
995
1133
  }
996
1134
  async function getEvent(eventId, userId = "me", profile = "default") {
997
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${eventId}`;
1135
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(eventId)}`;
998
1136
  if (process.env["NWORKS_VERBOSE"] === "1") {
999
1137
  console.error(`[nworks] GET ${url}`);
1000
1138
  }
@@ -1037,10 +1175,10 @@ async function updateEvent(opts) {
1037
1175
  eventComponents: [eventComponent],
1038
1176
  sendNotification: opts.sendNotification ?? false
1039
1177
  };
1040
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${opts.eventId}`;
1178
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(opts.eventId)}`;
1041
1179
  if (process.env["NWORKS_VERBOSE"] === "1") {
1042
1180
  console.error(`[nworks] PUT ${url}`);
1043
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1181
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1044
1182
  }
1045
1183
  const res = await authedFetch(
1046
1184
  url,
@@ -1056,7 +1194,7 @@ async function updateEvent(opts) {
1056
1194
  async function deleteEvent(eventId, userId = "me", sendNotification = false, profile = "default") {
1057
1195
  const params = new URLSearchParams();
1058
1196
  params.set("sendNotification", String(sendNotification));
1059
- 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()}`;
1060
1198
  if (process.env["NWORKS_VERBOSE"] === "1") {
1061
1199
  console.error(`[nworks] DELETE ${url}`);
1062
1200
  }
@@ -1182,9 +1320,15 @@ import { join as join2 } from "path";
1182
1320
  import { Command as Command7 } from "commander";
1183
1321
 
1184
1322
  // src/api/drive.ts
1185
- import { readFile as readFile4, stat } from "fs/promises";
1186
- import { basename } from "path";
1323
+ import { readFile as readFile4, stat as stat2 } from "fs/promises";
1324
+ import { basename as basename2 } from "path";
1187
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
+ ];
1188
1332
  async function authedFetch2(url, init, profile) {
1189
1333
  const token = await getValidUserToken(profile);
1190
1334
  const headers = new Headers(init.headers);
@@ -1206,8 +1350,8 @@ async function handleError2(res) {
1206
1350
  throw new ApiError(code, description, res.status);
1207
1351
  }
1208
1352
  async function listFiles(userId = "me", folderId, count = 20, cursor, profile = "default") {
1209
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1210
- 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;
1211
1355
  const params = new URLSearchParams();
1212
1356
  params.set("count", String(count));
1213
1357
  if (cursor) params.set("cursor", cursor);
@@ -1221,11 +1365,15 @@ async function listFiles(userId = "me", folderId, count = 20, cursor, profile =
1221
1365
  return { files: data.files ?? [], responseMetaData: data.responseMetaData };
1222
1366
  }
1223
1367
  async function uploadFile(localPath, userId = "me", folderId, overwrite = false, profile = "default") {
1224
- const fileName = basename(localPath);
1225
- const fileStat = await stat(localPath);
1368
+ const fileName = basename2(localPath);
1369
+ const safeName = sanitizeFileName(fileName);
1370
+ const fileStat = await stat2(localPath);
1226
1371
  const fileSize = fileStat.size;
1227
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1228
- 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;
1229
1377
  if (process.env["NWORKS_VERBOSE"] === "1") {
1230
1378
  console.error(`[nworks] POST ${createUrl} (create upload URL)`);
1231
1379
  }
@@ -1240,11 +1388,12 @@ async function uploadFile(localPath, userId = "me", folderId, overwrite = false,
1240
1388
  );
1241
1389
  if (!createRes.ok) return handleError2(createRes);
1242
1390
  const { uploadUrl } = await createRes.json();
1391
+ validateRedirectUrl(uploadUrl, ALLOWED_HOSTS);
1243
1392
  const fileBuffer = await readFile4(localPath);
1244
1393
  const boundary = `----nworks${Date.now()}`;
1245
1394
  const header = Buffer.from(
1246
1395
  `--${boundary}\r
1247
- Content-Disposition: form-data; name="Filedata"; filename="${fileName}"\r
1396
+ Content-Disposition: form-data; name="Filedata"; filename="${safeName}"\r
1248
1397
  Content-Type: application/octet-stream\r
1249
1398
  \r
1250
1399
  `
@@ -1270,8 +1419,11 @@ Content-Type: application/octet-stream\r
1270
1419
  }
1271
1420
  async function uploadBuffer(fileBuffer, fileName, userId = "me", folderId, overwrite = false, profile = "default") {
1272
1421
  const fileSize = fileBuffer.length;
1273
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1274
- 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;
1275
1427
  if (process.env["NWORKS_VERBOSE"] === "1") {
1276
1428
  console.error(`[nworks] POST ${createUrl} (create upload URL for buffer)`);
1277
1429
  }
@@ -1286,10 +1438,11 @@ async function uploadBuffer(fileBuffer, fileName, userId = "me", folderId, overw
1286
1438
  );
1287
1439
  if (!createRes.ok) return handleError2(createRes);
1288
1440
  const { uploadUrl } = await createRes.json();
1441
+ validateRedirectUrl(uploadUrl, ALLOWED_HOSTS);
1289
1442
  const boundary = `----nworks${Date.now()}`;
1290
1443
  const header = Buffer.from(
1291
1444
  `--${boundary}\r
1292
- Content-Disposition: form-data; name="Filedata"; filename="${fileName}"\r
1445
+ Content-Disposition: form-data; name="Filedata"; filename="${sanitizeFileName(fileName)}"\r
1293
1446
  Content-Type: application/octet-stream\r
1294
1447
  \r
1295
1448
  `
@@ -1314,7 +1467,7 @@ Content-Type: application/octet-stream\r
1314
1467
  return await uploadRes.json();
1315
1468
  }
1316
1469
  async function downloadFile(fileId, userId = "me", profile = "default") {
1317
- const url = `${BASE_URL3}/users/${userId}/drive/files/${fileId}/download`;
1470
+ const url = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files/${sanitizePathSegment(fileId)}/download`;
1318
1471
  if (process.env["NWORKS_VERBOSE"] === "1") {
1319
1472
  console.error(`[nworks] GET ${url} (get download URL)`);
1320
1473
  }
@@ -1331,10 +1484,11 @@ async function downloadFile(fileId, userId = "me", profile = "default") {
1331
1484
  if (!redirectRes.ok) return handleError2(redirectRes);
1332
1485
  throw new ApiError("NO_REDIRECT", "No download URL returned", redirectRes.status);
1333
1486
  }
1487
+ const safeLocation = validateRedirectUrl(location, ALLOWED_HOSTS);
1334
1488
  if (process.env["NWORKS_VERBOSE"] === "1") {
1335
- console.error(`[nworks] GET ${location} (download content)`);
1489
+ console.error(`[nworks] GET ${safeLocation} (download content)`);
1336
1490
  }
1337
- const downloadRes = await authedFetch2(location, { method: "GET" }, profile);
1491
+ const downloadRes = await fetch(safeLocation, { method: "GET" });
1338
1492
  if (!downloadRes.ok) return handleError2(downloadRes);
1339
1493
  const arrayBuffer = await downloadRes.arrayBuffer();
1340
1494
  const buffer = Buffer.from(arrayBuffer);
@@ -1471,7 +1625,7 @@ async function handleError3(res) {
1471
1625
  async function sendMail(opts) {
1472
1626
  const userId = opts.userId ?? "me";
1473
1627
  const profile = opts.profile ?? "default";
1474
- const url = `${BASE_URL4}/users/${userId}/mail`;
1628
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail`;
1475
1629
  if (process.env["NWORKS_VERBOSE"] === "1") {
1476
1630
  console.error(`[nworks] POST ${url}`);
1477
1631
  }
@@ -1500,7 +1654,7 @@ async function listMails(folderId = 0, userId = "me", count = 30, cursor, isUnre
1500
1654
  params.set("count", String(count));
1501
1655
  if (cursor) params.set("cursor", cursor);
1502
1656
  if (isUnread) params.set("isUnread", "true");
1503
- 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()}`;
1504
1658
  if (process.env["NWORKS_VERBOSE"] === "1") {
1505
1659
  console.error(`[nworks] GET ${url}`);
1506
1660
  }
@@ -1516,7 +1670,7 @@ async function listMails(folderId = 0, userId = "me", count = 30, cursor, isUnre
1516
1670
  };
1517
1671
  }
1518
1672
  async function readMail(mailId, userId = "me", profile = "default") {
1519
- const url = `${BASE_URL4}/users/${userId}/mail/${mailId}`;
1673
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail/${sanitizePathSegment(String(mailId))}`;
1520
1674
  if (process.env["NWORKS_VERBOSE"] === "1") {
1521
1675
  console.error(`[nworks] GET ${url}`);
1522
1676
  }
@@ -1640,7 +1794,7 @@ async function handleError4(res) {
1640
1794
  }
1641
1795
  async function resolveUserId(userId, profile) {
1642
1796
  if (userId !== "me") return userId;
1643
- const url = `${BASE_URL5}/users/me`;
1797
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}`;
1644
1798
  const res = await authedFetch4(url, { method: "GET" }, profile);
1645
1799
  if (!res.ok) return handleError4(res);
1646
1800
  const data = await res.json();
@@ -1655,7 +1809,7 @@ async function listTasks(categoryId = "default", userId = "me", count = 50, curs
1655
1809
  params.set("count", String(count));
1656
1810
  params.set("status", status);
1657
1811
  if (cursor) params.set("cursor", cursor);
1658
- const url = `${BASE_URL5}/users/${userId}/tasks?${params.toString()}`;
1812
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}/tasks?${params.toString()}`;
1659
1813
  if (process.env["NWORKS_VERBOSE"] === "1") {
1660
1814
  console.error(`[nworks] GET ${url}`);
1661
1815
  }
@@ -1679,10 +1833,10 @@ async function createTask(opts) {
1679
1833
  };
1680
1834
  if (opts.dueDate) body.dueDate = opts.dueDate;
1681
1835
  if (opts.categoryId) body.categoryId = opts.categoryId;
1682
- const url = `${BASE_URL5}/users/${userId}/tasks`;
1836
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}/tasks`;
1683
1837
  if (process.env["NWORKS_VERBOSE"] === "1") {
1684
1838
  console.error(`[nworks] POST ${url}`);
1685
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1839
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1686
1840
  }
1687
1841
  const res = await authedFetch4(
1688
1842
  url,
@@ -1705,7 +1859,7 @@ async function updateTask(opts) {
1705
1859
  if (opts.title !== void 0) body.title = opts.title;
1706
1860
  if (opts.content !== void 0) body.content = opts.content;
1707
1861
  if (opts.dueDate !== void 0) body.dueDate = opts.dueDate;
1708
- const url = `${BASE_URL5}/tasks/${opts.taskId}`;
1862
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(opts.taskId)}`;
1709
1863
  if (process.env["NWORKS_VERBOSE"] === "1") {
1710
1864
  console.error(`[nworks] PATCH ${url}`);
1711
1865
  }
@@ -1722,7 +1876,7 @@ async function updateTask(opts) {
1722
1876
  return await res.json();
1723
1877
  }
1724
1878
  async function completeTask(taskId, profile = "default") {
1725
- const url = `${BASE_URL5}/tasks/${taskId}/complete`;
1879
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}/complete`;
1726
1880
  if (process.env["NWORKS_VERBOSE"] === "1") {
1727
1881
  console.error(`[nworks] POST ${url}`);
1728
1882
  }
@@ -1735,7 +1889,7 @@ async function completeTask(taskId, profile = "default") {
1735
1889
  if (!res.ok) return handleError4(res);
1736
1890
  }
1737
1891
  async function incompleteTask(taskId, profile = "default") {
1738
- const url = `${BASE_URL5}/tasks/${taskId}/incomplete`;
1892
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}/incomplete`;
1739
1893
  if (process.env["NWORKS_VERBOSE"] === "1") {
1740
1894
  console.error(`[nworks] POST ${url}`);
1741
1895
  }
@@ -1748,7 +1902,7 @@ async function incompleteTask(taskId, profile = "default") {
1748
1902
  if (!res.ok) return handleError4(res);
1749
1903
  }
1750
1904
  async function deleteTask(taskId, profile = "default") {
1751
- const url = `${BASE_URL5}/tasks/${taskId}`;
1905
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}`;
1752
1906
  if (process.env["NWORKS_VERBOSE"] === "1") {
1753
1907
  console.error(`[nworks] DELETE ${url}`);
1754
1908
  }
@@ -1920,7 +2074,7 @@ async function listBoards(count = 20, cursor, profile = "default") {
1920
2074
  if (!res.ok) return handleError5(res);
1921
2075
  const text = await res.text();
1922
2076
  if (process.env["NWORKS_VERBOSE"] === "1") {
1923
- console.error(`[nworks] Response: ${text}`);
2077
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1924
2078
  }
1925
2079
  const data = safeParseJson(text);
1926
2080
  return { boards: data.boards ?? [], responseMetaData: data.responseMetaData };
@@ -1929,7 +2083,7 @@ async function listPosts(boardId, count = 20, cursor, profile = "default") {
1929
2083
  const params = new URLSearchParams();
1930
2084
  params.set("count", String(count));
1931
2085
  if (cursor) params.set("cursor", cursor);
1932
- const url = `${BASE_URL6}/boards/${boardId}/posts?${params.toString()}`;
2086
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(boardId)}/posts?${params.toString()}`;
1933
2087
  if (process.env["NWORKS_VERBOSE"] === "1") {
1934
2088
  console.error(`[nworks] GET ${url}`);
1935
2089
  }
@@ -1937,13 +2091,13 @@ async function listPosts(boardId, count = 20, cursor, profile = "default") {
1937
2091
  if (!res.ok) return handleError5(res);
1938
2092
  const text = await res.text();
1939
2093
  if (process.env["NWORKS_VERBOSE"] === "1") {
1940
- console.error(`[nworks] Response: ${text}`);
2094
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1941
2095
  }
1942
2096
  const data = safeParseJson(text);
1943
2097
  return { posts: data.posts ?? [], responseMetaData: data.responseMetaData };
1944
2098
  }
1945
2099
  async function readPost(boardId, postId, profile = "default") {
1946
- const url = `${BASE_URL6}/boards/${boardId}/posts/${postId}`;
2100
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(boardId)}/posts/${sanitizePathSegment(postId)}`;
1947
2101
  if (process.env["NWORKS_VERBOSE"] === "1") {
1948
2102
  console.error(`[nworks] GET ${url}`);
1949
2103
  }
@@ -1951,7 +2105,7 @@ async function readPost(boardId, postId, profile = "default") {
1951
2105
  if (!res.ok) return handleError5(res);
1952
2106
  const text = await res.text();
1953
2107
  if (process.env["NWORKS_VERBOSE"] === "1") {
1954
- console.error(`[nworks] Response: ${text}`);
2108
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1955
2109
  }
1956
2110
  return safeParseJson(text);
1957
2111
  }
@@ -1963,10 +2117,10 @@ async function createPost(opts) {
1963
2117
  };
1964
2118
  if (opts.enableComment !== void 0) body.enableComment = opts.enableComment;
1965
2119
  if (opts.sendNotifications !== void 0) body.sendNotifications = opts.sendNotifications;
1966
- const url = `${BASE_URL6}/boards/${opts.boardId}/posts`;
2120
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(opts.boardId)}/posts`;
1967
2121
  if (process.env["NWORKS_VERBOSE"] === "1") {
1968
2122
  console.error(`[nworks] POST ${url}`);
1969
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
2123
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1970
2124
  }
1971
2125
  const res = await authedFetch5(
1972
2126
  url,
@@ -1980,7 +2134,7 @@ async function createPost(opts) {
1980
2134
  if (res.status === 201 || res.ok) {
1981
2135
  const text = await res.text();
1982
2136
  if (process.env["NWORKS_VERBOSE"] === "1") {
1983
- console.error(`[nworks] Response: ${text}`);
2137
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1984
2138
  }
1985
2139
  return safeParseJson(text);
1986
2140
  }
@@ -2103,7 +2257,7 @@ async function runChecks(profile) {
2103
2257
  creds = await loadCredentials(profile);
2104
2258
  results.push({ check: "credentials", status: "OK", detail: `clientId: ${creds.clientId}` });
2105
2259
  } catch {
2106
- results.push({ check: "credentials", status: "FAIL", detail: "\uC778\uC99D \uC815\uBCF4 \uC5C6\uC74C. `nworks login` \uB610\uB294 nworks_setup \uD544\uC694" });
2260
+ results.push({ check: "credentials", status: "FAIL", detail: "\uC778\uC99D \uC815\uBCF4 \uC5C6\uC74C. CLI: `nworks login --user` / MCP: nworks_setup tool \uC0AC\uC6A9 (\uD658\uACBD\uBCC0\uC218 NWORKS_CLIENT_SECRET \uD544\uC694)" });
2107
2261
  return results;
2108
2262
  }
2109
2263
  if (hasServiceAccountCreds(creds)) {
@@ -2205,7 +2359,26 @@ var doctorCommand = new Command11("doctor").description("Check nworks configurat
2205
2359
  function registerTools(server) {
2206
2360
  server.tool(
2207
2361
  "nworks_setup",
2208
- "NAVER WORKS API \uC778\uC99D \uC815\uBCF4\uB97C \uC124\uC815\uD569\uB2C8\uB2E4. \uBBFC\uAC10 \uC815\uBCF4(Client Secret, Private Key \uACBD\uB85C)\uB294 \uC774 tool\uC758 \uD30C\uB77C\uBBF8\uD130\uB85C \uBC1B\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4 \u2014 \uC0AC\uC6A9\uC790\uAC00 MCP \uC124\uC815 \uD30C\uC77C(\uC608: claude_desktop_config.json)\uC758 env \uD544\uB4DC\uC5D0 NWORKS_CLIENT_SECRET(\uD544\uC218)\uACFC NWORKS_PRIVATE_KEY_PATH(Service Account \uC0AC\uC6A9 \uC2DC)\uB97C \uBBF8\uB9AC \uC124\uC815\uD574\uC57C \uD569\uB2C8\uB2E4. Developer Console: https://dev.worksmobile.com. \uD658\uACBD\uBCC0\uC218\uAC00 \uC124\uC815\uB418\uC9C0 \uC54A\uC740 \uACBD\uC6B0, \uC0AC\uC6A9\uC790\uC5D0\uAC8C \uC124\uC815 \uBC29\uBC95\uC744 \uC548\uB0B4\uD558\uC138\uC694. \uBA54\uC2DC\uC9C0/\uAD6C\uC131\uC6D0\uC870\uD68C\uB294 Service Account \uC778\uC99D(serviceAccount, botId + \uD658\uACBD\uBCC0\uC218 NWORKS_PRIVATE_KEY_PATH \uD544\uC694). \uCE98\uB9B0\uB354/\uBA54\uC77C/\uD560\uC77C/\uB4DC\uB77C\uC774\uBE0C/\uAC8C\uC2DC\uD310\uC740 User OAuth \uC778\uC99D(\uC124\uC815 \uD6C4 nworks_login_user\uB85C \uBE0C\uB77C\uC6B0\uC800 \uB85C\uADF8\uC778 \uD544\uC694). OAuth Redirect URI: http://localhost:9876/callback",
2362
+ `NAVER WORKS API \uC778\uC99D \uC815\uBCF4\uB97C \uC124\uC815\uD569\uB2C8\uB2E4.
2363
+
2364
+ \u25A0 \uC0AC\uC804 \uC900\uBE44 (\uC0AC\uC6A9\uC790\uAC00 \uC9C1\uC811 \uD574\uC57C \uD568):
2365
+ 1. https://dev.worksmobile.com \uC5D0\uC11C \uC571 \uC0DD\uC131 \uD6C4 Client ID\uC640 Client Secret\uC744 \uBC1C\uAE09\uBC1B\uC2B5\uB2C8\uB2E4.
2366
+ 2. MCP \uC124\uC815 \uD30C\uC77C(\uC608: claude_desktop_config.json)\uC758 nworks \uC11C\uBC84\uC5D0 env \uD544\uB4DC\uB97C \uCD94\uAC00\uD569\uB2C8\uB2E4:
2367
+ { "env": { "NWORKS_CLIENT_SECRET": "<\uBC1C\uAE09\uBC1B\uC740 Client Secret>" } }
2368
+ 3. MCP \uD074\uB77C\uC774\uC5B8\uD2B8(\uC608: Claude Desktop)\uB97C \uC7AC\uC2DC\uC791\uD569\uB2C8\uB2E4.
2369
+
2370
+ \u25A0 \uC774 tool\uC758 \uC5ED\uD560:
2371
+ - clientId(\uD544\uC218)\uC640 serviceAccount, botId, domainId(\uC120\uD0DD)\uB97C \uD30C\uB77C\uBBF8\uD130\uB85C \uBC1B\uC544 \uC800\uC7A5\uD569\uB2C8\uB2E4.
2372
+ - Client Secret\uC740 \uBCF4\uC548\uC744 \uC704\uD574 \uD30C\uB77C\uBBF8\uD130\uB85C \uBC1B\uC9C0 \uC54A\uC73C\uBA70, \uD658\uACBD\uBCC0\uC218 NWORKS_CLIENT_SECRET\uC5D0\uC11C \uC790\uB3D9\uC73C\uB85C \uC77D\uC2B5\uB2C8\uB2E4.
2373
+ - Service Account \uC0AC\uC6A9 \uC2DC \uD658\uACBD\uBCC0\uC218 NWORKS_PRIVATE_KEY_PATH\uB3C4 \uD544\uC694\uD569\uB2C8\uB2E4.
2374
+
2375
+ \u25A0 \uC124\uC815 \uD6C4 \uB2E4\uC74C \uB2E8\uACC4:
2376
+ - \uCE98\uB9B0\uB354/\uBA54\uC77C/\uB4DC\uB77C\uC774\uBE0C/\uD560\uC77C/\uAC8C\uC2DC\uD310 \u2192 nworks_login_user tool\uB85C \uBE0C\uB77C\uC6B0\uC800 \uB85C\uADF8\uC778 \uD544\uC694
2377
+ - \uBA54\uC2DC\uC9C0/\uAD6C\uC131\uC6D0\uC870\uD68C \u2192 Service Account \uC778\uC99D (serviceAccount + botId + NWORKS_PRIVATE_KEY_PATH)
2378
+
2379
+ \u25A0 \uD658\uACBD\uBCC0\uC218 NWORKS_CLIENT_SECRET\uC774 \uC5C6\uC73C\uBA74 \uC774 tool\uC740 \uC2E4\uD328\uD569\uB2C8\uB2E4. \uC2E4\uD328 \uC2DC \uC0AC\uC6A9\uC790\uC5D0\uAC8C \uC704 \uC0AC\uC804 \uC900\uBE44 \uB2E8\uACC4\uB97C \uC548\uB0B4\uD558\uC138\uC694.
2380
+
2381
+ OAuth Redirect URI: http://localhost:9876/callback`,
2209
2382
  {
2210
2383
  clientId: z.string().describe("Client ID (Developer Console\uC5D0\uC11C \uBC1C\uAE09)"),
2211
2384
  serviceAccount: z.string().optional().describe("Service Account ID (\uC608: xxxxx.serviceaccount@domain)"),
@@ -2220,19 +2393,14 @@ function registerTools(server) {
2220
2393
  content: [{ type: "text", text: JSON.stringify({
2221
2394
  error: true,
2222
2395
  message: "\uD658\uACBD\uBCC0\uC218 NWORKS_CLIENT_SECRET\uC774 \uC124\uC815\uB418\uC5B4 \uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.",
2223
- solution: "\uC0AC\uC6A9\uC790\uC5D0\uAC8C \uB2E4\uC74C \uC548\uB0B4\uB97C \uC804\uB2EC\uD558\uC138\uC694: MCP \uC124\uC815 \uD30C\uC77C(\uC608: claude_desktop_config.json)\uC758 nworks \uC11C\uBC84 \uC124\uC815\uC5D0 env \uD544\uB4DC\uB97C \uCD94\uAC00\uD574\uC57C \uD569\uB2C8\uB2E4.",
2224
- example: {
2225
- mcpServers: {
2226
- nworks: {
2227
- command: "npx",
2228
- args: ["-y", "nworks", "mcp"],
2229
- env: {
2230
- NWORKS_CLIENT_SECRET: "<Developer Console\uC5D0\uC11C \uBC1C\uAE09\uBC1B\uC740 Client Secret>"
2231
- }
2232
- }
2233
- }
2234
- },
2235
- developerConsole: "https://dev.worksmobile.com"
2396
+ userAction: [
2397
+ "1. https://dev.worksmobile.com \uC5D0\uC11C \uC571\uC758 Client Secret\uC744 \uD655\uC778\uD569\uB2C8\uB2E4.",
2398
+ "2. MCP \uC124\uC815 \uD30C\uC77C(\uC608: claude_desktop_config.json)\uC744 \uC5F4\uACE0, nworks \uC11C\uBC84 \uC124\uC815\uC5D0 \uB2E4\uC74C\uC744 \uCD94\uAC00\uD569\uB2C8\uB2E4:",
2399
+ ' "env": { "NWORKS_CLIENT_SECRET": "<Client Secret>" }',
2400
+ "3. MCP \uD074\uB77C\uC774\uC5B8\uD2B8(\uC608: Claude Desktop)\uB97C \uC7AC\uC2DC\uC791\uD569\uB2C8\uB2E4.",
2401
+ "4. \uC7AC\uC2DC\uC791 \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uC8FC\uC138\uC694."
2402
+ ],
2403
+ configExample: '{ "mcpServers": { "nworks": { "command": "npx", "args": ["-y", "nworks", "mcp"], "env": { "NWORKS_CLIENT_SECRET": "<Client Secret>" } } } }'
2236
2404
  }) }],
2237
2405
  isError: true
2238
2406
  };
@@ -2250,7 +2418,7 @@ function registerTools(server) {
2250
2418
  if (serviceAccount && resolvedPrivateKeyPath && botId) {
2251
2419
  nextSteps.push("Service Account \uC778\uC99D \uC900\uBE44 \uC644\uB8CC \u2014 \uBD07 \uBA54\uC2DC\uC9C0 \uB4F1 \uBC14\uB85C \uC0AC\uC6A9 \uAC00\uB2A5");
2252
2420
  } else if (serviceAccount && botId && !resolvedPrivateKeyPath) {
2253
- nextSteps.push("NWORKS_PRIVATE_KEY_PATH \uD658\uACBD\uBCC0\uC218\uAC00 \uC124\uC815\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. Service Account \uC778\uC99D\uC5D0\uB294 Private Key \uACBD\uB85C\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. \uC0AC\uC6A9\uC790\uC5D0\uAC8C MCP \uC124\uC815 \uD30C\uC77C(\uC608: claude_desktop_config.json)\uC758 env \uD544\uB4DC\uC5D0 NWORKS_PRIVATE_KEY_PATH\uB97C \uCD94\uAC00\uD558\uB3C4\uB85D \uC548\uB0B4\uD558\uC138\uC694. Private Key\uB294 Developer Console(https://dev.worksmobile.com)\uC5D0\uC11C \uB2E4\uC6B4\uB85C\uB4DC\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.");
2421
+ nextSteps.push('NWORKS_PRIVATE_KEY_PATH \uD658\uACBD\uBCC0\uC218\uAC00 \uC124\uC815\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. Service Account \uC778\uC99D\uC5D0\uB294 Private Key \uD30C\uC77C \uACBD\uB85C\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. \uC0AC\uC6A9\uC790\uC5D0\uAC8C \uC548\uB0B4\uD558\uC138\uC694: (1) Developer Console(https://dev.worksmobile.com)\uC5D0\uC11C Private Key\uB97C \uB2E4\uC6B4\uB85C\uB4DC (2) MCP \uC124\uC815 \uD30C\uC77C\uC758 env\uC5D0 NWORKS_PRIVATE_KEY_PATH\uB97C \uCD94\uAC00 (\uC608: "NWORKS_PRIVATE_KEY_PATH": "C:/keys/private.key") (3) MCP \uD074\uB77C\uC774\uC5B8\uD2B8 \uC7AC\uC2DC\uC791');
2254
2422
  }
2255
2423
  nextSteps.push("User OAuth\uAC00 \uD544\uC694\uD55C API\uB294 nworks_login_user tool\uB85C \uBE0C\uB77C\uC6B0\uC800 \uB85C\uADF8\uC778\uC744 \uC9C4\uD589\uD558\uC138\uC694");
2256
2424
  const mask = (s) => s.length <= 4 ? "****" : `****${s.slice(-Math.min(4, Math.floor(s.length / 3)))}`;
@@ -2262,7 +2430,7 @@ function registerTools(server) {
2262
2430
  success: true,
2263
2431
  message: "\uC778\uC99D \uC815\uBCF4\uAC00 \uC800\uC7A5\uB418\uC5C8\uC2B5\uB2C8\uB2E4.",
2264
2432
  nextSteps,
2265
- clientId,
2433
+ clientId: mask(clientId),
2266
2434
  clientSecret: `${mask(resolvedSecret)} (\uD658\uACBD\uBCC0\uC218)`,
2267
2435
  serviceAccount: serviceAccount ?? null,
2268
2436
  privateKeyPath: resolvedPrivateKeyPath ? `${mask(resolvedPrivateKeyPath)} (\uD658\uACBD\uBCC0\uC218)` : null,
@@ -2398,7 +2566,17 @@ function registerTools(server) {
2398
2566
  sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
2399
2567
  userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2400
2568
  },
2401
- 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
+ }) => {
2402
2580
  try {
2403
2581
  const result = await createEvent({
2404
2582
  summary,
@@ -2437,7 +2615,17 @@ function registerTools(server) {
2437
2615
  sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
2438
2616
  userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2439
2617
  },
2440
- 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
+ }) => {
2441
2629
  try {
2442
2630
  await updateEvent({
2443
2631
  eventId,
@@ -2550,11 +2738,12 @@ function registerTools(server) {
2550
2738
  overwrite ?? false
2551
2739
  );
2552
2740
  } else if (filePath) {
2741
+ const safePath = validateLocalPath(filePath);
2553
2742
  if (process.env["NWORKS_VERBOSE"] === "1") {
2554
- console.error(`[nworks] MCP upload: filePath=${filePath}`);
2743
+ console.error(`[nworks] MCP upload: filePath=${safePath}`);
2555
2744
  }
2556
2745
  result = await uploadFile(
2557
- filePath,
2746
+ safePath,
2558
2747
  userId ?? "me",
2559
2748
  folderId,
2560
2749
  overwrite ?? false
@@ -2569,10 +2758,11 @@ function registerTools(server) {
2569
2758
  content: [{ type: "text", text: JSON.stringify({ success: true, ...result }) }]
2570
2759
  };
2571
2760
  } catch (err) {
2572
- const error = err;
2573
- 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
+ }
2574
2764
  return {
2575
- content: [{ type: "text", text: `${mcpErrorHint(err, "drive.upload")}${detail}` }],
2765
+ content: [{ type: "text", text: mcpErrorHint(err, "drive.upload") }],
2576
2766
  isError: true
2577
2767
  };
2578
2768
  }
@@ -2597,7 +2787,10 @@ function registerTools(server) {
2597
2787
  if (outputDir) {
2598
2788
  const { writeFile: writeFile3 } = await import("fs/promises");
2599
2789
  const { join: join3 } = await import("path");
2600
- 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);
2601
2794
  await writeFile3(outPath, result.buffer);
2602
2795
  return {
2603
2796
  content: [{ type: "text", text: JSON.stringify({ success: true, fileName, path: outPath, size: result.buffer.length }) }]
@@ -3018,9 +3211,9 @@ function registerTools(server) {
3018
3211
  const existingScopes = existingToken?.scope?.split(" ").filter(Boolean) ?? [];
3019
3212
  const requestedScopes = expandScopes((scope ?? DEFAULT_SCOPE).split(" ").filter(Boolean));
3020
3213
  const mergedScopes = [.../* @__PURE__ */ new Set([...existingScopes, ...requestedScopes])].join(" ");
3021
- const state = Math.random().toString(36).substring(2);
3214
+ const state = generateSecureState();
3022
3215
  const authorizeUrl = buildAuthorizeUrl(creds.clientId, mergedScopes, state);
3023
- startOAuthCallbackServer(creds.clientId, creds.clientSecret).then(
3216
+ startOAuthCallbackServer(creds.clientId, creds.clientSecret, state).then(
3024
3217
  (token) => saveUserToken({
3025
3218
  accessToken: token.accessToken,
3026
3219
  refreshToken: token.refreshToken,
@@ -3065,9 +3258,10 @@ function registerTools(server) {
3065
3258
  const userToken = await loadUserToken();
3066
3259
  const isValid = token ? token.expiresAt > Date.now() / 1e3 : false;
3067
3260
  const userTokenValid = userToken ? userToken.expiresAt > Date.now() / 1e3 : false;
3261
+ const mask = (s) => s.length <= 4 ? "****" : `****${s.slice(-4)}`;
3068
3262
  const info = {
3069
3263
  serviceAccount: creds.serviceAccount ?? null,
3070
- clientId: creds.clientId,
3264
+ clientId: mask(creds.clientId),
3071
3265
  botId: creds.botId ?? null,
3072
3266
  tokenValid: isValid,
3073
3267
  userOAuth: userToken ? { valid: userTokenValid, scope: userToken.scope } : null
@@ -3089,6 +3283,18 @@ function registerTools(server) {
3089
3283
  {},
3090
3284
  async () => {
3091
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
+ }
3092
3298
  await clearCredentials();
3093
3299
  return {
3094
3300
  content: [
@@ -3096,7 +3302,7 @@ function registerTools(server) {
3096
3302
  type: "text",
3097
3303
  text: JSON.stringify({
3098
3304
  success: true,
3099
- message: "\uC778\uC99D \uC815\uBCF4\uC640 \uD1A0\uD070\uC774 \uBAA8\uB450 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uB2E4\uC2DC \uC0AC\uC6A9\uD558\uB824\uBA74 nworks_setup tool\uB85C \uC7AC\uC124\uC815\uD558\uC138\uC694."
3305
+ message: "\uC778\uC99D \uC815\uBCF4\uC640 \uD1A0\uD070\uC774 \uBAA8\uB450 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uB2E4\uC2DC \uC0AC\uC6A9\uD558\uB824\uBA74 nworks_setup tool\uB85C \uC7AC\uC124\uC815 \uD6C4 nworks_login_user\uB85C \uBE0C\uB77C\uC6B0\uC800 \uB85C\uADF8\uC778\uD558\uC138\uC694."
3100
3306
  })
3101
3307
  }
3102
3308
  ]
@@ -3116,8 +3322,21 @@ function registerTools(server) {
3116
3322
  async () => {
3117
3323
  try {
3118
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
+ });
3119
3338
  return {
3120
- content: [{ type: "text", text: JSON.stringify(results) }]
3339
+ content: [{ type: "text", text: JSON.stringify(maskedResults) }]
3121
3340
  };
3122
3341
  } catch (err) {
3123
3342
  return {