| 1 | # gh CLI compatibility, plan 2 of 3: scopes and the device flow |
| 2 | |
| 3 | > **For agentic workers: |
| 4 | |
| 5 | **Goal:** Tokens carry GitHub scopes that Ghost enforces, and `gh auth login --web |
| 6 | |
| 7 | **Architecture:** An API key's `permissions.scopes |
| 8 | |
| 9 | **Tech Stack:** NestJS 12, Better Auth 1.7.2 (`deviceAuthorization |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-10-09-gh-cli-compat-design.md |
| 12 | |
| 13 | **Out of scope, plan 3:** user-registered OAuth apps, the authorization-code flow (`GET /login/oauth/authorize |
| 14 | |
| 15 | ## Facts established while planning (read before starting) |
| 16 | |
| 17 | From `gh` v2.102.0 (`internal/authflow/flow.go`) and ` |
| 18 | |
| 19 | - `gh` uses one OAuth client id for every host, github.com and Enterprise alike: `178c6fc778ccc68e1d6a` |
| 20 | - `gh auth login --web` asks for `repo read:org gist`. |
| 21 | - Device code request: `POST https://HOST/login/device/code`, form body `client_id`, |
| 22 | - A 401, 403, 404 or 422 from `/login/device/code`, or a 200 without `verification_uri`, makes |
| 23 | - `interval` and `expires_in` are required, and are parsed as integers. |
| 24 | - Polling: `POST https://HOST/login/oauth/access_token`, form body `client_id`, |
| 25 | - The plugin's `/device/token` (`auth.api.deviceToken`) throws |
| 26 | - The plugin's `GET /device?user_code=` claims a pending code for the signed-in user and returns `{ user_code, status, client_id, scope }` |
| 27 | - Plugin defaults: codes expire in 30 minutes, the polling interval is 5 seconds, user codes are 8 characters. The table model is `deviceCode`. Ghost's `drizzleAdapter(db, { provider: 'pg' })` |
| 28 | - `@better-auth/api-key` runs with `enableSessionForAPIKeys` off, its default. API keys therefore authenticate only the git transport and the compat layer ( |
| 29 | - The git routes at the root are `:username/:repo/info/refs`, `…/git-upload-pack`, |
| 30 | |
| 31 | ## Global Constraints |
| 32 | |
| 33 | - Scope names are GitHub's. Known: `repo`, `public_repo`, |
| 34 | - Implications as on GitHub: `repo` ⊃ `public_repo`; |
| 35 | - A key with no `permissions.scopes`, and a session, hold every known scope. |
| 36 | - A key minted by the device flow stores `permissions: { scopes: [...] }` and `metadata: { oauthClientId }` |
| 37 | - Reading a private repository needs `repo`. Writing (issue mutations, pushes) needs `repo`, or |
| 38 | - Deviation from the spec, ruled here: the spec asks for one `@RequiresScope()` decorator. Writes need `repo` |
| 39 | - Deviation from the spec, ruled here: `login` and `device` are reserved going forward, with no migration check. An existing account under either name keeps working, and only its web profile path is shadowed by |
| 40 | - A missing scope on a readable resource answers 403. On GraphQL that is `type: FORBIDDEN` with GitHub's wording; on REST it is 403 with `X-Accepted-OAuth-Scopes` |
| 41 | - `/login/device/code` and `/login/oauth/access_token` answer form-encoded, or JSON when the request's |
| 42 | - `verification_uri` is `<WEB_APP_URL>/device`. |
| 43 | - `login` and `device` join |
| 44 | - Code standards (`docs/code-standards.md`): one-line comments, no `any`, error classes in |
| 45 | - New controllers and resolvers come from `bunx nest g …` (run from `apps/api`), and their generated specs get mock providers. |
| 46 | - Commits: `api: …`, `web: …`, |
| 47 | - E2E only against the throwaway containers. `$E2E` means, run from `apps/api`: |
| 48 | ```bash |
| 49 | TEST_DATABASE_URL=postgres://postgres:test@localhost:55433/postgres TEST_S3_ENDPOINT= |
| 50 | ``` |
| 51 | - Unit tests: from `apps/api`, `bunx vitest run <path>`. |
| 52 | |
| 53 | ## Review Focus |
| 54 | |
| 55 | 1. **A key holding only `public_repo |
| 56 | 2. **A device code polled before approval, after denial, or for another client id** must answer |
| 57 | 3. **The plugin's Better Auth session must never reach `gh |
| 58 | 4. **Scopes `gh |
| 59 | 5. **A key made in Ghost's UI** (no |
| 60 | |
| 61 | ## File structure |
| 62 | |
| 63 | ``` |
| 64 | apps/api/src/lib/github/ |
| 65 | scopes.ts (+ .spec.ts) KNOWN_SCOPES, hasScope, grantableScopes, scopesOfKey |
| 66 | github.errors.ts (modify) InsufficientScopesError |
| 67 | oauth-apps.ts GH_CLI_APP, oauthAppOf(clientId) |
| 68 | apps/api/src/github/ |
| 69 | auth/github-auth.middleware.ts (modify) real scopes |
| 70 | auth/require-scope.ts requireScope(viewer, needed, …) for resolvers and controllers |
| 71 | rest/github-rest.filter.ts (modify) X-Accepted-OAuth-Scopes |
| 72 | oauth/ (nest g) oauth.module.ts, oauth.controller.ts (+ spec), oauth-response.ts |
| 73 | apps/api/src/git/middleware/git-basic-auth/git-basic-auth.middleware.ts (modify) scope check |
| 74 | apps/api/src/lib/auth.ts (modify) deviceAuthorization plugin, RESERVED_NAMES |
| 75 | apps/api/src/app.setup.ts (modify) exclude /login routes from /api |
| 76 | packages/db/src/schema/auth.ts (modify) deviceCode table; migration generated |
| 77 | apps/web/src/lib/auth-client.ts (modify) deviceAuthorizationClient |
| 78 | apps/web/src/app/device/ layout.tsx, page.tsx, device-form.tsx |
| 79 | apps/api/test/ |
| 80 | harness.ts (modify) scopedKey(app, userId, scopes) |
| 81 | gh.ts (modify) startGh() streaming stderr |
| 82 | github-scopes.e2e-spec.ts scope enforcement on GraphQL, REST and git |
| 83 | github-oauth.e2e-spec.ts device flow over HTTP |
| 84 | gh-cli.e2e-spec.ts (modify) gh auth login --web, gh auth refresh |
| 85 | ``` |
| 86 | |
| 87 | --- |
| 88 | |
| 89 | ### Task 1: Scopes on keys, and ` |
| 90 | |
| 91 | **Files:** |
| 92 | - Create: `apps/api/src/lib/github/scopes.spec.ts` |
| 93 | - Modify: `apps/api/src/lib/github/scopes.ts`, `apps/api/src/github/auth/github-auth.middleware.ts` |
| 94 | - Create: `apps/api/test/github-scopes.e2e-spec.ts` |
| 95 | |
| 96 | **Interfaces:** |
| 97 | - Produces `KNOWN_SCOPES: readonly Scope[]`, `type Scope`, |
| 98 | - Produces `scopedKey(app: INestApplication, userId: string, scopes: string[]): Promise<string>` in the harness. |
| 99 | - `ALL_SCOPES` is removed; `KNOWN_SCOPES` replaces it. |
| 100 | |
| 101 | - [ ] **Step 1: Failing unit tests** |
| 102 | |
| 103 | `apps/api/src/lib/github/scopes.spec.ts`: |
| 104 | |
| 105 | ```ts |
| 106 | import { describe, expect |
| 107 | |
| 108 | import { grantableScopes, hasScope |
| 109 | |
| 110 | describe('hasScope', () |
| 111 | it('accepts a scope that is granted or implied by a broader one', |
| 112 | expect(hasScope(['repo |
| 113 | expect(hasScope(['repo |
| 114 | expect(hasScope(['admin:org |
| 115 | expect(hasScope(['admin:public_key |
| 116 | expect(hasScope(['user |
| 117 | }); |
| 118 | |
| 119 | it('refuses a broader scope than the one granted', |
| 120 | expect(hasScope(['public_repo |
| 121 | expect(hasScope(['read:org |
| 122 | expect(hasScope([], |
| 123 | }); |
| 124 | }); |
| 125 | |
| 126 | describe('grantableScopes', () |
| 127 | it('keeps the known scopes gh asks for and drops the rest', |
| 128 | expect(grantableScopes('repo read:org gist workflow codespace |
| 129 | expect(grantableScopes('')) |
| 130 | }); |
| 131 | }); |
| 132 | |
| 133 | describe('scopesOfKey', () |
| 134 | it('reads the scopes a key was granted', |
| 135 | expect(scopesOfKey({ |
| 136 | }); |
| 137 | |
| 138 | it('gives a key made in Ghost, which has no scopes, every scope', |
| 139 | expect(scopesOfKey(null)) |
| 140 | expect(scopesOfKey({})) |
| 141 | }); |
| 142 | }); |
| 143 | ``` |
| 144 | |
| 145 | Run: `bunx vitest run src/lib/github/scopes.spec.ts`. Expected: FAIL, `hasScope` not exported. |
| 146 | |
| 147 | - [ ] **Step 2: Implement `scopes.ts |
| 148 | |
| 149 | Replace `apps/api/src/lib/github/scopes.ts`: |
| 150 | |
| 151 | ```ts |
| 152 | /** The GitHub OAuth scopes Ghost recognizes. gh checks `repo` and `read:org` on login; the rest are granted so tools that ask for them keep working. */ |
| 153 | export const KNOWN_SCOPES = |
| 154 | 'repo', |
| 155 | 'public_repo', |
| 156 | 'read:org', |
| 157 | 'write:org', |
| 158 | 'admin:org', |
| 159 | 'user', |
| 160 | 'read:user', |
| 161 | 'user:email', |
| 162 | 'admin:public_key', |
| 163 | 'write:public_key', |
| 164 | 'read:public_key', |
| 165 | 'delete_repo', |
| 166 | 'gist', |
| 167 | ] as const; |
| 168 | |
| 169 | export type Scope |
| 170 | |
| 171 | /** What each scope also grants, as on GitHub. */ |
| 172 | const IMPLIES: Partial |
| 173 | repo: ['public_repo'], |
| 174 | 'admin:org': ['write:org' |
| 175 | 'write:org': ['read:org' |
| 176 | 'admin:public_key': ['write:public_key' |
| 177 | 'write:public_key': ['read:public_key' |
| 178 | user: ['read:user', |
| 179 | }; |
| 180 | |
| 181 | export function hasScope(granted |
| 182 | return granted.some |
| 183 | } |
| 184 | |
| 185 | /** The known scopes in a space-separated OAuth `scope` request, in order; unknown ones are dropped, so gh asking for one Ghost lacks still logs in. */ |
| 186 | export function grantableScopes(requested |
| 187 | return requested.split |
| 188 | } |
| 189 | |
| 190 | /** A key's granted scopes. Keys made in Ghost carry none and hold every scope, as Ghost keys always have. */ |
| 191 | export function scopesOfKey(permissions |
| 192 | return permissions?.scopes |
| 193 | } |
| 194 | ``` |
| 195 | |
| 196 | Run the unit test. Expected: PASS. |
| 197 | |
| 198 | - [ ] **Step 3: Harness helper for a scoped key** |
| 199 | |
| 200 | Append to `apps/api/test/harness.ts`: |
| 201 | |
| 202 | ```ts |
| 203 | /** An API key holding only `scopes`, as the device flow mints them. */ |
| 204 | export async function scopedKey |
| 205 | const auth = |
| 206 | const { key |
| 207 | return key; |
| 208 | } |
| 209 | ``` |
| 210 | |
| 211 | - [ ] **Step 4: Failing e2e** |
| 212 | |
| 213 | `apps/api/test/github-scopes.e2e-spec.ts`: |
| 214 | |
| 215 | ```ts |
| 216 | import type { INestApplication |
| 217 | import request from 'supertest |
| 218 | import { afterAll, beforeAll |
| 219 | |
| 220 | import { hasBackends, scopedKey |
| 221 | |
| 222 | describe.skipIf(!hasBackends |
| 223 | let app: |
| 224 | let owner: |
| 225 | const username = |
| 226 | |
| 227 | beforeAll(async () |
| 228 | ({ app } |
| 229 | owner = await |
| 230 | }); |
| 231 | |
| 232 | afterAll(async () |
| 233 | await app?.close |
| 234 | }); |
| 235 | |
| 236 | it('reports a scoped key its own scopes, and a Ghost key every scope', |
| 237 | const key = |
| 238 | const scoped = |
| 239 | expect(scoped.headers |
| 240 | const full = |
| 241 | expect(full.headers |
| 242 | }); |
| 243 | |
| 244 | it("never lets an API key into Ghost's own API", |
| 245 | const key = |
| 246 | await request(app |
| 247 | await request(app |
| 248 | }); |
| 249 | }); |
| 250 | ``` |
| 251 | |
| 252 | Run: `$E2E test/github-scopes.e2e-spec.ts`. Expected: the first case FAILS (`x-oauth-scopes` lists every scope). The second PASSES already: it pins the fact that keys never reach Ghost's own API. |
| 253 | |
| 254 | - [ ] **Step 5: Middleware reads the key's scopes** |
| 255 | |
| 256 | In `github-auth.middleware.ts`, replace the `ALL_SCOPES` import with ` |
| 257 | |
| 258 | ```ts |
| 259 | private async fromKey |
| 260 | const { valid, key: |
| 261 | return valid && apiKey ? { userId: apiKey. |
| 262 | } |
| 263 | |
| 264 | private async fromSession |
| 265 | const session = await |
| 266 | return session ? { userId: session. |
| 267 | } |
| 268 | ``` |
| 269 | |
| 270 | `grep -rn ALL_SCOPES apps/api` must print nothing afterwards. |
| 271 | |
| 272 | - [ ] **Step 6: Run, then commit** |
| 273 | |
| 274 | Run: `$E2E test/github-scopes.e2e-spec.ts test/github-rest.e2e-spec.ts`, then `bunx vitest run src/lib/github`. Expected: PASS. |
| 275 | |
| 276 | ```bash |
| 277 | git add apps/api/src/lib/github/scopes.ts apps/api/src/lib/github/scopes.spec.ts |
| 278 | git commit -m "api: give API keys GitHub scopes and report them in X-OAuth-Scopes |
| 279 | ``` |
| 280 | |
| 281 | --- |
| 282 | |
| 283 | ### Task 2: Enforce scopes on GraphQL and REST |
| 284 | |
| 285 | **Files:** |
| 286 | - Create: `apps/api/src/github/auth/require-scope.ts` |
| 287 | - Modify: `apps/api/src/lib/github/github.errors.ts`, `apps/api/src/github/rest/github-rest.filter.ts` |
| 288 | - Modify: `repository.resolver.ts` (`repository`, |
| 289 | - Modify: `apps/api/test/github-scopes.e2e-spec.ts` |
| 290 | |
| 291 | **Interfaces:** |
| 292 | - Consumes `hasScope`, `Scope` (Task 1). |
| 293 | - Produces `InsufficientScopesError(field: string, accepted: readonly Scope[], granted: readonly string[])`, with `readonly accepted` |
| 294 | - Produces `requireScope(viewer: GithubViewer, field: string, ...anyOf: Scope[]): void` and `requireWriteScope(viewer: GithubViewer, field: string, repository: { isPrivate: boolean }): void` |
| 295 | - Changes `RepositoryResolver.load(repositoryId: string, viewer: GithubViewer | null)`. It was `requesterId?: string` |
| 296 | |
| 297 | - [ ] **Step 1: Failing e2e cases** |
| 298 | |
| 299 | In `github-scopes.e2e-spec.ts`, add a GraphQL helper beside `username`, and create two repositories at the end of |
| 300 | |
| 301 | ```ts |
| 302 | const graphql = |
| 303 | request(app.getHttpServer |
| 304 | ``` |
| 305 | |
| 306 | ```ts |
| 307 | for (const [name |
| 308 | await request(app |
| 309 | } |
| 310 | ``` |
| 311 | |
| 312 | Then append the cases: |
| 313 | |
| 314 | ```ts |
| 315 | it('hides a private repository from a key without repo, and shows it to one with repo', |
| 316 | const publicOnly = |
| 317 | const hidden = |
| 318 | expect(hidden.body |
| 319 | await request(app |
| 320 | const full = |
| 321 | const shown = |
| 322 | expect(shown.body |
| 323 | }); |
| 324 | |
| 325 | it('lets public_repo write to a public repository and refuses a key with neither', |
| 326 | const repo = |
| 327 | const repositoryId = |
| 328 | const create = |
| 329 | const allowed = |
| 330 | expect(allowed.body |
| 331 | const refused = |
| 332 | expect(refused.body |
| 333 | }); |
| 334 | |
| 335 | it('answers a REST call without its scope with 403 and X-Accepted-OAuth-Scopes', |
| 336 | const key = |
| 337 | const response = |
| 338 | expect(response.headers |
| 339 | }); |
| 340 | |
| 341 | it('refuses creating a private repository with public_repo only', |
| 342 | const key = |
| 343 | await request(app |
| 344 | await request(app |
| 345 | }); |
| 346 | ``` |
| 347 | |
| 348 | Run: `$E2E test/github-scopes.e2e-spec.ts`. Expected: these four FAIL. |
| 349 | |
| 350 | - [ ] **Step 2: The error and the guard functions** |
| 351 | |
| 352 | Append to `apps/api/src/lib/github/github.errors.ts`: |
| 353 | |
| 354 | ```ts |
| 355 | /** GitHub's missing-scope refusal; `accepted` becomes REST's X-Accepted-OAuth-Scopes. */ |
| 356 | export class InsufficientScopesError |
| 357 | readonly status = |
| 358 | |
| 359 | constructor( |
| 360 | field: string, |
| 361 | readonly accepted: |
| 362 | granted: readonly |
| 363 | ) { |
| 364 | super( |
| 365 | `Your token has not been granted the required scopes to execute this query. The '${field}' field requires one of the following scopes: [${ |
| 366 | ); |
| 367 | } |
| 368 | } |
| 369 | ``` |
| 370 | |
| 371 | `apps/api/src/github/auth/require-scope.ts`: |
| 372 | |
| 373 | ```ts |
| 374 | import { InsufficientScopesError } |
| 375 | import { hasScope, type |
| 376 | import type { GithubViewer |
| 377 | |
| 378 | /** Refuses unless the viewer's token holds one of `anyOf`; `field` names the GraphQL field or REST route in GitHub's message. */ |
| 379 | export function requireScope(viewer |
| 380 | if (!anyOf.some |
| 381 | } |
| 382 | |
| 383 | /** A write to a repository needs `repo`, or `public_repo` when the repository is public. */ |
| 384 | export function requireWriteScope(viewer |
| 385 | if (repository.isPrivate |
| 386 | else requireScope(viewer, |
| 387 | } |
| 388 | ``` |
| 389 | |
| 390 | - [ ] **Step 3: REST filter sets the header** |
| 391 | |
| 392 | In `github-rest.filter.ts`, before `host.switchToHttp()…json(…)`: |
| 393 | |
| 394 | ```ts |
| 395 | const response = |
| 396 | if (exception instanceof |
| 397 | response.status(status |
| 398 | ``` |
| 399 | |
| 400 | and import `InsufficientScopesError` from `../../lib/github/github.errors.js`. |
| 401 | |
| 402 | - [ ] **Step 4: Private reads need `repo |
| 403 | |
| 404 | In `RepositoryResolver`, the scope check goes where both entry points meet. Change `load` and |
| 405 | |
| 406 | ```ts |
| 407 | async repository(@Args |
| 408 | const row = |
| 409 | // A token without `repo` cannot see private repositories at all, as on GitHub: they read as missing, not forbidden. |
| 410 | if (row.visibility |
| 411 | return this.toNode |
| 412 | } |
| 413 | |
| 414 | async load(repositoryId |
| 415 | const row = |
| 416 | if (row.visibility |
| 417 | return this.toNode |
| 418 | } |
| 419 | ``` |
| 420 | |
| 421 | Then update every caller (`grep -rn "\.load(" apps/api/src/github | grep -v loaders`) to pass the viewer instead of a user id: |
| 422 | |
| 423 | - `repository.resolver.ts` `parent`: |
| 424 | - `issue.resolver.ts` `repository`: |
| 425 | - `viewer.resolver.ts` `lookup` and |
| 426 | - `issue-mutations.resolver.ts` `repositoryFor` / |
| 427 | - `repository-mutations.resolver.ts`: `this.repositoryNodes.load(created.id, viewer)` |
| 428 | - `repos.controller.ts` `create`: |
| 429 | |
| 430 | - [ ] **Step 5: Writes need `repo |
| 431 | |
| 432 | In `issue-mutations.resolver.ts`, every mutation calls `requireWriteScope` once it knows the repository: |
| 433 | |
| 434 | - `createIssue`: after `repositoryFor`, |
| 435 | - `updateIssue`, `closeIssue`, |
| 436 | |
| 437 | `require(viewer, …)` returns the user id today. Change it to return the viewer, so the functions above can receive it: |
| 438 | |
| 439 | ```ts |
| 440 | private require(viewer |
| 441 | if (!viewer) |
| 442 | return viewer; |
| 443 | } |
| 444 | ``` |
| 445 | |
| 446 | and use `viewer.userId` where the old code used `userId`. |
| 447 | |
| 448 | `repository-mutations.resolver.ts` `createRepository`, after the signed-in check: `requireWriteScope(viewer, 'createRepository', { isPrivate: input.visibility !== RepositoryVisibility.PUBLIC }); |
| 449 | |
| 450 | `repos.controller.ts` `create`, after computing `visibility |
| 451 | |
| 452 | `users.controller.ts`: `keys` calls `requireScope(viewer, 'GET /user/keys', 'read:public_key') |
| 453 | |
| 454 | - [ ] **Step 6: Run, then commit** |
| 455 | |
| 456 | Run: `$E2E test/github-scopes.e2e-spec.ts test/github-graphql.e2e-spec.ts test/github-rest.e2e-spec.ts`, then `bunx vitest run src/lib/github src/github`. Expected: PASS. The earlier suites use Ghost keys, which hold every scope, so they are unchanged. |
| 457 | |
| 458 | ```bash |
| 459 | git add apps/api/src apps/api/test |
| 460 | git commit -m "api: enforce GitHub scopes on the compat GraphQL and REST APIs |
| 461 | ``` |
| 462 | |
| 463 | --- |
| 464 | |
| 465 | ### Task 3: Scopes over git |
| 466 | |
| 467 | **Files:** |
| 468 | - Modify: `apps/api/src/git/middleware/git-basic-auth/git-basic-auth.middleware.ts`, its `.spec.ts` |
| 469 | |
| 470 | **Interfaces:** |
| 471 | - Consumes `hasScope`, `scopesOfKey` (Task 1). |
| 472 | - `resolveKey` additionally returns `scopes: readonly string[] | null`. It is null for anonymous requests and LFS tokens, which are already scoped to one repository. |
| 473 | |
| 474 | - [ ] **Step 1: Failing unit cases** |
| 475 | |
| 476 | In the spec's `harness`, let `authorize` resolve ` |
| 477 | |
| 478 | ```ts |
| 479 | it('refuses a key without repo on a private repository, and a read-only scope on a push', |
| 480 | const publicOnly = |
| 481 | verify: { valid: true |
| 482 | }); |
| 483 | await publicOnly.run |
| 484 | expect(publicOnly.next |
| 485 | |
| 486 | const readOrg = |
| 487 | verify: { valid: true |
| 488 | authorize: vi.fn |
| 489 | }); |
| 490 | await readOrg.run |
| 491 | expect(readOrg.next |
| 492 | }); |
| 493 | |
| 494 | it('lets public_repo push to a public repository, and a Ghost key do anything', |
| 495 | const publicOnly = |
| 496 | verify: { valid: true |
| 497 | authorize: vi.fn |
| 498 | }); |
| 499 | await publicOnly.run |
| 500 | expect(publicOnly.next |
| 501 | |
| 502 | const ghost = |
| 503 | await ghost.run |
| 504 | expect(ghost.next |
| 505 | }); |
| 506 | ``` |
| 507 | |
| 508 | Import `RepositoryNotFoundError` from `../../../lib/repositories/repositories.errors.js` and |
| 509 | |
| 510 | Run: `bunx vitest run src/git/middleware/git-basic-auth`. Expected: the two new cases FAIL. |
| 511 | |
| 512 | - [ ] **Step 2: Implement** |
| 513 | |
| 514 | In `resolveKey`, return the key's scopes: `{ actor: { userId: apiKey.referenceId }, apiKeyId: apiKey.id, scopes: scopesOfKey(apiKey.permissions) }`. Every other return gets |
| 515 | |
| 516 | ```ts |
| 517 | // A scoped token reads private repositories only with `repo`, and pushes with `repo` or, to a public repository, `public_repo`. Unreadable reads as missing, as on GitHub. |
| 518 | if (scopes) { |
| 519 | const isPrivate = |
| 520 | if (isPrivate && |
| 521 | if (isPush && |
| 522 | } |
| 523 | ``` |
| 524 | |
| 525 | Destructure `scopes` alongside `actor`, ` |
| 526 | |
| 527 | - [ ] **Step 3: Run, then commit** |
| 528 | |
| 529 | Run: `bunx vitest run src/git`, then `$E2E test/lfs.e2e-spec.ts test/pull-refs.e2e-spec.ts` (they push with Ghost keys). Expected: PASS. |
| 530 | |
| 531 | ```bash |
| 532 | git add apps/api/src/git/middleware/git-basic-auth |
| 533 | git commit -m "api: enforce GitHub scopes on git over HTTP |
| 534 | ``` |
| 535 | |
| 536 | --- |
| 537 | |
| 538 | ### Task 4: The device flow at GitHub's paths |
| 539 | |
| 540 | **Files:** |
| 541 | - Modify: `packages/db/src/schema/auth.ts` (`deviceCode` table), then |
| 542 | - Modify: `apps/api/src/lib/auth.ts` (plugin, `RESERVED_NAMES`), |
| 543 | - Create: `apps/api/src/lib/github/oauth-apps.ts` |
| 544 | - Create (via `bunx nest g module github/oauth` and `bunx nest g controller github/oauth`): |
| 545 | - Create: `apps/api/src/github/oauth/oauth-response.ts` |
| 546 | - Create: `apps/api/test/github-oauth.e2e-spec.ts` |
| 547 | |
| 548 | **Interfaces:** |
| 549 | - Consumes `grantableScopes` (Task 1). |
| 550 | - Produces `GH_CLI_APP = { clientId: '178c6fc778ccc68e1d6a', name: 'GitHub CLI' }` and `oauthAppOf(clientId: string): { clientId: string; name: string } | null` |
| 551 | - Produces `OAUTH_ROUTES` (the two `/login/...` routes), excluded from the |
| 552 | - Produces HTTP `POST /login/device/code` and `POST /login/oauth/access_token` as described in Facts. |
| 553 | |
| 554 | - [ ] **Step 1: Failing e2e cases** |
| 555 | |
| 556 | `apps/api/test/github-oauth.e2e-spec.ts`: |
| 557 | |
| 558 | ```ts |
| 559 | import type { INestApplication |
| 560 | import request from 'supertest |
| 561 | import { afterAll, beforeAll |
| 562 | |
| 563 | import { hasBackends, signUp |
| 564 | |
| 565 | const GH = ' |
| 566 | const DEVICE_GRANT = ' |
| 567 | |
| 568 | describe.skipIf(!hasBackends |
| 569 | let app: |
| 570 | let owner: |
| 571 | const username = |
| 572 | const api = |
| 573 | const requestCode = |
| 574 | const poll = |
| 575 | const form = |
| 576 | const decide = |
| 577 | await api().get |
| 578 | await api().post |
| 579 | }; |
| 580 | |
| 581 | beforeAll(async () |
| 582 | ({ app } |
| 583 | owner = await |
| 584 | }); |
| 585 | |
| 586 | afterAll(async () |
| 587 | await app?.close |
| 588 | }); |
| 589 | |
| 590 | it('hands gh a device code, form-encoded, with the web /device page to visit', |
| 591 | const response = |
| 592 | expect(response.headers |
| 593 | const code = |
| 594 | expect(code).toMatchObject |
| 595 | expect(code.user_code |
| 596 | expect(code.device_code |
| 597 | }); |
| 598 | |
| 599 | it('answers JSON when asked, and refuses an unknown client so gh does not loop', |
| 600 | const json = |
| 601 | expect(json.body |
| 602 | await api().post |
| 603 | }); |
| 604 | |
| 605 | it('answers authorization_pending before approval, and access_denied after a deny', |
| 606 | const pending = |
| 607 | expect(form((await |
| 608 | |
| 609 | const denied = |
| 610 | await decide(denied |
| 611 | expect(form((await |
| 612 | }); |
| 613 | |
| 614 | it('mints a ghost_pat_ key with the known scopes gh asked for, never the session', |
| 615 | const code = |
| 616 | await decide(code |
| 617 | expect(form((await |
| 618 | const token = |
| 619 | expect(token).toMatchObject |
| 620 | expect(token.access_token |
| 621 | const status = |
| 622 | expect(status.headers |
| 623 | expect(form((await |
| 624 | }); |
| 625 | }); |
| 626 | ``` |
| 627 | |
| 628 | The approve case polls only once, right after approval, so the plugin's 5-second `slow_down` window never triggers. |
| 629 | |
| 630 | Run: `$E2E test/github-oauth.e2e-spec.ts`. Expected: FAIL (404 on `/login/device/code`). |
| 631 | |
| 632 | - [ ] **Step 2: The `deviceCode |
| 633 | |
| 634 | Append to `packages/db/src/schema/auth.ts`, after `apikey`: |
| 635 | |
| 636 | ```ts |
| 637 | /** Better Auth's device-authorization codes; `gh auth login --web` polls one until the user approves it on /device. */ |
| 638 | export const deviceCode = |
| 639 | "device_code", |
| 640 | { |
| 641 | id: text("id |
| 642 | deviceCode: text("device_code |
| 643 | userCode: text("user_code |
| 644 | userId: text("user_id |
| 645 | expiresAt: timestamp("expires_at |
| 646 | status: text("status |
| 647 | lastPolledAt: timestamp("last_polled_at |
| 648 | pollingInterval: integer("polling_interval |
| 649 | clientId: text("client_id |
| 650 | scope: text("scope |
| 651 | }, |
| 652 | (table) => |
| 653 | uniqueIndex("device_code_device_code_uidx"). |
| 654 | uniqueIndex("device_code_user_code_uidx"). |
| 655 | ], |
| 656 | ); |
| 657 | ``` |
| 658 | |
| 659 | From `packages/db`: `bun run db:generate`. Expected: a new ` |
| 660 | |
| 661 | - [ ] **Step 3: The app constant, the plugin, reserved names** |
| 662 | |
| 663 | `apps/api/src/lib/github/oauth-apps.ts`: |
| 664 | |
| 665 | ```ts |
| 666 | /** gh's OAuth app, the same client id on every GitHub host. Plan 3 replaces this constant with a registry of apps users create. */ |
| 667 | export const GH_CLI_APP = |
| 668 | |
| 669 | export function oauthAppOf(clientId |
| 670 | return clientId === |
| 671 | } |
| 672 | ``` |
| 673 | |
| 674 | In `apps/api/src/lib/auth.ts`: |
| 675 | |
| 676 | - Add `'device'` and `'login'` to |
| 677 | - Import `deviceAuthorization` from `better-auth/plugins` and |
| 678 | |
| 679 | ```ts |
| 680 | deviceAuthorization({ |
| 681 | verificationUri: `${config. |
| 682 | validateClient: (clientId) |
| 683 | }), |
| 684 | ``` |
| 685 | |
| 686 | - [ ] **Step 4: Root routes and the response shape** |
| 687 | |
| 688 | In `apps/api/src/app.setup.ts`, define the routes and exclude them: |
| 689 | |
| 690 | ```ts |
| 691 | /** GitHub's OAuth endpoints, which gh calls at the host root rather than under /api. */ |
| 692 | export const OAUTH_ROUTES = |
| 693 | { path: 'login/device/code', |
| 694 | { path: 'login/oauth/access_token', |
| 695 | ]; |
| 696 | ``` |
| 697 | |
| 698 | `app.setGlobalPrefix('/api', { exclude: [...GIT_TRANSPORT_ROUTES, ...OAUTH_ROUTES] });` Import `RequestMethod` from ` |
| 699 | |
| 700 | `apps/api/src/github/oauth/oauth-response.ts`: |
| 701 | |
| 702 | ```ts |
| 703 | import type { Request, |
| 704 | |
| 705 | /** GitHub answers its OAuth endpoints form-encoded unless the client asks for JSON; gh's client reads either. */ |
| 706 | export function sendOAuth(req |
| 707 | res.status(status |
| 708 | if (/application\/json |
| 709 | res.type('application/x-www-form-urlencoded |
| 710 | } |
| 711 | ``` |
| 712 | |
| 713 | - [ ] **Step 5: The controller** |
| 714 | |
| 715 | From `apps/api`: `bunx nest g module github/oauth` and ` |
| 716 | |
| 717 | `oauth.controller.ts`: |
| 718 | |
| 719 | ```ts |
| 720 | import { type Database, |
| 721 | import { Body, Controller |
| 722 | import { ApiExcludeController } |
| 723 | import { AllowAnonymous, AuthService |
| 724 | import { APIError } |
| 725 | import { eq } |
| 726 | import type { Request, |
| 727 | |
| 728 | import { DATABASE } |
| 729 | import type { Auth |
| 730 | import { oauthAppOf } |
| 731 | import { grantableScopes } |
| 732 | import { sendOAuth } |
| 733 | |
| 734 | const DEVICE_GRANT = ' |
| 735 | |
| 736 | /** GitHub's OAuth device flow at its own paths, translated onto Better Auth's deviceAuthorization plugin. */ |
| 737 | @Controller('login') |
| 738 | @ApiExcludeController() |
| 739 | @AllowAnonymous() |
| 740 | export class OauthController |
| 741 | constructor( |
| 742 | private readonly auth |
| 743 | @Inject(DATABASE) |
| 744 | ) {} |
| 745 | |
| 746 | @Post('device/code' |
| 747 | async deviceCode(@ |
| 748 | if (!body.client_id |
| 749 | const code = |
| 750 | sendOAuth(req, res |
| 751 | device_code: code.device_code |
| 752 | user_code: code.user_code |
| 753 | verification_uri: code.verification_uri |
| 754 | expires_in: code.expires_in |
| 755 | interval: code.interval |
| 756 | }); |
| 757 | } |
| 758 | |
| 759 | @Post('oauth/access_token' |
| 760 | async accessToken(@ |
| 761 | // ponytail: the device grant only; plan 3 adds grant_type=authorization_code for user-registered apps. |
| 762 | if (body.grant_type |
| 763 | const app = |
| 764 | if (!app) |
| 765 | |
| 766 | let granted: |
| 767 | try { |
| 768 | granted = await |
| 769 | } catch (error) |
| 770 | if (!(error |
| 771 | // GitHub answers pending, slow_down, expired and denied with 200 and `error` in the body; gh reads the body, not the status. |
| 772 | const { error: |
| 773 | return sendOAuth(req |
| 774 | } |
| 775 | |
| 776 | // The plugin answers with a browser session; gh gets a scoped API key instead, and the session is ended. |
| 777 | const [session] |
| 778 | if (!session) |
| 779 | const scopes = |
| 780 | const { key |
| 781 | body: { userId: session |
| 782 | }); |
| 783 | sendOAuth(req, res |
| 784 | } |
| 785 | } |
| 786 | ``` |
| 787 | |
| 788 | If `APIError` is not exported from `better-auth/api` in 1.7.2, import it from ` |
| 789 | |
| 790 | Generated `oauth.controller.spec.ts`: provide `{ provide: AuthService, useValue: {} }` and ` |
| 791 | |
| 792 | - [ ] **Step 6: Run, then commit** |
| 793 | |
| 794 | Run: `$E2E test/github-oauth.e2e-spec.ts test/github-scopes.e2e-spec.ts`, then `bunx vitest run src/lib/github src/github`. Expected: PASS. |
| 795 | |
| 796 | If the JSON case fails because `@thallesp/nestjs-better-auth`'s body parsers do not cover root routes, the form-encoded cases will fail too. Mount `express.urlencoded({ extended: false })` for |
| 797 | |
| 798 | ```bash |
| 799 | git add packages/db apps/api/src |
| 800 | git commit -m "api: serve GitHub's OAuth device flow, minting scoped keys for gh |
| 801 | ``` |
| 802 | |
| 803 | --- |
| 804 | |
| 805 | ### Task 5: The web ` |
| 806 | |
| 807 | **Files:** |
| 808 | - Modify: `apps/web/src/lib/auth-client.ts` |
| 809 | - Create: `apps/web/src/app/device/layout.tsx`, `page.tsx`, |
| 810 | |
| 811 | **Interfaces:** |
| 812 | - Consumes the plugin's `GET /api/auth/device?user_code=` → `{ user_code, status, client_id, scope }` |
| 813 | - Consumes `GH_CLI_APP.name` by duplicating the one name the web shows: `{ '178c6fc778ccc68e1d6a': 'GitHub CLI' }` |
| 814 | |
| 815 | - [ ] **Step 1: Client plugin** |
| 816 | |
| 817 | In `auth-client.ts`, import `deviceAuthorizationClient` from ` |
| 818 | |
| 819 | - [ ] **Step 2: Layout and page** |
| 820 | |
| 821 | `apps/web/src/app/device/layout.tsx`: |
| 822 | |
| 823 | ```tsx |
| 824 | export { SignedInLayout as |
| 825 | ``` |
| 826 | |
| 827 | `apps/web/src/app/device/page.tsx`: |
| 828 | |
| 829 | ```tsx |
| 830 | import type { Metadata |
| 831 | |
| 832 | import { DeviceForm } |
| 833 | |
| 834 | export const metadata: |
| 835 | |
| 836 | export default async function |
| 837 | const { user_code |
| 838 | return ( |
| 839 | <div className=" |
| 840 | <h1 className=" |
| 841 | <DeviceForm initialCode |
| 842 | </div> |
| 843 | ); |
| 844 | } |
| 845 | ``` |
| 846 | |
| 847 | - [ ] **Step 3: The form** |
| 848 | |
| 849 | `apps/web/src/app/device/device-form.tsx`, a client component with three states (enter code, review, done): |
| 850 | |
| 851 | ```tsx |
| 852 | "use client"; |
| 853 | |
| 854 | import { useState } |
| 855 | |
| 856 | import { Button } |
| 857 | import { Input } |
| 858 | import { authClient } |
| 859 | |
| 860 | const APP_NAMES: Record |
| 861 | |
| 862 | type Request |
| 863 | |
| 864 | export function DeviceForm({ |
| 865 | const [code, |
| 866 | const [request, |
| 867 | const [error, |
| 868 | const [done, |
| 869 | |
| 870 | async function lookUp |
| 871 | setError(null); |
| 872 | const userCode = |
| 873 | const { data, |
| 874 | if (error || |
| 875 | if (data.status |
| 876 | const clientId = |
| 877 | const scope = |
| 878 | setRequest({ userCode, |
| 879 | } |
| 880 | |
| 881 | async function decide |
| 882 | if (!request) |
| 883 | const { error |
| 884 | ? await authClient. |
| 885 | : await authClient. |
| 886 | if (error) return |
| 887 | setDone(decision === |
| 888 | } |
| 889 | |
| 890 | if (done) return |
| 891 | |
| 892 | if (request) |
| 893 | return ( |
| 894 | <div className=" |
| 895 | <p><strong>{ |
| 896 | <ul className=" |
| 897 | {error && <p |
| 898 | <div className=" |
| 899 | <Button onClick= |
| 900 | <Button variant= |
| 901 | </div> |
| 902 | </div> |
| 903 | ); |
| 904 | |
| 905 | return ( |
| 906 | <form className=" |
| 907 | <label htmlFor=" |
| 908 | <Input id= |
| 909 | {error && <p |
| 910 | <Button type= |
| 911 | </form> |
| 912 | ); |
| 913 | } |
| 914 | ``` |
| 915 | |
| 916 | Check the generated client method names before relying on them. Run `bun run check-types` from `apps/web`. If the client exposes |
| 917 | |
| 918 | - [ ] **Step 4: Check by hand, then commit** |
| 919 | |
| 920 | From the root: `bun run check-types` and `bun run lint` (the web lint errors already on ` |
| 921 | |
| 922 | ```bash |
| 923 | git add apps/web/src |
| 924 | git commit -m "web: device activation page for gh auth login --web |
| 925 | ``` |
| 926 | |
| 927 | --- |
| 928 | |
| 929 | ### Task 6: ` |
| 930 | |
| 931 | **Files:** |
| 932 | - Modify: `apps/api/test/gh.ts` (`startGh`), |
| 933 | |
| 934 | **Interfaces:** |
| 935 | - Produces `startGh(args: string[], options: { configDir: string; token?: string }): { stderrUntil(pattern: RegExp): Promise<RegExpMatchArray>; done: Promise<{ stdout: string; stderr: string; code: number }> }`. |
| 936 | |
| 937 | - [ ] **Step 1: Streaming helper** |
| 938 | |
| 939 | In `test/gh.ts`, split the env building out of `runGh` into ` |
| 940 | |
| 941 | ```ts |
| 942 | /** Starts gh and lets a test act on its stderr while it runs, as the device flow needs: gh prints the code, then polls. */ |
| 943 | export function startGh(args |
| 944 | const child = |
| 945 | child.stdin.end |
| 946 | let stdout = |
| 947 | let stderr = |
| 948 | const waiters: |
| 949 | child.stdout.on |
| 950 | child.stderr.on |
| 951 | stderr += chunk; |
| 952 | for (const waiter |
| 953 | const match = |
| 954 | if (match) { |
| 955 | waiters.splice(waiters |
| 956 | waiter.resolve(match |
| 957 | } |
| 958 | } |
| 959 | }); |
| 960 | const done = |
| 961 | return { |
| 962 | stderrUntil: (pattern: |
| 963 | const match = |
| 964 | if (match) resolve |
| 965 | else waiters.push( |
| 966 | }), |
| 967 | done, |
| 968 | }; |
| 969 | } |
| 970 | ``` |
| 971 | |
| 972 | `runGh` keeps its behavior and uses `ghEnv`. |
| 973 | |
| 974 | - [ ] **Step 2: The cases** |
| 975 | |
| 976 | Add to `gh-cli.e2e-spec.ts`. Each uses its own config dir, so the token-based cases keep theirs. `api` is the trusting supertest agent from |
| 977 | |
| 978 | ```ts |
| 979 | it('logs in through the device flow', |
| 980 | const dir = |
| 981 | const login = |
| 982 | const [, userCode |
| 983 | await api.get |
| 984 | await api.post |
| 985 | const result = |
| 986 | expect(result, result |
| 987 | const status = |
| 988 | expect(status.stdout |
| 989 | rmSync(dir, { |
| 990 | }, 60_000); |
| 991 | |
| 992 | it('widens scopes with gh auth refresh', |
| 993 | const dir = |
| 994 | await runGh(['auth |
| 995 | const refresh = |
| 996 | const [, userCode |
| 997 | await api.get |
| 998 | await api.post |
| 999 | expect((await refresh |
| 1000 | const status = |
| 1001 | expect(status.stdout |
| 1002 | rmSync(dir, { |
| 1003 | }, 60_000); |
| 1004 | ``` |
| 1005 | |
| 1006 | `gh` may print the user code with a hyphen (`ABCD-EFGH`), while the plugin stores it without one. If ` |
| 1007 | |
| 1008 | These cases run in CI only, like every `gh` case, because `GH_E2E_HOST` needs port 443. |
| 1009 | |
| 1010 | - [ ] **Step 3: Push and watch CI** |
| 1011 | |
| 1012 | Commit, push, and watch the run as plan 1's Task 17 did. Expected: the two new cases pass. If `gh` prints the code on a different line, adjust `stderrUntil`'s pattern to match its actual output from the CI log. |
| 1013 | |
| 1014 | ```bash |
| 1015 | git add apps/api/test |
| 1016 | git commit -m "api: run gh auth login --web and gh auth refresh in the gh e2e suite |
| 1017 | ``` |
| 1018 | |
| 1019 | --- |
| 1020 | |
| 1021 | ### Task 7: Documentation |
| 1022 | |
| 1023 | **Files:** |
| 1024 | - Modify: `apps/docs/content/docs/github-compatibility.mdx`, `docs/0040-github-compatibility-is-a-translation-layer.md` |
| 1025 | |
| 1026 | - [ ] **Step 1: Compatibility page** |
| 1027 | |
| 1028 | - Setup: add `gh auth login --hostname api.<domain> --web` beside the token login. It opens `<web>/device` |
| 1029 | - Supported commands: add `gh auth login --web`, `gh auth refresh`, |
| 1030 | - Known differences: replace "Every token is granted every scope `gh` checks for." with "API keys made in Ghost's settings hold every scope. Keys from `gh auth login --web` |
| 1031 | - Not yet supported: remove `gh auth login --web` and `gh auth refresh`. Add "OAuth apps other than |
| 1032 | |
| 1033 | - [ ] **Step 2: ADR addendum** |
| 1034 | |
| 1035 | Append to ADR 0040 under a new `## Addendum: scopes and the device flow`: |
| 1036 | |
| 1037 | - Keys store GitHub scopes in `permissions.scopes`. Keys without them hold every scope. |
| 1038 | - One function decides coverage, with GitHub's implications. |
| 1039 | - Private repositories need `repo` and read as missing without it. |
| 1040 | - The device flow runs on Better Auth's plugin, behind GitHub's paths at the host root. |
| 1041 | - `gh` receives a scoped `ghost_pat_` key, never the plugin's session. |
| 1042 | - Rejected: handing out the Better Auth session (a full browser session, for every API), and a separate token table (API keys already verify, rate-limit and revoke). |
| 1043 | |
| 1044 | - [ ] **Step 3: OpenAPI, then commit** |
| 1045 | |
| 1046 | From the root: `bun run openapi`. The OAuth controller is excluded from the document, so expect only scope-related description changes, if any. |
| 1047 | |
| 1048 | ```bash |
| 1049 | git add apps/docs docs/0040-github-compatibility-is-a-translation-layer.md |
| 1050 | git commit -m "docs: scopes and the gh device flow |
| 1051 | ``` |