taskover-mcp 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/auth-flow.js ADDED
@@ -0,0 +1,228 @@
1
+ const crypto = require("crypto");
2
+ const http = require("http");
3
+ const credentialStore = require("./credential-store.js");
4
+
5
+ const API_BASE = "https://api.taskover.gg";
6
+ const AUTH_PAGE_BASE = "https://taskover.com";
7
+
8
+ function generatePKCE() {
9
+ const verifier = crypto.randomBytes(32).toString("base64url");
10
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
11
+ return { verifier, challenge };
12
+ }
13
+
14
+ function generateState() {
15
+ return crypto.randomBytes(32).toString("hex");
16
+ }
17
+
18
+ async function openBrowser(url) {
19
+ try {
20
+ const { default: open } = await import("open");
21
+ await open(url);
22
+ return true;
23
+ } catch (_) {
24
+ const { exec } = require("child_process");
25
+ const cmd = process.platform === "win32" ? `start "" "${url}"`
26
+ : process.platform === "darwin" ? `open "${url}"`
27
+ : `xdg-open "${url}"`;
28
+ try { exec(cmd); return true; } catch (_e) { return false; }
29
+ }
30
+ }
31
+
32
+ function startCallbackServer(expectedState) {
33
+ return new Promise((resolve, reject) => {
34
+ const server = http.createServer();
35
+ let callbackUsed = false;
36
+
37
+ server.listen(0, "127.0.0.1", () => {
38
+ const port = server.address().port;
39
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
40
+
41
+ const codePromise = new Promise((resolveCode, rejectCode) => {
42
+ server.on("request", (req, res) => {
43
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
44
+
45
+ if (url.pathname !== "/callback") {
46
+ res.writeHead(404).end();
47
+ return;
48
+ }
49
+
50
+ if (callbackUsed) {
51
+ res.writeHead(400, { "Content-Type": "text/html" });
52
+ res.end(htmlPage("Already processed", "This authorization has already been handled.", "#fbbf24"));
53
+ return;
54
+ }
55
+
56
+ const code = url.searchParams.get("code");
57
+ const returnedState = url.searchParams.get("state");
58
+ const error = url.searchParams.get("error");
59
+
60
+ if (!returnedState || returnedState !== expectedState) {
61
+ callbackUsed = true;
62
+ res.writeHead(400, { "Content-Type": "text/html" });
63
+ res.end(htmlPage("Security Error", "State parameter mismatch. This may be a CSRF attack. The authorization has been rejected.", "#ef4444"));
64
+ setTimeout(() => server.close(), 500);
65
+ rejectCode(new Error("State mismatch on callback — possible CSRF. Auth rejected."));
66
+ return;
67
+ }
68
+
69
+ callbackUsed = true;
70
+
71
+ if (error) {
72
+ const msg = error === "user_cancelled" ? "You cancelled the authorization." : `Auth error: ${error}`;
73
+ res.writeHead(200, { "Content-Type": "text/html" });
74
+ res.end(htmlPage("Cancelled", msg, "#fbbf24"));
75
+ setTimeout(() => server.close(), 500);
76
+ rejectCode(new Error(msg));
77
+ return;
78
+ }
79
+
80
+ if (!code) {
81
+ res.writeHead(400, { "Content-Type": "text/html" });
82
+ res.end(htmlPage("Error", "No authorization code received.", "#ef4444"));
83
+ setTimeout(() => server.close(), 500);
84
+ rejectCode(new Error("No auth code received in callback"));
85
+ return;
86
+ }
87
+
88
+ res.writeHead(200, { "Content-Type": "text/html" });
89
+ res.end(htmlPage("Connected!", "You can close this tab. Your AI assistant is authenticated.", "#4ade80"));
90
+ setTimeout(() => server.close(), 500);
91
+ resolveCode(code);
92
+ });
93
+
94
+ setTimeout(() => {
95
+ if (!callbackUsed) {
96
+ callbackUsed = true;
97
+ server.close();
98
+ rejectCode(new Error("Auth timed out (5 min). Try your request again to re-launch authorization."));
99
+ }
100
+ }, 300000);
101
+ });
102
+
103
+ resolve({ port, redirectUri, codePromise, server });
104
+ });
105
+
106
+ server.on("error", (err) => {
107
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") {
108
+ reject(new Error(`Cannot bind localhost port for auth callback: ${err.message}. Is another instance running?`));
109
+ } else {
110
+ reject(err);
111
+ }
112
+ });
113
+ });
114
+ }
115
+
116
+ function escapeHtml(s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
117
+
118
+ function htmlPage(title, message, color) {
119
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head>`
120
+ + `<body style="font-family:system-ui,sans-serif;background:#161920;color:${color};`
121
+ + `display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center">`
122
+ + `<div><h2>${escapeHtml(title)}</h2><p style="color:#afafba">${escapeHtml(message)}</p></div></body></html>`;
123
+ }
124
+
125
+ async function exchangeCodeForTokens(code, codeVerifier, state, deviceLabel) {
126
+ const res = await fetch(`${API_BASE}/api/mcp/token`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({
130
+ grant_type: "authorization_code",
131
+ code,
132
+ code_verifier: codeVerifier,
133
+ state,
134
+ device_label: deviceLabel,
135
+ }),
136
+ signal: AbortSignal.timeout(15000),
137
+ });
138
+
139
+ if (!res.ok) {
140
+ const data = await res.json().catch(() => ({}));
141
+ throw new Error(data.error || `Token exchange failed (HTTP ${res.status})`);
142
+ }
143
+
144
+ return res.json();
145
+ }
146
+
147
+ async function refreshAccessToken(refreshToken) {
148
+ const res = await fetch(`${API_BASE}/api/mcp/token`, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({
152
+ grant_type: "refresh_token",
153
+ refresh_token: refreshToken,
154
+ }),
155
+ signal: AbortSignal.timeout(15000),
156
+ });
157
+
158
+ if (!res.ok) {
159
+ const data = await res.json().catch(() => ({}));
160
+ throw new Error(data.error || `Token refresh failed (HTTP ${res.status})`);
161
+ }
162
+
163
+ return res.json();
164
+ }
165
+
166
+ // Full browser PKCE auth flow. Does NOT check cached credentials.
167
+ // Called by auth-gate when no valid tokens exist.
168
+ async function authenticateFull(hostType) {
169
+ console.error("[AUTH] Opening browser for login...");
170
+
171
+ const pkce = generatePKCE();
172
+ const state = generateState();
173
+
174
+ let callbackResult;
175
+ try {
176
+ callbackResult = await startCallbackServer(state);
177
+ } catch (err) {
178
+ console.error(`[AUTH] ${err.message}`);
179
+ console.error("[AUTH] Fallback: set TASKOVER_API_KEY environment variable for headless auth.");
180
+ throw err;
181
+ }
182
+
183
+ const { port, redirectUri, codePromise } = callbackResult;
184
+
185
+ const authUrl = new URL(`${AUTH_PAGE_BASE}/mcp-auth`);
186
+ authUrl.searchParams.set("code_challenge", pkce.challenge);
187
+ authUrl.searchParams.set("code_challenge_method", "S256");
188
+ authUrl.searchParams.set("redirect_uri", redirectUri);
189
+ authUrl.searchParams.set("state", state);
190
+ authUrl.searchParams.set("host", hostType || "MCP");
191
+
192
+ console.error("[AUTH] If your browser doesn't open, visit:");
193
+ console.error(`[AUTH] ${authUrl.toString()}`);
194
+
195
+ const opened = await openBrowser(authUrl.toString());
196
+ if (!opened) {
197
+ console.error("[AUTH] Could not open browser automatically. Please open the URL above.");
198
+ }
199
+
200
+ console.error("[AUTH] Waiting for browser authorization...");
201
+ let code;
202
+ try {
203
+ code = await codePromise;
204
+ } catch (err) {
205
+ console.error(`[AUTH] ${err.message}`);
206
+ throw err;
207
+ }
208
+
209
+ console.error("[AUTH] Exchanging authorization code...");
210
+ const tokens = await exchangeCodeForTokens(code, pkce.verifier, state, hostType);
211
+
212
+ const storageType = await credentialStore.write({
213
+ access_token: tokens.access_token,
214
+ refresh_token: tokens.refresh_token,
215
+ expires_at: Date.now() + (tokens.expires_in * 1000),
216
+ host_type: hostType,
217
+ });
218
+
219
+ console.error(`[AUTH] Authenticated successfully! (credentials stored in ${storageType})`);
220
+ return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token };
221
+ }
222
+
223
+ async function logout() {
224
+ await credentialStore.clear();
225
+ console.error("[AUTH] Logged out. Credentials cleared.");
226
+ }
227
+
228
+ module.exports = { authenticateFull, refreshAccessToken, logout };
package/auth-gate.js ADDED
@@ -0,0 +1,253 @@
1
+ // mcp-server/auth-gate.js
2
+ // Singleton just-in-time auth coordinator.
3
+ // All tool calls that need auth go through ensureAuth().
4
+ // Guarantees: single browser popup, request hold+resume, bounded timeout.
5
+ //
6
+ // WRITE SAFETY GUARANTEE:
7
+ // The JIT auth flow guarantees at-most-once execution for mutating requests:
8
+ // 1. ensureAuth() blocks until auth succeeds — the write has not been attempted yet.
9
+ // 2. After auth, callRpc() executes the write exactly once.
10
+ // 3. On 401 during an RPC call, the server rejected the request (nothing was written).
11
+ // handleAuthFailure() re-authenticates, then callRpc() retries — this is the first real execution.
12
+ // 4. No write is ever sent to the server more than once for a single tool invocation.
13
+ // 5. Network-level failures (timeout, connection reset) are NOT retried — they surface
14
+ // as errors to the user. This ensures at-most-once even for partial server processing.
15
+
16
+ const credentialStore = require("./credential-store.js");
17
+
18
+ const COOLDOWN_MS = 10000; // 10s min gap between auth attempts after failure
19
+ const MAX_REAUTH_ATTEMPTS = 2; // max consecutive re-auth attempts before giving up
20
+ const BOOT_GRACE_MS = 8000; // 8s after boot — suppress browser auth from auto-discovery calls
21
+
22
+ // Structured lifecycle logging — concise, non-sensitive, never prints tokens or user data.
23
+ function _log(event, detail) {
24
+ console.error(`[AUTH] ${event}${detail ? " — " + detail : ""}`);
25
+ }
26
+
27
+ // States
28
+ const IDLE = "IDLE";
29
+ const AUTHENTICATED = "AUTHENTICATED";
30
+ const AUTH_IN_PROGRESS = "AUTH_IN_PROGRESS";
31
+ const FAILED = "FAILED";
32
+
33
+ let _state = IDLE;
34
+ let _accessToken = null;
35
+ let _refreshToken = null;
36
+ let _pendingAuthPromise = null; // dedup guard for concurrent ensureAuth() calls
37
+ let _pendingRefreshPromise = null; // dedup guard for concurrent 401 handling
38
+ let _lastFailureTime = 0;
39
+ let _reAuthAttempts = 0;
40
+ let _bootTime = Date.now(); // tracks server start for boot grace period
41
+ let _authFlow = null; // lazy-loaded to avoid circular deps
42
+ let _cloudAdapter = null;
43
+
44
+ function _getAuthFlow() {
45
+ if (!_authFlow) _authFlow = require("./auth-flow.js");
46
+ return _authFlow;
47
+ }
48
+
49
+ function _getCloudAdapter() {
50
+ if (!_cloudAdapter) _cloudAdapter = require("./cloud-adapter.js");
51
+ return _cloudAdapter;
52
+ }
53
+
54
+ // Called at boot to try loading cached credentials without browser.
55
+ // Does NOT launch browser auth if missing — just loads what's there.
56
+ // NOTE: Does NOT validate token against the server (unlike old authenticate()).
57
+ // If the token was revoked server-side, the first tool call will get 401
58
+ // and handleAuthFailure() will handle it gracefully.
59
+ async function tryLoadCachedTokens() {
60
+ try {
61
+ const cached = await credentialStore.read();
62
+ if (cached && cached.access_token && cached.refresh_token) {
63
+ // Check if access token is still valid (with 30s margin)
64
+ if (cached.expires_at && Date.now() < cached.expires_at - 30000) {
65
+ _accessToken = cached.access_token;
66
+ _refreshToken = cached.refresh_token;
67
+ _state = AUTHENTICATED;
68
+ _getCloudAdapter().setTokens(_accessToken, _refreshToken);
69
+ _log("cached_auth_loaded");
70
+ return true;
71
+ }
72
+ // Try refresh silently
73
+ try {
74
+ const authFlow = _getAuthFlow();
75
+ const tokens = await authFlow.refreshAccessToken(cached.refresh_token);
76
+ await credentialStore.write({
77
+ access_token: tokens.access_token,
78
+ refresh_token: tokens.refresh_token,
79
+ expires_at: Date.now() + (tokens.expires_in * 1000),
80
+ host_type: cached.host_type,
81
+ });
82
+ _accessToken = tokens.access_token;
83
+ _refreshToken = tokens.refresh_token;
84
+ _state = AUTHENTICATED;
85
+ _getCloudAdapter().setTokens(_accessToken, _refreshToken);
86
+ _log("token_refresh_success", "silent startup refresh");
87
+ return true;
88
+ } catch (_) {
89
+ // Refresh failed — credentials are stale, clear them.
90
+ // Do NOT launch browser. Just go to IDLE.
91
+ await credentialStore.clear();
92
+ _log("token_refresh_failed", "cached credentials expired, will authenticate on first tool use");
93
+ return false;
94
+ }
95
+ }
96
+ return false;
97
+ } catch (err) {
98
+ // If credential store or cloud adapter fails to load, boot unauthenticated
99
+ _log("cached_auth_error", err.message);
100
+ return false;
101
+ }
102
+ }
103
+
104
+ // The main gate. Every protected tool call awaits this.
105
+ // Returns { accessToken, refreshToken } or throws.
106
+ // When auth is in progress, all concurrent callers share the same pending promise
107
+ // so only ONE browser popup ever opens and all held requests resume together.
108
+ //
109
+ // callerMethod: optional RPC method name for logging which tool triggered auth.
110
+ async function ensureAuth(callerMethod) {
111
+ // Fast path: already authenticated
112
+ if (_state === AUTHENTICATED && _accessToken) {
113
+ return { accessToken: _accessToken, refreshToken: _refreshToken };
114
+ }
115
+
116
+ // Dedup: if auth is already in progress, all callers share the same promise
117
+ if (_state === AUTH_IN_PROGRESS && _pendingAuthPromise) {
118
+ _log("ensureAuth_dedup", `method=${callerMethod || "unknown"}, sharing pending auth`);
119
+ return _pendingAuthPromise;
120
+ }
121
+
122
+ // Boot grace period: suppress browser popups from MCP host auto-discovery calls
123
+ // (e.g. Claude calling taskovergg_dashboard immediately on connect).
124
+ // During the grace window, return a passive error instead of opening the browser.
125
+ // After the grace period, real user-initiated calls trigger auth normally.
126
+ if (_state === IDLE) {
127
+ const sinceBoot = Date.now() - _bootTime;
128
+ if (sinceBoot < BOOT_GRACE_MS) {
129
+ _log("boot_grace_suppressed", `method=${callerMethod || "unknown"}, ${Math.ceil((BOOT_GRACE_MS - sinceBoot) / 1000)}s remaining`);
130
+ throw new Error("AUTH_NOT_READY:TaskOver is not connected yet. Use any TaskOver tool to start authorization.");
131
+ }
132
+ }
133
+
134
+ // Cooldown after failure to prevent popup loops
135
+ if (_state === FAILED) {
136
+ const elapsed = Date.now() - _lastFailureTime;
137
+ if (elapsed < COOLDOWN_MS) {
138
+ _log("reauth_cooldown_active", `${Math.ceil((COOLDOWN_MS - elapsed) / 1000)}s remaining`);
139
+ throw new Error("AUTH_FAILED_RETRY:Authorization was recently declined or timed out. Please try again in a moment.");
140
+ }
141
+ }
142
+
143
+ // Launch browser auth — transitions to AUTH_IN_PROGRESS
144
+ _log("ensureAuth_trigger", `method=${callerMethod || "unknown"}`);
145
+ _state = AUTH_IN_PROGRESS;
146
+ _pendingAuthPromise = _doJitAuth();
147
+
148
+ try {
149
+ const result = await _pendingAuthPromise;
150
+ return result;
151
+ } finally {
152
+ _pendingAuthPromise = null;
153
+ }
154
+ }
155
+
156
+ async function _doJitAuth() {
157
+ const hostType = process.env.TASKOVER_HOST_TYPE || "MCP";
158
+
159
+ try {
160
+ _log("jit_auth_start", `host=${hostType}`);
161
+ const authFlow = _getAuthFlow();
162
+ const auth = await authFlow.authenticateFull(hostType);
163
+
164
+ _accessToken = auth.accessToken;
165
+ _refreshToken = auth.refreshToken;
166
+ _state = AUTHENTICATED;
167
+ _lastFailureTime = 0;
168
+ _reAuthAttempts = 0;
169
+ _getCloudAdapter().setTokens(_accessToken, _refreshToken);
170
+
171
+ _log("jit_auth_success");
172
+ return { accessToken: _accessToken, refreshToken: _refreshToken };
173
+ } catch (err) {
174
+ _state = FAILED;
175
+ _lastFailureTime = Date.now();
176
+ _log("jit_auth_failed", err.message);
177
+ throw new Error(`AUTH_FAILED_RETRY:${err.message}`);
178
+ }
179
+ }
180
+
181
+ // Called by cloud-adapter when a 401 is received during an RPC call.
182
+ // Tries refresh first, falls back to full browser re-auth.
183
+ // DEDUP: If another caller already refreshed successfully (state is AUTHENTICATED),
184
+ // return current tokens. If a refresh is already in progress, share the pending promise.
185
+ async function handleAuthFailure() {
186
+ // Dedup: if another concurrent 401 handler already refreshed, use those tokens
187
+ if (_state === AUTHENTICATED && _accessToken) {
188
+ return { accessToken: _accessToken, refreshToken: _refreshToken };
189
+ }
190
+
191
+ // Dedup: if refresh is already in progress, share the pending promise
192
+ if (_pendingRefreshPromise) {
193
+ return _pendingRefreshPromise;
194
+ }
195
+
196
+ // Max-retry guard to prevent infinite re-auth loops
197
+ _reAuthAttempts++;
198
+ if (_reAuthAttempts > MAX_REAUTH_ATTEMPTS) {
199
+ _log("reauth_max_attempts", `limit=${MAX_REAUTH_ATTEMPTS}`);
200
+ _reAuthAttempts = 0;
201
+ throw new Error("AUTH_FAILED_RETRY:Too many re-authentication attempts. Please try again later.");
202
+ }
203
+
204
+ _pendingRefreshPromise = _doHandleAuthFailure();
205
+ try {
206
+ return await _pendingRefreshPromise;
207
+ } finally {
208
+ _pendingRefreshPromise = null;
209
+ }
210
+ }
211
+
212
+ async function _doHandleAuthFailure() {
213
+ // Try refresh first
214
+ if (_refreshToken) {
215
+ try {
216
+ _log("token_refresh_start");
217
+ const authFlow = _getAuthFlow();
218
+ const tokens = await authFlow.refreshAccessToken(_refreshToken);
219
+ await credentialStore.write({
220
+ access_token: tokens.access_token,
221
+ refresh_token: tokens.refresh_token,
222
+ expires_at: Date.now() + (tokens.expires_in * 1000),
223
+ });
224
+ _accessToken = tokens.access_token;
225
+ _refreshToken = tokens.refresh_token;
226
+ _state = AUTHENTICATED;
227
+ _reAuthAttempts = 0;
228
+ _getCloudAdapter().setTokens(_accessToken, _refreshToken);
229
+ _log("token_refresh_success");
230
+ return { accessToken: _accessToken, refreshToken: _refreshToken };
231
+ } catch (_) {
232
+ _log("token_refresh_failed", "clearing credentials, will re-authenticate");
233
+ await credentialStore.clear();
234
+ }
235
+ }
236
+
237
+ // Refresh failed — need full re-auth
238
+ _state = IDLE;
239
+ _accessToken = null;
240
+ _refreshToken = null;
241
+ return ensureAuth();
242
+ }
243
+
244
+ function isAuthenticated() {
245
+ return _state === AUTHENTICATED && !!_accessToken;
246
+ }
247
+
248
+ module.exports = {
249
+ tryLoadCachedTokens,
250
+ ensureAuth,
251
+ handleAuthFailure,
252
+ isAuthenticated,
253
+ };
package/cloud-adapter.js CHANGED
@@ -44,6 +44,14 @@ const MCP_ALLOWED_READS = new Set([
44
44
  "getStories", "getScenes", "getSceneContent", "getStoryBible",
45
45
  "search",
46
46
  "dashboard", "contextExport",
47
+ // Blueprint Intelligence reads
48
+ "getDiscoveredBpDetail", "getSyncDebugInfo",
49
+ "computeBlueprintComplexity", "computeBlueprintHealth",
50
+ "getBlueprintTimeline", "generateBlueprintSummary",
51
+ "computeBlueprintReview", "computeProjectBlueprintReview",
52
+ "getBlueprintStandards", "getBlueprintViolations",
53
+ "buildBlueprintDependencyGraph", "computeImpactAnalysis",
54
+ "getBlueprintUsageMap", "getBlueprintIntelligenceDashboard",
47
55
  ]);
48
56
 
49
57
  // REVISED per M0 matrix (52 write methods). Removed: updateBug (no MCP tool),
@@ -80,6 +88,9 @@ const MCP_ALLOWED_WRITES = new Set([
80
88
  "addScene", "updateScene",
81
89
  "updateStoryBible",
82
90
  "addStoryCharacter",
91
+ // Blueprint Intelligence writes
92
+ "syncDiscoveredBps", "createBlueprintSnapshot",
93
+ "toggleBlueprintStandard", "evaluateBlueprintStandards", "resolveBlueprintViolation",
83
94
  ]);
84
95
 
85
96
  // SECURITY: Methods that are NEVER allowed through MCP, regardless of future changes.
@@ -102,10 +113,24 @@ const MCP_ALLOWED_METHODS = new Set([...MCP_ALLOWED_READS, ...MCP_ALLOWED_WRITES
102
113
 
103
114
  let _apiKey = null;
104
115
 
116
+ // Token-based auth state (browser auth — set via setTokens, managed by auth-gate)
117
+ let _accessToken = null;
118
+ let _refreshToken = null;
119
+
105
120
  function init(apiKey) {
106
121
  _apiKey = apiKey;
107
122
  }
108
123
 
124
+ // Called by auth-gate after successful auth or refresh. Can be called multiple times.
125
+ function setTokens(accessToken, refreshToken) {
126
+ _accessToken = accessToken;
127
+ _refreshToken = refreshToken;
128
+ }
129
+
130
+ function isInitialized() {
131
+ return !!(_apiKey || _accessToken);
132
+ }
133
+
109
134
  function isAllowed(method) {
110
135
  return MCP_ALLOWED_METHODS.has(method);
111
136
  }
@@ -114,8 +139,8 @@ function isWrite(method) {
114
139
  return MCP_ALLOWED_WRITES.has(method);
115
140
  }
116
141
 
117
- async function callRpc(method, args) {
118
- if (!_apiKey) throw new Error("Cloud adapter not initialized -- no API key");
142
+ async function _doRpcCall(method, args, token) {
143
+ if (!token) throw new Error("Cloud adapter not initialized no token");
119
144
  if (!isAllowed(method)) throw new Error(`Method "${method}" is not allowed through MCP`);
120
145
 
121
146
  const body = JSON.stringify({ method, args: args || [] });
@@ -131,7 +156,7 @@ async function callRpc(method, args) {
131
156
  method: "POST",
132
157
  headers: {
133
158
  "Content-Type": "application/json",
134
- "Authorization": `Bearer ${_apiKey}`,
159
+ "Authorization": `Bearer ${token}`,
135
160
  },
136
161
  body,
137
162
  signal: controller.signal,
@@ -139,16 +164,22 @@ async function callRpc(method, args) {
139
164
 
140
165
  clearTimeout(timer);
141
166
 
142
- if (res.status === 401) {
143
- throw new Error("AUTH_FAILED");
144
- }
167
+ if (res.status === 401) throw new Error("AUTH_FAILED");
145
168
  if (res.status === 429) {
146
169
  const retryAfter = res.headers.get("retry-after") || "60";
147
170
  throw new Error(`RATE_LIMITED:${retryAfter}`);
148
171
  }
149
172
  if (!res.ok) {
150
- const errBody = await res.text().catch(() => "");
151
- throw new Error(`Cloud API error ${res.status}: ${errBody.slice(0, 200)}`);
173
+ // Privacy: extract machine-readable code only — raw error body may echo customer content.
174
+ let errDetail = "";
175
+ try {
176
+ const errJson = await res.json();
177
+ // Data Control denials have a server-generated message safe to surface (no customer content)
178
+ if (errJson.code && errJson.code.startsWith("DATA_CONTROL_") && errJson.message) throw new Error(errJson.message);
179
+ if (errJson.code) errDetail = ` code=${errJson.code}`;
180
+ else if (typeof errJson.error === "string" && errJson.error.length <= 60) errDetail = ` msg=${errJson.error}`;
181
+ } catch (e) { if (e.message && !e.message.startsWith("Cloud API")) throw e; await res.text().catch(() => ""); /* drain body */ }
182
+ throw new Error(`Cloud API error ${res.status}${errDetail}`);
152
183
  }
153
184
 
154
185
  // SECURITY: Enforce response size limit using byte-accurate check.
@@ -170,12 +201,35 @@ async function callRpc(method, args) {
170
201
  }
171
202
  }
172
203
 
204
+ async function callRpc(method, args) {
205
+ // In API-key mode, just use the key directly — no auth gate involvement
206
+ if (_apiKey) {
207
+ return _doRpcCall(method, args, _apiKey);
208
+ }
209
+
210
+ // Browser-auth mode: ensure we have tokens via auth gate.
211
+ // Use the returned token directly (not the module-level _accessToken)
212
+ // to avoid stale-read issues if setTokens hasn't been called yet.
213
+ const authGate = require("./auth-gate.js");
214
+ const { accessToken } = await authGate.ensureAuth(method);
215
+ if (!accessToken) throw new Error("AUTH_FAILED_RETRY:No access token available");
216
+
217
+ try {
218
+ return await _doRpcCall(method, args, accessToken);
219
+ } catch (err) {
220
+ if (err.message === "AUTH_FAILED") {
221
+ // Token expired/revoked mid-session — let auth gate handle refresh or re-auth
222
+ const refreshed = await authGate.handleAuthFailure();
223
+ if (!refreshed.accessToken) throw new Error("AUTH_FAILED_RETRY:Re-authentication failed");
224
+ return await _doRpcCall(method, args, refreshed.accessToken);
225
+ }
226
+ throw err;
227
+ }
228
+ }
229
+
173
230
  async function validateKey() {
174
- // SECURITY: Validate auth via dedicated lightweight endpoint.
175
- // /api/auth/validate confirms the key is valid and returns userId + displayName.
176
- // Does NOT load business data (unlike listProjects which fetches all projects).
177
- // /api/health is unauthenticated and must NEVER be used for key validation.
178
- if (!_apiKey) return { valid: false, error: "No API key configured" };
231
+ const token = _accessToken || _apiKey;
232
+ if (!token) return { valid: false, error: "No token or API key configured" };
179
233
 
180
234
  const controller = new AbortController();
181
235
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -183,39 +237,32 @@ async function validateKey() {
183
237
  try {
184
238
  const res = await fetch(`${API_BASE}/api/auth/validate`, {
185
239
  method: "POST",
186
- headers: {
187
- "Content-Type": "application/json",
188
- "Authorization": `Bearer ${_apiKey}`,
189
- },
240
+ headers: { "Authorization": `Bearer ${token}` },
190
241
  signal: controller.signal,
191
242
  });
192
243
 
193
244
  clearTimeout(timer);
194
245
 
195
- if (res.status === 401) {
196
- return { valid: false, error: "API key is invalid or expired. Generate a new key at taskover.gg -> Settings -> Security." };
197
- }
198
- if (!res.ok) {
199
- return { valid: false, error: `Unexpected response from api.taskover.gg (HTTP ${res.status})` };
200
- }
246
+ if (res.status === 401) return { valid: false, error: "Token/key invalid or expired." };
247
+ if (!res.ok) return { valid: false, error: `Unexpected response (HTTP ${res.status})` };
201
248
 
202
249
  const data = await res.json();
203
250
  return { valid: true, displayName: data.displayName || "user" };
204
251
  } catch (err) {
205
252
  clearTimeout(timer);
206
- if (err.name === "AbortError") {
207
- return { valid: false, error: "Cannot reach api.taskover.gg (timeout). Check your internet connection." };
208
- }
253
+ if (err.name === "AbortError") return { valid: false, error: "Cannot reach api.taskover.gg (timeout)" };
209
254
  return { valid: false, error: `Cannot reach api.taskover.gg: ${err.message}` };
210
255
  }
211
256
  }
212
257
 
213
258
  module.exports = {
214
259
  init,
260
+ setTokens,
215
261
  isAllowed,
216
262
  isWrite,
217
263
  callRpc,
218
264
  validateKey,
265
+ isInitialized,
219
266
  MCP_ALLOWED_METHODS,
220
267
  MCP_ALLOWED_READS,
221
268
  MCP_ALLOWED_WRITES,