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 +228 -0
- package/auth-gate.js +253 -0
- package/cloud-adapter.js +73 -26
- package/credential-store.js +93 -0
- package/crypto.js +386 -0
- package/data-store.js +9352 -0
- package/data-store.json-backup.js +1264 -0
- package/db.js +2292 -0
- package/image-moderator.js +491 -0
- package/image-processor.js +160 -0
- package/image-upload-service.js +398 -0
- package/index.js +2294 -2068
- package/migrate-json-to-sqlite.js +256 -0
- package/package.json +29 -16
- package/publish/auth-flow.js +275 -0
- package/publish/cloud-adapter.js +246 -0
- package/publish/credential-store.js +93 -0
- package/publish/index.js +1433 -0
- package/publish/package.json +21 -0
- package/publish/tool-map.js +1146 -0
- package/scripts/build-publish.sh +95 -0
- package/scripts/test-auth-failure.js +68 -0
- package/scripts/test-success.js +232 -0
- package/scripts/test-validation.js +105 -0
- package/tool-map.js +58 -0
- /package/{README.md → publish/README.md} +0 -0
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
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
|
|
118
|
-
if (!
|
|
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 ${
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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,
|