Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit662ce86unknown_key

feat(connect-claude): user-facing one-click Claude Desktop / MCP setup page

Claude committed on May 19, 2026Parent: 58307ae
5 files changed+13720662ce866e9cd42f5b3cfb00fa19967bc55002bc1
5 changed files+1372−0
Addedsrc/__tests__/connect-claude.test.ts+225−0View fileUnifiedSplit
1/**
2 * Connect Claude — user-facing one-click MCP setup page.
3 *
4 * Coverage:
5 * 1. GET /connect/claude without auth → 302 to /login
6 * 2. GET /connect/claude/dxt without auth → 302 to /login
7 * 3. GET /connect/claude with a real session cookie → 200 + body markers
8 * 4. GET /settings/claude → 302 redirect (alias path)
9 * 5. POST /connect/claude/token requires auth
10 * 6. GET /api/connect/status requires auth + returns JSON shape
11 *
12 * The DB-backed tests are gated on `DATABASE_URL` to keep the suite green in
13 * environments without Postgres — matching `install.test.ts` and friends.
14 */
15
16import { describe, it, expect } from "bun:test";
17import app from "../app";
18
19const HAS_DB = Boolean(process.env.DATABASE_URL);
20
21// ---------------------------------------------------------------------------
22// 1. Auth gating — unauthenticated callers
23// ---------------------------------------------------------------------------
24
25describe("connect-claude — auth gating", () => {
26 it("GET /connect/claude without a session → 302 /login", async () => {
27 const res = await app.request("/connect/claude");
28 expect(res.status).toBe(302);
29 expect(res.headers.get("location") || "").toContain("/login");
30 });
31
32 it("GET /connect/claude/dxt without a session → 302 /login", async () => {
33 const res = await app.request("/connect/claude/dxt");
34 expect(res.status).toBe(302);
35 expect(res.headers.get("location") || "").toContain("/login");
36 });
37
38 it("GET /settings/claude without a session → 302 (alias or /login)", async () => {
39 const res = await app.request("/settings/claude");
40 // Either the requireAuth middleware redirects to /login OR the alias
41 // redirects to /connect/claude (which itself requires auth). Both are
42 // 302 — what matters is "never 200 anon".
43 expect(res.status).toBe(302);
44 const loc = res.headers.get("location") || "";
45 expect(loc).toMatch(/\/login|\/connect\/claude/);
46 });
47
48 it("POST /connect/claude/token without a session → 302 /login", async () => {
49 const res = await app.request("/connect/claude/token", {
50 method: "POST",
51 headers: { "content-type": "application/json" },
52 body: "{}",
53 });
54 expect(res.status).toBe(302);
55 expect(res.headers.get("location") || "").toContain("/login");
56 });
57
58 it("GET /api/connect/status without a session → 302 /login", async () => {
59 const res = await app.request("/api/connect/status");
60 expect(res.status).toBe(302);
61 expect(res.headers.get("location") || "").toContain("/login");
62 });
63});
64
65// ---------------------------------------------------------------------------
66// 2. DB-backed: a real session cookie reaches the rendered page.
67// ---------------------------------------------------------------------------
68
69describe("connect-claude — rendered surface (authed)", () => {
70 it.skipIf(!HAS_DB)(
71 "GET /connect/claude with a session → 200 + page markers",
72 async () => {
73 const { db } = await import("../db");
74 const { users, sessions } = await import("../db/schema");
75 const { eq } = await import("drizzle-orm");
76
77 const uname = "cc-test-" + Math.random().toString(36).slice(2, 10);
78 const [user] = await db
79 .insert(users)
80 .values({
81 username: uname,
82 email: `${uname}@example.com`,
83 passwordHash: "x",
84 })
85 .returning();
86 const sessionToken =
87 "sess_cc_" + Math.random().toString(36).slice(2) + Date.now();
88 await db.insert(sessions).values({
89 userId: user.id,
90 token: sessionToken,
91 expiresAt: new Date(Date.now() + 60_000),
92 });
93
94 try {
95 const res = await app.request("/connect/claude", {
96 headers: { cookie: `session=${sessionToken}` },
97 });
98 expect(res.status).toBe(200);
99 const body = await res.text();
100 // Hero copy + the MCP endpoint must appear (the JSON snippet and CLI
101 // command both reference it).
102 expect(body).toContain("Connect Claude");
103 expect(body).toContain("/mcp");
104 // The personalized .dxt download link is rendered.
105 expect(body).toContain("/connect/claude/dxt");
106 // The user's username is rendered in the hero eyebrow.
107 expect(body).toContain(uname);
108 // Tools list — at least one known tool name is on the page.
109 expect(body).toContain("gluecron_repo_search");
110 } finally {
111 try {
112 await db.delete(users).where(eq(users.id, user.id));
113 } catch {
114 /* ignore */
115 }
116 }
117 }
118 );
119
120 it.skipIf(!HAS_DB)(
121 "POST /connect/claude/token with a session mints a glc_ PAT",
122 async () => {
123 const { db } = await import("../db");
124 const { users, sessions, apiTokens } = await import("../db/schema");
125 const { eq } = await import("drizzle-orm");
126
127 const uname = "cc-mint-" + Math.random().toString(36).slice(2, 10);
128 const [user] = await db
129 .insert(users)
130 .values({
131 username: uname,
132 email: `${uname}@example.com`,
133 passwordHash: "x",
134 })
135 .returning();
136 const sessionToken =
137 "sess_cc_" + Math.random().toString(36).slice(2) + Date.now();
138 await db.insert(sessions).values({
139 userId: user.id,
140 token: sessionToken,
141 expiresAt: new Date(Date.now() + 60_000),
142 });
143
144 try {
145 const res = await app.request("/connect/claude/token", {
146 method: "POST",
147 headers: {
148 cookie: `session=${sessionToken}`,
149 "content-type": "application/json",
150 },
151 body: "{}",
152 });
153 expect(res.status).toBe(201);
154 const body = await res.json();
155 expect(typeof body.token).toBe("string");
156 expect(body.token.startsWith("glc_")).toBe(true);
157 // Row exists with admin scope.
158 const [row] = await db
159 .select()
160 .from(apiTokens)
161 .where(eq(apiTokens.id, body.id))
162 .limit(1);
163 expect(row).toBeDefined();
164 expect(row!.userId).toBe(user.id);
165 expect(row!.scopes).toContain("admin");
166 } finally {
167 try {
168 await db.delete(users).where(eq(users.id, user.id));
169 } catch {
170 /* ignore */
171 }
172 }
173 }
174 );
175
176 it.skipIf(!HAS_DB)(
177 "GET /connect/claude/dxt with a session returns a zip with the token embedded",
178 async () => {
179 const { db } = await import("../db");
180 const { users, sessions } = await import("../db/schema");
181 const { eq } = await import("drizzle-orm");
182
183 const uname = "cc-dxt-" + Math.random().toString(36).slice(2, 10);
184 const [user] = await db
185 .insert(users)
186 .values({
187 username: uname,
188 email: `${uname}@example.com`,
189 passwordHash: "x",
190 })
191 .returning();
192 const sessionToken =
193 "sess_cc_" + Math.random().toString(36).slice(2) + Date.now();
194 await db.insert(sessions).values({
195 userId: user.id,
196 token: sessionToken,
197 expiresAt: new Date(Date.now() + 60_000),
198 });
199
200 try {
201 const res = await app.request("/connect/claude/dxt", {
202 headers: { cookie: `session=${sessionToken}` },
203 });
204 expect(res.status).toBe(200);
205 expect(res.headers.get("content-disposition") || "").toContain(
206 `gluecron-${uname}.dxt`
207 );
208 const buf = new Uint8Array(await res.arrayBuffer());
209 // Zip magic — PK\x03\x04
210 expect(buf[0]).toBe(0x50);
211 expect(buf[1]).toBe(0x4b);
212 expect(buf[2]).toBe(0x03);
213 expect(buf[3]).toBe(0x04);
214 // Must NOT cache (every download is a fresh PAT mint).
215 expect(res.headers.get("cache-control") || "").toContain("no-store");
216 } finally {
217 try {
218 await db.delete(users).where(eq(users.id, user.id));
219 } catch {
220 /* ignore */
221 }
222 }
223 }
224 );
225});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
102102import pwaRoutes from "./routes/pwa";
103103import installRoutes from "./routes/install";
104104import dxtRoutes from "./routes/dxt";
105import connectClaudeRoutes from "./routes/connect-claude";
105106import releasesRoutes from "./routes/releases";
106107import requiredChecksRoutes from "./routes/required-checks";
107108import rulesetsRoutes from "./routes/rulesets";
430431app.route("/", installRoutes);
431432// BLOCK Q1 — /gluecron.dxt download (Claude Desktop one-click extension)
432433app.route("/", dxtRoutes);
434// Connect Claude — user-facing one-click MCP setup (/connect/claude). Mounted
435// next to the other one-click flows (install.sh + .dxt) for surface symmetry.
436app.route("/", connectClaudeRoutes);
433437app.route("/", releasesRoutes);
434438app.route("/", requiredChecksRoutes);
435439app.route("/", rulesetsRoutes);
Modifiedsrc/routes/admin.tsx+4−0View fileUnifiedSplit
912912 <span class="admin-action-icon">{Icons.bot}</span>
913913 Autopilot
914914 </a>
915 <a href="/connect/claude" class="admin-action is-primary">
916 <span class="admin-action-icon">{Icons.bot}</span>
917 Connect Claude
918 </a>
915919 <form
916920 method="post"
917921 action="/admin/demo/reseed"
Addedsrc/routes/connect-claude.tsx+1114−0View fileUnifiedSplit
Large file (1,114 lines). Load full file
Modifiedsrc/routes/settings.tsx+25−0View fileUnifiedSplit
637637 />
638638 <SettingsSubnav active="profile" />
639639
640 {/* Connect Claude · /connect/claude — visually distinct row that sits
641 above the standard settings sections. Tiny, intentional break from
642 the surrounding card density so it reads as a different *kind* of
643 action ("connect another app") rather than another setting. */}
644 <a
645 href="/connect/claude"
646 style={
647 "display:flex;align-items:center;justify-content:space-between;" +
648 "gap:12px;margin-bottom:var(--space-4);padding:14px 16px;" +
649 "background:linear-gradient(135deg,rgba(140,109,255,0.10),rgba(54,197,214,0.08));" +
650 "border:1px solid rgba(140,109,255,0.35);border-radius:12px;" +
651 "color:var(--text-strong);text-decoration:none;font-size:14px;"
652 }
653 >
654 <span>
655 <strong style="font-weight:600">Connect Claude →</strong>{" "}
656 <span style="color:var(--text-muted)">
657 one-click setup for Claude Desktop, Claude Code, and any MCP client.
658 </span>
659 </span>
660 <span style="color:var(--text-muted);font-size:13px;white-space:nowrap">
661 /connect/claude
662 </span>
663 </a>
664
640665 {success && (
641666 <Banner kind="success" text={decodeURIComponent(success)} />
642667 )}
643668