trapic-mcp 0.1.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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/trapic-mcp.mjs +902 -0
- package/bin/wrapper.sh +5 -0
- package/dist/archive.d.ts +7 -0
- package/dist/archive.js +116 -0
- package/dist/audit.d.ts +5 -0
- package/dist/audit.js +16 -0
- package/dist/background.d.ts +8 -0
- package/dist/background.js +17 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +20 -0
- package/dist/conflict.d.ts +14 -0
- package/dist/conflict.js +103 -0
- package/dist/embedding.d.ts +6 -0
- package/dist/embedding.js +74 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +104 -0
- package/dist/llm.d.ts +10 -0
- package/dist/llm.js +47 -0
- package/dist/ollama.d.ts +11 -0
- package/dist/ollama.js +63 -0
- package/dist/quota.d.ts +7 -0
- package/dist/quota.js +16 -0
- package/dist/rate-limit.d.ts +5 -0
- package/dist/rate-limit.js +38 -0
- package/dist/request-context.d.ts +3 -0
- package/dist/request-context.js +12 -0
- package/dist/supabase.d.ts +2 -0
- package/dist/supabase.js +16 -0
- package/dist/team-access.d.ts +5 -0
- package/dist/team-access.js +35 -0
- package/dist/tools/active.d.ts +2 -0
- package/dist/tools/active.js +63 -0
- package/dist/tools/assert.d.ts +3 -0
- package/dist/tools/assert.js +141 -0
- package/dist/tools/chain.d.ts +2 -0
- package/dist/tools/chain.js +118 -0
- package/dist/tools/context.d.ts +7 -0
- package/dist/tools/context.js +270 -0
- package/dist/tools/create.d.ts +2 -0
- package/dist/tools/create.js +126 -0
- package/dist/tools/extract.d.ts +2 -0
- package/dist/tools/extract.js +95 -0
- package/dist/tools/preload.d.ts +10 -0
- package/dist/tools/preload.js +112 -0
- package/dist/tools/search.d.ts +2 -0
- package/dist/tools/search.js +92 -0
- package/dist/tools/summary.d.ts +2 -0
- package/dist/tools/summary.js +176 -0
- package/dist/tools/update.d.ts +2 -0
- package/dist/tools/update.js +134 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +700 -0
- package/package.json +59 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Worker entry point for trapic-mcp
|
|
3
|
+
* Uses Web Standard Streamable HTTP transport (stateless mode)
|
|
4
|
+
* With Bearer token authentication
|
|
5
|
+
*/
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
8
|
+
import { registerCreate } from "./tools/create.js";
|
|
9
|
+
import { registerSearch } from "./tools/search.js";
|
|
10
|
+
import { registerChain } from "./tools/chain.js";
|
|
11
|
+
import { registerActive } from "./tools/active.js";
|
|
12
|
+
import { registerUpdate } from "./tools/update.js";
|
|
13
|
+
import { registerExtract } from "./tools/extract.js";
|
|
14
|
+
import { registerContext } from "./tools/context.js";
|
|
15
|
+
import { registerSummary } from "./tools/summary.js";
|
|
16
|
+
import { registerPreload } from "./tools/preload.js";
|
|
17
|
+
import { isRateLimited, recordRequest } from "./rate-limit.js";
|
|
18
|
+
import { getSupabase } from "./supabase.js";
|
|
19
|
+
import { runArchive } from "./archive.js";
|
|
20
|
+
import { setWaitUntil } from "./background.js";
|
|
21
|
+
// Auth code replay prevention (per-isolate layer)
|
|
22
|
+
const consumedCodes = new Map();
|
|
23
|
+
const CODE_EXPIRY_MS = 5 * 60 * 1000;
|
|
24
|
+
function cleanupConsumedCodes() {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
for (const [key, ts] of consumedCodes) {
|
|
27
|
+
if (now - ts > CODE_EXPIRY_MS)
|
|
28
|
+
consumedCodes.delete(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function generateRandomString(length) {
|
|
32
|
+
const bytes = new Uint8Array(length);
|
|
33
|
+
crypto.getRandomValues(bytes);
|
|
34
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
35
|
+
}
|
|
36
|
+
// Signed auth code: encodes userId + codeChallenge + redirectUri + expiry into a tamper-proof string
|
|
37
|
+
// Uses SUPABASE_SERVICE_ROLE_KEY as HMAC secret (available in all Worker instances)
|
|
38
|
+
async function createAuthCode(env, userId, codeChallenge, redirectUri) {
|
|
39
|
+
const payload = JSON.stringify({
|
|
40
|
+
uid: userId,
|
|
41
|
+
cc: codeChallenge,
|
|
42
|
+
ru: redirectUri,
|
|
43
|
+
exp: Date.now() + 5 * 60 * 1000,
|
|
44
|
+
});
|
|
45
|
+
const payloadB64 = btoa(payload);
|
|
46
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(env.SUPABASE_SERVICE_ROLE_KEY), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
47
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64));
|
|
48
|
+
const sigHex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
49
|
+
return `${payloadB64}.${sigHex}`;
|
|
50
|
+
}
|
|
51
|
+
async function verifyAuthCode(env, code) {
|
|
52
|
+
const parts = code.split(".");
|
|
53
|
+
if (parts.length !== 2)
|
|
54
|
+
return null;
|
|
55
|
+
const [payloadB64, sigHex] = parts;
|
|
56
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(env.SUPABASE_SERVICE_ROLE_KEY), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
57
|
+
const sigBytes = new Uint8Array(sigHex.match(/.{2}/g).map(h => parseInt(h, 16)));
|
|
58
|
+
const valid = await crypto.subtle.verify("HMAC", key, sigBytes, new TextEncoder().encode(payloadB64));
|
|
59
|
+
if (!valid)
|
|
60
|
+
return null;
|
|
61
|
+
const payload = JSON.parse(atob(payloadB64));
|
|
62
|
+
if (payload.exp < Date.now())
|
|
63
|
+
return null;
|
|
64
|
+
return { userId: payload.uid, codeChallenge: payload.cc, redirectUri: payload.ru };
|
|
65
|
+
}
|
|
66
|
+
async function hashToken(token) {
|
|
67
|
+
const data = new TextEncoder().encode(token);
|
|
68
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
69
|
+
return Array.from(new Uint8Array(hashBuffer))
|
|
70
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
71
|
+
.join("");
|
|
72
|
+
}
|
|
73
|
+
async function resolveUserId(authHeader) {
|
|
74
|
+
if (!authHeader?.startsWith("Bearer "))
|
|
75
|
+
return null;
|
|
76
|
+
const token = authHeader.slice(7);
|
|
77
|
+
if (!token.startsWith("tr_"))
|
|
78
|
+
return null;
|
|
79
|
+
const tokenHash = await hashToken(token);
|
|
80
|
+
const supabase = getSupabase();
|
|
81
|
+
const { data } = await supabase
|
|
82
|
+
.from("trace_api_tokens")
|
|
83
|
+
.select("user_id")
|
|
84
|
+
.eq("token_hash", tokenHash)
|
|
85
|
+
.is("revoked_at", null)
|
|
86
|
+
.single();
|
|
87
|
+
if (!data)
|
|
88
|
+
return null;
|
|
89
|
+
// Update last_used_at (fire-and-forget)
|
|
90
|
+
supabase
|
|
91
|
+
.from("trace_api_tokens")
|
|
92
|
+
.update({ last_used_at: new Date().toISOString() })
|
|
93
|
+
.eq("token_hash", tokenHash)
|
|
94
|
+
.then(() => { }, (err) => console.error("[bg]", err.message));
|
|
95
|
+
return data.user_id;
|
|
96
|
+
}
|
|
97
|
+
function escapeHtml(str) {
|
|
98
|
+
return str
|
|
99
|
+
.replace(/&/g, '&')
|
|
100
|
+
.replace(/</g, '<')
|
|
101
|
+
.replace(/>/g, '>')
|
|
102
|
+
.replace(/"/g, '"')
|
|
103
|
+
.replace(/'/g, ''');
|
|
104
|
+
}
|
|
105
|
+
function getCorsOrigin(request) {
|
|
106
|
+
const origin = request.headers.get("Origin") || "";
|
|
107
|
+
const allowed = ["https://trapic.ai", "https://www.trapic.ai", "http://localhost:5173"];
|
|
108
|
+
return allowed.includes(origin) ? origin : allowed[0];
|
|
109
|
+
}
|
|
110
|
+
function isValidRedirectUri(uri) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = new URL(uri);
|
|
113
|
+
const allowed = ["localhost", "127.0.0.1", "trapic.ai"];
|
|
114
|
+
return allowed.some(h => parsed.hostname === h || parsed.hostname.endsWith("." + h));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function createServer(userId) {
|
|
121
|
+
const server = new McpServer({
|
|
122
|
+
name: "trapic-mcp",
|
|
123
|
+
version: "0.1.0",
|
|
124
|
+
});
|
|
125
|
+
registerCreate(server, userId);
|
|
126
|
+
registerSearch(server, userId);
|
|
127
|
+
registerChain(server, userId);
|
|
128
|
+
registerActive(server, userId);
|
|
129
|
+
registerUpdate(server, userId);
|
|
130
|
+
registerExtract(server);
|
|
131
|
+
registerContext(server, userId);
|
|
132
|
+
registerSummary(server, userId);
|
|
133
|
+
registerPreload(server, userId);
|
|
134
|
+
return server;
|
|
135
|
+
}
|
|
136
|
+
export default {
|
|
137
|
+
async fetch(request, env, ctx) {
|
|
138
|
+
setWaitUntil((p) => ctx.waitUntil(p));
|
|
139
|
+
const url = new URL(request.url);
|
|
140
|
+
// CORS preflight
|
|
141
|
+
if (request.method === "OPTIONS") {
|
|
142
|
+
return new Response(null, {
|
|
143
|
+
headers: {
|
|
144
|
+
"Access-Control-Allow-Origin": "*",
|
|
145
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
146
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, mcp-session-id, mcp-protocol-version",
|
|
147
|
+
"Access-Control-Max-Age": "86400",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// Health check (no auth needed)
|
|
152
|
+
if (url.pathname === "/health") {
|
|
153
|
+
return Response.json({
|
|
154
|
+
status: "ok",
|
|
155
|
+
server: "trapic-mcp",
|
|
156
|
+
version: "0.1.0",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Inject env vars for all modules that use process.env (supabase.ts, embedding.ts, llm.ts)
|
|
160
|
+
globalThis.process = globalThis.process || { env: {} };
|
|
161
|
+
globalThis.process.env.SUPABASE_URL = env.SUPABASE_URL;
|
|
162
|
+
globalThis.process.env.SUPABASE_SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
|
163
|
+
globalThis.process.env.OPENAI_API_KEY = env.OPENAI_API_KEY;
|
|
164
|
+
if (env.OPENAI_EMBED_MODEL)
|
|
165
|
+
globalThis.process.env.OPENAI_EMBED_MODEL = env.OPENAI_EMBED_MODEL;
|
|
166
|
+
if (env.GROQ_API_KEY)
|
|
167
|
+
globalThis.process.env.GROQ_API_KEY = env.GROQ_API_KEY;
|
|
168
|
+
if (env.GROQ_CHAT_MODEL)
|
|
169
|
+
globalThis.process.env.GROQ_CHAT_MODEL = env.GROQ_CHAT_MODEL;
|
|
170
|
+
// ── OAuth 2.1 Discovery & Flow ─────────────────────────────────────────
|
|
171
|
+
const origin = url.origin; // e.g. https://mcp.trapic.ai
|
|
172
|
+
// Protected Resource Metadata (RFC 9728)
|
|
173
|
+
if (url.pathname === "/.well-known/oauth-protected-resource") {
|
|
174
|
+
return Response.json({
|
|
175
|
+
resource: origin,
|
|
176
|
+
authorization_servers: [origin],
|
|
177
|
+
bearer_methods_supported: ["header"],
|
|
178
|
+
}, { headers: { "Access-Control-Allow-Origin": "*" } });
|
|
179
|
+
}
|
|
180
|
+
// Authorization Server Metadata (RFC 8414)
|
|
181
|
+
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
|
182
|
+
return Response.json({
|
|
183
|
+
issuer: origin,
|
|
184
|
+
authorization_endpoint: `${origin}/oauth/authorize`,
|
|
185
|
+
token_endpoint: `${origin}/oauth/token`,
|
|
186
|
+
registration_endpoint: `${origin}/register`,
|
|
187
|
+
response_types_supported: ["code"],
|
|
188
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
189
|
+
code_challenge_methods_supported: ["S256"],
|
|
190
|
+
token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
|
|
191
|
+
scopes_supported: ["trapic"],
|
|
192
|
+
}, { headers: { "Access-Control-Allow-Origin": "*" } });
|
|
193
|
+
}
|
|
194
|
+
// Dynamic Client Registration (RFC 7591)
|
|
195
|
+
if (url.pathname === "/register" && request.method === "POST") {
|
|
196
|
+
try {
|
|
197
|
+
const body = await request.json();
|
|
198
|
+
// Generate a client_id for the registering client
|
|
199
|
+
const clientId = `client_${generateRandomString(16)}`;
|
|
200
|
+
return Response.json({
|
|
201
|
+
client_id: clientId,
|
|
202
|
+
client_name: body.client_name || "MCP Client",
|
|
203
|
+
redirect_uris: body.redirect_uris || [],
|
|
204
|
+
grant_types: body.grant_types || ["authorization_code"],
|
|
205
|
+
response_types: body.response_types || ["code"],
|
|
206
|
+
token_endpoint_auth_method: body.token_endpoint_auth_method || "none",
|
|
207
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
208
|
+
}, {
|
|
209
|
+
status: 201,
|
|
210
|
+
headers: {
|
|
211
|
+
"Access-Control-Allow-Origin": getCorsOrigin(request),
|
|
212
|
+
"Content-Type": "application/json",
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
return Response.json({ error: "invalid_client_metadata" }, {
|
|
218
|
+
status: 400,
|
|
219
|
+
headers: { "Access-Control-Allow-Origin": getCorsOrigin(request) },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// OAuth Authorize — shows login form
|
|
224
|
+
if (url.pathname === "/oauth/authorize" && request.method === "GET") {
|
|
225
|
+
const clientId = url.searchParams.get("client_id") || "";
|
|
226
|
+
const redirectUri = url.searchParams.get("redirect_uri") || "";
|
|
227
|
+
const state = url.searchParams.get("state") || "";
|
|
228
|
+
const codeChallenge = url.searchParams.get("code_challenge") || "";
|
|
229
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "";
|
|
230
|
+
if (!redirectUri || !codeChallenge || codeChallengeMethod !== "S256") {
|
|
231
|
+
return new Response("Missing required OAuth parameters", { status: 400 });
|
|
232
|
+
}
|
|
233
|
+
if (!isValidRedirectUri(redirectUri)) {
|
|
234
|
+
return new Response("Invalid redirect_uri", { status: 400 });
|
|
235
|
+
}
|
|
236
|
+
const errorMsg = url.searchParams.get("error") || "";
|
|
237
|
+
const html = `<!DOCTYPE html>
|
|
238
|
+
<html lang="en">
|
|
239
|
+
<head>
|
|
240
|
+
<meta charset="utf-8" />
|
|
241
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
242
|
+
<title>Trapic — Sign In</title>
|
|
243
|
+
<style>
|
|
244
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
245
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0a0a0a; color: #e5e5e5; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
246
|
+
.card { background: #171717; border: 1px solid #262626; border-radius: 12px; padding: 2rem; width: 100%; max-width: 380px; }
|
|
247
|
+
.logo { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.25rem; }
|
|
248
|
+
.desc { color: #737373; font-size: 0.875rem; margin-bottom: 1.5rem; }
|
|
249
|
+
label { display: block; font-size: 0.8rem; color: #a3a3a3; margin-bottom: 0.25rem; }
|
|
250
|
+
input[type="email"], input[type="password"] { width: 100%; padding: 0.6rem 0.75rem; background: #0a0a0a; border: 1px solid #333; border-radius: 8px; color: #e5e5e5; font-size: 0.9rem; margin-bottom: 1rem; outline: none; }
|
|
251
|
+
input:focus { border-color: #525252; }
|
|
252
|
+
button { width: 100%; padding: 0.6rem; background: #fff; color: #000; border: none; border-radius: 9999px; font-size: 0.9rem; font-weight: 600; cursor: pointer; }
|
|
253
|
+
button:hover { opacity: 0.9; }
|
|
254
|
+
.error { color: #ef4444; font-size: 0.8rem; margin-bottom: 1rem; }
|
|
255
|
+
</style>
|
|
256
|
+
</head>
|
|
257
|
+
<body>
|
|
258
|
+
<div class="card">
|
|
259
|
+
<div class="logo">Trapic</div>
|
|
260
|
+
<div class="desc">Sign in to authorize access to your knowledge base</div>
|
|
261
|
+
${errorMsg ? `<div class="error">${escapeHtml(errorMsg)}</div>` : ""}
|
|
262
|
+
<form method="POST" action="/oauth/authorize">
|
|
263
|
+
<input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
|
|
264
|
+
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirectUri)}" />
|
|
265
|
+
<input type="hidden" name="state" value="${escapeHtml(state)}" />
|
|
266
|
+
<input type="hidden" name="code_challenge" value="${escapeHtml(codeChallenge)}" />
|
|
267
|
+
<input type="hidden" name="code_challenge_method" value="${escapeHtml(codeChallengeMethod)}" />
|
|
268
|
+
<label>Email</label>
|
|
269
|
+
<input type="email" name="email" required autocomplete="email" autofocus />
|
|
270
|
+
<label>Password</label>
|
|
271
|
+
<input type="password" name="password" required autocomplete="current-password" />
|
|
272
|
+
<button type="submit">Sign In</button>
|
|
273
|
+
</form>
|
|
274
|
+
</div>
|
|
275
|
+
</body>
|
|
276
|
+
</html>`;
|
|
277
|
+
return new Response(html, {
|
|
278
|
+
headers: {
|
|
279
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
280
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
|
|
281
|
+
"X-Content-Type-Options": "nosniff",
|
|
282
|
+
"X-Frame-Options": "DENY",
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
// OAuth Authorize POST — validates credentials, issues auth code via 302 redirect
|
|
287
|
+
if (url.pathname === "/oauth/authorize" && request.method === "POST") {
|
|
288
|
+
// Parse form body (native form POST sends application/x-www-form-urlencoded)
|
|
289
|
+
const ct = request.headers.get("content-type") || "";
|
|
290
|
+
let email = "", password = "", clientId = "", redirectUri = "", state = "", codeChallenge = "";
|
|
291
|
+
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
292
|
+
const text = await request.text();
|
|
293
|
+
const p = new URLSearchParams(text);
|
|
294
|
+
email = p.get("email") || "";
|
|
295
|
+
password = p.get("password") || "";
|
|
296
|
+
clientId = p.get("client_id") || "";
|
|
297
|
+
redirectUri = p.get("redirect_uri") || "";
|
|
298
|
+
state = p.get("state") || "";
|
|
299
|
+
codeChallenge = p.get("code_challenge") || "";
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const body = await request.json();
|
|
303
|
+
email = body.email || "";
|
|
304
|
+
password = body.password || "";
|
|
305
|
+
clientId = body.client_id || "";
|
|
306
|
+
redirectUri = body.redirect_uri || "";
|
|
307
|
+
state = body.state || "";
|
|
308
|
+
codeChallenge = body.code_challenge || "";
|
|
309
|
+
}
|
|
310
|
+
if (!isValidRedirectUri(redirectUri)) {
|
|
311
|
+
return new Response("Invalid redirect_uri", { status: 400 });
|
|
312
|
+
}
|
|
313
|
+
// On error, redirect back to login form with error message
|
|
314
|
+
const loginRedirect = (error) => {
|
|
315
|
+
const backUrl = new URL(`${origin}/oauth/authorize`);
|
|
316
|
+
backUrl.searchParams.set("client_id", clientId);
|
|
317
|
+
backUrl.searchParams.set("redirect_uri", redirectUri);
|
|
318
|
+
backUrl.searchParams.set("state", state);
|
|
319
|
+
backUrl.searchParams.set("code_challenge", codeChallenge);
|
|
320
|
+
backUrl.searchParams.set("code_challenge_method", "S256");
|
|
321
|
+
backUrl.searchParams.set("error", error);
|
|
322
|
+
return new Response(null, { status: 302, headers: { "Location": backUrl.toString() } });
|
|
323
|
+
};
|
|
324
|
+
try {
|
|
325
|
+
const supabase = getSupabase();
|
|
326
|
+
const { data: authData, error: authError } = await supabase.auth.signInWithPassword({
|
|
327
|
+
email, password,
|
|
328
|
+
});
|
|
329
|
+
if (authError || !authData.user) {
|
|
330
|
+
return loginRedirect(authError?.message || "Invalid credentials");
|
|
331
|
+
}
|
|
332
|
+
// Generate signed auth code (stateless, works across Worker instances)
|
|
333
|
+
const code = await createAuthCode(env, authData.user.id, codeChallenge, redirectUri);
|
|
334
|
+
const redirectUrl = new URL(redirectUri);
|
|
335
|
+
redirectUrl.searchParams.set("code", code);
|
|
336
|
+
if (state)
|
|
337
|
+
redirectUrl.searchParams.set("state", state);
|
|
338
|
+
// Always 302 redirect to redirect_uri — this is what OAuth clients expect
|
|
339
|
+
return new Response(null, { status: 302, headers: { "Location": redirectUrl.toString() } });
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
return loginRedirect("Server error");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// OAuth Token Exchange — code → access token
|
|
346
|
+
if (url.pathname === "/oauth/token" && request.method === "POST") {
|
|
347
|
+
const corsH = { "Access-Control-Allow-Origin": getCorsOrigin(request), "Content-Type": "application/json" };
|
|
348
|
+
try {
|
|
349
|
+
let grantType, code, codeVerifier, redirectUri;
|
|
350
|
+
const contentType = request.headers.get("content-type") || "";
|
|
351
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
352
|
+
const text = await request.text();
|
|
353
|
+
const params = new URLSearchParams(text);
|
|
354
|
+
grantType = params.get("grant_type") || "";
|
|
355
|
+
code = params.get("code") || "";
|
|
356
|
+
codeVerifier = params.get("code_verifier") || "";
|
|
357
|
+
redirectUri = params.get("redirect_uri") || "";
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
const body = await request.json();
|
|
361
|
+
grantType = body.grant_type || "";
|
|
362
|
+
code = body.code || "";
|
|
363
|
+
codeVerifier = body.code_verifier || "";
|
|
364
|
+
redirectUri = body.redirect_uri || "";
|
|
365
|
+
}
|
|
366
|
+
if (grantType !== "authorization_code") {
|
|
367
|
+
return Response.json({ error: "unsupported_grant_type" }, { status: 400, headers: corsH });
|
|
368
|
+
}
|
|
369
|
+
// Auth code replay prevention
|
|
370
|
+
cleanupConsumedCodes();
|
|
371
|
+
const codeKey = code.slice(0, 64);
|
|
372
|
+
if (consumedCodes.has(codeKey)) {
|
|
373
|
+
return Response.json({ error: "invalid_grant", error_description: "Code already used" }, { status: 400, headers: corsH });
|
|
374
|
+
}
|
|
375
|
+
const stored = await verifyAuthCode(env, code);
|
|
376
|
+
if (!stored) {
|
|
377
|
+
return Response.json({ error: "invalid_grant", error_description: "Code expired or invalid" }, { status: 400, headers: corsH });
|
|
378
|
+
}
|
|
379
|
+
// Mark code as consumed
|
|
380
|
+
consumedCodes.set(codeKey, Date.now());
|
|
381
|
+
if (stored.redirectUri !== redirectUri) {
|
|
382
|
+
return Response.json({ error: "invalid_grant", error_description: "Redirect URI mismatch" }, { status: 400, headers: corsH });
|
|
383
|
+
}
|
|
384
|
+
// Verify PKCE S256
|
|
385
|
+
const encoder = new TextEncoder();
|
|
386
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(codeVerifier));
|
|
387
|
+
const expectedChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
388
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
389
|
+
if (expectedChallenge !== stored.codeChallenge) {
|
|
390
|
+
return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400, headers: corsH });
|
|
391
|
+
}
|
|
392
|
+
// Generate a new tr_ API token for this user
|
|
393
|
+
const supabase = getSupabase();
|
|
394
|
+
const rawToken = `tr_${generateRandomString(24)}`;
|
|
395
|
+
const tokenHash = await hashToken(rawToken);
|
|
396
|
+
await supabase.from("trace_api_tokens").insert({
|
|
397
|
+
user_id: stored.userId,
|
|
398
|
+
token_hash: tokenHash,
|
|
399
|
+
token_prefix: rawToken.slice(0, 7),
|
|
400
|
+
name: "claude-ai-connector",
|
|
401
|
+
});
|
|
402
|
+
return Response.json({
|
|
403
|
+
access_token: rawToken,
|
|
404
|
+
token_type: "Bearer",
|
|
405
|
+
scope: "trapic",
|
|
406
|
+
}, { headers: corsH });
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
return Response.json({ error: "server_error", error_description: e.message }, { status: 500, headers: corsH });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// ── Auth endpoints ────────────────────────────────────────────────────
|
|
413
|
+
if (url.pathname.startsWith("/auth/")) {
|
|
414
|
+
const headers = {
|
|
415
|
+
"Access-Control-Allow-Origin": getCorsOrigin(request),
|
|
416
|
+
"Content-Type": "application/json",
|
|
417
|
+
};
|
|
418
|
+
const supabase = getSupabase();
|
|
419
|
+
function generateApiToken() {
|
|
420
|
+
const bytes = new Uint8Array(24);
|
|
421
|
+
crypto.getRandomValues(bytes);
|
|
422
|
+
const hex = Array.from(bytes)
|
|
423
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
424
|
+
.join("");
|
|
425
|
+
return `tr_${hex}`;
|
|
426
|
+
}
|
|
427
|
+
// POST /auth/login — no auth needed
|
|
428
|
+
if (url.pathname === "/auth/login" && request.method === "POST") {
|
|
429
|
+
try {
|
|
430
|
+
const body = (await request.json());
|
|
431
|
+
if (!body.email || !body.password) {
|
|
432
|
+
return Response.json({ error: "email and password required" }, { status: 400, headers });
|
|
433
|
+
}
|
|
434
|
+
// Rate limit login attempts per email
|
|
435
|
+
const loginKey = `login:${body.email}`;
|
|
436
|
+
const loginRate = isRateLimited(loginKey);
|
|
437
|
+
if (loginRate.limited) {
|
|
438
|
+
return Response.json({ error: "Too many login attempts. Try again later." }, { status: 429, headers });
|
|
439
|
+
}
|
|
440
|
+
recordRequest(loginKey);
|
|
441
|
+
const { data: authData, error: authError } = await supabase.auth.signInWithPassword({
|
|
442
|
+
email: body.email,
|
|
443
|
+
password: body.password,
|
|
444
|
+
});
|
|
445
|
+
if (authError || !authData.user) {
|
|
446
|
+
return Response.json({ error: authError?.message || "Login failed" }, { status: 401, headers });
|
|
447
|
+
}
|
|
448
|
+
// Generate API token
|
|
449
|
+
const rawToken = generateApiToken();
|
|
450
|
+
const tokenHash = await hashToken(rawToken);
|
|
451
|
+
const tokenPrefix = rawToken.slice(0, 7);
|
|
452
|
+
const { error: insertError } = await supabase
|
|
453
|
+
.from("trace_api_tokens")
|
|
454
|
+
.insert({
|
|
455
|
+
user_id: authData.user.id,
|
|
456
|
+
token_hash: tokenHash,
|
|
457
|
+
token_prefix: tokenPrefix,
|
|
458
|
+
name: body.token_name || "cli",
|
|
459
|
+
});
|
|
460
|
+
if (insertError) {
|
|
461
|
+
return Response.json({ error: insertError.message }, { status: 400, headers });
|
|
462
|
+
}
|
|
463
|
+
return Response.json({
|
|
464
|
+
token: rawToken,
|
|
465
|
+
user: { id: authData.user.id, email: authData.user.email },
|
|
466
|
+
}, { headers });
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
return Response.json({ error: err.message || "Internal error" }, { status: 500, headers });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// All other auth routes require Bearer token
|
|
473
|
+
const authHeader = request.headers.get("Authorization");
|
|
474
|
+
const userId = await resolveUserId(authHeader);
|
|
475
|
+
if (!userId) {
|
|
476
|
+
return Response.json({ error: "Unauthorized" }, { status: 401, headers });
|
|
477
|
+
}
|
|
478
|
+
const currentTokenHash = authHeader
|
|
479
|
+
? await hashToken(authHeader.slice(7))
|
|
480
|
+
: null;
|
|
481
|
+
// POST /auth/logout — revoke current token
|
|
482
|
+
if (url.pathname === "/auth/logout" && request.method === "POST") {
|
|
483
|
+
if (currentTokenHash) {
|
|
484
|
+
await supabase
|
|
485
|
+
.from("trace_api_tokens")
|
|
486
|
+
.update({ revoked_at: new Date().toISOString() })
|
|
487
|
+
.eq("token_hash", currentTokenHash);
|
|
488
|
+
}
|
|
489
|
+
return Response.json({ ok: true }, { headers });
|
|
490
|
+
}
|
|
491
|
+
// GET /auth/whoami — current user + token info
|
|
492
|
+
if (url.pathname === "/auth/whoami") {
|
|
493
|
+
const { data: userData } = await supabase.auth.admin.getUserById(userId);
|
|
494
|
+
const { data: tokenData } = await supabase
|
|
495
|
+
.from("trace_api_tokens")
|
|
496
|
+
.select("token_prefix, name, created_at, last_used_at")
|
|
497
|
+
.eq("token_hash", currentTokenHash)
|
|
498
|
+
.single();
|
|
499
|
+
// Get usage stats
|
|
500
|
+
const { count: activeTraces } = await supabase
|
|
501
|
+
.from("traces")
|
|
502
|
+
.select("*", { count: "exact", head: true })
|
|
503
|
+
.eq("author", userId)
|
|
504
|
+
.eq("status", "active");
|
|
505
|
+
return Response.json({
|
|
506
|
+
user: {
|
|
507
|
+
id: userId,
|
|
508
|
+
email: userData?.user?.email || null,
|
|
509
|
+
created_at: userData?.user?.created_at || null,
|
|
510
|
+
},
|
|
511
|
+
token: tokenData || null,
|
|
512
|
+
stats: { active_traces: activeTraces || 0 },
|
|
513
|
+
}, { headers });
|
|
514
|
+
}
|
|
515
|
+
// GET /auth/tokens — list all tokens
|
|
516
|
+
if (url.pathname === "/auth/tokens") {
|
|
517
|
+
const { data: tokens } = await supabase
|
|
518
|
+
.from("trace_api_tokens")
|
|
519
|
+
.select("id, token_prefix, name, created_at, last_used_at, revoked_at")
|
|
520
|
+
.eq("user_id", userId)
|
|
521
|
+
.order("created_at", { ascending: false });
|
|
522
|
+
// Mark which token is the current one
|
|
523
|
+
let currentTokenId = null;
|
|
524
|
+
if (currentTokenHash) {
|
|
525
|
+
const { data: ct } = await supabase
|
|
526
|
+
.from("trace_api_tokens")
|
|
527
|
+
.select("id")
|
|
528
|
+
.eq("token_hash", currentTokenHash)
|
|
529
|
+
.single();
|
|
530
|
+
currentTokenId = ct?.id || null;
|
|
531
|
+
}
|
|
532
|
+
const result = (tokens || []).map((t) => ({
|
|
533
|
+
...t,
|
|
534
|
+
is_current: t.id === currentTokenId,
|
|
535
|
+
}));
|
|
536
|
+
return Response.json({ tokens: result }, { headers });
|
|
537
|
+
}
|
|
538
|
+
// POST /auth/tokens/create — create new API token
|
|
539
|
+
if (url.pathname === "/auth/tokens/create" && request.method === "POST") {
|
|
540
|
+
const body = (await request.json());
|
|
541
|
+
const rawToken = generateApiToken();
|
|
542
|
+
const tokenHash = await hashToken(rawToken);
|
|
543
|
+
const tokenPrefix = rawToken.slice(0, 7);
|
|
544
|
+
const { error: insertError } = await supabase
|
|
545
|
+
.from("trace_api_tokens")
|
|
546
|
+
.insert({
|
|
547
|
+
user_id: userId,
|
|
548
|
+
token_hash: tokenHash,
|
|
549
|
+
token_prefix: tokenPrefix,
|
|
550
|
+
name: body?.name || "default",
|
|
551
|
+
});
|
|
552
|
+
if (insertError) {
|
|
553
|
+
return Response.json({ error: insertError.message }, { status: 400, headers });
|
|
554
|
+
}
|
|
555
|
+
return Response.json({
|
|
556
|
+
token: rawToken,
|
|
557
|
+
prefix: tokenPrefix,
|
|
558
|
+
name: body?.name || "default",
|
|
559
|
+
}, { headers });
|
|
560
|
+
}
|
|
561
|
+
// POST /auth/tokens/revoke — revoke a specific token
|
|
562
|
+
if (url.pathname === "/auth/tokens/revoke" && request.method === "POST") {
|
|
563
|
+
const body = (await request.json());
|
|
564
|
+
if (!body?.token_id) {
|
|
565
|
+
return Response.json({ error: "token_id required" }, { status: 400, headers });
|
|
566
|
+
}
|
|
567
|
+
const { data: updated, error } = await supabase
|
|
568
|
+
.from("trace_api_tokens")
|
|
569
|
+
.update({ revoked_at: new Date().toISOString() })
|
|
570
|
+
.eq("id", body.token_id)
|
|
571
|
+
.eq("user_id", userId)
|
|
572
|
+
.is("revoked_at", null)
|
|
573
|
+
.select("id")
|
|
574
|
+
.single();
|
|
575
|
+
if (error || !updated) {
|
|
576
|
+
return Response.json({ error: "Token not found or already revoked" }, { status: 404, headers });
|
|
577
|
+
}
|
|
578
|
+
return Response.json({ ok: true, revoked: body.token_id }, { headers });
|
|
579
|
+
}
|
|
580
|
+
return Response.json({ error: "Not found" }, { status: 404, headers });
|
|
581
|
+
}
|
|
582
|
+
// ── Send invite email ────────────────────────────────────────
|
|
583
|
+
if (url.pathname === "/api/send-invite" && request.method === "POST") {
|
|
584
|
+
const corsH = {
|
|
585
|
+
"Access-Control-Allow-Origin": getCorsOrigin(request),
|
|
586
|
+
"Content-Type": "application/json",
|
|
587
|
+
};
|
|
588
|
+
if (!env.RESEND_API_KEY) {
|
|
589
|
+
return Response.json({ error: "Email service not configured" }, { status: 500, headers: corsH });
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
const body = await request.json();
|
|
593
|
+
if (!body.email || !body.token) {
|
|
594
|
+
return Response.json({ error: "email and token required" }, { status: 400, headers: corsH });
|
|
595
|
+
}
|
|
596
|
+
const inviteLink = `https://trapic.ai/team?invite=${body.token}`;
|
|
597
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
598
|
+
method: "POST",
|
|
599
|
+
headers: {
|
|
600
|
+
"Content-Type": "application/json",
|
|
601
|
+
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
|
602
|
+
},
|
|
603
|
+
body: JSON.stringify({
|
|
604
|
+
from: "Trapic <noreply@trapic.ai>",
|
|
605
|
+
to: [body.email],
|
|
606
|
+
subject: `${body.inviter_name || "Someone"} invited you to join ${body.team_name || "a team"} on Trapic`,
|
|
607
|
+
html: `
|
|
608
|
+
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
|
609
|
+
<h2 style="font-size: 20px; font-weight: 600; margin-bottom: 8px;">You're invited to join ${body.team_name || "a team"}</h2>
|
|
610
|
+
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
|
|
611
|
+
${body.inviter_name || "A team member"} has invited you to collaborate on <strong>Trapic</strong> — the AI knowledge management platform.
|
|
612
|
+
</p>
|
|
613
|
+
<a href="${inviteLink}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background: #111; color: #fff; text-decoration: none; border-radius: 8px; font-size: 14px; font-weight: 500;">
|
|
614
|
+
Accept Invitation
|
|
615
|
+
</a>
|
|
616
|
+
<p style="margin-top: 24px; color: #9ca3af; font-size: 12px;">
|
|
617
|
+
This invitation expires in 7 days. If you didn't expect this, you can ignore it.
|
|
618
|
+
</p>
|
|
619
|
+
</div>
|
|
620
|
+
`,
|
|
621
|
+
}),
|
|
622
|
+
});
|
|
623
|
+
if (!res.ok) {
|
|
624
|
+
const err = await res.text();
|
|
625
|
+
return Response.json({ error: `Email failed: ${err}` }, { status: 500, headers: corsH });
|
|
626
|
+
}
|
|
627
|
+
return Response.json({ status: "sent" }, { headers: corsH });
|
|
628
|
+
}
|
|
629
|
+
catch (e) {
|
|
630
|
+
return Response.json({ error: e.message }, { status: 500, headers: corsH });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// MCP endpoint
|
|
634
|
+
if (url.pathname === "/mcp") {
|
|
635
|
+
// Fix Accept header: some MCP clients (e.g. Claude Code) may not include
|
|
636
|
+
// both application/json and text/event-stream, which the SDK requires.
|
|
637
|
+
const accept = request.headers.get("Accept") || "";
|
|
638
|
+
if (request.method === "POST" && (!accept.includes("application/json") || !accept.includes("text/event-stream"))) {
|
|
639
|
+
const headers = new Headers(request.headers);
|
|
640
|
+
headers.set("Accept", "application/json, text/event-stream");
|
|
641
|
+
request = new Request(request, { headers });
|
|
642
|
+
}
|
|
643
|
+
// Authenticate via Bearer token only (no query parameter support)
|
|
644
|
+
const authHeader = request.headers.get("Authorization");
|
|
645
|
+
const userId = await resolveUserId(authHeader);
|
|
646
|
+
// Reject unauthenticated requests (with OAuth discovery hint)
|
|
647
|
+
if (!userId) {
|
|
648
|
+
return Response.json({ error: "Unauthorized. Provide a valid Bearer token via Authorization header." }, {
|
|
649
|
+
status: 401,
|
|
650
|
+
headers: {
|
|
651
|
+
"Access-Control-Allow-Origin": "*",
|
|
652
|
+
"WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
// Rate limit authenticated users
|
|
657
|
+
if (userId) {
|
|
658
|
+
const rateCheck = isRateLimited(userId);
|
|
659
|
+
if (rateCheck.limited) {
|
|
660
|
+
return Response.json({ error: "Rate limit exceeded. Try again in 60 seconds.", retry_after_seconds: 60 }, {
|
|
661
|
+
status: 429,
|
|
662
|
+
headers: {
|
|
663
|
+
"Access-Control-Allow-Origin": "*",
|
|
664
|
+
"Retry-After": "60",
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
recordRequest(userId); // fire-and-forget
|
|
669
|
+
}
|
|
670
|
+
const server = createServer(userId);
|
|
671
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
672
|
+
sessionIdGenerator: undefined,
|
|
673
|
+
enableJsonResponse: true,
|
|
674
|
+
});
|
|
675
|
+
await server.connect(transport);
|
|
676
|
+
const response = await transport.handleRequest(request);
|
|
677
|
+
// Add CORS headers
|
|
678
|
+
const headers = new Headers(response.headers);
|
|
679
|
+
headers.set("Access-Control-Allow-Origin", "*");
|
|
680
|
+
headers.set("Access-Control-Expose-Headers", "mcp-session-id");
|
|
681
|
+
return new Response(response.body, {
|
|
682
|
+
status: response.status,
|
|
683
|
+
statusText: response.statusText,
|
|
684
|
+
headers,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
688
|
+
},
|
|
689
|
+
async scheduled(event, env, ctx) {
|
|
690
|
+
ctx.waitUntil(runArchive({
|
|
691
|
+
SUPABASE_URL: env.SUPABASE_URL,
|
|
692
|
+
SUPABASE_SERVICE_ROLE_KEY: env.SUPABASE_SERVICE_ROLE_KEY,
|
|
693
|
+
ARCHIVE_BUCKET: env.ARCHIVE_BUCKET,
|
|
694
|
+
}).then((log) => {
|
|
695
|
+
console.log(`[archive] ${new Date().toISOString()} ${log}`);
|
|
696
|
+
}).catch((err) => {
|
|
697
|
+
console.error(`[archive] Error: ${err.message}`);
|
|
698
|
+
}));
|
|
699
|
+
},
|
|
700
|
+
};
|