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.
| 06139e6 | 1 | /** |
| 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 | ||
| 13 | import { createHash, randomBytes } from "node:crypto"; | |
| 14 | import { and, desc, eq, ilike, isNull, or, sql } from "drizzle-orm"; | |
| 15 | import { db } from "../db"; | |
| 16 | import { | |
| 17 | apps, | |
| 18 | appBots, | |
| 19 | appInstallations, | |
| 20 | appInstallTokens, | |
| 21 | appEvents, | |
| 22 | users, | |
| 23 | type App, | |
| 24 | type AppInstallation, | |
| 25 | } from "../db/schema"; | |
| 26 | ||
| 27 | export 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 | ||
| 41 | export type Permission = (typeof KNOWN_PERMISSIONS)[number]; | |
| 42 | ||
| 43 | export 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 | ||
| 54 | export 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 | */ | |
| 62 | export 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]`. */ | |
| 71 | export function botUsername(slug: string): string { | |
| 72 | return `${slug}[bot]`; | |
| 73 | } | |
| 74 | ||
| 75 | /** Is `granted` a subset of `requested`? Used when validating install forms. */ | |
| 76 | export 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. */ | |
| 85 | export 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. */ | |
| 98 | export 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. */ | |
| 110 | export 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 | ||
| 123 | export 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 | ||
| 129 | export function hashBearer(token: string): string { | |
| 130 | return createHash("sha256").update(token).digest("hex"); | |
| 131 | } | |
| 132 | ||
| 133 | // ---------- DB helpers ---------- | |
| 134 | ||
| 135 | export 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 | ||
| 160 | export 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 | ||
| 173 | export 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. */ | |
| 186 | export 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 | |
| 7746dee | 214 | const botUname = botUsername(slug); |
| 06139e6 | 215 | await db.insert(appBots).values({ |
| 216 | appId: row.id, | |
| 7746dee | 217 | username: botUname, |
| 06139e6 | 218 | displayName: `${args.name} (bot)`, |
| 219 | avatarUrl: args.iconUrl, | |
| 220 | }); | |
| 7746dee | 221 | // 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 | } | |
| 06139e6 | 251 | 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 | ||
| 261 | export 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 | ||
| 277 | export 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 | ||
| 303 | export interface InstallArgs { | |
| 304 | appId: string; | |
| 305 | installedBy: string; | |
| 306 | targetType: "user" | "org" | "repository"; | |
| 307 | targetId: string; | |
| 308 | grantedPermissions: readonly string[]; | |
| 309 | } | |
| 310 | ||
| 311 | export 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 | ||
| 380 | export 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. */ | |
| 418 | export 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 | */ | |
| 439 | export 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. */ | |
| 483 | export 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. */ | |
| 500 | export 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 | } |