feat(web): sign in with GitHub through the Luke GitHub App, with the App's server client and install flow
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 byauthSecrets) 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 theaccountrow is the App's expiring user token with its refresh token, both sealed underBETTER_AUTH_SECRETas 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 andupdateAccountOnSignInreplaces the row's tokens with the App's;auth-github-sign-in.test.tsholds that end to end over an in-memory database with GitHub faked behindfetch. - Nothing hands a browser the GitHub token. Better Auth's
/get-access-tokenand/refresh-tokenendpoints join/tokeninDISABLED_AUTH_PATHS; the one reader of the token is the server. - A
GitHubAppEffect service (apps/web/server/github/github-app.ts), built into the web runtime besideHostedEnvironmentfromGITHUB_APP_ID,GITHUB_APP_SLUG, the client pair,GITHUB_APP_PRIVATE_KEY(PEM;\n-flattened newlines restored) andBETTER_AUTH_SECRET. It answers the App's RS256 JWT,installation(id)as the App,userToken(userId)off the account row with refresh, anduserInstallations(userId)on that token. The refresh happens inside one transaction underFOR UPDATEon 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 answersGitHubAppNotConfigurednaming what is missing. The service holds no client: GitHub runs on the ambientHttpClientand the row on the ambientSqlClient, sogithub-app.test.tsstands 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, routesgithub/installandgithub/installed):GET /api/github/installāhttps://github.com/apps/<slug>/installations/new;GET /api/github/installedis the Setup URL, which confirmsinstallation_idas this App's own through the App JWT and sends the browser to/github-installed.html?status=ā¦, one word fromGITHUB_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.mdgains "The Luke GitHub App";PRIVACY.md"Your account" says what the App's access is, replacing the oldrepowording (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.shexit 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.shgreen. (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, andauthSecretsreading the App's variables. - Gateway envelope goldens unchanged. (CI)
- No new
astype assertion outside the allowlisted files: oneas GitHubInstallStatusbehind a set-membership check, with itsSAFETYcomment. (CI via anti-slop) - No
Effect.runPromise/runSync/runForkoutside 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.ts1 ā 3,auth-deployment.test.ts+1). - No hand-rolled file replaced; no new permanent adaptor.
- Root
CLAUDE.md/AGENTS.mdunchanged and byte-identical; no sentence there names a changed part. -
PRIVACY.mdchanged: "Your account" now describes the GitHub App's access and token handling, called out above. - Package deps match imports; no new dependency (
better-auth/cryptoandnode:cryptoare what seal and sign). (CI) - Barrel/door rule holds:
@sidecar/hostedgains 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_SECRETto the App's client, which is what lets a Preview sign-in complete. - Devin Review's two flags are fixed in this PR:
/user/installationsis read page by page, and a 401 on an unexpired user token isGitHubSignInRequired(the authorization was revoked) rather than an outage.
š¤ Generated with Claude Code