Skip to content

Commit f50c611

Browse files
committed
feat(orb): installation registry from Orb App install events
Second piece of the central Gittensory Orb GitHub App (#1255). Maintains orb_github_installations (migration 0064) from the verified /v1/orb/webhook `installation` lifecycle events — one row per install of the shared Orb App, recording account + repository_selection and the suspend/unsuspend/deleted lifecycle. This is the registry onboarding + the token-broker (later PRs) read to know which installations exist and who owns them. - registered=0 by default — the Mirror-style manual-onboarding gate (an install is RECORDED but not trusted/active until a human opts it in), mirroring #1274. - The upsert runs synchronously in the receiver, BEFORE recording the webhook event, so a failed registry write is flipped to "error" + 500 and GitHub redelivers (the dedup guard only suppresses non-error rows). No-op for every non-installation event. Additive; stacked on #1293 (the webhook receiver). installation_repositories repo-delta tracking and PR-outcome processing are follow-ups. Advances #1255.
1 parent a40febb commit f50c611

5 files changed

Lines changed: 147 additions & 6 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Gittensory Orb central GitHub App (#1255) — installation registry. One row per install of the shared Orb
2+
-- App, maintained from the verified /v1/orb/webhook installation events. This is what onboarding + the
3+
-- token-broker (later PRs) read to know which installations exist, who owns them, and whether an operator has
4+
-- registered them. registered=0 by default — the Mirror-style manual-onboarding gate (an install is RECORDED
5+
-- but does not count / activate until a human opts it in), mirroring #1274's orb_instances trust model.
6+
CREATE TABLE IF NOT EXISTS orb_github_installations (
7+
installation_id INTEGER PRIMARY KEY NOT NULL,
8+
account_login TEXT, -- the org/user the App is installed on
9+
account_type TEXT, -- 'Organization' | 'User'
10+
repository_selection TEXT, -- 'all' | 'selected'
11+
registered INTEGER NOT NULL DEFAULT 0,
12+
suspended_at TEXT, -- set on 'suspend', cleared on 'unsuspend'
13+
removed_at TEXT, -- set on 'deleted' (kept for audit rather than hard-deleted)
14+
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
15+
last_event_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
16+
);
17+
18+
CREATE INDEX IF NOT EXISTS orb_github_installations_registered_idx ON orb_github_installations(registered);

src/orb/installations.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Gittensory Orb central GitHub App (#1255) — installation registry maintenance.
2+
//
3+
// Keeps orb_github_installations in sync with the App's `installation` lifecycle events (created /
4+
// new_permissions_accepted / suspend / unsuspend / deleted). A fast, idempotent upsert run synchronously
5+
// from the verified webhook receiver — onboarding + the token-broker (later PRs) read this registry.
6+
// registered stays 0 (the manual-onboarding gate) and is NEVER touched here — an install is recorded but not
7+
// trusted until an operator opts it in.
8+
import type { GitHubWebhookPayload } from "../types";
9+
10+
export async function upsertOrbInstallation(env: Env, eventName: string, payload: GitHubWebhookPayload): Promise<void> {
11+
if (eventName !== "installation") return; // installation_repositories repo-delta tracking is a follow-up
12+
const inst = payload.installation;
13+
if (!inst?.id) return;
14+
15+
switch (payload.action) {
16+
case "created":
17+
case "new_permissions_accepted":
18+
await env.DB.prepare(
19+
`INSERT INTO orb_github_installations (installation_id, account_login, account_type, repository_selection, last_event_at)
20+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
21+
ON CONFLICT(installation_id) DO UPDATE SET
22+
account_login = excluded.account_login, account_type = excluded.account_type,
23+
repository_selection = excluded.repository_selection,
24+
suspended_at = NULL, removed_at = NULL, last_event_at = CURRENT_TIMESTAMP`,
25+
)
26+
.bind(inst.id, inst.account?.login ?? null, inst.account?.type ?? null, inst.repository_selection ?? null)
27+
.run();
28+
return;
29+
case "deleted":
30+
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();
31+
return;
32+
case "suspend":
33+
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();
34+
return;
35+
case "unsuspend":
36+
await env.DB.prepare(`UPDATE orb_github_installations SET suspended_at = NULL, last_event_at = CURRENT_TIMESTAMP WHERE installation_id = ?`).bind(inst.id).run();
37+
return;
38+
default:
39+
return; // other installation actions carry no registry change
40+
}
41+
}

src/orb/webhook.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type { Context } from "hono";
1111
import type { GitHubWebhookPayload } from "../types";
1212
import { sha256Hex, verifyGitHubSignature } from "../utils/crypto";
13+
import { upsertOrbInstallation } from "./installations";
1314

1415
const DEFAULT_MAX_ORB_WEBHOOK_BODY_BYTES = 1024 * 1024;
1516

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

56-
await recordOrbWebhookEvent(c.env, {
57+
const eventMeta = {
5758
deliveryId,
5859
eventName,
5960
action: payload.action ?? null,
6061
installationId: payload.installation?.id ?? null,
6162
repositoryFullName: payload.repository?.full_name ?? null,
6263
payloadHash,
63-
});
64+
};
6465

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

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

7587
async function recordOrbWebhookEvent(
7688
env: Env,
77-
e: { deliveryId: string; eventName: string; action: string | null; installationId: number | null; repositoryFullName: string | null; payloadHash: string },
89+
e: { deliveryId: string; eventName: string; action: string | null; installationId: number | null; repositoryFullName: string | null; payloadHash: string; status: string },
7890
): Promise<void> {
7991
await env.DB.prepare(
8092
`INSERT INTO orb_webhook_events (delivery_id, event_name, action, installation_id, repository_full_name, payload_hash, status)
81-
VALUES (?, ?, ?, ?, ?, ?, 'received')
93+
VALUES (?, ?, ?, ?, ?, ?, ?)
8294
ON CONFLICT(delivery_id) DO UPDATE SET
83-
status = 'received', payload_hash = excluded.payload_hash, action = excluded.action,
95+
status = excluded.status, payload_hash = excluded.payload_hash, action = excluded.action,
8496
installation_id = excluded.installation_id, repository_full_name = excluded.repository_full_name`,
8597
)
86-
.bind(e.deliveryId, e.eventName, e.action, e.installationId, e.repositoryFullName, e.payloadHash)
98+
.bind(e.deliveryId, e.eventName, e.action, e.installationId, e.repositoryFullName, e.payloadHash, e.status)
8799
.run();
88100
}
89101

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import { upsertOrbInstallation } from "../../src/orb/installations";
3+
import { createTestEnv, type TestD1Database } from "../helpers/d1";
4+
5+
const created = (id: number) => ({ action: "created", installation: { id, account: { login: "acme", type: "Organization" }, repository_selection: "selected" } });
6+
const get = (e: Env, id: number) =>
7+
(e.DB as unknown as TestD1Database)
8+
.prepare("SELECT account_login, account_type, repository_selection, registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id=?")
9+
.bind(id)
10+
.first<{ account_login: string; account_type: string; repository_selection: string; registered: number; suspended_at: string | null; removed_at: string | null }>();
11+
12+
describe("upsertOrbInstallation", () => {
13+
it("'created' registers the install (registered=0 — the manual-onboarding gate)", async () => {
14+
const e = createTestEnv();
15+
await upsertOrbInstallation(e, "installation", created(100));
16+
expect(await get(e, 100)).toMatchObject({ account_login: "acme", account_type: "Organization", repository_selection: "selected", registered: 0, suspended_at: null, removed_at: null });
17+
});
18+
19+
it("'created' with a minimal installation stores null account/type/selection", async () => {
20+
const e = createTestEnv();
21+
await upsertOrbInstallation(e, "installation", { action: "created", installation: { id: 300 } });
22+
expect(await get(e, 300)).toMatchObject({ account_login: null, account_type: null, repository_selection: null, registered: 0 });
23+
});
24+
25+
it("'suspend' then 'unsuspend' toggle suspended_at", async () => {
26+
const e = createTestEnv();
27+
await upsertOrbInstallation(e, "installation", created(101));
28+
await upsertOrbInstallation(e, "installation", { action: "suspend", installation: { id: 101 } });
29+
expect((await get(e, 101))?.suspended_at).not.toBeNull();
30+
await upsertOrbInstallation(e, "installation", { action: "unsuspend", installation: { id: 101 } });
31+
expect((await get(e, 101))?.suspended_at).toBeNull();
32+
});
33+
34+
it("'deleted' sets removed_at; 'new_permissions_accepted' re-activates (clears removed_at)", async () => {
35+
const e = createTestEnv();
36+
await upsertOrbInstallation(e, "installation", created(102));
37+
await upsertOrbInstallation(e, "installation", { action: "deleted", installation: { id: 102 } });
38+
expect((await get(e, 102))?.removed_at).not.toBeNull();
39+
await upsertOrbInstallation(e, "installation", { action: "new_permissions_accepted", installation: { id: 102, account: { login: "acme", type: "Organization" }, repository_selection: "all" } });
40+
const row = await get(e, 102);
41+
expect(row?.removed_at).toBeNull();
42+
expect(row?.repository_selection).toBe("all");
43+
});
44+
45+
it("does nothing for a non-installation event, a missing installation id, or an unknown action", async () => {
46+
const e = createTestEnv();
47+
await upsertOrbInstallation(e, "pull_request", created(200)); // wrong event
48+
await upsertOrbInstallation(e, "installation", { action: "created" }); // no installation object
49+
await upsertOrbInstallation(e, "installation", { action: "created", installation: { id: 0 } }); // falsy id
50+
expect(await get(e, 200)).toBeFalsy(); // never inserted
51+
const e2 = createTestEnv();
52+
await upsertOrbInstallation(e2, "installation", created(201));
53+
await upsertOrbInstallation(e2, "installation", { action: "labeled", installation: { id: 201 } }); // unknown action → no change
54+
expect((await get(e2, 201))?.removed_at).toBeNull();
55+
});
56+
});

test/integration/orb-webhook.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ const row = (e: Env, delivery: string) =>
4141
(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 }>();
4242

4343
describe("handleOrbWebhook (POST /v1/orb/webhook)", () => {
44+
it("500 + records 'error' when the install-registry upsert fails (so GitHub redelivers)", async () => {
45+
const e = env();
46+
const real = e.DB;
47+
// Throw on the installations upsert only; the webhook_events read/write still go to the real DB.
48+
(e as { DB: unknown }).DB = {
49+
prepare: (sql: string) =>
50+
sql.includes("orb_github_installations") ? { bind: () => ({ run: () => Promise.reject(new Error("boom")) }) } : real.prepare(sql),
51+
};
52+
const res = await post(e, INSTALL, { delivery: "up-err" });
53+
expect(res.status).toBe(500);
54+
const stored = await (real as unknown as TestD1Database).prepare("SELECT status FROM orb_webhook_events WHERE delivery_id=?").bind("up-err").first<{ status: string }>();
55+
expect(stored?.status).toBe("error"); // not suppressed → GitHub can retry
56+
});
57+
4458
it("400 when the GitHub delivery or event header is missing", async () => {
4559
expect((await post(env(), INSTALL, { delivery: null as unknown as string })).status).toBe(400);
4660
expect((await post(env(), INSTALL, { event: null as unknown as string })).status).toBe(400);

0 commit comments

Comments
 (0)