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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/bin/trapic-mcp.mjs +902 -0
  4. package/bin/wrapper.sh +5 -0
  5. package/dist/archive.d.ts +7 -0
  6. package/dist/archive.js +116 -0
  7. package/dist/audit.d.ts +5 -0
  8. package/dist/audit.js +16 -0
  9. package/dist/background.d.ts +8 -0
  10. package/dist/background.js +17 -0
  11. package/dist/config.d.ts +46 -0
  12. package/dist/config.js +20 -0
  13. package/dist/conflict.d.ts +14 -0
  14. package/dist/conflict.js +103 -0
  15. package/dist/embedding.d.ts +6 -0
  16. package/dist/embedding.js +74 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +104 -0
  19. package/dist/llm.d.ts +10 -0
  20. package/dist/llm.js +47 -0
  21. package/dist/ollama.d.ts +11 -0
  22. package/dist/ollama.js +63 -0
  23. package/dist/quota.d.ts +7 -0
  24. package/dist/quota.js +16 -0
  25. package/dist/rate-limit.d.ts +5 -0
  26. package/dist/rate-limit.js +38 -0
  27. package/dist/request-context.d.ts +3 -0
  28. package/dist/request-context.js +12 -0
  29. package/dist/supabase.d.ts +2 -0
  30. package/dist/supabase.js +16 -0
  31. package/dist/team-access.d.ts +5 -0
  32. package/dist/team-access.js +35 -0
  33. package/dist/tools/active.d.ts +2 -0
  34. package/dist/tools/active.js +63 -0
  35. package/dist/tools/assert.d.ts +3 -0
  36. package/dist/tools/assert.js +141 -0
  37. package/dist/tools/chain.d.ts +2 -0
  38. package/dist/tools/chain.js +118 -0
  39. package/dist/tools/context.d.ts +7 -0
  40. package/dist/tools/context.js +270 -0
  41. package/dist/tools/create.d.ts +2 -0
  42. package/dist/tools/create.js +126 -0
  43. package/dist/tools/extract.d.ts +2 -0
  44. package/dist/tools/extract.js +95 -0
  45. package/dist/tools/preload.d.ts +10 -0
  46. package/dist/tools/preload.js +112 -0
  47. package/dist/tools/search.d.ts +2 -0
  48. package/dist/tools/search.js +92 -0
  49. package/dist/tools/summary.d.ts +2 -0
  50. package/dist/tools/summary.js +176 -0
  51. package/dist/tools/update.d.ts +2 -0
  52. package/dist/tools/update.js +134 -0
  53. package/dist/worker.d.ts +15 -0
  54. package/dist/worker.js +700 -0
  55. 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, '&amp;')
100
+ .replace(/</g, '&lt;')
101
+ .replace(/>/g, '&gt;')
102
+ .replace(/"/g, '&quot;')
103
+ .replace(/'/g, '&#39;');
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
+ };