The GraphQL schema is code-first, generated to `apps/api/github.schema.gql` and committed. A test checks every type, field, argument and enum value in it against `@octokit/graphql-schema`, GitHub's published schema.
`/api/v3/meta` reports GitHub Enterprise Server `3.17.0`. Introspection and GraphiQL are on in every environment. The REST routes appear in the OpenAPI document under the `
API keys store GitHub scopes in `permissions.scopes`. A key without them, made in Ghost's settings, and a browser session hold every scope. `X-OAuth-Scopes` reports what the key holds.
Organizations own apps too, registered through `/api/organizations/:slug/oauth-apps` by its admins and owners, and then managed at `/api/oauth-apps/:clientId` like a user's. An organization's app has the organization's id in the plugin's
`oauth_client.metadata.verified` marks an app the instance vouches for, shown as a badge on the approval page. Only an operator sets it, in the database or the seed; no API writes it. The seed sets it on `gh`, and adds it to an existing `
`GET /login/oauth/authorize` checks the app and GitHub's callback rule (same scheme, host and port, a path equal to the callback's or under it) before anything redirects, then hands the request to the plugin, which checks the session and the stored consent and sends the user to `<web>/login/oauth/authorize`. The plugin reads an empty `
- A Ghost-owned registry (two tables, PKCE as one hash compare): smaller, but the plugin is kept so Ghost can later act as an OIDC provider without a second migration.
An OAuth app can turn on GitHub's "Expire user authorization tokens", stored as `oauth_client.metadata.expireUserTokens`. Its grants then mint a `ghost_pat_` key with an 8-hour expiry, which the api-key plugin enforces, and a
Refresh tokens live in `oauth_app_refresh_token`, a Ghost table holding each token's sha256. Its `access_key_id` has no foreign key: the api-key plugin deletes expired keys on its own, and a cascade would take the refresh token with them. For the same reason, Authorized apps lists an app when the user holds a key or an unexpired refresh token for it, and revoking deletes both. A refresh and a revoke of the same user's grant take one advisory lock, so a revoke never misses a pair a refresh is minting.
- The plugin's `oauth_refresh_token` table: its rows belong to the plugin's access tokens and sessions, which Ghost revokes right after the code exchange.
- Re-hashing client secrets in Ghost to check them: the plugin owns the hashing, so Ghost asks the plugin.
`
, on the API host. Users point the GitHub CLI at it with
`
GH_HOST=api.<domain>
`
. Every resolver and controller calls the existing Ghost services, so permissions, numbering, events and notifications stay where they are.
to<GitHubType>
`
mappers are allowed there and nowhere else. Each one is a last resort and carries a one-line comment saying why a query could not select GitHub's shape directly.
O_
`
,
`
R_
`
,
`
I_
`
,
`
PR_
`
,
`
IC_
`
,
`
LA_
`
.
GitHub compatibility
`
tag.
https://HOST/api/graphql
`
. The API host already serves
`
/api
`
and the git transport, so
`
gh repo clone
`
and git credentials resolve against the same host.
`
repo
`
covers
`
public_repo
`
,
`
admin:org
`
covers
`
write:org
`
and
`
read:org
`
, and so on). The compat GraphQL and REST APIs and the git transport all call it. Writes need
`
repo
`
, or
`
public_repo
`
on a public repository. A private repository needs
`
repo
`
, and answers as missing without it, so a token cannot learn that it exists.
`
plugin runs it.
`
apps/api/src/github/oauth/
`
serves it at GitHub's paths on the API host root,
`
/login/device/code
`
and
`
/login/oauth/access_token
`
, in GitHub's wire format. The approval page is
`
<web>/device
`
.
GitHub CLI
`
, holding the requested scopes Ghost knows. The session the plugin creates is deleted at once. API keys never authenticate Ghost's own
`
/api
`
, only the compat layer and git.
gh
`
, client id
`
178c6fc778ccc68e1d6a
`
, is a built-in app: no owner, no secret, device flow on.
`
seedBuiltInRows
`
in
`
packages/db/src/seed.ts
`
inserts it from code after migrating (the deploy migrator, the e2e setup, and
`
seed-github-cli-app
`
for a database migrated with drizzle-kit), not a migration. Users register their own apps through
`
/api/oauth-apps
`
; the service writes through the plugin so secrets are hashed its way, and refuses to change a built-in app. An app's device-flow switch is
`
oauth_client.metadata.deviceFlow
`
, which the device flow's
`
validateClient
`
reads.
`
oauth_client.referenceId
`
and no
`
userId
`
. The plugin fills
`
referenceId
`
from its
`
clientReference
`
hook, and only lets a call whose
`
clientReference
`
matches change the app. The hook reads
`
oauthAppOrganization
`
, an
`
AsyncLocalStorage
`
in
`
apps/api/src/lib/github/oauth-apps.ts
`
: the service runs each plugin call for an organization's app inside it, after checking that the user administers that organization. Outside it the hook returns nothing, and apps belong to users. A built-in app is one with neither
`
userId
`
nor
`
referenceId
`
.
`
referenceId
`
has no foreign key, so
`
afterDeleteOrganization
`
deletes the organization's apps and the keys they minted, and
`
onOrganizationDeleted
`
removes their logos.
gh
`
row without touching its other fields.
/oauth2/consent
`
and
`
/oauth2/continue
`
, which a browser must reach. Ghost calls the rest server-side through
`
auth.api
`
. The OIDC routes (
`
userinfo
`
,
`
register
`
,
`
end-session
`
, discovery) stay blocked until Ghost offers "Sign in with Ghost".
scope
`
as every scope, so a request with no known scope asks for the sentinel scope
`
public
`
, which
`
grantableScopes
`
drops.
grant_type
`
. The plugin checks the secret, the code, its
`
redirect_uri
`
and PKCE. Ghost reads the token's user through introspection, revokes the token, and mints a
`
ghost_pat_
`
key named after the app with
`
metadata.oauthClientId
`
, exactly as for the device flow. Revoking an authorized app deletes the user's keys whose
`
metadata.oauthClientId
`
names it, and their consent row.
`
ghost_rt_
`
refresh token that lasts 6 months.
`
POST /login/oauth/access_token
`
with
`
grant_type=refresh_token
`
checks the client's secret through the plugin's introspection endpoint, spends the refresh token, deletes the old key and mints a new pair under the app's current switch.
`
gh
`
's built-in app cannot be edited, so it never gets expiring tokens.