diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..83525fa0a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +node_modules +**/node_modules +dist +**/dist +artifacts +.superpowers diff --git a/README.md b/README.md index be4c9ee4f..1987eaed1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A GitHub-only fork of [Vercel Labs emulate](https://github.com/vercel-labs/emulate) for local integration tests. It retains the upstream GitHub API and shared core, with a standalone library, CLI, and localhost demo. Licensed under Apache-2.0 with upstream attribution and third-party notices preserved. -This baseline demonstrates OAuth App sign-in, GitHub App tokens for a seeded installation, API calls, and signed webhook delivery. Browser-driven GitHub App installation is the next iteration. See the [roadmap](docs/roadmap.md) for the remaining flow gaps. +The emulator supports OAuth App sign-in, browser GitHub App installation with account and repository selection, installation-token API calls, and signed webhook delivery. Installation forms have bounded, expiring storage, and invalid setup callbacks fall back to a local settings URL. See [GitHub App installation](docs/github-app-installation.md) for the browser flow and discovery endpoints, and the [roadmap](docs/roadmap.md) for remaining gaps. ## Run the demo @@ -108,7 +108,7 @@ The root build script keeps esbuild's working directory in the system temporary ## Upstream contributions -`main` keeps upstream history and layout. The default `github-only` branch contains this reduced edition. Upstream GitHub/core source is unchanged in the baseline; wrapper and packaging changes are kept separate from later protocol improvements. Follow the [contribution procedure](docs/upstream-workflow.md) to propose behavior-only patches against Vercel's full repository after tests prove them. +`main` keeps upstream history and layout. The default `github-only` branch contains this reduced edition. Protocol improvements and their regression tests stay in the preserved upstream package paths, separate from wrapper and packaging changes. Follow the [contribution procedure](docs/upstream-workflow.md) to propose behavior-only patches against Vercel's full repository after tests prove them. Retained packages: diff --git a/docs/github-app-installation.md b/docs/github-app-installation.md new file mode 100644 index 000000000..01903e315 --- /dev/null +++ b/docs/github-app-installation.md @@ -0,0 +1,42 @@ +# GitHub App installation + +Seed a GitHub App with an optional `setup_url` to exercise browser installation after OAuth sign-in. The existing demo uses a seeded installation; use a consuming application or browser test to drive this flow. + +```json +{ + "github": { + "users": [{ "login": "developer" }], + "orgs": [{ "login": "example-org", "members": [{ "login": "developer", "role": "admin" }] }], + "repos": [{ "owner": "example-org", "name": "widget", "private": true }], + "apps": [{ + "app_id": 12345, + "slug": "example-app", + "name": "Example App", + "setup_url": "http://localhost:3005/github/setup", + "webhook_url": "http://localhost:3005/webhooks", + "webhook_secret": "local-webhook-secret", + "permissions": { "contents": "read", "metadata": "read" }, + "events": ["installation", "issues", "pull_request"], + "installations": [] + }] + } +} +``` + +The standalone wrapper generates omitted App keys. Direct `seedFromConfig` callers must supply a key or materialize the configuration first. Callback and webhook addresses must be reachable from their respective browser and emulator processes. + +1. Complete OAuth sign-in through `/login/oauth/authorize` and `/login/oauth/callback`. The callback establishes the `_emu_session` browser cookie. +2. Open `/apps/example-app/installations/new?state=YOUR_OPAQUE_STATE` in that browser. The picker offers the signed-in user's own account and organizations where that user has the `admin` role. +3. Choose **Install on example-org**. Select **All repositories** or **Only select repositories**, then check the desired repositories and choose **Install**. Repository checkboxes have accessible names such as **Select repository: example-org/widget**. +4. The emulator creates the installation and dispatches an `installation` webhook with action `created`, installation/account metadata, selected repositories, and the sender. App webhook delivery uses the configured secret for `X-Hub-Signature-256` and is best effort. +5. The browser returns to the configured `setup_url` with `installation_id`, `setup_action=install`, and the original `state`. Existing callback query parameters are retained. Query or form fields cannot override the callback URL. The callback must be an absolute HTTP(S) URL. An omitted, malformed, relative, or non-HTTP(S) callback uses the installation's local settings URL; an installation settings UI is not implemented. The redirect is resolved before saving the installation. + +Installation forms expire after 10 minutes, are bound to their user, App, and account, and can be submitted once. Pending forms are limited to 32 per user across accounts and 1024 per emulator instance. Reaching either limit evicts the oldest applicable form. Reopen the installation page after expiry or eviction. The server rechecks membership and repository ownership at submission, rejects empty selected sets and cross-account IDs with `422`, and rejects a duplicate App/account installation with `409`. Form sessions and pending forms are in memory and reset with the emulator. + +## Discover memberships and installed repositories + +`GET /user/memberships/orgs?state=active&per_page=100` requires a user token with `read:org`, `write:org`, or `admin:org`. Responses include `state: "active"`, `role: "admin" | "member"`, and organization metadata. The emulator has active memberships only; requesting `state=pending` returns an empty list. Installation tokens cannot call this endpoint. OAuth-authenticated `GET /user` and `PATCH /user` report the token's granted scopes in `X-OAuth-Scopes`. + +Use an App JWT for `GET /repositories/:id/installation`. It returns only an installation belonging to that App whose current selection includes the repository, or `404`. + +Exchange an App JWT at `POST /app/installations/:installation_id/access_tokens`, then call `GET /installation/repositories` with the resulting installation token. The response contains `total_count` and full `repositories` metadata. Results must belong to the installation account and satisfy both the token's requested scope and the current installation selection. Revoked or suspended installations are rejected. Membership and repository listings use `page`, `per_page` (up to 100), and `Link` headers. diff --git a/docs/roadmap.md b/docs/roadmap.md index 520a00763..c273d4021 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,11 +4,11 @@ Make Vercel's existing GitHub emulator runnable as a GitHub-only fork. Prove OAuth App sign-in, a GitHub App installation-token exchange against a seeded installation, authenticated API changes, and signed webhook reception over real localhost HTTP. -This baseline does not provide browser-driven GitHub App installation. It does not claim complete GitHub OAuth or API compatibility. Keep the remaining gaps visible while the working subset is validated. +The follow-up implementation adds [browser GitHub App installation](github-app-installation.md), setup callbacks, signed creation webhooks, and scoped repository discovery. Complete GitHub OAuth and API compatibility remain outside the verified subset. ## Next behavior to prove -Exercise the application's full connection journey: user authorization, account and repository selection, installation setup callback, discovery of installations/repositories, and subsequent API access and webhooks. Add focused regression tests before changes, and keep behavior commits suitable for upstream review. Check existing upstream PR #265 and issue #328 before duplicating installation-discovery work. +Verify the supported connection journey in consuming applications: OAuth user authorization, account and repository selection, setup callback, scoped discovery, subsequent API access, and signed webhooks. The protocol regression suite covers these emulator contracts. Keep behavior commits suitable for upstream review. ## State consistency diff --git a/docs/verification.md b/docs/verification.md index e4d6096ec..6907ccef1 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,4 +1,6 @@ -# Baseline verification +# Verification + +## Original baseline Verified on 2026-11-03 with Node 24.21.0 and pnpm 11.2.2. @@ -18,4 +20,25 @@ The retained `packages/@emulators/core` and `packages/@emulators/github` trees h A browser run also completed sign-in as `octocat`, returned through the OAuth callback, created an issue using the seeded installation, and displayed one signature-verified webhook. The local demonstration used `127.0.0.98:3005` because another application owned `127.0.0.1:3005`. -These checks establish the baseline's existing seeded-installation flow. They do not establish browser-driven GitHub App installation, SQLite behavior, native Git transport, or complete GitHub protocol fidelity. Those remain roadmap work. +These checks establish the baseline's existing seeded-installation flow. They do not establish browser-driven GitHub App installation, SQLite behavior, native Git transport, or complete GitHub protocol fidelity. The follow-up checks below cover the added installation contracts. SQLite and native Git transport remain roadmap work. + + +## Browser installation follow-up + +Verified on 2026-11-03 with Node 24.21.0 and pnpm 11.2.2. + +| Check | Result | +| --- | --- | +| `pnpm build` | Pass for core, GitHub, wrapper, CLI, and declarations | +| `pnpm type-check` | Pass for all three packages | +| `pnpm format:check` | Pass | +| `pnpm lint` | Pass with 17 existing warnings and zero errors | +| `pnpm test` | 266 tests pass: core 103, GitHub 157, wrapper 6 | +| `pnpm test:smoke` | Pass for the original seeded-installation HTTP flow | +| `node scripts/package-smoke.mjs` | Pass for an external consumer of the packed package | + +The new route tests first failed on missing endpoints or missing OAuth scope headers. The passing suite covers browser session reuse, authorized account selection, repeated repository checkboxes, opaque setup state, configured callback ownership, nonce replay and user/App/account binding, current membership checks, duplicate installation rejection, and repository ownership. An actual local HTTP receiver verifies the `installation.created` payload and HMAC signature from the existing App webhook dispatcher. + +API regression tests cover paginated membership roles and scope enforcement, granted OAuth scope headers, full installation repository metadata, token/current-installation scope intersection, revoked and suspended installations, and App JWT ownership for repository installation lookup. These tests exercise emulator routes and real webhook delivery; the consuming application's complete browser journey requires its own end-to-end test. + +The review follow-up reproduced 13 failing cases before adding pending-form capacity limits and safe setup-callback fallback. All 23 installation-flow tests now pass, including oldest-form eviction, retained neighboring forms, user isolation, expiry, malformed and non-HTTP callbacks, preserved opaque state, and exactly one signed webhook after installation. The built CLI help reports the verified form limits and callback behavior. diff --git a/examples/smoke.mjs b/examples/smoke.mjs index d7ca428fb..1a2a5b611 100644 --- a/examples/smoke.mjs +++ b/examples/smoke.mjs @@ -34,7 +34,7 @@ try { assert.equal(event.payload.installation.id, 100); console.log("PASS: OAuth redirects, user identity, App JWT, installation token, API mutation, and verified webhook agree."); - console.log("Browser GitHub App installation remains a separate, unimplemented flow."); + console.log("Browser GitHub App installation is covered separately by the protocol regression tests."); } finally { await demo.close(); } diff --git a/packages/@emulators/github/README.md b/packages/@emulators/github/README.md index 9a8054e7c..1168c120f 100644 --- a/packages/@emulators/github/README.md +++ b/packages/@emulators/github/README.md @@ -15,6 +15,8 @@ npm install @emulators/github ### Users - `GET /user` — authenticated user - `PATCH /user` — update profile +- `GET /user/memberships/orgs` returns active organization memberships with admin/member roles, `state` filtering, and pagination. Requires `read:org`, `write:org`, or `admin:org`. +- OAuth-authenticated `GET /user` and `PATCH /user` include the granted `X-OAuth-Scopes` header. - `GET /users/:username` — get user - `GET /users` — list users - `GET /users/:username/repos` — list user repos @@ -131,6 +133,15 @@ npm install @emulators/github - Check suites: create, get, preferences, rerequest, list by ref. Ref based lookups accept branch and tag refs containing slashes. - Automatic suite status rollup from check run results +### GitHub Apps + +- `GET /apps/:slug/installations/new` displays account and repository selection using the browser session established by OAuth sign-in. +- `POST /apps/:slug/installations/new` creates an installation, dispatches `installation.created`, and redirects to the App's configured `setup_url`. +- `GET /installation/repositories` returns paginated repository metadata for an installation token, intersected with the current installation scope. +- `GET /repositories/:id/installation` finds the current installation for the authenticated App JWT. + +See [GitHub App installation](../../../docs/github-app-installation.md) for configuration and callback behavior. + ### Misc - `GET /_emulate/installation-tokens` — inspect secret-free GitHub App installation-token metadata - `GET /rate_limit` — rate limit status @@ -182,7 +193,10 @@ github: permissions: contents: read issues: write - events: [push, pull_request] + events: [installation, push, pull_request] + setup_url: "http://localhost:3005/github/setup" + webhook_url: "http://localhost:3005/webhooks" + webhook_secret: "local-webhook-secret" installations: - installation_id: 100 account: my-org diff --git a/packages/@emulators/github/src/__tests__/app-installation-flow.test.ts b/packages/@emulators/github/src/__tests__/app-installation-flow.test.ts new file mode 100644 index 000000000..7d28e8032 --- /dev/null +++ b/packages/@emulators/github/src/__tests__/app-installation-flow.test.ts @@ -0,0 +1,386 @@ +import { createHmac } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Hono, Store, WebhookDispatcher, authMiddleware, createApiErrorHandler, type TokenMap } from "@emulators/core"; +import { githubPlugin, getGitHubStore, seedFromConfig } from "../index.js"; + +const base = "http://localhost:4200"; +const servers: Server[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +function fixture(webhookUrl?: string, setupUrl = "https://client.example/setup?keep=1") { + const store = new Store(); + const tokens: TokenMap = new Map(); + const app = new Hono(); + app.onError(createApiErrorHandler()); + app.use("*", authMiddleware(tokens)); + githubPlugin.register(app as any, store, new WebhookDispatcher(), base, tokens); + githubPlugin.seed?.(store, base); + seedFromConfig(store, base, { + users: [{ login: "octocat" }, { login: "other" }], + orgs: [ + { login: "acme", members: [{ login: "octocat", role: "admin" }] }, + { login: "member-org", members: [{ login: "octocat", role: "member" }] }, + { login: "stranger-org" }, + ], + repos: [ + { owner: "acme", name: "widget", private: true }, + { owner: "acme", name: "another" }, + { owner: "other", name: "secret", private: true }, + { owner: "octocat", name: "personal" }, + ], + apps: [ + { + app_id: 42, + slug: "example-app", + name: "Example App", + private_key: "fixture-key", + setup_url: setupUrl, + webhook_url: webhookUrl, + webhook_secret: "fixture-webhook-secret", + events: ["installation"], + permissions: { contents: "read", metadata: "read" }, + installations: [], + }, + ], + }); + return { app, gh: getGitHubStore(store), store }; +} + +type Fixture = ReturnType; +async function session(f: Fixture, login = "octocat") { + const response = await f.app.request(`${base}/login/oauth/callback`, { + method: "POST", + body: new URLSearchParams({ + login, + redirect_uri: "https://client.example/oauth", + scope: "read:org", + client_id: "fixture", + }), + }); + return response.headers.get("set-cookie")!.split(";")[0]; +} +async function form(f: Fixture, cookie: string, account = "acme", state = "opaque + /?&=%") { + const picker = await f.app.request(`${base}/apps/example-app/installations/new?state=${encodeURIComponent(state)}`, { + headers: { Cookie: cookie }, + }); + const html = await picker.text(); + const link = html.match(new RegExp(`href="([^"]+)"[^>]*>Install on ${account}`)); + expect(link, html).not.toBeNull(); + const response = await f.app.request(new URL(link![1].replaceAll("&", "&"), base).href, { + headers: { Cookie: cookie }, + }); + expect(response.status).toBe(200); + const formHtml = await response.text(); + const nonce = formHtml.match(/name="nonce" value="([^"]+)"/)![1]; + return { + html: formHtml, + body: new URLSearchParams({ nonce, account, repository_selection: "selected" }), + url: `${base}/apps/example-app/installations/new`, + }; +} +async function submit(f: Fixture, cookie: string, data: Awaited>) { + return f.app.request(data.url, { method: "POST", headers: { Cookie: cookie }, body: data.body }); +} + +describe("GitHub App browser installation", () => { + it("requires a shared OAuth session cookie and offers only the user's own account and admin organizations", async () => { + const f = fixture(); + expect((await f.app.request(`${base}/apps/example-app/installations/new`)).status).toBe(401); + const cookie = await session(f); + const response = await f.app.request(`${base}/apps/example-app/installations/new`, { headers: { Cookie: cookie } }); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("Install on octocat"); + expect(html).toContain("Install on acme"); + expect(html).not.toContain("Install on other"); + expect(html).not.toContain("Install on member-org"); + expect(html).not.toContain("Install on stranger-org"); + }); + + it("creates a selected installation and preserves opaque state in the configured setup callback", async () => { + const f = fixture(); + const cookie = await session(f); + const data = await form(f, cookie); + expect(data.html).toContain("Select repository: acme/widget"); + expect(data.html).not.toContain("other/secret"); + const repo = f.gh.repos.findOneBy("full_name", "acme/widget")!; + data.body.append("repository_ids", String(repo.id)); + data.body.set("setup_url", "https://attacker.example/"); + const response = await submit(f, cookie, data); + expect(response.status).toBe(302); + const redirect = new URL(response.headers.get("location")!); + expect(redirect.origin + redirect.pathname).toBe("https://client.example/setup"); + expect(redirect.searchParams.get("keep")).toBe("1"); + expect(redirect.searchParams.get("state")).toBe("opaque + /?&=%"); + expect(redirect.searchParams.get("setup_action")).toBe("install"); + const installation = f.gh.appInstallations.all()[0]; + expect(installation).toMatchObject({ + app_id: 42, + account_login: "acme", + repository_selection: "selected", + repository_ids: [repo.id], + }); + expect(installation.installation_id).toBeGreaterThan(0); + expect(redirect.searchParams.get("installation_id")).toBe(String(installation.installation_id)); + expect((await submit(f, cookie, data)).status).toBe(403); + }); + + it("delivers a signed installation.created webhook through the real app dispatcher", async () => { + const deliveries: Array<{ headers: import("node:http").IncomingHttpHeaders; body: string }> = []; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + deliveries.push({ headers: req.headers, body }); + res.end("ok"); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as import("node:net").AddressInfo; + const f = fixture(`http://127.0.0.1:${address.port}/webhook`); + const cookie = await session(f); + const data = await form(f, cookie); + data.body.set("repository_selection", "all"); + expect((await submit(f, cookie, data)).status).toBe(302); + expect(deliveries).toHaveLength(1); + const delivery = deliveries[0]; + expect(delivery.headers["x-github-event"]).toBe("installation"); + expect(delivery.headers["x-hub-signature-256"]).toBe( + `sha256=${createHmac("sha256", "fixture-webhook-secret").update(delivery.body).digest("hex")}`, + ); + const payload = JSON.parse(delivery.body); + expect(payload).toMatchObject({ + action: "created", + installation: { + id: f.gh.appInstallations.all()[0].installation_id, + app_id: 42, + account: { login: "acme", type: "Organization" }, + repository_selection: "all", + }, + sender: { login: "octocat" }, + }); + expect(payload.repositories.map((repo: any) => repo.full_name)).toEqual(["acme/widget", "acme/another"]); + }); + + it("rejects cross-account IDs, altered account fields, missing sessions, and nonces from another user", async () => { + const f = fixture(); + const cookie = await session(f); + const data = await form(f, cookie); + data.body.append("repository_ids", String(f.gh.repos.findOneBy("full_name", "other/secret")!.id)); + expect((await submit(f, cookie, data)).status).toBe(422); + expect(f.gh.appInstallations.all()).toHaveLength(0); + const second = await form(f, cookie); + second.body.set("account", "other"); + second.body.append("repository_ids", String(f.gh.repos.findOneBy("full_name", "acme/widget")!.id)); + expect((await submit(f, "", second)).status).toBe(401); + expect((await submit(f, await session(f, "other"), second)).status).toBe(403); + expect((await submit(f, cookie, second)).status).toBe(403); + expect(f.gh.appInstallations.all()).toHaveLength(0); + }); + + it("validates membership again at submission and prevents duplicate app/account installations", async () => { + const f = fixture(); + const cookie = await session(f); + const first = await form(f, cookie); + const second = await form(f, cookie); + first.body.set("repository_selection", "all"); + expect((await submit(f, cookie, first)).status).toBe(302); + second.body.set("repository_selection", "all"); + expect((await submit(f, cookie, second)).status).toBe(409); + expect(f.gh.appInstallations.all()).toHaveLength(1); + const personal = await form(f, cookie, "octocat"); + personal.body.set("repository_selection", "all"); + expect((await submit(f, cookie, personal)).status).toBe(302); + expect(new Set(f.gh.appInstallations.all().map((i) => i.installation_id)).size).toBe(2); + const f2 = fixture(); + const cookie2 = await session(f2); + const pending = await form(f2, cookie2); + for (const member of f2.gh.teamMembers.all()) f2.gh.teamMembers.delete(member.id); + expect((await submit(f2, cookie2, pending)).status).toBe(403); + }); + it("accepts repeated repository checkboxes and rejects a nonce submitted for another app", async () => { + const f = fixture(); + const cookie = await session(f); + const data = await form(f, cookie); + for (const repo of f.gh.repos.all().filter((repo) => repo.full_name.startsWith("acme/"))) { + data.body.append("repository_ids", String(repo.id)); + } + expect((await submit(f, cookie, data)).status).toBe(302); + expect(f.gh.appInstallations.all()[0].repository_ids).toHaveLength(2); + const second = await form(f, cookie, "octocat"); + second.body.set("repository_selection", "all"); + second.url = `${base}/apps/another-app/installations/new`; + expect((await submit(f, cookie, second)).status).toBe(403); + }); + + it("rejects non-admin account queries and malformed or empty selected repositories", async () => { + const f = fixture(); + const cookie = await session(f); + for (const account of ["other", "member-org", "stranger-org"]) { + const response = await f.app.request(`${base}/apps/example-app/installations/new?account=${account}`, { + headers: { Cookie: cookie }, + }); + expect(response.status).toBe(403); + } + for (const ids of [[], ["1oops"], ["0"], ["-1"]]) { + const data = await form(f, cookie); + for (const id of ids) data.body.append("repository_ids", id); + expect((await submit(f, cookie, data)).status).toBe(422); + } + expect(f.gh.appInstallations.all()).toHaveLength(0); + }); +}); + +describe("installation form lifetime and callback recovery", () => { + it("evicts the oldest of more than 32 forms for one user while preserving another user's form", async () => { + const f = fixture(); + const cookie = await session(f); + const otherCookie = await session(f, "other"); + const otherForm = await form(f, otherCookie, "other"); + const tabs = []; + for (let index = 0; index < 33; index++) { + tabs.push(await form(f, cookie, index === 32 ? "octocat" : "acme")); + } + for (const tab of [tabs[0], tabs[1], tabs.at(-1)!, otherForm]) tab.body.set("repository_selection", "all"); + expect((await submit(f, cookie, tabs[0])).status).toBe(403); + expect(f.gh.appInstallations.all()).toHaveLength(0); + expect(f.store.getData>("github.installation.pendingForms")!.size).toBeLessThanOrEqual(33); + expect((await submit(f, cookie, tabs[1])).status).toBe(302); + expect((await submit(f, cookie, tabs.at(-1)!)).status).toBe(302); + expect((await submit(f, otherCookie, otherForm)).status).toBe(302); + expect( + f.gh.appInstallations + .all() + .map((installation) => installation.account_login) + .sort(), + ).toEqual(["acme", "octocat", "other"]); + }); + + it("evicts the oldest global form after 1024 pending forms spread across users", async () => { + const f = fixture(); + const users = Array.from({ length: 33 }, (_, index) => ({ login: `capacity-user-${index}` })); + seedFromConfig(f.store, base, { users }); + let oldest: { cookie: string; data: Awaited> } | undefined; + let retained: typeof oldest; + let latest: typeof oldest; + for (let userIndex = 0; userIndex < users.length; userIndex++) { + const login = users[userIndex].login; + const cookie = await session(f, login); + for (let index = 0; index < (userIndex === 32 ? 1 : 32); index++) { + const data = await form(f, cookie, login); + data.body.set("repository_selection", "all"); + oldest ??= { cookie, data }; + if (userIndex === 0 && index === 1) retained = { cookie, data }; + latest = { cookie, data }; + } + } + expect((await submit(f, oldest!.cookie, oldest!.data)).status).toBe(403); + expect(f.gh.appInstallations.all()).toHaveLength(0); + expect(f.store.getData>("github.installation.pendingForms")!.size).toBeLessThanOrEqual(1024); + expect((await submit(f, retained!.cookie, retained!.data)).status).toBe(302); + expect((await submit(f, latest!.cookie, latest!.data)).status).toBe(302); + expect(f.gh.appInstallations.all()).toHaveLength(2); + expect((await submit(f, latest!.cookie, latest!.data)).status).toBe(403); + }, 15_000); + + it("cleans forms older than ten minutes while preserving both unexpired tabs", async () => { + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + const f = fixture(); + const cookie = await session(f); + const expired = await form(f, cookie); + now += 9 * 60 * 1000; + const personalTab = await form(f, cookie, "octocat"); + now += 60 * 1000 + 1; + const organizationTab = await form(f, cookie); + expect(f.store.getData>("github.installation.pendingForms")!.size).toBe(2); + for (const tab of [expired, personalTab, organizationTab]) tab.body.set("repository_selection", "all"); + expect((await submit(f, cookie, expired)).status).toBe(403); + expect((await submit(f, cookie, personalTab)).status).toBe(302); + expect((await submit(f, cookie, organizationTab)).status).toBe(302); + expect( + f.gh.appInstallations + .all() + .map((installation) => installation.account_login) + .sort(), + ).toEqual(["acme", "octocat"]); + }); + + it.each([ + "", + "/setup", + "//client.example/setup", + "not a URL", + "https://[", + "javascript:alert(1)", + "data:text/html,local", + "file:///tmp/setup", + "ftp://client.example/setup", + "mailto:local@example.test", + ])("falls back to local settings when the seeded setup URL is %j", async (setupUrl) => { + const f = fixture(undefined, setupUrl); + const cookie = await session(f); + const state = "opaque + /?&=%#return"; + const data = await form(f, cookie, "acme", state); + data.body.set("repository_selection", "all"); + const response = await submit(f, cookie, data); + expect(response.status).toBe(302); + expect(f.gh.appInstallations.all()).toHaveLength(1); + const installation = f.gh.appInstallations.all()[0]; + const redirect = new URL(response.headers.get("location")!); + expect(redirect.origin + redirect.pathname).toBe(`${base}/settings/installations/${installation.installation_id}`); + expect(redirect.searchParams.get("state")).toBe(state); + expect(redirect.searchParams.get("installation_id")).toBe(String(installation.installation_id)); + expect(redirect.searchParams.get("setup_action")).toBe("install"); + expect((await submit(f, cookie, data)).status).toBe(403); + expect(f.gh.appInstallations.all()).toHaveLength(1); + }); + + it.each(["http://client.example/setup?keep=1", "https://client.example/setup?keep=1"])( + "continues to the configured HTTP callback %s", + async (setupUrl) => { + const f = fixture(undefined, setupUrl); + const cookie = await session(f); + const state = "opaque + /?&=%#return"; + const data = await form(f, cookie, "acme", state); + data.body.set("repository_selection", "all"); + const response = await submit(f, cookie, data); + expect(response.status).toBe(302); + const redirect = new URL(response.headers.get("location")!); + const configured = new URL(setupUrl); + expect(redirect.origin + redirect.pathname).toBe(configured.origin + configured.pathname); + expect(redirect.searchParams.get("keep")).toBe("1"); + expect(redirect.searchParams.get("state")).toBe(state); + expect(f.gh.appInstallations.all()).toHaveLength(1); + }, + ); + + it("delivers one installation webhook and saves once when the seeded callback is malformed", async () => { + const deliveries: string[] = []; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + deliveries.push(body); + res.end("ok"); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as import("node:net").AddressInfo; + const f = fixture(`http://127.0.0.1:${address.port}/webhook`, "https://["); + const cookie = await session(f); + const data = await form(f, cookie); + data.body.set("repository_selection", "all"); + const response = await submit(f, cookie, data); + expect(response.status).toBe(302); + expect(new URL(response.headers.get("location")!).origin).toBe(base); + expect(f.gh.appInstallations.all()).toHaveLength(1); + expect(deliveries).toHaveLength(1); + expect(JSON.parse(deliveries[0]).installation.id).toBe(f.gh.appInstallations.all()[0].installation_id); + expect((await submit(f, cookie, data)).status).toBe(403); + expect(f.gh.appInstallations.all()).toHaveLength(1); + expect(deliveries).toHaveLength(1); + }); +}); diff --git a/packages/@emulators/github/src/__tests__/installation-repositories.test.ts b/packages/@emulators/github/src/__tests__/installation-repositories.test.ts new file mode 100644 index 000000000..862b5f60a --- /dev/null +++ b/packages/@emulators/github/src/__tests__/installation-repositories.test.ts @@ -0,0 +1,213 @@ +import { generateKeyPairSync, sign } from "crypto"; +import { describe, expect, it } from "vitest"; +import { Hono, Store, WebhookDispatcher, authMiddleware, createApiErrorHandler, type TokenMap } from "@emulators/core"; +import { createAppKeyResolver, getGitHubStore, githubPlugin, seedFromConfig } from "../index.js"; + +type RepositoryList = { total_count: number; repositories: Array<{ full_name: string }> }; + +const base = "http://localhost:4200"; +const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, +}); + +function appJwt(appId: number) { + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ iss: String(appId), iat: now - 60, exp: now + 300 })).toString( + "base64url", + ); + const unsigned = `${header}.${payload}`; + return `${unsigned}.${sign("RSA-SHA256", Buffer.from(unsigned), privateKey).toString("base64url")}`; +} + +function fixture(selection: "all" | "selected" = "all") { + const store = new Store(); + const tokenMap: TokenMap = new Map(); + const app = new Hono(); + app.onError(createApiErrorHandler()); + app.use("*", authMiddleware(tokenMap, createAppKeyResolver(store))); + githubPlugin.register(app as any, store, new WebhookDispatcher(), base, tokenMap); + githubPlugin.seed?.(store, base); + seedFromConfig(store, base, { + users: [{ login: "octocat" }], + orgs: [{ login: "acme" }, { login: "other" }], + repos: [ + { owner: "acme", name: "alpha", private: true }, + { owner: "acme", name: "beta" }, + { owner: "other", name: "outside" }, + { owner: "octocat", name: "personal" }, + ], + apps: [ + { + app_id: 11, + slug: "sample-app", + name: "Sample App", + private_key: privateKey, + permissions: { contents: "read" }, + installations: [ + { installation_id: 101, account: "acme", repository_selection: selection, repositories: ["alpha"] }, + ], + }, + { app_id: 22, slug: "other-app", name: "Other App", private_key: privateKey }, + ], + }); + return { app, gh: getGitHubStore(store), tokenMap }; +} + +async function installationToken(f: ReturnType, body: Record = {}) { + const response = await f.app.request(`${base}/app/installations/101/access_tokens`, { + method: "POST", + headers: { Authorization: `Bearer ${appJwt(11)}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(201); + return ((await response.json()) as { token: string }).token; +} + +function bearer(token: string) { + return { Authorization: `Bearer ${token}` }; +} + +describe("installation repository APIs", () => { + it("lists full account repository metadata with pagination for an all-repositories token", async () => { + const f = fixture(); + const token = await installationToken(f); + const response = await f.app.request(`${base}/installation/repositories?per_page=1`, { headers: bearer(token) }); + expect(response.status).toBe(200); + expect(response.headers.get("Link")).toContain("page=2"); + expect(response.headers.get("Link")).toContain('rel="next"'); + const data = (await response.json()) as RepositoryList; + expect(data.total_count).toBe(2); + expect(data.repositories).toEqual([ + expect.objectContaining({ + full_name: "acme/alpha", + private: true, + default_branch: "main", + owner: expect.objectContaining({ login: "acme" }), + }), + ]); + const second = await f.app.request(`${base}/installation/repositories?per_page=1&page=2`, { + headers: bearer(token), + }); + expect(((await second.json()) as RepositoryList).repositories.map((repo) => repo.full_name)).toEqual(["acme/beta"]); + expect(second.headers.get("Link")).toContain('rel="prev"'); + }); + + it("intersects the token scope with the current installation selection", async () => { + const f = fixture(); + const beta = f.gh.repos.findOneBy("full_name", "acme/beta")!; + const token = await installationToken(f, { repository_ids: [beta.id] }); + let response = await f.app.request(`${base}/installation/repositories`, { headers: bearer(token) }); + expect(response.status).toBe(200); + expect(((await response.json()) as RepositoryList).repositories.map((repo) => repo.full_name)).toEqual([ + "acme/beta", + ]); + const installation = f.gh.appInstallations.all()[0]; + const alpha = f.gh.repos.findOneBy("full_name", "acme/alpha")!; + f.gh.appInstallations.update(installation.id, { repository_selection: "selected", repository_ids: [alpha.id] }); + response = await f.app.request(`${base}/installation/repositories`, { headers: bearer(token) }); + expect(await response.json()).toEqual({ total_count: 0, repositories: [] }); + }); + + it("excludes out-of-account IDs even if current installation and token list them", async () => { + const f = fixture("selected"); + const token = await installationToken(f); + const outside = f.gh.repos.findOneBy("full_name", "other/outside")!; + const installation = f.gh.appInstallations.all()[0]; + f.gh.appInstallations.update(installation.id, { repository_ids: [outside.id] }); + f.tokenMap.get(token)!.installation!.repositoryIds = [outside.id]; + const response = await f.app.request(`${base}/installation/repositories`, { headers: bearer(token) }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ total_count: 0, repositories: [] }); + }); + + it("rejects missing, user, and app authentication for installation listing", async () => { + const f = fixture(); + f.tokenMap.set("oauth-user", { login: "octocat", id: 3, scopes: ["repo"] }); + for (const headers of [{}, bearer("oauth-user"), bearer(appJwt(11))]) { + const response = await f.app.request(`${base}/installation/repositories`, { headers }); + expect(response.status).toBe(401); + } + }); + + it("rejects revoked, suspended, and mismatched installation tokens", async () => { + const f = fixture(); + const token = await installationToken(f); + const installation = f.gh.appInstallations.all()[0]; + f.gh.appInstallations.update(installation.id, { suspended_at: new Date().toISOString() }); + expect((await f.app.request(`${base}/installation/repositories`, { headers: bearer(token) })).status).toBe(403); + f.gh.appInstallations.update(installation.id, { suspended_at: null, app_id: 22 }); + expect((await f.app.request(`${base}/installation/repositories`, { headers: bearer(token) })).status).toBe(403); + f.gh.appInstallations.update(installation.id, { app_id: 11 }); + f.tokenMap.get(token)!.installation!.accountId = 999; + expect((await f.app.request(`${base}/installation/repositories`, { headers: bearer(token) })).status).toBe(403); + f.gh.appInstallations.delete(installation.id); + expect((await f.app.request(`${base}/installation/repositories`, { headers: bearer(token) })).status).toBe(403); + }); + + it("finds repository installation by numeric ID only for the authenticated app", async () => { + const f = fixture("selected"); + const alpha = f.gh.repos.findOneBy("full_name", "acme/alpha")!; + const response = await f.app.request(`${base}/repositories/${alpha.id}/installation`, { + headers: bearer(appJwt(11)), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual( + expect.objectContaining({ + id: 101, + app_id: 11, + target_type: "Organization", + account: expect.objectContaining({ login: "acme" }), + }), + ); + expect( + (await f.app.request(`${base}/repositories/${alpha.id}/installation`, { headers: bearer(appJwt(22)) })).status, + ).toBe(404); + expect((await f.app.request(`${base}/repositories/${alpha.id}/installation`)).status).toBe(401); + }); + + it("resolves each app to its own disjoint account installation", async () => { + const f = fixture(); + const outside = f.gh.repos.findOneBy("full_name", "other/outside")!; + f.gh.appInstallations.insert({ + installation_id: 202, + app_id: 22, + account_id: outside.owner_id, + account_type: outside.owner_type, + account_login: "other", + repository_selection: "all", + repository_ids: [], + permissions: {}, + events: [], + suspended_at: null, + }); + const own = await f.app.request(`${base}/repositories/${outside.id}/installation`, { headers: bearer(appJwt(22)) }); + expect(own.status).toBe(200); + expect(await own.json()).toEqual(expect.objectContaining({ id: 202, app_id: 22 })); + const foreign = await f.app.request(`${base}/repositories/${outside.id}/installation`, { + headers: bearer(appJwt(11)), + }); + expect(foreign.status).toBe(404); + }); + + it("returns 404 for missing, suspended, unselected, and out-of-account repositories", async () => { + const f = fixture("selected"); + for (const id of [ + 999, + f.gh.repos.findOneBy("full_name", "acme/beta")!.id, + f.gh.repos.findOneBy("full_name", "other/outside")!.id, + ]) { + expect( + (await f.app.request(`${base}/repositories/${id}/installation`, { headers: bearer(appJwt(11)) })).status, + ).toBe(404); + } + const installation = f.gh.appInstallations.all()[0]; + const alpha = f.gh.repos.findOneBy("full_name", "acme/alpha")!; + f.gh.appInstallations.update(installation.id, { suspended_at: new Date().toISOString() }); + expect( + (await f.app.request(`${base}/repositories/${alpha.id}/installation`, { headers: bearer(appJwt(11)) })).status, + ).toBe(404); + }); +}); diff --git a/packages/@emulators/github/src/__tests__/user-memberships.test.ts b/packages/@emulators/github/src/__tests__/user-memberships.test.ts new file mode 100644 index 000000000..37602876e --- /dev/null +++ b/packages/@emulators/github/src/__tests__/user-memberships.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + Hono, + Store, + WebhookDispatcher, + authMiddleware, + createApiErrorHandler, + createErrorHandler, + type TokenMap, +} from "@emulators/core"; +import { getGitHubStore, githubPlugin, seedFromConfig } from "../index.js"; + +const base = "http://localhost:4000"; + +function createTestApp() { + const store = new Store(); + const tokens: TokenMap = new Map(); + const app = new Hono(); + app.onError(createApiErrorHandler()); + app.use("*", createErrorHandler()); + app.use("*", authMiddleware(tokens)); + githubPlugin.register(app as any, store, new WebhookDispatcher(), base, tokens); + githubPlugin.seed?.(store, base); + seedFromConfig(store, base, { + users: [{ login: "alice" }, { login: "bob" }], + orgs: [ + { login: "zeta", members: [{ login: "alice" }] }, + { login: "acme", members: [{ login: "alice", role: "admin" }] }, + { login: "other", members: [{ login: "bob", role: "admin" }] }, + ], + }); + const user = getGitHubStore(store).users.findOneBy("login", "alice")!; + for (const scope of ["read:org", "write:org", "admin:org", "repo"]) { + tokens.set(scope, { login: user.login, id: user.id, scopes: [scope] }); + } + tokens.set("unscoped", { login: user.login, id: user.id, scopes: [] }); + tokens.set("multiple", { login: user.login, id: user.id, scopes: ["repo", "read:org", "user:email"] }); + tokens.set("installation", { + login: user.login, + id: user.id, + scopes: ["read:org"], + installation: { + installationId: 1, + appId: 1, + accountId: user.id, + accountType: "User", + permissions: {}, + repositoryIds: [], + repositorySelection: "all", + }, + }); + return app; +} + +function headers(token: string) { + return { Authorization: `Bearer ${token}` }; +} + +describe("authenticated user organization memberships", () => { + let app: Hono; + beforeEach(() => { + app = createTestApp(); + }); + + it("returns active memberships with shared administrator and member roles", async () => { + const response = await app.request(`${base}/user/memberships/orgs?state=active&per_page=100`, { + headers: headers("read:org"), + }); + expect(response.status).toBe(200); + const memberships = (await response.json()) as any[]; + expect(memberships).toHaveLength(2); + expect(memberships.map(({ state, role, organization }) => [state, role, organization.login])).toEqual([ + ["active", "admin", "acme"], + ["active", "member", "zeta"], + ]); + expect(memberships[0].organization.id).toEqual(expect.any(Number)); + expect(memberships[0].organization.url).toBe(`${base}/orgs/acme`); + expect(memberships[0].url).toBe(`${base}/orgs/acme/memberships/alice`); + }); + + it("paginates memberships with Link headers while preserving the state filter", async () => { + const response = await app.request(`${base}/user/memberships/orgs?state=active&per_page=1`, { + headers: headers("read:org"), + }); + expect(response.status).toBe(200); + expect(((await response.json()) as any[]).map((membership) => membership.organization.login)).toEqual(["acme"]); + const next = response.headers.get("Link")!.match(/<([^>]+)>; rel="next"/)![1]!; + expect(new URL(next).searchParams.get("state")).toBe("active"); + const nextResponse = await app.request(next, { headers: headers("read:org") }); + expect(((await nextResponse.json()) as any[]).map((membership) => membership.organization.login)).toEqual(["zeta"]); + }); + + it.each(["read:org", "write:org", "admin:org"])("accepts the %s scope", async (scope) => { + const response = await app.request(`${base}/user/memberships/orgs`, { headers: headers(scope) }); + expect(response.status).toBe(200); + }); + + it("does not invent pending memberships", async () => { + const response = await app.request(`${base}/user/memberships/orgs?state=pending`, { + headers: headers("read:org"), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual([]); + }); + + it("rejects unsupported membership states", async () => { + const response = await app.request(`${base}/user/memberships/orgs?state=inactive`, { + headers: headers("read:org"), + }); + expect(response.status).toBe(422); + }); + + it("requires authentication", async () => { + const response = await app.request(`${base}/user/memberships/orgs`); + expect(response.status).toBe(401); + }); + + it.each(["repo", "installation"])("rejects %s credentials", async (token) => { + const response = await app.request(`${base}/user/memberships/orgs`, { headers: headers(token) }); + expect(response.status).toBe(403); + }); + + it.each([ + ["repo", "repo"], + ["unscoped", ""], + ])("does not add ungranted OAuth scopes for %s", async (token, scopes) => { + const response = await app.request(`${base}/user`, { headers: headers(token) }); + expect(response.status).toBe(200); + expect(response.headers.get("X-OAuth-Scopes")).toBe(scopes); + }); + + it("reports OAuth scopes when updating /user", async () => { + const response = await app.request(`${base}/user`, { + method: "PATCH", + headers: { ...headers("multiple"), "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Alice" }), + }); + expect(response.status).toBe(200); + expect(response.headers.get("X-OAuth-Scopes")).toBe("repo, read:org, user:email"); + }); + + it("reports the actual OAuth token scopes on /user", async () => { + const response = await app.request(`${base}/user`, { headers: headers("multiple") }); + expect(response.status).toBe(200); + expect(response.headers.get("X-OAuth-Scopes")).toBe("repo, read:org, user:email"); + }); +}); diff --git a/packages/@emulators/github/src/entities.ts b/packages/@emulators/github/src/entities.ts index 545e5eb2f..be4e8f08e 100644 --- a/packages/@emulators/github/src/entities.ts +++ b/packages/@emulators/github/src/entities.ts @@ -491,6 +491,7 @@ export interface GitHubApp extends Entity { private_key: string; permissions: Record; events: string[]; + setup_url?: string | null; webhook_url: string | null; webhook_secret: string | null; description: string | null; diff --git a/packages/@emulators/github/src/helpers.ts b/packages/@emulators/github/src/helpers.ts index b88f20a67..963172c35 100644 --- a/packages/@emulators/github/src/helpers.ts +++ b/packages/@emulators/github/src/helpers.ts @@ -26,6 +26,7 @@ import type { GitHubBlob, GitHubTree, GitHubApp, + GitHubAppInstallation, } from "./entities.js"; import type { GitHubStore } from "./store.js"; @@ -759,3 +760,54 @@ export function getNextMilestoneNumber(store: GitHubStore, repoId: number): numb const milestones = store.milestones.findBy("repo_id", repoId); return milestones.reduce((max, m) => Math.max(max, m.number), 0) + 1; } + +export function formatInstallation( + inst: GitHubAppInstallation, + ghApp: GitHubApp | undefined, + baseUrl: string, + gh: GitHubStore, +) { + const account = inst.account_type === "Organization" ? gh.orgs.get(inst.account_id) : gh.users.get(inst.account_id); + + return { + id: inst.installation_id, + account: account + ? { + login: account.login, + id: account.id, + node_id: account.node_id, + type: inst.account_type, + avatar_url: `${baseUrl}/avatars/u/${account.login}`, + url: `${baseUrl}/${inst.account_type === "Organization" ? "orgs" : "users"}/${account.login}`, + } + : null, + repository_selection: inst.repository_selection, + access_tokens_url: `${baseUrl}/app/installations/${inst.installation_id}/access_tokens`, + repositories_url: `${baseUrl}/installation/repositories`, + html_url: `${baseUrl}/settings/installations/${inst.installation_id}`, + app_id: inst.app_id, + app_slug: ghApp?.slug ?? null, + target_type: inst.account_type, + permissions: inst.permissions, + events: inst.events, + created_at: inst.created_at, + updated_at: inst.updated_at, + single_file_name: null, + has_multiple_single_files: false, + single_file_paths: [], + suspended_by: null, + suspended_at: inst.suspended_at, + }; +} + +export function reposForAccount( + gh: GitHubStore, + accountId: number, + accountType: GitHubRepo["owner_type"], +): GitHubRepo[] { + return gh.repos.all().filter((repo) => repo.owner_id === accountId && repo.owner_type === accountType); +} + +export function formatRepoBrief(repo: GitHubRepo) { + return { id: repo.id, node_id: repo.node_id, name: repo.name, full_name: repo.full_name, private: repo.private }; +} diff --git a/packages/@emulators/github/src/index.ts b/packages/@emulators/github/src/index.ts index 1ba16372f..13e3ae18c 100644 --- a/packages/@emulators/github/src/index.ts +++ b/packages/@emulators/github/src/index.ts @@ -32,6 +32,8 @@ import { checksRoutes } from "./routes/checks.js"; import { rateLimitRoutes } from "./routes/rate-limit.js"; import { metaRoutes } from "./routes/meta.js"; import { oauthRoutes } from "./routes/oauth.js"; +import { appInstallationFlowRoutes } from "./routes/app-installation-flow.js"; +import { installationRepositoriesRoutes } from "./routes/installation-repositories.js"; import { appsRoutes } from "./routes/apps.js"; import { installationTokenRoutes } from "./routes/installation-tokens.js"; import { findOrCreateBlob, findOrCreateCommit, findOrCreateTree } from "./git-helpers.js"; @@ -86,6 +88,7 @@ export interface GitHubSeedConfig { private_key?: string; permissions?: Record; events?: string[]; + setup_url?: string; webhook_url?: string; webhook_secret?: string; description?: string; @@ -513,6 +516,7 @@ export function seedFromConfig(store: Store, baseUrl: string, config: GitHubSeed private_key: privateKey, permissions: a.permissions ?? {}, events: a.events ?? [], + setup_url: a.setup_url ?? null, webhook_url: a.webhook_url ?? null, webhook_secret: a.webhook_secret ?? null, description: a.description ?? null, @@ -557,6 +561,7 @@ function findInstallationsForRepo( ownerLogin: string, repoName: string | undefined, event: string, + payload?: unknown, ): GitHubAppInstallation[] { const repoEntity = repoName ? gh.repos.findOneBy("full_name", `${ownerLogin}/${repoName}`) : null; const ownerUser = gh.users.findOneBy("login", ownerLogin); @@ -567,6 +572,10 @@ function findInstallationsForRepo( const results: GitHubAppInstallation[] = []; for (const inst of gh.appInstallations.all()) { + if (event === "installation") { + const target = (payload as { installation?: { id?: number } } | undefined)?.installation?.id; + if (target !== inst.installation_id) continue; + } if (inst.account_id !== ownerId || inst.account_type !== ownerType) continue; if (inst.suspended_at) continue; @@ -588,6 +597,7 @@ function enrichPayloadWithInstallation(payload: unknown, installation: GitHubApp return { ...(payload as Record), installation: { + ...((payload as { installation?: object }).installation ?? {}), id: installation.installation_id, node_id: generateNodeId("Installation", installation.installation_id), }, @@ -602,7 +612,7 @@ async function deliverToAppWebhookUrls( ownerLogin: string, repoName: string | undefined, ): Promise { - const installations = findInstallationsForRepo(gh, ownerLogin, repoName, event); + const installations = findInstallationsForRepo(gh, ownerLogin, repoName, event, payload); for (const inst of installations) { const ghApp = gh.apps.all().find((a) => a.app_id === inst.app_id); @@ -647,7 +657,7 @@ export const githubPlugin: ServicePlugin = { owner: string, repo?: string, ): Promise => { - const installations = findInstallationsForRepo(gh, owner, repo, event); + const installations = findInstallationsForRepo(gh, owner, repo, event, payload); const enrichedPayload = installations.length > 0 ? enrichPayloadWithInstallation(payload, installations[0]) : payload; @@ -675,6 +685,8 @@ export const githubPlugin: ServicePlugin = { metaRoutes(ctx); oauthRoutes(ctx); appsRoutes(ctx); + appInstallationFlowRoutes(ctx); + installationRepositoriesRoutes(ctx); installationTokenRoutes(ctx); contentsRoutes(ctx); // Registered last: the catch-all /commits/:ref{.+} route must not shadow diff --git a/packages/@emulators/github/src/route-helpers.ts b/packages/@emulators/github/src/route-helpers.ts index 5a6f06e33..89b6e9885 100644 --- a/packages/@emulators/github/src/route-helpers.ts +++ b/packages/@emulators/github/src/route-helpers.ts @@ -1,6 +1,7 @@ -import type { AuthUser } from "@emulators/core"; -import { ApiError, notFound, unauthorized, forbidden } from "@emulators/core"; +import type { AuthUser, Context, AppEnv, Store } from "@emulators/core"; +import { ApiError, notFound, unauthorized, forbidden, parseCookies } from "@emulators/core"; import type { GitHubStore } from "./store.js"; +import { getGitHubStore } from "./store.js"; import type { GitHubRepo, GitHubUser } from "./entities.js"; import { generateNodeId } from "./helpers.js"; @@ -331,3 +332,50 @@ export function assertIssueWrite(gh: GitHubStore, authUser: AuthUser | undefined assertRepoPermission(gh, authUser, repo, "issues", "write"); return assertAuthenticatedActor(gh, authUser); } + +export function getBrowserSessionMap(store: Store): Map { + let map = store.getData>("github.oauth.sessionMap"); + if (!map) { + map = new Map(); + store.setData("github.oauth.sessionMap", map); + } + return map; +} + +/** Resolve browser identity from the same session established by OAuth sign-in. */ +export function resolveBrowserSessionUser(store: Store, c: Context): { login: string; id: number } | null { + const sessionId = parseCookies(c.req.header("Cookie") ?? "")["_emu_session"]; + const login = sessionId ? getBrowserSessionMap(store).get(sessionId) : undefined; + const user = login ? getGitHubStore(store).users.findOneBy("login", login) : undefined; + return user ? { login: user.login, id: user.id } : null; +} + +type OrgMemberRow = { user: GitHubUser; orgRole: "admin" | "member" }; + +export function listOrgMembersDeduped(gh: GitHubStore, orgId: number): OrgMemberRow[] { + const byUser = new Map(); + for (const team of gh.teams.findBy("org_id", orgId)) { + for (const m of gh.teamMembers.findBy("team_id", team.id)) { + const user = gh.users.get(m.user_id); + if (!user) continue; + const isAdmin = m.role === "maintainer"; + const prev = byUser.get(user.id); + if (!prev) { + byUser.set(user.id, { user, isAdmin }); + } else { + byUser.set(user.id, { user, isAdmin: prev.isAdmin || isAdmin }); + } + } + } + return [...byUser.values()] + .map(({ user, isAdmin }) => ({ + user, + orgRole: isAdmin ? ("admin" as const) : ("member" as const), + })) + .sort((a, b) => a.user.id - b.user.id); +} + +export function orgRoleForUser(gh: GitHubStore, orgId: number, userId: number): "admin" | "member" | null { + const row = listOrgMembersDeduped(gh, orgId).find((r) => r.user.id === userId); + return row?.orgRole ?? null; +} diff --git a/packages/@emulators/github/src/routes/app-installation-flow.ts b/packages/@emulators/github/src/routes/app-installation-flow.ts new file mode 100644 index 000000000..a3f71869a --- /dev/null +++ b/packages/@emulators/github/src/routes/app-installation-flow.ts @@ -0,0 +1,183 @@ +import { randomBytes } from "crypto"; +import type { RouteContext } from "@emulators/core"; +import { escapeAttr, escapeHtml, renderCardPage, renderErrorPage } from "@emulators/core"; +import { getGitHubStore } from "../store.js"; +import { formatUser, formatInstallation, formatRepoBrief, reposForAccount } from "../helpers.js"; +import { orgRoleForUser, resolveBrowserSessionUser } from "../route-helpers.js"; + +type Account = { id: number; login: string; type: "User" | "Organization" }; +type PendingInstallation = { + userId: number; + appId: number; + account: Account; + state: string; + createdAt: number; +}; +const SERVICE_LABEL = "GitHub"; +const MAX_PENDING_FORMS_PER_USER = 32; +const MAX_PENDING_FORMS = 1024; +const FORM_TTL_MS = 10 * 60 * 1000; + +export function appInstallationFlowRoutes({ app, store, baseUrl, webhooks }: RouteContext): void { + const gh = getGitHubStore(store); + function forms(): Map { + let pending = store.getData>("github.installation.pendingForms"); + if (!pending) { + pending = new Map(); + store.setData("github.installation.pendingForms", pending); + } + for (const [nonce, form] of pending) { + if (Date.now() - form.createdAt > FORM_TTL_MS) pending.delete(nonce); + } + return pending; + } + function accounts(userId: number): Account[] { + const user = gh.users.get(userId); + return [ + ...(user ? [{ id: user.id, login: user.login, type: "User" as const }] : []), + ...gh.orgs + .all() + .filter((org) => orgRoleForUser(gh, org.id, userId) === "admin") + .map((org) => ({ id: org.id, login: org.login, type: "Organization" as const })), + ]; + } + const error = (title: string, message: string) => renderErrorPage(title, message, SERVICE_LABEL); + + app.get("/apps/:slug/installations/new", (c) => { + const user = resolveBrowserSessionUser(store, c); + if (!user) return c.html(error("Unauthorized", "Sign in through OAuth before installing an application."), 401); + const ghApp = gh.apps.findOneBy("slug", c.req.param("slug")); + if (!ghApp) return c.html(error("Not Found", "Application not found."), 404); + const state = c.req.query("state") ?? ""; + const path = `/apps/${encodeURIComponent(ghApp.slug)}/installations/new`; + const allowedAccounts = accounts(user.id); + const selectedLogin = c.req.query("account"); + if (!selectedLogin) { + const body = allowedAccounts + .map((account) => { + const params = new URLSearchParams({ state, account: account.login }); + return `Install on ${escapeHtml(account.login)}`; + }) + .join("\n"); + return c.html( + renderCardPage(`Install ${ghApp.name}`, "Choose an account for this application.", body, SERVICE_LABEL), + ); + } + const account = allowedAccounts.find((candidate) => candidate.login === selectedLogin); + if (!account) return c.html(error("Forbidden", "You cannot install an application on this account."), 403); + const nonce = randomBytes(24).toString("base64url"); + const pendingForms = forms(); + const userForms = [...pendingForms].filter(([, form]) => form.userId === user.id); + for (const [oldest] of userForms.slice(0, Math.max(0, userForms.length - MAX_PENDING_FORMS_PER_USER + 1))) { + pendingForms.delete(oldest); + } + while (pendingForms.size >= MAX_PENDING_FORMS) pendingForms.delete(pendingForms.keys().next().value!); + pendingForms.set(nonce, { userId: user.id, appId: ghApp.app_id, account, state, createdAt: Date.now() }); + const repositories = reposForAccount(gh, account.id, account.type); + const repositoryFields = repositories + .map( + (repo) => + ``, + ) + .join("\n"); + const body = `
+ + + + + ${repositoryFields} + +
`; + return c.html( + renderCardPage(`Install ${ghApp.name}`, `Install on ${escapeHtml(account.login)}.`, body, SERVICE_LABEL), + ); + }); + + app.post("/apps/:slug/installations/new", async (c) => { + const user = resolveBrowserSessionUser(store, c); + if (!user) return c.html(error("Unauthorized", "Sign in through OAuth before installing an application."), 401); + const body = await c.req.parseBody(); + const nonce = typeof body.nonce === "string" ? body.nonce : ""; + const pending = forms().get(nonce); + forms().delete(nonce); + const ghApp = gh.apps.findOneBy("slug", c.req.param("slug")); + if ( + !pending || + !ghApp || + pending.userId !== user.id || + pending.appId !== ghApp.app_id || + body.account !== pending.account.login + ) { + return c.html(error("Forbidden", "The installation form is invalid or expired."), 403); + } + const account = accounts(user.id).find( + (candidate) => candidate.id === pending.account.id && candidate.type === pending.account.type, + ); + if (!account) return c.html(error("Forbidden", "You cannot install an application on this account."), 403); + if ( + gh.appInstallations + .all() + .some( + (installation) => + installation.app_id === ghApp.app_id && + installation.account_id === account.id && + installation.account_type === account.type, + ) + ) { + return c.html(error("Already installed", "This application is already installed on this account."), 409); + } + const selection = body.repository_selection; + const rawIds = + body.repository_ids === undefined + ? [] + : Array.isArray(body.repository_ids) + ? body.repository_ids + : [body.repository_ids]; + const repoIds = [...new Set(rawIds.map((id) => (typeof id === "string" && /^\d+$/.test(id) ? Number(id) : NaN)))]; + const repositories = reposForAccount(gh, account.id, account.type); + if ( + (selection !== "all" && selection !== "selected") || + repoIds.some((id) => !repositories.some((repo) => repo.id === id)) || + (selection === "selected" && repoIds.length === 0) + ) { + return c.html(error("Invalid repositories", "Select repositories belonging to this account."), 422); + } + const installationId = + Math.max(0, ...gh.appInstallations.all().map((installation) => installation.installation_id)) + 1; + let callback = new URL(`${baseUrl}/settings/installations/${installationId}`); + try { + const configured = new URL(ghApp.setup_url ?? ""); + if (configured.protocol === "http:" || configured.protocol === "https:") callback = configured; + } catch { + // Invalid callback configuration falls back before the installation is saved. + } + callback.searchParams.set("installation_id", String(installationId)); + callback.searchParams.set("setup_action", "install"); + callback.searchParams.set("state", pending.state); + const installation = gh.appInstallations.insert({ + installation_id: installationId, + app_id: ghApp.app_id, + account_type: account.type, + account_id: account.id, + account_login: account.login, + repository_selection: selection, + repository_ids: selection === "selected" ? repoIds : [], + permissions: { ...ghApp.permissions }, + events: [...ghApp.events], + suspended_at: null, + }); + const selectedRepos = selection === "all" ? repositories : repositories.filter((repo) => repoIds.includes(repo.id)); + await webhooks.dispatch( + "installation", + "created", + { + action: "created", + installation: formatInstallation(installation, ghApp, baseUrl, gh), + repositories: selectedRepos.map(formatRepoBrief), + sender: formatUser(gh.users.get(user.id)!, baseUrl), + }, + account.login, + ); + return c.redirect(callback.toString(), 302); + }); +} diff --git a/packages/@emulators/github/src/routes/apps.ts b/packages/@emulators/github/src/routes/apps.ts index a3c3f6783..4bdd90160 100644 --- a/packages/@emulators/github/src/routes/apps.ts +++ b/packages/@emulators/github/src/routes/apps.ts @@ -1,7 +1,7 @@ import { randomBytes } from "crypto"; import type { RouteContext, AuthApp } from "@emulators/core"; import { getGitHubStore } from "../store.js"; -import { formatApp } from "../helpers.js"; +import { formatApp, formatInstallation, formatRepoBrief, reposForAccount } from "../helpers.js"; export function appsRoutes({ app, store, baseUrl, tokenMap }: RouteContext): void { const gh = getGitHubStore(store); @@ -55,7 +55,7 @@ export function appsRoutes({ app, store, baseUrl, tokenMap }: RouteContext): voi const installations = gh.appInstallations.findBy("app_id", authApp.appId); const ghApp = gh.apps.all().find((a) => a.app_id === authApp.appId); - return c.json(installations.map((inst) => formatInstallation(inst, ghApp, baseUrl))); + return c.json(installations.map((inst) => formatInstallation(inst, ghApp, baseUrl, gh))); }); app.get("/app/installations/:installation_id", (c) => { @@ -80,7 +80,7 @@ export function appsRoutes({ app, store, baseUrl, tokenMap }: RouteContext): voi } const ghApp = gh.apps.all().find((a) => a.app_id === authApp.appId); - return c.json(formatInstallation(inst, ghApp, baseUrl)); + return c.json(formatInstallation(inst, ghApp, baseUrl, gh)); }); app.post("/app/installations/:installation_id/access_tokens", async (c) => { @@ -130,9 +130,7 @@ export function appsRoutes({ app, store, baseUrl, tokenMap }: RouteContext): voi return c.json({ message: "The repositories field must contain up to 500 repository names." }, 422); } - const accountRepos = gh.repos - .all() - .filter((repo) => repo.owner_id === inst.account_id && repo.owner_type === inst.account_type); + const accountRepos = reposForAccount(gh, inst.account_id, inst.account_type); const resolvedIds: number[] = []; for (const name of body.repositories as string[]) { const repo = accountRepos.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase()); @@ -217,14 +215,8 @@ export function appsRoutes({ app, store, baseUrl, tokenMap }: RouteContext): voi const repos = requestedRepoIds .map((id) => gh.repos.get(id)) - .filter(Boolean) - .map((r) => ({ - id: r!.id, - node_id: r!.node_id, - name: r!.name, - full_name: r!.full_name, - private: r!.private, - })); + .filter((repo): repo is NonNullable => Boolean(repo)) + .map(formatRepoBrief); return c.json( { @@ -254,11 +246,11 @@ export function appsRoutes({ app, store, baseUrl, tokenMap }: RouteContext): voi inst.account_type === repo.owner_type ) { const ghApp = gh.apps.all().find((a) => a.app_id === inst.app_id); - return c.json(formatInstallation(inst, ghApp, baseUrl)); + return c.json(formatInstallation(inst, ghApp, baseUrl, gh)); } if (inst.repository_selection === "selected" && inst.repository_ids.includes(repo.id)) { const ghApp = gh.apps.all().find((a) => a.app_id === inst.app_id); - return c.json(formatInstallation(inst, ghApp, baseUrl)); + return c.json(formatInstallation(inst, ghApp, baseUrl, gh)); } } @@ -278,7 +270,7 @@ export function appsRoutes({ app, store, baseUrl, tokenMap }: RouteContext): voi } const ghApp = gh.apps.all().find((a) => a.app_id === inst.app_id); - return c.json(formatInstallation(inst, ghApp, baseUrl)); + return c.json(formatInstallation(inst, ghApp, baseUrl, gh)); }); app.get("/users/:username/installation", (c) => { @@ -294,40 +286,6 @@ export function appsRoutes({ app, store, baseUrl, tokenMap }: RouteContext): voi } const ghApp = gh.apps.all().find((a) => a.app_id === inst.app_id); - return c.json(formatInstallation(inst, ghApp, baseUrl)); + return c.json(formatInstallation(inst, ghApp, baseUrl, gh)); }); - - function formatInstallation(inst: any, ghApp: any, baseUrl: string) { - const account = inst.account_type === "Organization" ? gh.orgs.get(inst.account_id) : gh.users.get(inst.account_id); - - return { - id: inst.installation_id, - account: account - ? { - login: account.login, - id: account.id, - node_id: account.node_id, - type: inst.account_type, - avatar_url: `${baseUrl}/avatars/u/${account.login}`, - url: `${baseUrl}/${inst.account_type === "Organization" ? "orgs" : "users"}/${account.login}`, - } - : null, - repository_selection: inst.repository_selection, - access_tokens_url: `${baseUrl}/app/installations/${inst.installation_id}/access_tokens`, - repositories_url: `${baseUrl}/installation/repositories`, - html_url: `${baseUrl}/settings/installations/${inst.installation_id}`, - app_id: inst.app_id, - app_slug: ghApp?.slug ?? null, - target_type: inst.account_type, - permissions: inst.permissions, - events: inst.events, - created_at: inst.created_at, - updated_at: inst.updated_at, - single_file_name: null, - has_multiple_single_files: false, - single_file_paths: [], - suspended_by: null, - suspended_at: inst.suspended_at, - }; - } } diff --git a/packages/@emulators/github/src/routes/installation-repositories.ts b/packages/@emulators/github/src/routes/installation-repositories.ts new file mode 100644 index 000000000..6b3ad72e5 --- /dev/null +++ b/packages/@emulators/github/src/routes/installation-repositories.ts @@ -0,0 +1,63 @@ +import type { RouteContext } from "@emulators/core"; +import { forbidden, parsePagination, setLinkHeader, unauthorized } from "@emulators/core"; +import { formatRepo, formatInstallation, reposForAccount } from "../helpers.js"; +import { getGitHubStore } from "../store.js"; + +export function installationRepositoriesRoutes({ app, store, baseUrl }: RouteContext): void { + const gh = getGitHubStore(store); + + app.get("/installation/repositories", (c) => { + const tokenInstallation = c.get("authUser")?.installation; + if (!tokenInstallation) throw unauthorized(); + + const installation = gh.appInstallations.all().find((candidate) => { + return ( + candidate.installation_id === tokenInstallation.installationId && + candidate.app_id === tokenInstallation.appId && + candidate.account_id === tokenInstallation.accountId && + candidate.account_type === tokenInstallation.accountType + ); + }); + if (!installation || installation.suspended_at || !gh.apps.findOneBy("app_id", installation.app_id)) { + throw forbidden(); + } + + const repositories = reposForAccount(gh, installation.account_id, installation.account_type).filter((repo) => { + if (installation.repository_selection === "selected" && !installation.repository_ids.includes(repo.id)) + return false; + return tokenInstallation.repositorySelection === "all" || tokenInstallation.repositoryIds.includes(repo.id); + }); + const { page, per_page } = parsePagination(c); + setLinkHeader(c, repositories.length, page, per_page); + return c.json({ + total_count: repositories.length, + repositories: repositories + .slice((page - 1) * per_page, page * per_page) + .map((repo) => formatRepo(repo, gh, baseUrl)), + }); + }); + + app.get("/repositories/:repository_id/installation", (c) => { + const authApp = c.get("authApp"); + if (!authApp) throw unauthorized(); + + const repositoryId = Number(c.req.param("repository_id")); + const repo = Number.isSafeInteger(repositoryId) && repositoryId > 0 ? gh.repos.get(repositoryId) : undefined; + const installation = repo + ? gh.appInstallations.all().find((candidate) => { + return ( + candidate.app_id === authApp.appId && + !candidate.suspended_at && + candidate.account_id === repo.owner_id && + candidate.account_type === repo.owner_type && + (candidate.repository_selection === "all" || candidate.repository_ids.includes(repo.id)) + ); + }) + : undefined; + if (!installation) { + return c.json({ message: "Not Found", documentation_url: "https://docs.github.com/rest" }, 404); + } + const ghApp = gh.apps.findOneBy("app_id", authApp.appId); + return c.json(formatInstallation(installation, ghApp, baseUrl, gh)); + }); +} diff --git a/packages/@emulators/github/src/routes/oauth.ts b/packages/@emulators/github/src/routes/oauth.ts index 94ccd05b7..6391e4c9f 100644 --- a/packages/@emulators/github/src/routes/oauth.ts +++ b/packages/@emulators/github/src/routes/oauth.ts @@ -11,11 +11,11 @@ import { renderUserButton, matchesRedirectUri, constantTimeSecretEqual, - parseCookies, debug, } from "@emulators/core"; import { getGitHubStore } from "../store.js"; import { formatUser, formatUserFull } from "../helpers.js"; +import { getBrowserSessionMap, resolveBrowserSessionUser } from "../route-helpers.js"; type PendingCode = { login: string; @@ -45,15 +45,6 @@ function getTokenToClientId(store: Store): Map { return map; } -function getSessionMap(store: Store): Map { - let map = store.getData>("github.oauth.sessionMap"); - if (!map) { - map = new Map(); - store.setData("github.oauth.sessionMap", map); - } - return map; -} - /** On read: drops entries older than {@link PENDING_CODE_TTL_MS}. */ function getPendingCodeIfValid(store: Store, code: string): PendingCode | undefined { const map = getPendingCodes(store); @@ -77,17 +68,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo const user = gh.users.findOneBy("login", authUser.login); if (user) return { login: user.login, id: user.id }; } - const cookieHeader = c.req.header("Cookie") ?? ""; - const cookies = parseCookies(cookieHeader); - const sessionId = cookies["_emu_session"]; - if (sessionId) { - const login = getSessionMap(store).get(sessionId); - if (login) { - const user = gh.users.findOneBy("login", login); - if (user) return { login: user.login, id: user.id }; - } - } - return null; + return resolveBrowserSessionUser(store, c); } // ---------- OAuth authorize page ---------- @@ -181,7 +162,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo ); const sessionId = randomBytes(24).toString("base64url"); - getSessionMap(store).set(sessionId, login); + getBrowserSessionMap(store).set(sessionId, login); c.header("Set-Cookie", `_emu_session=${sessionId}; Path=/; HttpOnly; SameSite=Lax`); const sep = redirect_uri.includes("?") ? "&" : "?"; diff --git a/packages/@emulators/github/src/routes/orgs.ts b/packages/@emulators/github/src/routes/orgs.ts index fcba156d9..9edf49d4e 100644 --- a/packages/@emulators/github/src/routes/orgs.ts +++ b/packages/@emulators/github/src/routes/orgs.ts @@ -14,6 +14,8 @@ import { lookupRepo, } from "../helpers.js"; +import { listOrgMembersDeduped, orgRoleForUser } from "../route-helpers.js"; + const MEMBERS_TEAM_SLUG = "members"; function notFound() { @@ -65,7 +67,7 @@ function uniqueTeamSlug(gh: GitHubStore, orgId: number, base: string): string { return slug; } -function orgsForAuthenticatedUser(gh: GitHubStore, userId: number): GitHubOrg[] { +export function orgsForAuthenticatedUser(gh: GitHubStore, userId: number): GitHubOrg[] { const memberships = gh.teamMembers.findBy("user_id", userId); const orgIds = new Set(); for (const m of memberships) { @@ -77,36 +79,6 @@ function orgsForAuthenticatedUser(gh: GitHubStore, userId: number): GitHubOrg[] return orgs; } -type OrgMemberRow = { user: GitHubUser; orgRole: "admin" | "member" }; - -function listOrgMembersDeduped(gh: GitHubStore, orgId: number): OrgMemberRow[] { - const byUser = new Map(); - for (const team of teamsForOrg(gh, orgId)) { - for (const m of gh.teamMembers.findBy("team_id", team.id)) { - const user = gh.users.get(m.user_id); - if (!user) continue; - const isAdmin = m.role === "maintainer"; - const prev = byUser.get(user.id); - if (!prev) { - byUser.set(user.id, { user, isAdmin }); - } else { - byUser.set(user.id, { user, isAdmin: prev.isAdmin || isAdmin }); - } - } - } - return [...byUser.values()] - .map(({ user, isAdmin }) => ({ - user, - orgRole: isAdmin ? ("admin" as const) : ("member" as const), - })) - .sort((a, b) => a.user.id - b.user.id); -} - -function orgRoleForUser(gh: GitHubStore, orgId: number, userId: number): "admin" | "member" | null { - const row = listOrgMembersDeduped(gh, orgId).find((r) => r.user.id === userId); - return row?.orgRole ?? null; -} - function syncTeamMemberCount(gh: GitHubStore, teamId: number) { const n = gh.teamMembers.findBy("team_id", teamId).length; gh.teams.update(teamId, { members_count: n }); @@ -210,6 +182,32 @@ export function orgsAndTeamsRoutes({ app, store, baseUrl }: RouteContext): void return c.json(page.map((o) => formatOrgBrief(o, baseUrl))); }); + app.get("/user/memberships/orgs", (c) => { + const auth = requireAuthUser(c); + if (auth.installation || !auth.scopes.some((scope) => ["read:org", "write:org", "admin:org"].includes(scope))) { + throw forbidden(); + } + const user = gh.users.findOneBy("login", auth.login); + if (!user) throw unauthorized(); + const state = c.req.query("state"); + if (state && state !== "active" && state !== "pending") { + throw new ApiError(422, "Invalid state parameter"); + } + const orgs = state === "pending" ? [] : orgsForAuthenticatedUser(gh, user.id); + const { page, per_page } = parsePagination(c); + setLinkHeader(c, orgs.length, page, per_page); + return c.json( + orgs.slice((page - 1) * per_page, page * per_page).map((org) => ({ + url: orgMembershipUrl(baseUrl, org.login, user.login), + state: "active", + role: orgRoleForUser(gh, org.id, user.id), + organization: formatOrgBrief(org, baseUrl), + organization_url: `${baseUrl}/orgs/${org.login}`, + user: formatUser(user, baseUrl), + })), + ); + }); + app.get("/user/orgs", (c) => { const auth = requireAuthUser(c); const user = gh.users.findOneBy("login", auth.login); diff --git a/packages/@emulators/github/src/routes/users.ts b/packages/@emulators/github/src/routes/users.ts index 1da820b7e..1142a37df 100644 --- a/packages/@emulators/github/src/routes/users.ts +++ b/packages/@emulators/github/src/routes/users.ts @@ -68,6 +68,7 @@ export function usersRoutes({ app, store, baseUrl }: RouteContext): void { if (!user) { throw notFoundResponse(); } + if (!authUser.installation) c.header("X-OAuth-Scopes", authUser.scopes.join(", ")); return c.json(formatUserFull(user, baseUrl)); }); @@ -122,6 +123,7 @@ export function usersRoutes({ app, store, baseUrl }: RouteContext): void { if (!updated) { throw notFoundResponse(); } + if (!authUser.installation) c.header("X-OAuth-Scopes", authUser.scopes.join(", ")); return c.json(formatUserFull(updated, baseUrl)); }); diff --git a/packages/github-emulator/README.md b/packages/github-emulator/README.md index 04e9acdd3..2d6182c15 100644 --- a/packages/github-emulator/README.md +++ b/packages/github-emulator/README.md @@ -22,4 +22,6 @@ The default seed is synthetic. Configure `seed` and `tokens` for your tests. The After installing the tarball, `npx github-emulator --help` lists CLI options. It listens on loopback port 4200 by default and accepts `--port`, `--host`, `--base-url`, and `--config` for JSON configuration. -Current limitations include browser App installation, GitHub App user-token refresh, native Git clone/push, and guaranteed cancellation of in-flight webhooks during reset. The baseline uses upstream's typed in-memory state; SQLite is a planned iteration. See the repository roadmap for the supported proof flows and contribution plan. +Browser App installation supports the OAuth session, account and repository selection, a configured setup callback, and signed `installation.created` webhooks. Pending forms expire after 10 minutes and are limited to 32 per user and 1024 per instance. Invalid callback configuration uses the installation's local settings URL. See [installation configuration](../../docs/github-app-installation.md). + +Current limitations include GitHub App user-token refresh, native Git clone/push, and guaranteed cancellation of in-flight webhooks during reset. The baseline uses upstream's typed in-memory state; SQLite is a planned iteration. See the repository roadmap for the supported proof flows and contribution plan. diff --git a/packages/github-emulator/src/cli.ts b/packages/github-emulator/src/cli.ts index fd5f1eb2b..7c08a77bc 100644 --- a/packages/github-emulator/src/cli.ts +++ b/packages/github-emulator/src/cli.ts @@ -15,6 +15,8 @@ Usage: github-emulator [options] Source checkout: pnpm start --config examples/github.json Demo application: pnpm demo (http://127.0.0.1:3005) +Installation forms expire after 10 minutes (32 per user, 1024 per instance). +App setup callbacks must use HTTP(S); invalid values use local settings. `; async function main(): Promise { diff --git a/skills/github/SKILL.md b/skills/github/SKILL.md index ec77d3913..6ea31b0f4 100644 --- a/skills/github/SKILL.md +++ b/skills/github/SKILL.md @@ -1,6 +1,6 @@ --- name: github -description: Run and test the GitHub-only fork of Vercel Labs emulate locally, including OAuth App sign-in, App installation tokens, API calls, and signed webhooks. +description: Run and test the GitHub-only fork of Vercel Labs emulate locally, including OAuth App sign-in, browser App installation, installation tokens, API calls, and signed webhooks. --- # GitHub-only emulator @@ -33,6 +33,14 @@ The smoke command uses real HTTP requests and signature verification. It proves Point OAuth authorization at `$GITHUB_EMULATOR_URL/login/oauth/authorize`, token exchange at `$GITHUB_EMULATOR_URL/login/oauth/access_token`, and API calls at `$GITHUB_EMULATOR_URL`. Set Octokit's `baseUrl` to that origin. Use synthetic credentials from the seed config; unknown bearer tokens are rejected by this wrapper. +## Install a GitHub App + +Seed `apps[].setup_url` as the application callback and configure `webhook_url`, `webhook_secret`, and `events: ['installation']`. Start with `installations: []` to exercise creation. Complete OAuth sign-in first so the browser has `_emu_session`, then visit `/apps/:slug/installations/new?state=...` in the same browser. Choose **Install on ACCOUNT**, select all or specific repositories, and submit **Install**. The callback receives `installation_id`, `setup_action=install`, and the original `state`. The server uses only the configured callback URL. + +Use an App JWT for `/repositories/:id/installation`, then exchange it for an installation token and call `/installation/repositories`. Request `read:org` for OAuth organization discovery through `/user/memberships/orgs`; `/user` reports granted scopes in `X-OAuth-Scopes`. See `docs/github-app-installation.md` for the authorization and pagination contracts. + +Pending installation forms expire after 10 minutes. The emulator retains at most 32 forms per user and 1024 per instance, evicting the oldest when a limit is reached. Reopen the installation page if a form has expired or been evicted. Setup callbacks must be absolute HTTP(S) URLs; omitted, malformed, relative, and non-HTTP(S) values use the installation's local settings URL. + ## Iteration boundary -Browser-driven App installation and full user authorization/refresh are not implemented in the baseline. SQLite remains planned; upstream's typed store is retained initially. Keep source changes and regression tests for protocol behavior in their own commits under the upstream package paths. See `docs/upstream-workflow.md` before preparing an upstream PR. Do not publish npm releases during the baseline. +Full user authorization/refresh remains outside the verified subset. SQLite remains planned; upstream's typed store is retained initially. Keep source changes and regression tests for protocol behavior in their own commits under the upstream package paths. See `docs/upstream-workflow.md` before preparing an upstream PR. Do not publish npm releases during the baseline.