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 | |
| 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 | ||
| 230 | export 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 | ||
| 246 | export 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 | ||
| 272 | export interface InstallArgs { | |
| 273 | appId: string; | |
| 274 | installedBy: string; | |
| 275 | targetType: "user" | "org" | "repository"; | |
| 276 | targetId: string; | |
| 277 | grantedPermissions: readonly string[]; | |
| 278 | } | |
| 279 | ||
| 280 | export 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 | ||
| 349 | export 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. */ | |
| 387 | export 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 | */ | |
| 408 | export 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. */ | |
| 452 | export 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. */ | |
| 469 | export 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 | } |