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.tsBlame484 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
214 await db.insert(appBots).values({
215 appId: row.id,
216 username: botUsername(slug),
217 displayName: `${args.name} (bot)`,
218 avatarUrl: args.iconUrl,
219 });
220 return row;
221 } catch (err: any) {
222 if (String(err?.message || "").includes("duplicate")) continue;
223 console.error("[marketplace] createApp:", err);
224 return null;
225 }
226 }
227 return null;
228}
229
230export async function listInstallationsForApp(
231 appId: string
232): Promise<AppInstallation[]> {
233 try {
234 return await db
235 .select()
236 .from(appInstallations)
237 .where(
238 and(eq(appInstallations.appId, appId), isNull(appInstallations.uninstalledAt))
239 )
240 .orderBy(desc(appInstallations.createdAt));
241 } catch {
242 return [];
243 }
244}
245
246export async function listInstallationsForTarget(
247 targetType: "user" | "org" | "repository",
248 targetId: string
249): Promise<Array<AppInstallation & { app: App | null }>> {
250 try {
251 const rows = await db
252 .select({
253 install: appInstallations,
254 app: apps,
255 })
256 .from(appInstallations)
257 .leftJoin(apps, eq(appInstallations.appId, apps.id))
258 .where(
259 and(
260 eq(appInstallations.targetType, targetType),
261 eq(appInstallations.targetId, targetId),
262 isNull(appInstallations.uninstalledAt)
263 )
264 )
265 .orderBy(desc(appInstallations.createdAt));
266 return rows.map((r) => ({ ...r.install, app: r.app }));
267 } catch {
268 return [];
269 }
270}
271
272export interface InstallArgs {
273 appId: string;
274 installedBy: string;
275 targetType: "user" | "org" | "repository";
276 targetId: string;
277 grantedPermissions: readonly string[];
278}
279
280export async function installApp(
281 args: InstallArgs
282): Promise<AppInstallation | null> {
283 try {
284 // Find the app to validate permissions
285 const [app] = await db
286 .select()
287 .from(apps)
288 .where(eq(apps.id, args.appId))
289 .limit(1);
290 if (!app) return null;
291 const appPerms = parsePermissions(app.permissions);
292 const granted = normalisePermissions(args.grantedPermissions);
293 // Only allow granting what the app actually requests
294 const filtered = granted.filter((p) => appPerms.includes(p));
295 // If a non-uninstalled row exists, soft-update it (idempotent)
296 const [existing] = await db
297 .select()
298 .from(appInstallations)
299 .where(
300 and(
301 eq(appInstallations.appId, args.appId),
302 eq(appInstallations.targetType, args.targetType),
303 eq(appInstallations.targetId, args.targetId),
304 isNull(appInstallations.uninstalledAt)
305 )
306 )
307 .limit(1);
308 if (existing) {
309 await db
310 .update(appInstallations)
311 .set({ grantedPermissions: JSON.stringify(filtered) })
312 .where(eq(appInstallations.id, existing.id));
313 await db.insert(appEvents).values({
314 appId: args.appId,
315 installationId: existing.id,
316 kind: "installed",
317 payload: JSON.stringify({ updated: true }),
318 });
319 return existing;
320 }
321 const [row] = await db
322 .insert(appInstallations)
323 .values({
324 appId: args.appId,
325 installedBy: args.installedBy,
326 targetType: args.targetType,
327 targetId: args.targetId,
328 grantedPermissions: JSON.stringify(filtered),
329 })
330 .returning();
331 if (row) {
332 await db.insert(appEvents).values({
333 appId: args.appId,
334 installationId: row.id,
335 kind: "installed",
336 payload: JSON.stringify({
337 targetType: args.targetType,
338 targetId: args.targetId,
339 }),
340 });
341 }
342 return row || null;
343 } catch (err) {
344 console.error("[marketplace] installApp:", err);
345 return null;
346 }
347}
348
349export async function uninstallApp(installationId: string): Promise<boolean> {
350 try {
351 const [row] = await db
352 .update(appInstallations)
353 .set({ uninstalledAt: new Date() })
354 .where(
355 and(
356 eq(appInstallations.id, installationId),
357 isNull(appInstallations.uninstalledAt)
358 )
359 )
360 .returning();
361 if (row) {
362 await db.insert(appEvents).values({
363 appId: row.appId,
364 installationId: row.id,
365 kind: "uninstalled",
366 });
367 // Revoke all tokens
368 await db
369 .update(appInstallTokens)
370 .set({ revokedAt: new Date() })
371 .where(
372 and(
373 eq(appInstallTokens.installationId, installationId),
374 isNull(appInstallTokens.revokedAt)
375 )
376 );
377 return true;
378 }
379 return false;
380 } catch (err) {
381 console.error("[marketplace] uninstallApp:", err);
382 return false;
383 }
384}
385
386/** Issue a bearer token scoped to a single installation. Default TTL: 1h. */
387export async function issueInstallToken(
388 installationId: string,
389 ttlSeconds = 3600
390): Promise<{ token: string; expiresAt: Date } | null> {
391 try {
392 const { token, hash } = generateBearerToken();
393 const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
394 await db
395 .insert(appInstallTokens)
396 .values({ installationId, tokenHash: hash, expiresAt });
397 return { token, expiresAt };
398 } catch (err) {
399 console.error("[marketplace] issueInstallToken:", err);
400 return null;
401 }
402}
403
404/**
405 * Verify a bearer and return the matched installation + permissions. Returns
406 * null when the token is unknown, revoked, or expired.
407 */
408export async function verifyInstallToken(
409 token: string
410): Promise<{
411 installation: AppInstallation;
412 app: App;
413 botUsername: string;
414 permissions: Permission[];
415} | null> {
416 if (!token || !token.startsWith("ghi_")) return null;
417 const hash = hashBearer(token);
418 try {
419 const [row] = await db
420 .select({
421 inst: appInstallations,
422 app: apps,
423 tok: appInstallTokens,
424 })
425 .from(appInstallTokens)
426 .innerJoin(
427 appInstallations,
428 eq(appInstallTokens.installationId, appInstallations.id)
429 )
430 .innerJoin(apps, eq(appInstallations.appId, apps.id))
431 .where(eq(appInstallTokens.tokenHash, hash))
432 .limit(1);
433 if (!row) return null;
434 if (row.tok.revokedAt) return null;
435 if (row.tok.expiresAt < new Date()) return null;
436 if (row.inst.uninstalledAt) return null;
437 if (row.inst.suspendedAt) return null;
438 const perms = parsePermissions(row.inst.grantedPermissions);
439 const slug = row.app.slug;
440 return {
441 installation: row.inst,
442 app: row.app,
443 botUsername: botUsername(slug),
444 permissions: perms,
445 };
446 } catch {
447 return null;
448 }
449}
450
451/** Admin-ish listing of an app's recent event log. */
452export async function listEventsForApp(
453 appId: string,
454 limit = 50
455): Promise<Array<typeof appEvents.$inferSelect>> {
456 try {
457 return await db
458 .select()
459 .from(appEvents)
460 .where(eq(appEvents.appId, appId))
461 .orderBy(desc(appEvents.createdAt))
462 .limit(limit);
463 } catch {
464 return [];
465 }
466}
467
468/** Count live installs — for app detail page. */
469export async function countInstalls(appId: string): Promise<number> {
470 try {
471 const [r] = await db
472 .select({ n: sql<number>`count(*)::int` })
473 .from(appInstallations)
474 .where(
475 and(
476 eq(appInstallations.appId, appId),
477 isNull(appInstallations.uninstalledAt)
478 )
479 );
480 return Number(r?.n || 0);
481 } catch {
482 return 0;
483 }
484}