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

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

graphql.tsBlame497 lines · 2 contributors
eae38d1Claude1/**
2 * Block G2 — GraphQL mirror of REST.
3 *
4 * A deliberately-tiny, dependency-free GraphQL-over-HTTP handler. Supports
5 * a fixed set of query fields that mirror the existing REST endpoints:
6 *
7 * query {
8 * viewer { id username email }
9 * user(username:"...") { id username createdAt repos { id name visibility } }
10 * repository(owner:"...", name:"...") {
11 * id name description visibility starCount forkCount createdAt
12 * owner { id username }
13 * issues(state:"open", limit:20) { id number title state createdAt }
14 * pullRequests(state:"open", limit:20) { id number title state createdAt }
15 * }
16 * search(q:"...", limit:20) { id name ownerUsername }
17 * rateLimit { remaining reset }
18 * }
19 *
20 * No mutations — callers should use the REST + /api endpoints for writes.
21 * This keeps the attack surface small and sidesteps the need for an
22 * auth/authz layer beyond softAuth.
23 *
24 * The parser is a hand-rolled recursive descent over the subset of
25 * GraphQL syntax we actually support (selection sets, named fields,
26 * string/number/boolean/enum args). It's not a spec-complete parser —
27 * it rejects anything it doesn't understand with a friendly error.
28 */
29
30import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
31import { db } from "../db";
32import {
33 issues,
34 pullRequests,
35 repositories,
36 users,
37} from "../db/schema";
38
39// ---------- Types ----------
40
41export interface GqlError {
42 message: string;
43 path?: string[];
44}
45
46export interface GqlResponse {
47 data?: Record<string, any> | null;
48 errors?: GqlError[];
49}
50
51type ArgValue = string | number | boolean | null;
52
53interface Field {
54 name: string;
55 alias?: string;
56 args: Record<string, ArgValue>;
57 selections: Field[];
58}
59
60// ---------- Parser ----------
61
62/** Parses a query. Returns either the top-level selection set or an error. */
63export function parseQuery(
64 src: string
65): { ok: true; fields: Field[] } | { ok: false; error: string } {
66 const s = src.trim();
67 try {
68 const p = new Parser(s);
69 p.skipWs();
70 // Optional "query" or "query Name" prefix.
71 if (p.peekWord() === "query") {
72 p.consumeWord("query");
73 p.skipWs();
74 // optional operation name
75 if (p.peek() && /[A-Za-z_]/.test(p.peek()!)) p.readName();
76 p.skipWs();
77 }
78 const fields = p.parseSelectionSet();
79 return { ok: true, fields };
80 } catch (err) {
81 return { ok: false, error: (err as Error).message };
82 }
83}
84
85class Parser {
86 private i = 0;
87 constructor(private src: string) {}
88
89 peek(): string | null {
90 return this.i < this.src.length ? this.src[this.i] : null;
91 }
92
93 peekWord(): string {
94 this.skipWs();
95 let j = this.i;
96 let out = "";
97 while (j < this.src.length && /[A-Za-z_]/.test(this.src[j])) {
98 out += this.src[j];
99 j++;
100 }
101 return out;
102 }
103
104 consumeWord(expected: string) {
105 this.skipWs();
106 const w = this.readName();
107 if (w !== expected) {
108 throw new Error(`expected '${expected}', got '${w}'`);
109 }
110 }
111
112 readName(): string {
113 this.skipWs();
114 let out = "";
115 while (this.i < this.src.length && /[A-Za-z0-9_]/.test(this.src[this.i])) {
116 out += this.src[this.i];
117 this.i++;
118 }
119 if (!out) throw new Error(`expected identifier at ${this.i}`);
120 return out;
121 }
122
123 expect(ch: string) {
124 this.skipWs();
125 if (this.src[this.i] !== ch) {
126 throw new Error(`expected '${ch}' at ${this.i}, got '${this.src[this.i] || "EOF"}'`);
127 }
128 this.i++;
129 }
130
131 skipWs() {
132 while (
133 this.i < this.src.length &&
134 /[\s,]/.test(this.src[this.i])
135 )
136 this.i++;
137 // Line comments (#)
138 if (this.src[this.i] === "#") {
139 while (this.i < this.src.length && this.src[this.i] !== "\n") this.i++;
140 this.skipWs();
141 }
142 }
143
144 parseSelectionSet(): Field[] {
145 this.skipWs();
146 this.expect("{");
147 const out: Field[] = [];
148 while (true) {
149 this.skipWs();
150 if (this.peek() === "}") {
151 this.i++;
152 return out;
153 }
154 out.push(this.parseField());
155 }
156 }
157
158 parseField(): Field {
159 this.skipWs();
160 const first = this.readName();
161 let alias: string | undefined;
162 let name = first;
163 this.skipWs();
164 if (this.peek() === ":") {
165 this.i++;
166 alias = first;
167 name = this.readName();
168 }
169 this.skipWs();
170 const args: Record<string, ArgValue> = {};
171 if (this.peek() === "(") {
172 this.i++;
173 while (true) {
174 this.skipWs();
175 if (this.peek() === ")") {
176 this.i++;
177 break;
178 }
179 const argName = this.readName();
180 this.skipWs();
181 this.expect(":");
182 const val = this.parseValue();
183 args[argName] = val;
184 }
185 }
186 this.skipWs();
187 let selections: Field[] = [];
188 if (this.peek() === "{") {
189 selections = this.parseSelectionSet();
190 }
191 return { name, alias, args, selections };
192 }
193
194 parseValue(): ArgValue {
195 this.skipWs();
196 const ch = this.peek();
197 if (ch === '"') {
198 this.i++;
199 let out = "";
200 while (this.i < this.src.length && this.src[this.i] !== '"') {
201 if (this.src[this.i] === "\\") {
202 this.i++;
203 out += this.src[this.i];
204 } else {
205 out += this.src[this.i];
206 }
207 this.i++;
208 }
209 this.expect('"');
210 return out;
211 }
212 // number
213 if (ch && /[0-9-]/.test(ch)) {
214 let out = "";
215 while (this.i < this.src.length && /[0-9.-]/.test(this.src[this.i])) {
216 out += this.src[this.i];
217 this.i++;
218 }
219 return Number(out);
220 }
221 // boolean / null / enum
222 const w = this.readName();
223 if (w === "true") return true;
224 if (w === "false") return false;
225 if (w === "null") return null;
226 return w; // enum-ish
227 }
228}
229
230// ---------- Executor ----------
231
232type Resolver = (
233 args: Record<string, ArgValue>,
234 sel: Field[],
235 ctx: ExecCtx
236) => Promise<any>;
237
238export interface ExecCtx {
239 user: { id: string; username?: string } | null;
240}
241
242async function resolveSelections(
243 obj: Record<string, any>,
244 selections: Field[]
245): Promise<Record<string, any>> {
246 if (!obj || selections.length === 0) return obj;
247 const out: Record<string, any> = {};
248 for (const s of selections) {
249 const key = s.alias || s.name;
250 const val = obj[s.name];
251 if (val == null) {
252 out[key] = null;
253 continue;
254 }
255 if (Array.isArray(val)) {
256 out[key] = val.map((v) =>
257 typeof v === "object" ? resolveSelections(v, s.selections) : v
258 );
259 // Awaits inside map are wrapped individually above (resolveSelections
260 // returns a promise only if we call it async). Normalise here:
261 out[key] = await Promise.all(
262 val.map((v) =>
263 typeof v === "object"
264 ? resolveSelections(v, s.selections)
265 : Promise.resolve(v)
266 )
267 );
268 } else if (typeof val === "object") {
269 out[key] = await resolveSelections(val, s.selections);
270 } else {
271 out[key] = val;
272 }
273 }
274 return out;
275}
276
277const ROOT: Record<string, Resolver> = {
278 viewer: async (_args, sel, ctx) => {
279 if (!ctx.user) return null;
280 const [u] = await db
281 .select({
282 id: users.id,
283 username: users.username,
284 email: users.email,
285 })
286 .from(users)
287 .where(eq(users.id, ctx.user.id))
288 .limit(1);
289 return u ? resolveSelections(u, sel) : null;
290 },
291
292 user: async (args, sel, _ctx) => {
293 const username = String(args.username || "");
294 if (!username) return null;
43755faccantynz-alt295 const [row] = await db
eae38d1Claude296 .select({
297 id: users.id,
298 username: users.username,
299 email: users.email,
300 createdAt: users.createdAt,
301 })
302 .from(users)
303 .where(eq(users.username, username))
304 .limit(1);
43755faccantynz-alt305 if (!row) return null;
306
307 // `email` is PII and this resolver is reachable anonymously, so it was
308 // handing out the address of every account on the platform to anyone who
309 // asked — bulk harvesting with one query per username. Only the account
310 // holder sees their own address here; the `viewer` resolver above is the
311 // supported way to read it. Null rather than omitted so the field stays
312 // valid for clients that request it.
313 const isSelf = _ctx?.user?.id === row.id;
314 const u = { ...row, email: isSelf ? row.email : null };
eae38d1Claude315 const needsRepos = sel.some((s) => s.name === "repos");
316 let repos: any[] = [];
317 if (needsRepos) {
318 repos = await db
319 .select({
320 id: repositories.id,
321 name: repositories.name,
0316dbbClaude322 isPrivate: repositories.isPrivate,
eae38d1Claude323 starCount: repositories.starCount,
324 createdAt: repositories.createdAt,
325 })
326 .from(repositories)
327 .where(
328 and(
329 eq(repositories.ownerId, u.id),
0316dbbClaude330 eq(repositories.isPrivate, false)
eae38d1Claude331 )
332 )
333 .orderBy(desc(repositories.createdAt))
334 .limit(100);
335 }
336 return resolveSelections({ ...u, repos }, sel);
337 },
338
339 repository: async (args, sel, _ctx) => {
340 const owner = String(args.owner || "");
341 const name = String(args.name || "");
342 if (!owner || !name) return null;
343 const [r] = await db
344 .select({
345 id: repositories.id,
346 name: repositories.name,
347 description: repositories.description,
0316dbbClaude348 isPrivate: repositories.isPrivate,
eae38d1Claude349 starCount: repositories.starCount,
350 forkCount: repositories.forkCount,
351 createdAt: repositories.createdAt,
352 ownerId: repositories.ownerId,
353 ownerUsername: users.username,
354 })
355 .from(repositories)
356 .innerJoin(users, eq(repositories.ownerId, users.id))
357 .where(and(eq(users.username, owner), eq(repositories.name, name)))
358 .limit(1);
0316dbbClaude359 if (!r || r.isPrivate) return null;
eae38d1Claude360
361 const payload: Record<string, any> = {
362 ...r,
363 owner: { id: r.ownerId, username: r.ownerUsername },
364 };
365
366 const issuesSel = sel.find((s) => s.name === "issues");
367 if (issuesSel) {
368 const state = String(issuesSel.args.state || "open");
369 const limit = Math.min(100, Number(issuesSel.args.limit || 20));
370 payload.issues = await db
371 .select({
372 id: issues.id,
373 number: issues.number,
374 title: issues.title,
375 state: issues.state,
376 createdAt: issues.createdAt,
377 })
378 .from(issues)
379 .where(
380 and(eq(issues.repositoryId, r.id), eq(issues.state, state))
381 )
382 .orderBy(desc(issues.createdAt))
383 .limit(limit);
384 }
385
386 const prSel = sel.find((s) => s.name === "pullRequests");
387 if (prSel) {
388 const state = String(prSel.args.state || "open");
389 const limit = Math.min(100, Number(prSel.args.limit || 20));
390 payload.pullRequests = await db
391 .select({
392 id: pullRequests.id,
393 number: pullRequests.number,
394 title: pullRequests.title,
395 state: pullRequests.state,
396 createdAt: pullRequests.createdAt,
397 })
398 .from(pullRequests)
399 .where(
400 and(
401 eq(pullRequests.repositoryId, r.id),
402 eq(pullRequests.state, state)
403 )
404 )
405 .orderBy(desc(pullRequests.createdAt))
406 .limit(limit);
407 }
408
409 return resolveSelections(payload, sel);
410 },
411
412 search: async (args, sel, _ctx) => {
413 const q = String(args.q || "").trim();
414 if (!q) return [];
415 const limit = Math.min(50, Number(args.limit || 20));
416 const rows = await db
417 .select({
418 id: repositories.id,
419 name: repositories.name,
420 ownerUsername: users.username,
421 })
422 .from(repositories)
423 .innerJoin(users, eq(repositories.ownerId, users.id))
424 .where(
425 and(
0316dbbClaude426 eq(repositories.isPrivate, false),
eae38d1Claude427 or(
428 ilike(repositories.name, `%${q}%`),
429 ilike(repositories.description, `%${q}%`)
430 )!
431 )
432 )
433 .limit(limit);
434 return Promise.all(rows.map((r) => resolveSelections(r, sel)));
435 },
436
437 rateLimit: async (_args, _sel, _ctx) => {
438 // GraphQL doesn't share the REST rate-limit state directly. Surface a
439 // permissive synthetic window so clients can consume the shape.
440 return { remaining: 1000, reset: Math.floor(Date.now() / 1000) + 3600 };
441 },
442};
443
444export async function execute(
445 src: string,
446 ctx: ExecCtx
447): Promise<GqlResponse> {
448 const parsed = parseQuery(src);
449 if (!parsed.ok) {
450 return { errors: [{ message: parsed.error }] };
451 }
452 const out: Record<string, any> = {};
453 const errors: GqlError[] = [];
454 for (const field of parsed.fields) {
455 const resolver = ROOT[field.name];
456 const key = field.alias || field.name;
457 if (!resolver) {
458 errors.push({
459 message: `Unknown root field '${field.name}'`,
460 path: [key],
461 });
462 out[key] = null;
463 continue;
464 }
465 try {
466 const raw = await resolver(field.args, field.selections, ctx);
467 if (raw == null) {
468 out[key] = null;
469 continue;
470 }
471 if (Array.isArray(raw)) {
472 out[key] = await Promise.all(
473 raw.map((v) =>
474 typeof v === "object"
475 ? resolveSelections(v, field.selections)
476 : Promise.resolve(v)
477 )
478 );
479 } else if (typeof raw === "object") {
480 // resolver may already have expanded object to selection set; if not,
481 // do it now.
482 out[key] = await resolveSelections(raw, field.selections);
483 } else {
484 out[key] = raw;
485 }
486 } catch (err) {
487 errors.push({
488 message: (err as Error).message || "execution error",
489 path: [key],
490 });
491 out[key] = null;
492 }
493 }
494 const response: GqlResponse = { data: out };
495 if (errors.length > 0) response.errors = errors;
496 return response;
497}