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

api.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.

api.tsBlame288 lines · 2 contributors
fc1817aClaude1/**
2 * REST API routes for repository management.
3 */
4
5import { Hono } from "hono";
829a046Claude6import { eq, and, isNull, ilike, sql } from "drizzle-orm";
fc1817aClaude7import { db } from "../db";
7437605Claude8import { users, repositories, organizations, orgMembers } from "../db/schema";
fc1817aClaude9import { initBareRepo, repoExists } from "../git/repository";
06d5ffeClaude10import { hashPassword } from "../lib/auth";
7437605Claude11import { orgRoleAtLeast } from "../lib/orgs";
6cd2f0eClaude12import { renderMarkdown } from "../lib/markdown";
fc1817aClaude13
14const api = new Hono().basePath("/api");
15
16// Create repository
17api.post("/repos", async (c) => {
63807e5Claude18 let body: {
fc1817aClaude19 name: string;
20 owner: string;
7437605Claude21 orgSlug?: string;
fc1817aClaude22 description?: string;
23 isPrivate?: boolean;
63807e5Claude24 };
25 try {
26 body = await c.req.json();
27 } catch {
28 return c.json({ error: "Invalid JSON body" }, 400);
29 }
fc1817aClaude30
31 if (!body.name || !body.owner) {
32 return c.json({ error: "name and owner are required" }, 400);
33 }
34
35 // Validate repo name
36 if (!/^[a-zA-Z0-9._-]+$/.test(body.name)) {
37 return c.json({ error: "Invalid repository name" }, 400);
38 }
2cd3bb4ccantynz-alt39 // Normalize to lowercase — repo slugs are canonically lowercase so
40 // "MyRepo" and "myrepo" can't both exist and cause "Vapron" vs "vapron"
41 // confusion. Applied before the duplicate checks + on-disk init below.
42 body.name = body.name.toLowerCase();
fc1817aClaude43
63807e5Claude44 try {
7437605Claude45 // Find creator (user who is performing the action)
63807e5Claude46 const [owner] = await db
47 .select()
48 .from(users)
49 .where(eq(users.username, body.owner));
fc1817aClaude50
63807e5Claude51 if (!owner) {
52 return c.json({ error: "Owner not found" }, 404);
53 }
fc1817aClaude54
7437605Claude55 // B2: if orgSlug supplied, place the repo in the org namespace.
56 // Requires the creator to be an admin+ of the org.
57 let orgId: string | null = null;
58 let namespaceSlug = body.owner;
59 if (body.orgSlug) {
60 const [org] = await db
61 .select({ id: organizations.id, slug: organizations.slug })
62 .from(organizations)
63 .where(eq(organizations.slug, body.orgSlug))
64 .limit(1);
65 if (!org) return c.json({ error: "Organization not found" }, 404);
66
67 const [mem] = await db
68 .select({ role: orgMembers.role })
69 .from(orgMembers)
70 .where(
71 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, owner.id))
72 )
73 .limit(1);
74 if (!mem || !orgRoleAtLeast(mem.role, "admin")) {
75 return c.json({ error: "Admin rights required on org" }, 403);
76 }
77 orgId = org.id;
78 namespaceSlug = org.slug;
79 }
80
81 // Duplicate check: scoped to the right namespace.
82 if (orgId) {
83 const [existing] = await db
84 .select({ id: repositories.id })
85 .from(repositories)
86 .where(
87 and(eq(repositories.orgId, orgId), eq(repositories.name, body.name))
88 )
89 .limit(1);
90 if (existing) {
91 return c.json({ error: "Repository already exists" }, 409);
92 }
93 } else {
94 const [existing] = await db
95 .select({ id: repositories.id })
96 .from(repositories)
97 .where(
98 and(
99 eq(repositories.ownerId, owner.id),
100 eq(repositories.name, body.name),
101 isNull(repositories.orgId)
102 )
103 )
104 .limit(1);
105 if (existing) {
106 return c.json({ error: "Repository already exists" }, 409);
107 }
108 }
109 if (await repoExists(namespaceSlug, body.name)) {
63807e5Claude110 return c.json({ error: "Repository already exists" }, 409);
111 }
fc1817aClaude112
7437605Claude113 // Init bare repo on disk, keyed by the namespace slug (user or org).
114 const diskPath = await initBareRepo(namespaceSlug, body.name);
63807e5Claude115
116 // Insert into DB
117 const [repo] = await db
118 .insert(repositories)
119 .values({
120 name: body.name,
121 ownerId: owner.id,
7437605Claude122 orgId,
63807e5Claude123 description: body.description || null,
124 isPrivate: body.isPrivate || false,
125 diskPath,
126 })
127 .returning();
3ef4c9dClaude128
63807e5Claude129 // Green-ecosystem bootstrap: settings, protection, labels, welcome issue
130 if (repo) {
cde9983Claude131 const { bootstrapRepository, ensureAiBuildSeedIssue } = await import("../lib/repo-bootstrap");
63807e5Claude132 await bootstrapRepository({
133 repositoryId: repo.id,
134 ownerUserId: owner.id,
135 });
cde9983Claude136 // Fire-and-forget — never block the response
137 ensureAiBuildSeedIssue(repo.id, owner.id).catch(err =>
138 console.warn('[ai-build-seed]', err?.message)
139 );
63807e5Claude140 }
fc1817aClaude141
63807e5Claude142 return c.json(repo, 201);
143 } catch (err) {
144 console.error("[api] POST /repos:", err);
145 return c.json({ error: "Service unavailable" }, 503);
fc1817aClaude146 }
147});
148
149// List user's repositories
150api.get("/users/:username/repos", async (c) => {
151 const { username } = c.req.param();
63807e5Claude152 try {
153 const [owner] = await db
154 .select()
155 .from(users)
156 .where(eq(users.username, username));
157 if (!owner) return c.json({ error: "User not found" }, 404);
158
159 const repos = await db
160 .select()
161 .from(repositories)
162 .where(eq(repositories.ownerId, owner.id));
163
164 return c.json(repos);
165 } catch (err) {
166 console.error("[api] /users/:username/repos:", err);
167 return c.json({ error: "Service unavailable" }, 503);
168 }
fc1817aClaude169});
170
7437605Claude171// Get single repository (resolves both user- and org-owned namespaces)
fc1817aClaude172api.get("/repos/:owner/:name", async (c) => {
173 const { owner: ownerName, name } = c.req.param();
63807e5Claude174 try {
7437605Claude175 const { loadRepoByPath } = await import("../lib/namespace");
176 const repo = await loadRepoByPath(ownerName, name);
63807e5Claude177 if (!repo) return c.json({ error: "Not found" }, 404);
178 return c.json(repo);
179 } catch (err) {
180 console.error("[api] /repos/:owner/:name:", err);
181 return c.json({ error: "Service unavailable" }, 503);
182 }
fc1817aClaude183});
184
185// Quick-setup: create user + repo in one call (dev convenience)
186api.post("/setup", async (c) => {
63807e5Claude187 let body: {
fc1817aClaude188 username: string;
189 email: string;
190 repoName: string;
191 description?: string;
63807e5Claude192 };
193 try {
194 body = await c.req.json();
195 } catch {
196 return c.json({ error: "Invalid JSON body" }, 400);
197 }
198 if (!body.username || !body.email || !body.repoName) {
199 return c.json(
200 { error: "username, email, and repoName are required" },
201 400
202 );
fc1817aClaude203 }
204
63807e5Claude205 try {
206 // Upsert user
207 let [user] = await db
208 .select()
209 .from(users)
210 .where(eq(users.username, body.username));
fc1817aClaude211
63807e5Claude212 if (!user) {
213 [user] = await db
214 .insert(users)
215 .values({
216 username: body.username,
217 email: body.email,
218 passwordHash: await hashPassword("changeme"),
219 })
220 .returning();
221 }
fc1817aClaude222
63807e5Claude223 // Create repo if not exists
224 if (!(await repoExists(body.username, body.repoName))) {
225 const diskPath = await initBareRepo(body.username, body.repoName);
226 const [repo] = await db
227 .insert(repositories)
228 .values({
229 name: body.repoName,
230 ownerId: user.id,
231 description: body.description || null,
232 diskPath,
233 })
234 .returning();
235 if (repo) {
236 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
237 await bootstrapRepository({
238 repositoryId: repo.id,
239 ownerUserId: user.id,
240 });
241 }
3ef4c9dClaude242 }
fc1817aClaude243
63807e5Claude244 return c.json({
245 user: user.username,
246 repo: body.repoName,
247 status: "ready",
fc1817aClaude248 });
63807e5Claude249 } catch (err) {
250 console.error("[api] POST /setup:", err);
251 return c.json({ error: "Service unavailable" }, 503);
fc1817aClaude252 }
253});
254
6cd2f0eClaude255// Markdown preview — render markdown to HTML for the live preview tab.
256// Body: { text: string }. Returns { html: string }.
257// Rate-limited by the global limiter in middleware. No auth required.
258api.post("/markdown/preview", async (c) => {
259 let text = "";
260 try {
261 const body = await c.req.json();
262 text = String(body.text || "").slice(0, 64_000);
263 } catch {
264 return c.json({ html: "" }, 400);
265 }
266 const html = renderMarkdown(text);
267 return c.json({ html });
268});
269
829a046Claude270// User mention autocomplete — returns up to 8 matching usernames for `@` suggestions.
271// Public endpoint (no auth required) since usernames are public.
272api.get("/users/suggest", async (c) => {
273 const q = String(c.req.query("q") || "").trim().replace(/^@/, "").slice(0, 39);
274 if (!q) return c.json({ users: [] });
275 try {
276 const rows = await db
277 .select({ username: users.username, displayName: users.displayName })
278 .from(users)
279 .where(ilike(users.username, `${q}%`))
280 .orderBy(sql`lower(${users.username})`)
281 .limit(8);
282 return c.json({ users: rows });
283 } catch {
284 return c.json({ users: [] });
285 }
286});
287
fc1817aClaude288export default api;