feat(web): sign in with GitHub through the Luke GitHub App, with the App's server client and install flow

Merged
DA
11d ago
+2065-76

Summary

  • Sign in with GitHub is the Luke GitHub App's user authorization. Better Auth's GitHub provider now takes the App's client (GITHUB_APP_CLIENT_ID / GITHUB_APP_CLIENT_SECRET, read by authSecrets) and asks for no OAuth scope (GITHUB_SIGN_IN): a GitHub App has permissions, not scopes, and the App's "Email addresses: read" is what answers the email read Luke needs. The token a sign-in leaves on the account row is the App's expiring user token with its refresh token, both sealed under BETTER_AUTH_SECRET as before, with their expiries beside them. An account from before the App is the same GitHub user id, so Better Auth signs it in to the user it already has and updateAccountOnSignIn replaces the row's tokens with the App's; auth-github-sign-in.test.ts holds that end to end over an in-memory database with GitHub faked behind fetch.
  • Nothing hands a browser the GitHub token. Better Auth's /get-access-token and /refresh-token endpoints join /token in DISABLED_AUTH_PATHS; the one reader of the token is the server.
  • A GitHubApp Effect service (apps/web/server/github/github-app.ts), built into the web runtime beside HostedEnvironment from GITHUB_APP_ID, GITHUB_APP_SLUG, the client pair, GITHUB_APP_PRIVATE_KEY (PEM; \n-flattened newlines restored) and BETTER_AUTH_SECRET. It answers the App's RS256 JWT, installation(id) as the App, userToken(userId) off the account row with refresh, and userInstallations(userId) on that token. The refresh happens inside one transaction under FOR UPDATE on the row, because GitHub retires a refresh token the moment it is used. A deployment missing any variable still builds the layer and every read answers GitHubAppNotConfigured naming what is missing. The service holds no client: GitHub runs on the ambient HttpClient and the row on the ambient SqlClient, so github-app.test.ts stands a fake at the GitHub boundary and PGlite beneath it. Failures carry a status or a kind and never the request, since the request carries the bearer.
  • The install flow (apps/web/server/github-install-app.ts, routes github/install and github/installed): GET /api/github/install → https://github.com/apps/<slug>/installations/new; GET /api/github/installed is the Setup URL, which confirms installation_id as this App's own through the App JWT and sends the browser to /github-installed.html?status=…, one word from GITHUB_INSTALL_STATUS (installed, updated, requested, not-found, unavailable). The page (apps/web/src/github-installed.tsx) draws a fixed card per status and interpolates nothing the address brought. The desktop picks the landing up in a later PR.
  • Docs: apps/web/server/README.md gains "The Luke GitHub App"; PRIVACY.md "Your account" says what the App's access is, replacing the old repo wording (a product decision, called out here).

For Dean: the App's Setup URL

Set the App's Setup URL to https://tryluke.dev/api/github/installed with Redirect on update checked.

Preview sign-in

Production's OAuth proxy exchanges the code itself, under production's own GitHub client. A Preview of this branch sends GitHub the App's client id, so the exchange succeeds only while production's GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET hold the App's client id and secret. Dean chose this (option 1) and is swapping production's two variables to the App's client; nothing on the branch is Preview-specific, and the README records the dependency.

Not in this PR

Listing repositories, plan.repository, sandboxes, any desktop UI, and installation access tokens (POST /app/installations/{id}/access_tokens) for the coding agents' sandbox firewall, which the coder PR adds when it has a caller.

Evidence

  • Platform-independent checks: ./scripts/check.sh exit 0 locally (lint, knip, typecheck, discarded-effect, tests, build).
  • CI could not verify this on a Mac; no Mac or desktop surface changed.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged. (CI)
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added. The App client on the authorize URL and the exchange, the no-scope request, the pre-App account signing in to the same user with the App's tokens sealed, the disabled token endpoints, the App JWT's signature and window, installation confirmed/not found/refused/dropped, the user token handed out, refreshed under the row lock and re-sealed, refusal/expiry/pre-App/no-account/wrong-secret → GitHubSignInRequired, the environment layer with and without variables, both routes and every landing status, the landing status vocabulary, and authSecrets reading the App's variables.
  • Gateway envelope goldens unchanged. (CI)
  • No new as type assertion outside the allowlisted files: one as GitHubInstallStatus behind a set-membership check, with its SAFETY comment. (CI via anti-slop)
  • No Effect.runPromise/runSync/runFork outside the runtime edges and shims. (CI)
  • No new raw async primitives. (CI)
  • No Effect, Stream, or Layer dropped as a statement. (CI via pnpm discarded-effect)
  • Test count delta: +31 (github-app.test.ts +18, github-install-app.test.ts +8, github-install-wire.test.ts +2, auth-github-sign-in.test.ts 1 → 3, auth-deployment.test.ts +1).
  • No hand-rolled file replaced; no new permanent adaptor.
  • Root CLAUDE.md/AGENTS.md unchanged and byte-identical; no sentence there names a changed part.
  • PRIVACY.md changed: "Your account" now describes the GitHub App's access and token handling, called out above.
  • Package deps match imports; no new dependency (better-auth/crypto and node:crypto are what seal and sign). (CI)
  • Barrel/door rule holds: @sidecar/hosted gains a vocabulary module only. (CI)

Notes

  • Blockers or follow-up verification: none. Dean is setting the Setup URL above and swapping production's GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET to the App's client, which is what lets a Preview sign-in complete.
  • Devin Review's two flags are fixed in this PR: /user/installations is read page by page, and a 401 on an unexpired user token is GitHubSignInRequired (the authorization was revoked) rather than an outage.

šŸ¤– Generated with Claude Code