Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions migrations/0064_orb_github_installations.sql
Original file line number Diff line number Diff line change
@@ -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);
41 changes: 41 additions & 0 deletions src/orb/installations.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
24 changes: 18 additions & 6 deletions src/orb/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -53,15 +54,26 @@ export async function handleOrbWebhook(c: Context<{ Bindings: Env }>): Promise<R
return c.json({ ok: true, deliveryId, eventName, status: "duplicate" }, 202);
}

await recordOrbWebhookEvent(c.env, {
const eventMeta = {
deliveryId,
eventName,
action: payload.action ?? null,
installationId: payload.installation?.id ?? null,
repositoryFullName: payload.repository?.full_name ?? null,
payloadHash,
});
};

// Maintain the installation registry from `installation` lifecycle events BEFORE recording, so a failed
// upsert is flipped to "error" + 500 and GitHub redelivers (the dedup guard only suppresses non-error rows).
// No-op for every other event in PR2 — PR/review-outcome processing lands in a later queue-backed PR.
try {
await upsertOrbInstallation(c.env, eventName, payload);
} catch {
await recordOrbWebhookEvent(c.env, { ...eventMeta, status: "error" });
return c.json({ error: "processing_failed", deliveryId }, 500);
}

await recordOrbWebhookEvent(c.env, { ...eventMeta, status: "received" });
return c.json({ ok: true, deliveryId, eventName, status: "received" }, 202);
}

Expand All @@ -74,16 +86,16 @@ async function getOrbWebhookEvent(env: Env, deliveryId: string): Promise<{ paylo

async function recordOrbWebhookEvent(
env: Env,
e: { deliveryId: string; eventName: string; action: string | null; installationId: number | null; repositoryFullName: string | null; payloadHash: string },
e: { deliveryId: string; eventName: string; action: string | null; installationId: number | null; repositoryFullName: string | null; payloadHash: string; status: string },
): Promise<void> {
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();
}

Expand Down
56 changes: 56 additions & 0 deletions test/integration/orb-installations.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 14 additions & 0 deletions test/integration/orb-webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading