diff --git a/migrations/0068_orb_enrollments.sql b/migrations/0068_orb_enrollments.sql new file mode 100644 index 0000000000..84309c2799 --- /dev/null +++ b/migrations/0068_orb_enrollments.sql @@ -0,0 +1,23 @@ +-- Gittensory Orb central GitHub App (#1255) — the token-broker enrollment ledger. A maintainer authorizes the +-- Orb App (OAuth) and is bound, server-side, to a SPECIFIC installation they administer; their self-hosted +-- container is then issued a one-time enrollment secret (stored HASHED, never plaintext) which it exchanges for +-- short-lived installation tokens. installation_id is written here at the OAuth callback after an authority +-- check — the container can never name a different installation at token-exchange time. registered=1 on the +-- referenced install is still required to mint (the das-github-mirror trust gate). The whole broker is gated by +-- ORB_BROKER_ENABLED (default off) so this table is inert until enabled. +CREATE TABLE IF NOT EXISTS orb_enrollments ( + enroll_id TEXT PRIMARY KEY NOT NULL, -- opaque id, returned to the container + installation_id INTEGER, -- bound at the OAuth callback; NULL while pending + maintainer_login TEXT, + maintainer_github_id INTEGER, + secret_hash TEXT, -- SHA-256 of the one-time secret; NULL until enrolled + state TEXT NOT NULL DEFAULT 'pending', -- pending | authorized | enrolled | revoked + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + authorized_at TEXT, + enrolled_at TEXT, + last_token_at TEXT, + revoked_at TEXT +); +-- A given secret hashes to exactly one enrollment (NULLs are distinct in SQLite, so many pending rows are fine). +CREATE UNIQUE INDEX IF NOT EXISTS orb_enrollments_secret_hash_idx ON orb_enrollments(secret_hash); +CREATE INDEX IF NOT EXISTS orb_enrollments_installation_idx ON orb_enrollments(installation_id); diff --git a/src/env.d.ts b/src/env.d.ts index cd16f982b9..27bff25116 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -48,6 +48,9 @@ declare global { ORB_GITHUB_APP_PRIVATE_KEY?: string; ORB_GITHUB_CLIENT_ID?: string; ORB_GITHUB_CLIENT_SECRET?: string; + /** Master flag for the Orb token-broker (enrollment OAuth + /v1/orb/token). Default-off: every broker route + * early-404s until this is "true", so the deploy is byte-identical until an operator enables it. */ + ORB_BROKER_ENABLED?: string; GITHUB_APP_PRIVATE_KEY: string; GITHUB_APP_ID: string; GITHUB_APP_SLUG: string; diff --git a/src/orb/app-auth.ts b/src/orb/app-auth.ts index 350a60f7b1..e0289fb989 100644 --- a/src/orb/app-auth.ts +++ b/src/orb/app-auth.ts @@ -56,7 +56,7 @@ export async function listOrbAppInstallations(env: Env): Promise { +export async function createOrbInstallationToken(env: Env, installationId: number): Promise<{ token: string; expiresAt: string }> { const jwt = await createOrbAppJwt(env); const response = await timeoutFetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, { method: "POST", @@ -66,7 +66,8 @@ export async function createOrbInstallationToken(env: Env, installationId: numbe const body = await response.text(); throw new Error(`Failed to create Orb installation token (${response.status}): ${body.slice(0, 200)}`); } - const payload = (await response.json()) as { token?: string }; + const payload = (await response.json()) as { token?: string; expires_at?: string }; if (!payload.token) throw new Error("Orb installation token response did not include a token."); - return payload.token; + // Surface GitHub's real expiry (~1h) so the broker never invents one; absent only on a malformed response. + return { token: payload.token, expiresAt: payload.expires_at ?? "" }; } diff --git a/test/unit/orb-app-auth.test.ts b/test/unit/orb-app-auth.test.ts index 00481817f9..b2023eda40 100644 --- a/test/unit/orb-app-auth.test.ts +++ b/test/unit/orb-app-auth.test.ts @@ -44,9 +44,11 @@ describe("listOrbAppInstallations", () => { describe("createOrbInstallationToken", () => { const env = async (): Promise => orbEnv({ ORB_GITHUB_APP_PRIVATE_KEY: await pkcs8Pem() }); - it("returns the minted token", async () => { - vi.stubGlobal("fetch", async () => Response.json({ token: "ghs_minted" })); - expect(await createOrbInstallationToken(await env(), 42)).toBe("ghs_minted"); + it("returns the minted token + GitHub's real expiry (empty only when absent)", async () => { + vi.stubGlobal("fetch", async () => Response.json({ token: "ghs_minted", expires_at: "2026-06-25T07:00:00Z" })); + expect(await createOrbInstallationToken(await env(), 42)).toEqual({ token: "ghs_minted", expiresAt: "2026-06-25T07:00:00Z" }); + vi.stubGlobal("fetch", async () => Response.json({ token: "ghs_noexp" })); + expect((await createOrbInstallationToken(await env(), 42)).expiresAt).toBe(""); }); it("throws on a non-ok response or a missing token", async () => {