`GH_HOST=<ghost api host> gh …` works against a Ghost instance the way it does against GitHub Enterprise Server. Beyond `gh`, Ghost aims for as much compatibility with GitHub's GraphQL and REST APIs as it can reach, so tools built for GitHub work with less friction. This spec covers the foundation and the first milestone; later milestones add surface on the same foundation.
Resolvers and controllers call the existing Ghost services for every read and write, so permission checks, numbering, events and notifications stay where they are. Nothing outside `src/github/` imports from it. Only `src/github/` imports from
Mappers are a last resort. They hide where a value came from and make a wrong field hard to trace, so the first choice is always a query that selects GitHub's field names directly, or a GraphQL field resolver that reads the one value it needs. A `to<GitHubType>` mapper in `apps/api/src/lib/github/` (
A new ADR, `docs/0040-github-compatibility-is-a-translation-layer.md`, records the layout, the mapper exception, `GH_HOST=api.DOMAIN`, OAuth apps issuing scoped
The aim is to support every way `gh` authenticates. Anything that proves out of reach is listed as unsupported on the compatibility page (see [Compatibility documentation]
|`gh auth setup-git`, git credential helper | Git Basic auth with the token as password; the git middleware already verifies API keys and also enforces scopes |
- The compat layer enforces scopes with one `@RequiresScope()` decorator on resolvers and REST routes, checked by the token guard. A missing scope answers as GitHub does (`FORBIDDEN`
- Ghost's own REST API refuses keys issued to OAuth apps. Its controllers have no scope checks, and a third-party app must not reach past what the user approved.
Users can register their own OAuth apps, and `gh` is one of them. `@better-auth/oauth-provider` (new dependency) holds the app registry, the authorize and consent flow, authorization codes and PKCE.
- Users manage apps under settings: name, homepage, callback URL, client ID, client secret (shown once, can be regenerated), and an "Enable device flow" switch, as GitHub has.
-`gh` is seeded by migration as a built-in app owned by the instance, with `gh`'s client ID (confirmed by the capture spike), no secret, and device flow on. It cannot be edited or deleted by users.
- Authorization code flow at GitHub's paths, on the API host root: `GET /login/oauth/authorize` redirects to a consent page on the web app, `POST /login/oauth/access_token`
- Access tokens are `ghost_pat_` API keys carrying the approved scopes. They do not expire, as GitHub OAuth app tokens do not, and they show in the user's authorized apps. The spike confirms the plugin lets the token endpoint issue our key; if it does not, the alias issues the key after the plugin validates the code.
5. The web app gets a `/device` page: enter the user code, see the app and the requested scopes, approve or deny. It uses the existing auth form components and requires a signed-in session.
- Introspection is on in every environment: `gh` sends feature-detection introspection queries to non-github.com hosts, and Apollo turns introspection off under `NODE_ENV=production`
-`@thallesp/nestjs-better-auth` 2.7.0 lists `@nestjs/graphql ^13` as an optional peer. If it breaks with 14, patch or fork the package; the compat guard sets the viewer on the context itself regardless.
The capture spike fixes the exact fields each type needs for milestone 1. Fields beyond that are added as compatibility work continues; the conformance test guards every addition.
Per-request DataLoaders in `graphql/loaders/` batch the relations a list asks for on every row: users by ID, labels by issue, assignees by issue, comment counts by issue. A loader is added when a field needs it, not ahead of time.
- Errors use GitHub's `{ message, documentation_url, errors? }` with the matching status. A dedicated exception filter on the compat controllers produces it; `DomainErrorFilter`
Before types and routes are written, point real `gh` at a logging stub (`GH_HOST` to a local HTTPS server that records every request and answers from canned fixtures) and run every milestone-1 command. Record method, path, query string, headers, request body and the full GraphQL document for each. The log fixes:
A docs-site page, `apps/docs/content/docs/github-compatibility.mdx`, lists what works: each `gh` command and auth path, each GraphQL type and REST route Ghost serves, and each known difference from GitHub. Anything unsupported is listed with the reason, so a user finds the gap there before they find it in a failing command. Every milestone updates the page in the same PR as the code, and the CI
and API links point at the API origin. Both come from existing configuration.
resources/
`
: it speaks GitHub's protocol, not Ghost's. Its REST controllers do appear in Ghost's OpenAPI document, grouped under the
`
GitHub compatibility
`
tag so they read apart from Ghost's own routes. The web app's generated client does not need them; if the generator would pull them in, it filters that tag out. The GraphQL endpoint is documented by its own schema and GraphiQL.
`
src/lib/github/
`
.
`
toIssueNode
`
, later
`
toIssueRest
`
) is written only when a service's output cannot be used that way, and the ADR asks each one to say why in one line. Mapping Ghost's shapes onto GitHub's is the purpose of this layer, so the ban on reshaping functions is relaxed inside
`
src/github/
`
on those terms. It still applies everywhere else. If
`
scripts/check-standards.ts
`
enforces section 2, it gets a path exemption for
`
apps/api/src/github/
`
. GraphQL and REST mappers are separate, because the two GitHub shapes differ (camelCase nodes vs snake_case objects with URLs); the services under them are shared.
`
ghost_pat_
`
keys, and scoped keys being refused by Ghost's own API.
(
#compatibility-documentation
)
), with the reason.
|
Same header, same guard
|
`
as a built-in OAuth app
|
`
with
`
X-OAuth-Scopes
`
,
`
viewer { login }
`
|
|
`
GET
`
/
`
POST /api/v3/user/keys
`
over the existing SSH key service
|
`
switch
`
|
Local to
`
gh
`
; nothing to serve
|
`
in some paths). A guard in
`
src/github/auth/
`
accepts both prefixes and verifies the value as a Better Auth API key (
`
ghost_pat_…
`
). The resolved user and the token's scopes are set on the request and on the GraphQL context. Anonymous requests are allowed; anything that needs a user answers as GitHub does (401 on REST, an error on GraphQL mutations).
`
gh auth login
`
and
`
gh auth status
`
read the scopes header and fail without the scopes they need.
`
public_repo
`
,
`
read:org
`
,
`
write:org
`
,
`
user
`
,
`
read:user
`
,
`
user:email
`
,
`
admin:public_key
`
,
`
delete_repo
`
and
`
gist
`
(accepted and granted, though gists do not exist).
on GraphQL, 403 with
`
X-Accepted-OAuth-Scopes
`
on REST).
, or
`
public_repo
`
for a public repository.
exchanges the code. Responses follow GitHub's format: form-encoded by default, JSON when
`
Accept: application/json
`
.
/device/token
`
,
`
/device
`
,
`
/device/approve
`
,
`
/device/deny
`
).
`
validateClient
`
accepts a client ID that names a registered OAuth app with device flow on. Run the plugin's schema through the usual Drizzle migration.
`
https://HOST/login/oauth/access_token
`
, at the host root. The
`
/login/...
`
paths join the global-prefix exclusion next to
`
GIT_TRANSPORT_ROUTES
`
and are registered before the git routes, which would otherwise read
`
login
`
as a username.
`
login
`
and
`
device
`
join
`
RESERVED_NAMES
`
in
`
lib/auth.ts
`
; the migration checks no existing user or organization holds either name.
`
verification_uri
`
set to
`
<web origin>/device
`
.
`
auth.api.deviceToken
`
, the authorization-code grant goes to the OAuth provider. On a device-flow success it does not hand out the session token: it creates a
`
ghost_pat_
`
API key for the app and the approved scopes, revokes the device session, and returns
`
{ access_token, token_type: "bearer", scope }
`
. Pending, slow-down, expired and denied states are returned in the status and body shape
`
gh
`
's poller expects (GitHub answers pending with 200 and
`
error
`
in the body; the spike confirms what
`
gh
`
requires).
.
`
@apollo/server
`
,
`
graphql
`
,
`
dataloader
`
,
`
@better-auth/oauth-provider
`
. Dev:
`
@octokit/graphql-schema
`
.
unless told otherwise. GraphiQL is on in every environment.
`
HTML
`
,
`
GitObjectID
`
`
RepositoryOwner
`
,
`
Comment
`
,
`
Closable
`
,
`
Labelable
`
,
`
Assignable
`
,
`
UniformResourceLocatable
`
`
Repository
`
,
`
Ref
`
,
`
Issue
`
,
`
IssueComment
`
,
`
Label
`
,
`
PageInfo
`
, the connection and edge types for each list, and each mutation's payload
`
RepositoryVisibility
`
,
`
IssueOrderField
`
,
`
OrderDirection
`
,
`
SearchType
`
`
first
`
and
`
after
`
map onto the services' existing
`
keyset()
`
cursors.
`
last
`
and
`
before
`
answer an
`
UNPROCESSABLE
`
error until a service supports paging backwards.
`
totalCount
`
reuses the services' count queries.
`
issue,
`
IC_
`
issue comment,
`
U_
`
user,
`
O_
`
organization,
`
LA_
`
label. The codec in
`
apps/api/src/lib/github/node-id.ts
`
encodes, decodes, and rejects an ID whose prefix does not match the type a mutation expects.
`
node(id:)
`
and
`
nodes(ids:)
`
dispatch on the prefix.
`
,
`
user(login)
`
,
`
organization(login)
`
,
`
node(id)
`
,
`
nodes(ids)
`
,
`
search(type: ISSUE, query, first, after)
`
.
`
search
`
translates GitHub's issue query syntax (
`
repo:
`
,
`
is:open
`
,
`
is:closed
`
,
`
label:
`
,
`
assignee:
`
,
`
author:
`
, free text) onto Ghost's existing issue search and filters; unsupported qualifiers are ignored, as GitHub ignores unknown ones.
`
,
`
reopenIssue
`
,
`
addComment
`
,
`
addLabelsToLabelable
`
,
`
removeLabelsFromLabelable
`
,
`
addAssigneesToAssignable
`
,
`
removeAssigneesFromAssignable
`
. Each takes GitHub's
`
input: { …, clientMutationId }
`
and returns its payload
`
{ <node>, clientMutationId }
`
. A mutation with no viewer is refused.
`
type
`
one of
`
NOT_FOUND
`
,
`
FORBIDDEN
`
,
`
UNPROCESSABLE
`
, and GitHub's wording, for example
`
Could not resolve to a Repository with the name 'owner/repo'.
`
. A private repository the viewer cannot read answers
`
NOT_FOUND
`
, as it does today and as GitHub does.
GET /user
`
,
`
GET /users/:login
`
,
`
GET /repos/:owner/:repo
`
,
`
GET /repos/:owner/:repo/readme
`
,
`
GET
`
/
`
POST /user/keys
`
, and
`
POST /user/repos
`
/
`
POST /orgs/:org/repos
`
if
`
repo create
`
uses them. The capture spike settles the list. Milestone 2 extends REST across the same domains (issues, comments, labels) from the same services.
links.
and Ghost's own error format are unchanged.
)
, including
`
gh auth refresh
`
and
`
gh auth setup-git
`
`
github.schema.gql
`
and
`
@octokit/graphql-schema
`
. For every type, interface, field, argument, enum value and scalar exposed, assert it exists in GitHub's schema with the same type and is not less nullable. A second assertion rebuilds the schema and fails if the committed file is stale.
`
, throwaway Postgres and RustFS).
**
One spec per resolver and mutation: anonymous vs authenticated, private repository as
`
NOT_FOUND
`
. The token guard with
`
token
`
and
`
Bearer
`
prefixes and the scopes header. Device flow over HTTP: code, approve, token, the returned value is a
`
ghost_pat_
`
key, pending polls in
`
gh
`
's expected shape, denied and expired codes. OAuth apps: register, authorize with consent, exchange a code (form-encoded and JSON responses), PKCE, revoke an authorization and its keys. Scopes: a missing scope answers
`
FORBIDDEN
`
/ 403 with
`
X-Accepted-OAuth-Scopes
`
; git refuses a push from a key without
`
repo
`
; Ghost's own API refuses a key issued to an OAuth app. A user named
`
login
`
or
`
device
`
can no longer be created, and git routes keep working.
`
e2e (
`
test/gh-cli.e2e-spec.ts
`
).
**
Skipped when
`
gh
`
is not on
`
PATH
`
. The harness serves the app over HTTPS with a self-signed certificate made in
`
global-setup.ts
`
;
`
SSL_CERT_FILE
`
makes
`
gh
`
trust it and
`
GIT_SSL_CAINFO
`
makes
`
git
`
trust it. Each run uses a temporary
`
GH_CONFIG_DIR
`
with
`
GH_HOST
`
,
`
GH_PROMPT_DISABLED=1
`
,
`
NO_COLOR=1
`
,
`
GH_BROWSER=true
`
. It runs every milestone-1 command and every auth path in the table, parses
`
--json
`
output and asserts on it. The device-flow case starts
`
gh auth login --web
`
, reads the user code from stderr, approves it over HTTP as the test user, and asserts
`
gh
`
exits 0 and
`
gh auth status
`
passes. Go on macOS ignores
`
SSL_CERT_FILE
`
, so this suite targets Linux (CI) first.
`
gh
`
suite covers every command the page lists as supported.
`
--no-file-parallelism
`
, including the
`
gh
`
spec
`
release.edited
`
). Fix it in its own PR before this workflow is required, so the workflow starts green.