Simulators
GitHub Apps
A GitHub App signs a short-lived JWT, exchanges it for an installation token and acts as its bot user, and the simulator serves that whole path. Sign a JWT with any RSA key, read the installation, mint a token and write as the bot:
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
key=$(mktemp -d)/app.pem; openssl genrsa -out $key 2048 2>/dev/null
hdr=$(printf '{"alg":"RS256","typ":"JWT"}' | b64)
claims=$(printf '{"iss":4125,"iat":1769904000,"exp":1769904540}' | b64)
jwt="$hdr.$claims.$(printf '%s' "$hdr.$claims" | openssl dgst -sha256 -sign $key | b64)"
curl -s "$RYSTIC_URL/repos/tb-owner/tb-repo/installation" -H "Authorization: Bearer $jwt" | jq -c '{id, app_slug, repository_selection}'
# -> {"id":41250001,"app_slug":"tb-agent","repository_selection":"all"}
tok=$(curl -s -X POST "$RYSTIC_URL/app/installations/41250001/access_tokens" -H "Authorization: Bearer $jwt" | jq -r .token)
curl -s -X POST "$RYSTIC_URL/repos/tb-owner/tb-repo/issues" -H "Authorization: token $tok" -H 'Content-Type: application/json' -d '{"title":"opened by the app"}' | jq -c '{number, user: {login: .user.login, type: .user.type}}'
# -> {"number":45,"user":{"login":"tb-agent[bot]","type":"Bot"}}
curl -s "$RYSTIC_URL/user" -H "Authorization: Bearer $jwt" | jq -c '{message}'
# -> {"message":"Bad credentials"}
curl -s -X DELETE "$RYSTIC_URL/installation/token" -H "Authorization: token $tok" -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s "$RYSTIC_URL/repos/tb-owner/tb-repo/issues/45" -H "Authorization: token $tok" | jq -c '{message}'
# -> {"message":"Bad credentials"}
Octokit’s createAppAuth, Probot and actions/create-github-app-token make the same two calls: give them the app id 4125, any RSA private key and the simulator’s URL as the base URL.
The installed app
Every authored scenario installs one app, tb-agent (app id 4125, client id Iv23litbagent4125), on tb-owner’s account as installation 41250001. The installation covers every repository, with write on checks, contents, issues, pull_requests and statuses.
A scenario seeds its own app with state_github_app, state_installation and the bot’s state_user record (<slug>[bot], type Bot). Without that user record, the bot’s writes return 401 Bad credentials.
Differs from GitHub: the app’s own webhook (/app/hook) is not served, so an app-style agent registers a repository or organization hook instead.
The app JWT
The simulator reads a JWT by structure: an RS256 header, an iss naming a seeded app by id or client id, an integer iat and an exp at most eleven minutes after iat. That is GitHub’s ten minutes plus the minute its documentation back-dates iat by. Any other JWT returns 401 with A JSON web token could not be decoded, or GitHub’s message for the claim that failed.
Differs from GitHub: the simulator never checks the signature. It checks neither iat nor exp against a clock, because the simulator’s clock is not yours.
The JWT authenticates only the app routes and the token exchange:
GET /appGET /app/installationsGET /app/installations/{id}GET /repos/{owner}/{repo}/installationGET /orgs/{org}/installationGET /users/{username}/installationPOST /app/installations/{id}/access_tokens, the token exchange
On every other route, REST or GraphQL and read or write, a request carrying the JWT returns 401 Bad credentials before the handler runs, whether or not its claims would pass. The two installation-token routes below are the exceptions.
Installation tokens
POST /app/installations/{id}/access_tokens mints a ghs_ token, narrowed by repositories, repository_ids and permissions, that expires an hour later on the simulator’s clock. GET /installation/repositories lists what the token reaches, and DELETE /installation/token revokes it. Both refuse any credential that is not an installation token, the app JWT included. GET /installation/repositories returns 403 Resource not accessible by personal access token. Once a token the simulator minted is revoked or expired, every served route returns 401 Bad credentials to it.
Differs from GitHub: DELETE /installation/token returns 501 to any credential that is not an installation token.
A ghs_ token the simulator never minted, such as the GITHUB_TOKEN of a workflow run, is accepted like a personal token. X-Rystic-Actor still overrides the token’s identity.
What the bot may do
A request presenting an installation token acts as the app’s bot user, tb-agent[bot] (type Bot), so what it writes is the bot’s. Its standing on a repository is the token’s grant:
| The token | Standing |
|---|---|
| doesn’t reach the repository (by its narrowing, else its installation’s) | none |
| belongs to a suspended installation | none |
grants administration: write | admin |
| grants any other repository write permission | write |
| otherwise | read |
Differs from GitHub: the token’s permissions grant one standing per repository, not GitHub’s check per endpoint.
What a write does with a token whose grant doesn’t cover it:
| Write | Result |
|---|---|
| creating a pull request, an issue, a review, a review comment or a reply; merging; dismissing a review; requesting a review | 403 Resource not accessible by integration |
| issue comments, labels, assignees and milestones | accepted: they check no write access |
| creating a commit status | refused whatever the grant: it asks for the repository’s owner or a collaborator |
Differs from GitHub: issue comments, labels, assignees and milestones take the token whatever its grant, even a read-only one. GitHub refuses them without issues or pull_requests write.
Differs from GitHub: creating a commit status refuses the token even when it grants statuses: write.
What is not compared with GitHub
The account the simulator is compared with GitHub through uses a personal access token, not an app. Every response only an app’s JWT or installation token reaches is the simulator’s own behavior. Only a request with no credential or a malformed one is compared with GitHub.