diff --git a/migrations/0064_orb_github_installations.sql b/migrations/0064_orb_github_installations.sql new file mode 100644 index 0000000000..d3d0bb51cd --- /dev/null +++ b/migrations/0064_orb_github_installations.sql @@ -0,0 +1,18 @@ +-- Gittensory Orb central GitHub App (#1255) — installation registry. One row per install of the shared Orb +-- App, maintained from the verified /v1/orb/webhook installation events. This is what onboarding + the +-- token-broker (later PRs) read to know which installations exist, who owns them, and whether an operator has +-- registered them. registered=0 by default — the Mirror-style manual-onboarding gate (an install is RECORDED +-- but does not count / activate until a human opts it in), mirroring #1274's orb_instances trust model. +CREATE TABLE IF NOT EXISTS orb_github_installations ( + installation_id INTEGER PRIMARY KEY NOT NULL, + account_login TEXT, -- the org/user the App is installed on + account_type TEXT, -- 'Organization' | 'User' + repository_selection TEXT, -- 'all' | 'selected' + registered INTEGER NOT NULL DEFAULT 0, + suspended_at TEXT, -- set on 'suspend', cleared on 'unsuspend' + removed_at TEXT, -- set on 'deleted' (kept for audit rather than hard-deleted) + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_event_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS orb_github_installations_registered_idx ON orb_github_installations(registered); diff --git a/src/orb/installations.ts b/src/orb/installations.ts new file mode 100644 index 0000000000..6e0e30fb75 --- /dev/null +++ b/src/orb/installations.ts @@ -0,0 +1,41 @@ +// Gittensory Orb central GitHub App (#1255) — installation registry maintenance. +// +// Keeps orb_github_installations in sync with the App's `installation` lifecycle events (created / +// new_permissions_accepted / suspend / unsuspend / deleted). A fast, idempotent upsert run synchronously +// from the verified webhook receiver — onboarding + the token-broker (later PRs) read this registry. +// registered stays 0 (the manual-onboarding gate) and is NEVER touched here — an install is recorded but not +// trusted until an operator opts it in. +import type { GitHubWebhookPayload } from "../types"; + +export async function upsertOrbInstallation(env: Env, eventName: string, payload: GitHubWebhookPayload): Promise { + if (eventName !== "installation") return; // installation_repositories repo-delta tracking is a follow-up + const inst = payload.installation; + if (!inst?.id) return; + + switch (payload.action) { + case "created": + case "new_permissions_accepted": + await env.DB.prepare( + `INSERT INTO orb_github_installations (installation_id, account_login, account_type, repository_selection, last_event_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(installation_id) DO UPDATE SET + account_login = excluded.account_login, account_type = excluded.account_type, + repository_selection = excluded.repository_selection, + suspended_at = NULL, removed_at = NULL, last_event_at = CURRENT_TIMESTAMP`, + ) + .bind(inst.id, inst.account?.login ?? null, inst.account?.type ?? null, inst.repository_selection ?? null) + .run(); + return; + case "deleted": + await env.DB.prepare(`UPDATE orb_github_installations SET removed_at = CURRENT_TIMESTAMP, last_event_at = CURRENT_TIMESTAMP WHERE installation_id = ?`).bind(inst.id).run(); + return; + case "suspend": + await env.DB.prepare(`UPDATE orb_github_installations SET suspended_at = CURRENT_TIMESTAMP, last_event_at = CURRENT_TIMESTAMP WHERE installation_id = ?`).bind(inst.id).run(); + return; + case "unsuspend": + await env.DB.prepare(`UPDATE orb_github_installations SET suspended_at = NULL, last_event_at = CURRENT_TIMESTAMP WHERE installation_id = ?`).bind(inst.id).run(); + return; + default: + return; // other installation actions carry no registry change + } +} diff --git a/src/orb/webhook.ts b/src/orb/webhook.ts index b41866e057..81ae46c863 100644 --- a/src/orb/webhook.ts +++ b/src/orb/webhook.ts @@ -10,6 +10,7 @@ import type { Context } from "hono"; import type { GitHubWebhookPayload } from "../types"; import { sha256Hex, verifyGitHubSignature } from "../utils/crypto"; +import { upsertOrbInstallation } from "./installations"; const DEFAULT_MAX_ORB_WEBHOOK_BODY_BYTES = 1024 * 1024; @@ -53,15 +54,26 @@ export async function handleOrbWebhook(c: Context<{ Bindings: Env }>): Promise { await env.DB.prepare( `INSERT INTO orb_webhook_events (delivery_id, event_name, action, installation_id, repository_full_name, payload_hash, status) - VALUES (?, ?, ?, ?, ?, ?, 'received') + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(delivery_id) DO UPDATE SET - status = 'received', payload_hash = excluded.payload_hash, action = excluded.action, + status = excluded.status, payload_hash = excluded.payload_hash, action = excluded.action, installation_id = excluded.installation_id, repository_full_name = excluded.repository_full_name`, ) - .bind(e.deliveryId, e.eventName, e.action, e.installationId, e.repositoryFullName, e.payloadHash) + .bind(e.deliveryId, e.eventName, e.action, e.installationId, e.repositoryFullName, e.payloadHash, e.status) .run(); } diff --git a/test/integration/orb-installations.test.ts b/test/integration/orb-installations.test.ts new file mode 100644 index 0000000000..3a4536afc2 --- /dev/null +++ b/test/integration/orb-installations.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { upsertOrbInstallation } from "../../src/orb/installations"; +import { createTestEnv, type TestD1Database } from "../helpers/d1"; + +const created = (id: number) => ({ action: "created", installation: { id, account: { login: "acme", type: "Organization" }, repository_selection: "selected" } }); +const get = (e: Env, id: number) => + (e.DB as unknown as TestD1Database) + .prepare("SELECT account_login, account_type, repository_selection, registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id=?") + .bind(id) + .first<{ account_login: string; account_type: string; repository_selection: string; registered: number; suspended_at: string | null; removed_at: string | null }>(); + +describe("upsertOrbInstallation", () => { + it("'created' registers the install (registered=0 — the manual-onboarding gate)", async () => { + const e = createTestEnv(); + await upsertOrbInstallation(e, "installation", created(100)); + expect(await get(e, 100)).toMatchObject({ account_login: "acme", account_type: "Organization", repository_selection: "selected", registered: 0, suspended_at: null, removed_at: null }); + }); + + it("'created' with a minimal installation stores null account/type/selection", async () => { + const e = createTestEnv(); + await upsertOrbInstallation(e, "installation", { action: "created", installation: { id: 300 } }); + expect(await get(e, 300)).toMatchObject({ account_login: null, account_type: null, repository_selection: null, registered: 0 }); + }); + + it("'suspend' then 'unsuspend' toggle suspended_at", async () => { + const e = createTestEnv(); + await upsertOrbInstallation(e, "installation", created(101)); + await upsertOrbInstallation(e, "installation", { action: "suspend", installation: { id: 101 } }); + expect((await get(e, 101))?.suspended_at).not.toBeNull(); + await upsertOrbInstallation(e, "installation", { action: "unsuspend", installation: { id: 101 } }); + expect((await get(e, 101))?.suspended_at).toBeNull(); + }); + + it("'deleted' sets removed_at; 'new_permissions_accepted' re-activates (clears removed_at)", async () => { + const e = createTestEnv(); + await upsertOrbInstallation(e, "installation", created(102)); + await upsertOrbInstallation(e, "installation", { action: "deleted", installation: { id: 102 } }); + expect((await get(e, 102))?.removed_at).not.toBeNull(); + await upsertOrbInstallation(e, "installation", { action: "new_permissions_accepted", installation: { id: 102, account: { login: "acme", type: "Organization" }, repository_selection: "all" } }); + const row = await get(e, 102); + expect(row?.removed_at).toBeNull(); + expect(row?.repository_selection).toBe("all"); + }); + + it("does nothing for a non-installation event, a missing installation id, or an unknown action", async () => { + const e = createTestEnv(); + await upsertOrbInstallation(e, "pull_request", created(200)); // wrong event + await upsertOrbInstallation(e, "installation", { action: "created" }); // no installation object + await upsertOrbInstallation(e, "installation", { action: "created", installation: { id: 0 } }); // falsy id + expect(await get(e, 200)).toBeFalsy(); // never inserted + const e2 = createTestEnv(); + await upsertOrbInstallation(e2, "installation", created(201)); + await upsertOrbInstallation(e2, "installation", { action: "labeled", installation: { id: 201 } }); // unknown action → no change + expect((await get(e2, 201))?.removed_at).toBeNull(); + }); +}); diff --git a/test/integration/orb-webhook.test.ts b/test/integration/orb-webhook.test.ts index f1091852c3..dca8a719d7 100644 --- a/test/integration/orb-webhook.test.ts +++ b/test/integration/orb-webhook.test.ts @@ -41,6 +41,20 @@ const row = (e: Env, delivery: string) => (e.DB as unknown as TestD1Database).prepare("SELECT event_name, action, installation_id, repository_full_name, status FROM orb_webhook_events WHERE delivery_id=?").bind(delivery).first<{ event_name: string; action: string; installation_id: number; repository_full_name: string; status: string }>(); describe("handleOrbWebhook (POST /v1/orb/webhook)", () => { + it("500 + records 'error' when the install-registry upsert fails (so GitHub redelivers)", async () => { + const e = env(); + const real = e.DB; + // Throw on the installations upsert only; the webhook_events read/write still go to the real DB. + (e as { DB: unknown }).DB = { + prepare: (sql: string) => + sql.includes("orb_github_installations") ? { bind: () => ({ run: () => Promise.reject(new Error("boom")) }) } : real.prepare(sql), + }; + const res = await post(e, INSTALL, { delivery: "up-err" }); + expect(res.status).toBe(500); + const stored = await (real as unknown as TestD1Database).prepare("SELECT status FROM orb_webhook_events WHERE delivery_id=?").bind("up-err").first<{ status: string }>(); + expect(stored?.status).toBe("error"); // not suppressed → GitHub can retry + }); + it("400 when the GitHub delivery or event header is missing", async () => { expect((await post(env(), INSTALL, { delivery: null as unknown as string })).status).toBe(400); expect((await post(env(), INSTALL, { event: null as unknown as string })).status).toBe(400);