Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

claude-integration.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

claude-integration.tsBlame324 lines · 1 contributor
f5b9ef5Claude1/**
2 * Claude Code Integration Receiver
3 *
4 * Lets any Claude Code session or repository report into Gluecron with zero
5 * config. Bearer token authenticated against api_tokens (SHA-256 hash).
6 *
7 * Routes
8 * POST /api/claude/connect — validate token, auto-create repo, return git remote + MCP URL
9 * GET /api/claude/connect — same auth, return existing connection info
10 * POST /api/claude/session — fire-and-forget session telemetry (no auth required)
11 */
12
13import { Hono } from "hono";
14import { eq, and } from "drizzle-orm";
15import { createHash } from "crypto";
16import { db } from "../db";
17import { users, repositories, activityFeed, apiTokens } from "../db/schema";
18import { initBareRepo } from "../git/repository";
19import { config } from "../lib/config";
20import type { AuthEnv } from "../middleware/auth";
21
22const claudeIntegration = new Hono<AuthEnv>();
23
24// ─── Auth helper ────────────────────────────────────────────────────────────
25
26function sha256hex(value: string): string {
27 return createHash("sha256").update(value).digest("hex");
28}
29
30/**
31 * Extract + validate Bearer token. Returns { user, token } on success,
32 * or { error } if the token is missing / invalid.
33 */
34async function authenticateBearer(
35 authHeader: string | undefined
36): Promise<
37 | { ok: true; user: typeof users.$inferSelect; tokenRow: typeof apiTokens.$inferSelect }
38 | { ok: false; error: string; status: 401 }
39> {
40 if (!authHeader?.startsWith("Bearer ")) {
41 return { ok: false, error: "Missing or malformed Authorization header. Use: Bearer <token>", status: 401 };
42 }
43
44 const raw = authHeader.slice(7).trim();
45 if (!raw) {
46 return { ok: false, error: "Empty bearer token", status: 401 };
47 }
48
49 const tokenHash = sha256hex(raw);
50
51 try {
52 const [tokenRow] = await db
53 .select()
54 .from(apiTokens)
55 .where(eq(apiTokens.tokenHash, tokenHash))
56 .limit(1);
57
58 if (!tokenRow) {
59 return { ok: false, error: "Invalid API token", status: 401 };
60 }
61
62 if (tokenRow.expiresAt && new Date(tokenRow.expiresAt) < new Date()) {
63 return { ok: false, error: "API token has expired", status: 401 };
64 }
65
66 const [user] = await db
67 .select()
68 .from(users)
69 .where(eq(users.id, tokenRow.userId))
70 .limit(1);
71
72 if (!user) {
73 return { ok: false, error: "Token owner not found", status: 401 };
74 }
75
76 // Touch last-used timestamp (best effort — no await)
77 db.update(apiTokens)
78 .set({ lastUsedAt: new Date() })
79 .where(eq(apiTokens.id, tokenRow.id))
80 .catch(() => {});
81
82 return { ok: true, user, tokenRow };
83 } catch (err) {
84 return { ok: false, error: "Authentication failed", status: 401 };
85 }
86}
87
88// ─── POST /api/claude/connect ───────────────────────────────────────────────
89
90claudeIntegration.post("/api/claude/connect", async (c) => {
91 const auth = await authenticateBearer(c.req.header("Authorization"));
92 if (!auth.ok) {
93 return c.json({ ok: false, error: auth.error }, auth.status);
94 }
95
96 const { user } = auth;
97
98 let body: { username?: string; repoName?: string; description?: string };
99 try {
100 body = await c.req.json();
101 } catch {
102 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
103 }
104
105 // repoName is optional — if omitted, return basic connection info without a repo
106 const repoName = body.repoName?.trim();
107 const description = body.description?.trim() || null;
108
109 try {
110 const baseUrl = config.appBaseUrl;
111
112 if (!repoName) {
113 // No repo requested — just confirm the token is valid
114 return c.json({
115 ok: true,
116 username: user.username,
117 mcpUrl: `${baseUrl}/mcp`,
118 message: "Token valid. Provide repoName to auto-create a repository.",
119 });
120 }
121
122 // Validate repo name
123 if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
124 return c.json({ ok: false, error: "Invalid repository name. Use letters, digits, hyphens, dots, or underscores." }, 400);
125 }
126
127 // Check if repo already exists
128 const [existing] = await db
129 .select()
130 .from(repositories)
131 .where(and(eq(repositories.ownerId, user.id), eq(repositories.name, repoName)))
132 .limit(1);
133
134 if (existing) {
135 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
136 const mcpUrl = `${baseUrl}/mcp`;
137 return c.json({
138 ok: true,
139 created: false,
140 gitRemote,
141 mcpUrl,
142 repoId: existing.id,
143 message: "Repository already exists.",
144 });
145 }
146
147 // Auto-create bare repo on disk
148 const diskPath = await initBareRepo(user.username, repoName);
149
150 // Insert into repositories table
151 const [repo] = await db
152 .insert(repositories)
153 .values({
154 name: repoName,
155 ownerId: user.id,
156 description,
157 isPrivate: false,
158 diskPath,
159 })
160 .returning();
161
162 if (!repo) {
163 return c.json({ ok: false, error: "Failed to create repository record" }, 500);
164 }
165
166 // Log to activity feed
167 await db.insert(activityFeed).values({
168 repositoryId: repo.id,
169 userId: user.id,
170 action: "repo_created",
171 targetType: "repository",
172 targetId: repo.id,
173 metadata: JSON.stringify({ source: "claude_connect", via: "api" }),
174 }).catch(() => {});
175
176 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
177 const mcpUrl = `${baseUrl}/mcp`;
178
179 return c.json({
180 ok: true,
181 created: true,
182 gitRemote,
183 mcpUrl,
184 repoId: repo.id,
185 message: `Repository '${repoName}' created. Push with: git push gluecron main`,
186 });
187 } catch (err) {
188 const msg = err instanceof Error ? err.message : String(err);
189 return c.json({ ok: false, error: `Server error: ${msg}` }, 500);
190 }
191});
192
193// ─── GET /api/claude/connect ────────────────────────────────────────────────
194
195claudeIntegration.get("/api/claude/connect", async (c) => {
196 const auth = await authenticateBearer(c.req.header("Authorization"));
197 if (!auth.ok) {
198 return c.json({ ok: false, error: auth.error }, auth.status);
199 }
200
201 const { user } = auth;
202 const repoName = c.req.query("repo");
203
204 try {
205 const baseUrl = config.appBaseUrl;
206
207 if (!repoName) {
208 // List all repos for this user
209 const repos = await db
210 .select({ id: repositories.id, name: repositories.name, description: repositories.description, createdAt: repositories.createdAt })
211 .from(repositories)
212 .where(eq(repositories.ownerId, user.id));
213
214 return c.json({
215 ok: true,
216 username: user.username,
217 mcpUrl: `${baseUrl}/mcp`,
218 repos: repos.map((r) => ({
219 ...r,
220 gitRemote: `${baseUrl}/${user.username}/${r.name}.git`,
221 })),
222 });
223 }
224
225 const [repo] = await db
226 .select()
227 .from(repositories)
228 .where(and(eq(repositories.ownerId, user.id), eq(repositories.name, repoName)))
229 .limit(1);
230
231 if (!repo) {
232 return c.json({ ok: false, error: `Repository '${repoName}' not found` }, 404);
233 }
234
235 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
236 const mcpUrl = `${baseUrl}/mcp`;
237
238 return c.json({
239 ok: true,
240 username: user.username,
241 repoId: repo.id,
242 repoName: repo.name,
243 gitRemote,
244 mcpUrl,
245 });
246 } catch (err) {
247 const msg = err instanceof Error ? err.message : String(err);
248 return c.json({ ok: false, error: `Server error: ${msg}` }, 500);
249 }
250});
251
252// ─── POST /api/claude/session ────────────────────────────────────────────────
253// Fire-and-forget telemetry. No auth required — sessions post to this.
254
255claudeIntegration.post("/api/claude/session", async (c) => {
256 let body: {
257 sessionId?: string;
258 repoName?: string;
259 event?: "start" | "push" | "issue" | "pr";
260 payload?: unknown;
261 };
262
263 try {
264 body = await c.req.json();
265 } catch {
266 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
267 }
268
269 const { sessionId, repoName, event, payload } = body;
270
271 if (!event || !["start", "push", "issue", "pr"].includes(event)) {
272 return c.json({ ok: false, error: "event must be one of: start, push, issue, pr" }, 400);
273 }
274
275 // Best-effort: look up the repo if repoName is provided (need owner resolution via query param or body)
276 try {
277 let repositoryId: string | null = null;
278 let userId: string | null = null;
279
280 if (repoName) {
281 // Try to find via owner in payload or query string
282 const ownerHint =
283 (payload && typeof payload === "object" && "owner" in payload
284 ? (payload as Record<string, unknown>).owner
285 : undefined) as string | undefined;
286
287 if (ownerHint) {
288 const [ownerRow] = await db
289 .select({ id: users.id })
290 .from(users)
291 .where(eq(users.username, ownerHint))
292 .limit(1);
293
294 if (ownerRow) {
295 userId = ownerRow.id;
296 const [repo] = await db
297 .select({ id: repositories.id })
298 .from(repositories)
299 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
300 .limit(1);
301 if (repo) repositoryId = repo.id;
302 }
303 }
304 }
305
306 if (repositoryId) {
307 await db.insert(activityFeed).values({
308 repositoryId,
309 userId: userId ?? undefined,
310 action: "claude_session",
311 targetType: "session",
312 targetId: sessionId ?? null,
313 metadata: JSON.stringify({ sessionId, event, repoName, payload }),
314 });
315 }
316 // If no repo found, silently swallow (fire-and-forget)
317 } catch {
318 // Intentionally swallowed — telemetry must not block callers
319 }
320
321 return c.json({ ok: true });
322});
323
324export default claudeIntegration;