Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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

marketplace.tsBlame515 lines · 1 contributor
06139e6Claude1/**
2 * Block H — App marketplace + bot identities.
3 *
4 * Known permission names. These are the vocabulary that apps declare and
5 * installers grant. Permissions are string-matched at request time — for v1,
6 * handlers that consume app tokens call `hasPermission(token, "issues:write")`
7 * and fail closed when absent.
8 *
9 * Higher-level permissions imply lower ones (write implies read) so the UI
10 * only presents one level at a time.
11 */
12
13import { createHash, randomBytes } from "node:crypto";
14import { and, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 apps,
18 appBots,
19 appInstallations,
20 appInstallTokens,
21 appEvents,
22 users,
23 type App,
24 type AppInstallation,
25} from "../db/schema";
26
27export const KNOWN_PERMISSIONS = [
28 "contents:read",
29 "contents:write",
30 "issues:read",
31 "issues:write",
32 "pulls:read",
33 "pulls:write",
34 "checks:read",
35 "checks:write",
36 "deployments:read",
37 "deployments:write",
38 "metadata:read",
39] as const;
40
41export type Permission = (typeof KNOWN_PERMISSIONS)[number];
42
43export const KNOWN_EVENTS = [
44 "push",
45 "issues",
46 "issue_comment",
47 "pull_request",
48 "pull_request_review",
49 "check_run",
50 "deployment",
51 "release",
52] as const;
53
54export type EventName = (typeof KNOWN_EVENTS)[number];
55
56// ---------- Pure helpers ----------
57
58/**
59 * Slugify an app name — lowercase alphanumeric + dashes, trim leading/trailing
60 * dashes, cap at 40 chars.
61 */
62export function slugify(name: string): string {
63 return name
64 .toLowerCase()
65 .replace(/[^a-z0-9]+/g, "-")
66 .replace(/^-+|-+$/g, "")
67 .slice(0, 40);
68}
69
70/** Bot usernames are always `<slug>[bot]`. */
71export function botUsername(slug: string): string {
72 return `${slug}[bot]`;
73}
74
75/** Is `granted` a subset of `requested`? Used when validating install forms. */
76export function permissionsSubset(
77 granted: readonly string[],
78 requested: readonly string[]
79): boolean {
80 const req = new Set(requested);
81 return granted.every((g) => req.has(g));
82}
83
84/** Normalise + de-dup permissions. Drops unknown values. */
85export function normalisePermissions(input: readonly string[]): Permission[] {
86 const seen = new Set<string>();
87 const out: Permission[] = [];
88 for (const p of input) {
89 if ((KNOWN_PERMISSIONS as readonly string[]).includes(p) && !seen.has(p)) {
90 seen.add(p);
91 out.push(p as Permission);
92 }
93 }
94 return out;
95}
96
97/** Parse a JSON permission list out of the DB column. */
98export function parsePermissions(raw: string | null | undefined): Permission[] {
99 if (!raw) return [];
100 try {
101 const parsed = JSON.parse(raw);
102 if (!Array.isArray(parsed)) return [];
103 return normalisePermissions(parsed);
104 } catch {
105 return [];
106 }
107}
108
109/** write:* implies read:* on the same resource family. */
110export function hasPermission(
111 granted: readonly string[],
112 required: string
113): boolean {
114 if (granted.includes(required)) return true;
115 // write implies read
116 if (required.endsWith(":read")) {
117 const writeEquivalent = required.replace(":read", ":write");
118 return granted.includes(writeEquivalent);
119 }
120 return false;
121}
122
123export function generateBearerToken(): { token: string; hash: string } {
124 const token = "ghi_" + randomBytes(24).toString("hex");
125 const hash = createHash("sha256").update(token).digest("hex");
126 return { token, hash };
127}
128
129export function hashBearer(token: string): string {
130 return createHash("sha256").update(token).digest("hex");
131}
132
133// ---------- DB helpers ----------
134
135export async function listPublicApps(query = ""): Promise<App[]> {
136 try {
137 const rows = await db
138 .select()
139 .from(apps)
140 .where(
141 query
142 ? and(
143 eq(apps.isPublic, true),
144 or(
145 ilike(apps.name, `%${query}%`),
146 ilike(apps.description, `%${query}%`),
147 ilike(apps.slug, `%${query}%`)
148 )!
149 )
150 : eq(apps.isPublic, true)
151 )
152 .orderBy(desc(apps.createdAt))
153 .limit(100);
154 return rows;
155 } catch {
156 return [];
157 }
158}
159
160export async function getAppBySlug(slug: string): Promise<App | null> {
161 try {
162 const [r] = await db
163 .select()
164 .from(apps)
165 .where(eq(apps.slug, slug))
166 .limit(1);
167 return r || null;
168 } catch {
169 return null;
170 }
171}
172
173export interface CreateAppArgs {
174 name: string;
175 description?: string;
176 iconUrl?: string;
177 homepageUrl?: string;
178 webhookUrl?: string;
179 creatorId: string;
180 permissions: readonly string[];
181 defaultEvents?: readonly string[];
182 isPublic?: boolean;
183}
184
185/** Create an app + matching bot. Slug is derived from name; retries on collision. */
186export async function createApp(args: CreateAppArgs): Promise<App | null> {
187 const baseSlug = slugify(args.name) || "app";
188 for (let attempt = 0; attempt < 6; attempt++) {
189 const slug = attempt === 0 ? baseSlug : `${baseSlug}-${randomBytes(2).toString("hex")}`;
190 const webhookSecret = randomBytes(24).toString("hex");
191 try {
192 const [row] = await db
193 .insert(apps)
194 .values({
195 slug,
196 name: args.name,
197 description: args.description || "",
198 iconUrl: args.iconUrl,
199 homepageUrl: args.homepageUrl,
200 webhookUrl: args.webhookUrl,
201 webhookSecret,
202 creatorId: args.creatorId,
203 permissions: JSON.stringify(normalisePermissions(args.permissions)),
204 defaultEvents: JSON.stringify(
205 (args.defaultEvents || []).filter((e) =>
206 (KNOWN_EVENTS as readonly string[]).includes(e)
207 )
208 ),
209 isPublic: args.isPublic ?? true,
210 })
211 .returning();
212 if (!row) return null;
213 // Create matching bot account
7746deeClaude214 const botUname = botUsername(slug);
06139e6Claude215 await db.insert(appBots).values({
216 appId: row.id,
7746deeClaude217 username: botUname,
06139e6Claude218 displayName: `${args.name} (bot)`,
219 avatarUrl: args.iconUrl,
220 });
7746deeClaude221 // Create a synthetic users row so install-token push auth has a real
222 // users.id to attribute to. Email + passwordHash are deliberately
223 // unusable so bots cannot log in via password — auth is install-token
224 // only via Authorization: Bearer ghi_*. Idempotent: onConflictDoNothing
225 // keeps re-runs safe (e.g. if the apps insert succeeds but the bot
226 // row had to be retried).
227 const botEmail = `${botUname}@gluecron.local`;
228 try {
229 await db
230 .insert(users)
231 .values({
232 username: botUname,
233 email: botEmail,
234 displayName: `${args.name} (bot)`,
235 // Random non-functional hash. Anyone trying to "guess" it
236 // would still fail bcrypt-compare since no password makes it.
237 passwordHash: `!bot:${randomBytes(24).toString("hex")}`,
238 avatarUrl: args.iconUrl,
239 // Bots opt out of all email notifications by default.
240 notifyEmailOnMention: false,
241 notifyEmailOnAssign: false,
242 notifyEmailOnGateFail: false,
243 })
244 .onConflictDoNothing?.();
245 } catch {
246 // Bot user already exists or onConflictDoNothing isn't supported on
247 // this driver — either way, the install-token resolver will fail
248 // soft on lookup and treat the call as anonymous. Do not let this
249 // block app creation.
250 }
06139e6Claude251 return row;
252 } catch (err: any) {
253 if (String(err?.message || "").includes("duplicate")) continue;
254 console.error("[marketplace] createApp:", err);
255 return null;
256 }
257 }
258 return null;
259}
260
261export async function listInstallationsForApp(
262 appId: string
263): Promise<AppInstallation[]> {
264 try {
265 return await db
266 .select()
267 .from(appInstallations)
268 .where(
269 and(eq(appInstallations.appId, appId), isNull(appInstallations.uninstalledAt))
270 )
271 .orderBy(desc(appInstallations.createdAt));
272 } catch {
273 return [];
274 }
275}
276
277export async function listInstallationsForTarget(
278 targetType: "user" | "org" | "repository",
279 targetId: string
280): Promise<Array<AppInstallation & { app: App | null }>> {
281 try {
282 const rows = await db
283 .select({
284 install: appInstallations,
285 app: apps,
286 })
287 .from(appInstallations)
288 .leftJoin(apps, eq(appInstallations.appId, apps.id))
289 .where(
290 and(
291 eq(appInstallations.targetType, targetType),
292 eq(appInstallations.targetId, targetId),
293 isNull(appInstallations.uninstalledAt)
294 )
295 )
296 .orderBy(desc(appInstallations.createdAt));
297 return rows.map((r) => ({ ...r.install, app: r.app }));
298 } catch {
299 return [];
300 }
301}
302
303export interface InstallArgs {
304 appId: string;
305 installedBy: string;
306 targetType: "user" | "org" | "repository";
307 targetId: string;
308 grantedPermissions: readonly string[];
309}
310
311export async function installApp(
312 args: InstallArgs
313): Promise<AppInstallation | null> {
314 try {
315 // Find the app to validate permissions
316 const [app] = await db
317 .select()
318 .from(apps)
319 .where(eq(apps.id, args.appId))
320 .limit(1);
321 if (!app) return null;
322 const appPerms = parsePermissions(app.permissions);
323 const granted = normalisePermissions(args.grantedPermissions);
324 // Only allow granting what the app actually requests
325 const filtered = granted.filter((p) => appPerms.includes(p));
326 // If a non-uninstalled row exists, soft-update it (idempotent)
327 const [existing] = await db
328 .select()
329 .from(appInstallations)
330 .where(
331 and(
332 eq(appInstallations.appId, args.appId),
333 eq(appInstallations.targetType, args.targetType),
334 eq(appInstallations.targetId, args.targetId),
335 isNull(appInstallations.uninstalledAt)
336 )
337 )
338 .limit(1);
339 if (existing) {
340 await db
341 .update(appInstallations)
342 .set({ grantedPermissions: JSON.stringify(filtered) })
343 .where(eq(appInstallations.id, existing.id));
344 await db.insert(appEvents).values({
345 appId: args.appId,
346 installationId: existing.id,
347 kind: "installed",
348 payload: JSON.stringify({ updated: true }),
349 });
350 return existing;
351 }
352 const [row] = await db
353 .insert(appInstallations)
354 .values({
355 appId: args.appId,
356 installedBy: args.installedBy,
357 targetType: args.targetType,
358 targetId: args.targetId,
359 grantedPermissions: JSON.stringify(filtered),
360 })
361 .returning();
362 if (row) {
363 await db.insert(appEvents).values({
364 appId: args.appId,
365 installationId: row.id,
366 kind: "installed",
367 payload: JSON.stringify({
368 targetType: args.targetType,
369 targetId: args.targetId,
370 }),
371 });
372 }
373 return row || null;
374 } catch (err) {
375 console.error("[marketplace] installApp:", err);
376 return null;
377 }
378}
379
380export async function uninstallApp(installationId: string): Promise<boolean> {
381 try {
382 const [row] = await db
383 .update(appInstallations)
384 .set({ uninstalledAt: new Date() })
385 .where(
386 and(
387 eq(appInstallations.id, installationId),
388 isNull(appInstallations.uninstalledAt)
389 )
390 )
391 .returning();
392 if (row) {
393 await db.insert(appEvents).values({
394 appId: row.appId,
395 installationId: row.id,
396 kind: "uninstalled",
397 });
398 // Revoke all tokens
399 await db
400 .update(appInstallTokens)
401 .set({ revokedAt: new Date() })
402 .where(
403 and(
404 eq(appInstallTokens.installationId, installationId),
405 isNull(appInstallTokens.revokedAt)
406 )
407 );
408 return true;
409 }
410 return false;
411 } catch (err) {
412 console.error("[marketplace] uninstallApp:", err);
413 return false;
414 }
415}
416
417/** Issue a bearer token scoped to a single installation. Default TTL: 1h. */
418export async function issueInstallToken(
419 installationId: string,
420 ttlSeconds = 3600
421): Promise<{ token: string; expiresAt: Date } | null> {
422 try {
423 const { token, hash } = generateBearerToken();
424 const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
425 await db
426 .insert(appInstallTokens)
427 .values({ installationId, tokenHash: hash, expiresAt });
428 return { token, expiresAt };
429 } catch (err) {
430 console.error("[marketplace] issueInstallToken:", err);
431 return null;
432 }
433}
434
435/**
436 * Verify a bearer and return the matched installation + permissions. Returns
437 * null when the token is unknown, revoked, or expired.
438 */
439export async function verifyInstallToken(
440 token: string
441): Promise<{
442 installation: AppInstallation;
443 app: App;
444 botUsername: string;
445 permissions: Permission[];
446} | null> {
447 if (!token || !token.startsWith("ghi_")) return null;
448 const hash = hashBearer(token);
449 try {
450 const [row] = await db
451 .select({
452 inst: appInstallations,
453 app: apps,
454 tok: appInstallTokens,
455 })
456 .from(appInstallTokens)
457 .innerJoin(
458 appInstallations,
459 eq(appInstallTokens.installationId, appInstallations.id)
460 )
461 .innerJoin(apps, eq(appInstallations.appId, apps.id))
462 .where(eq(appInstallTokens.tokenHash, hash))
463 .limit(1);
464 if (!row) return null;
465 if (row.tok.revokedAt) return null;
466 if (row.tok.expiresAt < new Date()) return null;
467 if (row.inst.uninstalledAt) return null;
468 if (row.inst.suspendedAt) return null;
469 const perms = parsePermissions(row.inst.grantedPermissions);
470 const slug = row.app.slug;
471 return {
472 installation: row.inst,
473 app: row.app,
474 botUsername: botUsername(slug),
475 permissions: perms,
476 };
477 } catch {
478 return null;
479 }
480}
481
482/** Admin-ish listing of an app's recent event log. */
483export async function listEventsForApp(
484 appId: string,
485 limit = 50
486): Promise<Array<typeof appEvents.$inferSelect>> {
487 try {
488 return await db
489 .select()
490 .from(appEvents)
491 .where(eq(appEvents.appId, appId))
492 .orderBy(desc(appEvents.createdAt))
493 .limit(limit);
494 } catch {
495 return [];
496 }
497}
498
499/** Count live installs — for app detail page. */
500export async function countInstalls(appId: string): Promise<number> {
501 try {
502 const [r] = await db
503 .select({ n: sql<number>`count(*)::int` })
504 .from(appInstallations)
505 .where(
506 and(
507 eq(appInstallations.appId, appId),
508 isNull(appInstallations.uninstalledAt)
509 )
510 );
511 return Number(r?.n || 0);
512 } catch {
513 return 0;
514 }
515}