feat: cast github-app create/register — run the App Manifest flow instead of transcribing it

The GitHub App was the one piece of a Coolify instance cast could not
reproduce. There is no REST endpoint that creates one — no POST /apps, no
GraphQL mutation, no `gh app` subcommand, no PAT scope — so `create` runs the
only programmatic path there is: GitHub's App Manifest flow, a one-shot page
served on 127.0.0.1 whose form POST the operator's own browser session
authenticates, followed by an unauthenticated code exchange.

That exchange is the only moment GitHub yields the private key, the client
secret and the webhook secret together; all three are persisted to
<state>/github-apps/ at 0600 under a .gitignore of `*`.

`create` does not reimplement `register`: it obtains credentials and then calls
exactly that path. Both verbs end at GET /github-apps/{id}/repositories,
asserting the repo is actually reachable — the check that turns a silent
misconfiguration into an error next to the thing that caused it.

github_apps.<org>/<repo> in environments.yaml (--name only seeds an absent
entry, and is refused when it disagrees), the client secret is stdin-only, and
--webhook-secret is optional. scripts/register-github-app.sh is deleted.

No new dependencies: node:http for the callback, node:crypto's
createSign("RSA-SHA256") for the App JWT that recovers the installation id from
the App's own key rather than from a spoofable redirect parameter.

Closes #7

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-20 10:46:43 +00:00
parent f2c2bb3470
commit a9805d31bd
7 changed files with 2633 additions and 38 deletions

View file

@ -73,6 +73,53 @@ actually cutting it, and this file starts there.
what it was told to write. Basic-auth-only is the safe slice until what it was told to write. Basic-auth-only is the safe slice until
then. then.
- **`cast github-app create` / `cast github-app register`** (#7, superseding
#5) — the GitHub App was the one piece of a Coolify instance cast could
not reproduce: made by hand in a browser, its four identifiers copied out
of the UI by eye, its private key downloaded to `~/Downloads`, its details
fed to `scripts/register-github-app.sh` as a six-variable env pile.
Nothing about that survived in state. There is no REST endpoint that
creates a GitHub App — no `POST /apps`, no GraphQL mutation, no `gh app`
subcommand, no PAT scope — so `create` runs the only programmatic path
there is, GitHub's App Manifest flow: a one-shot page served on
`127.0.0.1` whose form POST your own browser session authenticates,
followed by an unauthenticated code exchange. That exchange is the only
moment GitHub ever yields the private key, the client secret and the
webhook secret together, and cast now persists all three to
`<state>/github-apps/` at 0600 under a `.gitignore` of `*`, so `git add
-A` in the state repo cannot commit them by accident. `register` adopts
credentials you already hold (an App made by hand, or a DR restore from a
stored PEM) and reads the client secret from **stdin only** — argv is
visible in `ps`. `create` does not reimplement it: it obtains credentials
and then calls exactly the `register` path.
#5's three footguns are gone structurally rather than by validation. The
Coolify-facing name is resolved from `github_apps.<org>/<repo>` in
`environments.yaml` — the value every later `cast apply` resolves the App
by — and `--name` only *seeds* an absent entry (keyed by full slug, #6),
and is refused outright when it disagrees with one that exists. The state
file is written only after the App is registered *and* verified, because a
state file naming an App that does not work is worse than one naming none.
`--webhook-secret` is optional: a webhook-inactive App is the correct shape
for a tailnet-only Coolify, and nobody has to invent a placeholder.
Both verbs end at the step that matters most: `GET
/github-apps/{id}/repositories`, asserting the repo is actually reachable.
Until now a misconfigured App failed silently and surfaced hours later, in
a different command, as an unresolvable source at `cast apply` time.
No new dependencies — `node:http` serves the callback, `node:crypto`'s
`createSign("RSA-SHA256")` mints the App JWT that recovers the installation
id from the App's own key (never from the `installation_id` GitHub appends
to a redirect, which GitHub documents as a spoofable hint).
**Unvalidated, and load-bearing**: that GitHub accepts a `redirect_url` on
`http://127.0.0.1:<port>` at all. The manifest docs are silent on the
scheme, the precedent (Probot's setup flow) is strong, and validating it
requires a logged-in GitHub session — so the first real run is an
operator's, and README keeps the manual browser path documented until
`create` has succeeded once.
### Changed ### Changed
- **`state:needs-human` no longer waits on the cron to become true** (#131) - **`state:needs-human` no longer waits on the cron to become true** (#131)
@ -161,6 +208,13 @@ actually cutting it, and this file starts there.
block, because the password cannot be read and a block a rebuild cannot block, because the password cannot be read and a block a rebuild cannot
honour is exactly the failure `UNCAPTURED.md` exists to prevent. honour is exactly the failure `UNCAPTURED.md` exists to prevent.
### Removed
- **`scripts/register-github-app.sh`** — replaced by `cast github-app
register`. Kept as a thin wrapper it would have preserved exactly the
interface #5 catalogued as producing three live footguns, while adding a
second surface to keep in step with the CLI.
### Fixed ### Fixed
- **A PR that deletes a shipped release heading is now CI-red** (#133, - **A PR that deletes a shipped release heading is now CI-red** (#133,

129
README.md
View file

@ -119,6 +119,9 @@ cast inventory <org>/<repo> --env <env>
cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]] cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]]
cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project] cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22] cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast github-app create <org>/<repo> --env <env> [--name <n>] [--port 8765]
cast github-app register <org>/<repo> --env <env> --app-id <id> --installation-id <id> \
--client-id <id> --client-secret-stdin --private-key <file>
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>] cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
``` ```
@ -166,6 +169,12 @@ cast team [--env <env>]
environment's name at a plan that says, for every database, whether it is backed environment's name at a plan that says, for every database, whether it is backed
up and when the last backup landed. See *Tearing an environment down* below. up and when the last backup landed. See *Tearing an environment down* below.
- **`server add`** — uploads a server's private key and registers it with Coolify. - **`server add`** — uploads a server's private key and registers it with Coolify.
- **`github-app create`** — creates the GitHub App Coolify clones private repos
with, by running GitHub's App Manifest flow, then registers it. Two browser
clicks, zero transcription. See *The GitHub App* below.
- **`github-app register`** — adopts an App you already hold: one created by hand,
or a disaster-recovery restore from a stored private key. `create` ends by
running exactly this.
- **`smoke`** — contract test against the project's `smoke_target`: proves - **`smoke`** — contract test against the project's `smoke_target`: proves
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior, every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior,
@ -208,6 +217,121 @@ no credentials at all it says so, and names the fix.
The token is never put in the clone URL or in `http.extraheader` — both leak it The token is never put in the clone URL or in `http.extraheader` — both leak it
into `ps`, and the latter persists it into the clone's git config. into `ps`, and the latter persists it into the clone's git config.
## The GitHub App: `cast github-app`
That is how *cast* clones. **Coolify** clones with a GitHub App, and the App used
to be the one piece of a Coolify instance cast could not reproduce: created by
hand in a browser, its four identifiers copied out of the UI by eye, its private
key downloaded to `~/Downloads`, its details fed to a shell script as a
six-variable env pile. Nothing about that survived in state. Rebuild the instance
and you redid the hoops from memory.
```sh
cast github-app create heavy-duty/incubator --env prod --name hdb-coolify-prod
```
There is **no REST endpoint that creates a GitHub App** — no `POST /apps`, no
GraphQL mutation, no `gh app` subcommand, and no PAT scope that unlocks one. The
only programmatic path is GitHub's [App Manifest
flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest):
a browser form POST whose authentication is your existing GitHub session,
followed by an unauthenticated code exchange. It is how Coolify's own *Create
GitHub App* button works, and it is why this command serves you a page instead of
calling an API.
What `create` does:
1. If `gh` is on `PATH` and authenticated, checks you are an **admin** of the org
— so you learn you cannot create Apps there *before* the browser dance, not
after. `gh` is never required; an absent one skips the check silently.
2. Resolves the App's Coolify-facing name from **`github_apps.<org>/<repo>` in
`environments.yaml`**, which is what every later `cast apply` resolves this
repo's App by. `--name` seeds that entry when it is absent and is **refused**
when it disagrees with one that exists.
3. Serves a one-shot page on `127.0.0.1` that submits an App manifest —
`contents: read` + `metadata: read`, webhook inactive, private.
4. You click *Create GitHub App*; GitHub redirects back to the loopback server,
which checks the CSRF `state` and shuts down.
5. Exchanges the code. **This response is the only moment GitHub ever hands over
the private key, the client secret and the webhook secret together.**
6. Prints (and tries to open) the install URL; you pick the repository.
7. Recovers the installation id by minting an RS256 JWT with the App's own key —
never from the `installation_id` GitHub appends to a redirect, which GitHub
documents as a spoofable hint.
8. Uploads the key to Coolify and creates the App record.
9. **Asks Coolify which repositories the App can actually see, and fails if
`<org>/<repo>` is not among them.** This is the step that matters most:
without it a misconfigured App fails silently and surfaces hours later, in a
different command, as an unresolvable source at `cast apply` time.
`register` is the same command from step 8 onwards, for an App you already hold —
one made by hand, or a disaster-recovery restore from a stored PEM:
```sh
pbpaste | cast github-app register heavy-duty/incubator --env prod \
--app-id 12345 --installation-id 99887766 --client-id Iv23li… \
--client-secret-stdin --private-key ~/Downloads/app.private-key.pem
```
The client secret is read from **stdin only** — argv is visible in `ps` and kept
in shell history. `--webhook-secret` is optional: a webhook-**inactive** App is
the right shape for a tailnet-only Coolify where deliveries can never arrive and
deploys are CI-triggered, and cast generates a value rather than making you
invent one.
### Where the credentials land
Into the state directory you point cast at — cast itself stores nothing:
```
<state>/github-apps/
├── .gitignore # `*` — written by cast
├── <name>.pem # 0600, the private key
└── <name>.json # 0600, app id, installation id, client id + secret, webhook secret
```
All three secrets, because GitHub shows them once and `register` needs the client
secret to be re-runnable at all — a disaster-recovery restore that is missing it
is not a restore. They are written **plaintext at 0600**, not into `secrets/`:
that store holds per-repo-per-env *application* env vars, whose whole purpose is
to be decrypted and injected into the running container, which is the last place
an App private key belongs — and its age identity may not exist on the machine
doing the bootstrap at all. Encrypting the one credential that makes recovery
possible behind a key that might not be there is how DR fails at the moment it is
needed.
So the guard is structural rather than cryptographic: the `.gitignore` means
`git add -A` in your state repo cannot commit these by accident. Committing them
stays possible and has to be deliberate — encrypt them yourself and commit the
ciphertext, or keep the directory out of the repo and back it up somewhere that
is not a git remote.
### Until it has worked once
`create`'s design rests on GitHub accepting a `redirect_url` on
`http://127.0.0.1:<port>`. The manifest docs are silent on the scheme (loopback
HTTP is documented for *OAuth* redirect URIs), and the precedent is strong —
Probot's setup flow does exactly this — but it is unvalidated, because validating
it needs a logged-in GitHub session. **The manual path below stays supported
until `create` has succeeded against a real GitHub once.** If it fails, create
the App by hand in the browser and use `github-app register`, which does not
depend on the assumption at all.
<details>
<summary>The manual path</summary>
1. Org → Settings → Developer settings → GitHub Apps → **New GitHub App**.
Permissions: **Contents: Read-only**, **Metadata: Read-only**. Uncheck
*Active* under Webhook. Uncheck *Any account* (keep it private).
2. Note the **App ID** and **Client ID**; generate a **client secret**; generate
and download a **private key**.
3. **Install App** → pick the repository. The installation id is the last path
segment of the URL you land on (`…/settings/installations/<id>`).
4. Feed all of it to `cast github-app register` (above), which validates the name
against state and verifies the repo is reachable.
</details>
## Many Coolifys ## Many Coolifys
`--instance <name>` reads `<state>/.coolify/<name>.env` instead of `--instance <name>` reads `<state>/.coolify/<name>.env` instead of
@ -1003,8 +1127,9 @@ the way back to zero from a half-applied first run.
## Scripts ## Scripts
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with Operational helpers, all argument-driven (`scripts/`): restore a database backup
Coolify, restore a database backup into a target container. into a target container. (`register-github-app.sh` is gone — it is
`cast github-app register` now.)
**They run where cast runs — off the box.** They drive the Coolify API, or reach a **They run where cast runs — off the box.** They drive the Coolify API, or reach a
box over SSH; none of them expects to be executing *on* a server. Anything that box over SSH; none of them expects to be executing *on* a server. Anything that

View file

@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Register a GitHub App (created via the manifest flow on your org) with a
# Coolify instance, so cast can create applications from private repos.
#
# usage: CAST_STATE=~/infra ./register-github-app.sh
#
# Takes everything as input — the app's identity is yours, not this tool's.
# APP_NAME must match the `github_apps.<repo>` value in your environments.yaml:
# that is the name cast resolves when it creates an application.
set -euo pipefail
STATE="${CAST_STATE:-.}"
# shellcheck disable=SC1091
source "${STATE}/.coolify.env"
: "${APP_NAME:?the Coolify-facing GitHub App name (must match github_apps.<repo> in environments.yaml)}"
: "${ORG:?the GitHub org or user the App is installed on}"
: "${APP_ID:?}"; : "${INSTALLATION_ID:?}"; : "${CLIENT_ID:?}"; : "${CLIENT_SECRET:?}"
: "${WEBHOOK_SECRET:?}"; : "${PRIVATE_KEY_FILE:?path to the App private key PEM}"
api() { curl -fsS -H "Authorization: Bearer ${COOLIFY_ACCESS_TOKEN}" -H "Content-Type: application/json" "$@"; }
KEY_UUID=$(api -X POST "${COOLIFY_BASE_URL}/api/v1/security/keys" \
-d "$(jq -n --arg name "${APP_NAME}-key" --rawfile pk "$PRIVATE_KEY_FILE" \
'{name:$name, private_key:$pk}')" | jq -r .uuid)
api -X POST "${COOLIFY_BASE_URL}/api/v1/github-apps" -d "$(jq -n \
--arg name "$APP_NAME" --arg org "$ORG" \
--arg app_id "$APP_ID" --arg inst "$INSTALLATION_ID" --arg cid "$CLIENT_ID" \
--arg csec "$CLIENT_SECRET" --arg wh "$WEBHOOK_SECRET" --arg key "$KEY_UUID" \
--arg api_url "https://api.github.com" --arg html_url "https://github.com" \
'{name:$name, organization:$org, api_url:$api_url, html_url:$html_url,
app_id:($app_id|tonumber), installation_id:($inst|tonumber), client_id:$cid, client_secret:$csec,
webhook_secret:$wh, private_key_uuid:$key}')"
echo "github app registered as ${APP_NAME}"

View file

@ -84,6 +84,13 @@ import {
renderFleetDiff, renderFleetDiff,
renderProjectHeading, renderProjectHeading,
} from "./fleet.js"; } from "./fleet.js";
import {
createGithubApp,
generateWebhookSecret,
registerGithubApp,
resolveAppName,
seedGithubAppBinding,
} from "./github-app.js";
import { import {
type LiveResource, type LiveResource,
type SweepEnvironment, type SweepEnvironment,
@ -124,6 +131,10 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast inventory --env <env> --emit-draft <dir> [--recipient age1] [--no-secrets] cast inventory --env <env> --emit-draft <dir> [--recipient age1] [--no-secrets]
cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project] cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22] cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast github-app create <org>/<repo> --env <env> [--name <n>] [--port 8765] [--force]
cast github-app register <org>/<repo> --env <env> --app-id <id> --installation-id <id>
--client-id <id> --client-secret-stdin --private-key <file>
[--webhook-secret <v>] [--name <n>] [--force]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>] cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
cast versions # list installed versions cast versions # list installed versions
@ -179,6 +190,25 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
coordinate: --path, --project, --environment, --resource, coordinate: --path, --project, --environment, --resource,
--hostname-overlay. --hostname-overlay.
github-app (the credential Coolify clones private repos with):
create runs GitHub's App Manifest flow the ONLY programmatic way to make a
GitHub App then falls through into exactly what \`register\` does. It
serves a one-shot page on 127.0.0.1, your browser session authenticates
the form, and the conversion response hands over the private key, the
client secret and the webhook secret in one body. Nothing is transcribed.
register adopts credentials you already hold: an App created by hand, or a
disaster-recovery restore from a stored PEM. The client secret is read
from STDIN (never argv); --webhook-secret is optional, because a
webhook-INACTIVE App is the right shape for a tailnet-only Coolify.
--name seeds \`github_apps.<org>/<repo>\` in environments.yaml when it is
ABSENT, and is refused when it disagrees with an entry that exists.
The state file is the authority: its value is what every later
\`cast apply\` resolves this repo's App by.
--force overwrite an existing PEM/credentials file under <state>/github-apps/.
Both verbs end by asking Coolify which repositories the App can actually see
and failing if <org>/<repo> is not among them the check that turns a silent
misconfiguration into an error next to the thing that caused it.
capture (adopt a hand-built instance into the age secret store): capture (adopt a hand-built instance into the age secret store):
--generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder, --generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
for a manifest that has not declared generated_secrets yet. for a manifest that has not declared generated_secrets yet.
@ -1423,6 +1453,145 @@ function formatVersion(): string {
return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`; return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`;
} }
// `--client-secret-stdin`, mirroring `docker login --password-stdin`: argv is
// visible in `ps` and lands in shell history, and a GitHub App client secret is
// shown by GitHub exactly once.
async function readAllStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8").trim();
}
// `cast github-app register|create` — one command surface, ONE registration
// implementation (see src/github-app.ts). Everything up to the point where
// credentials exist differs between the two verbs; everything from there on is
// registerGithubApp, which `create` calls rather than reimplements.
async function githubAppCommand(rest: string[]): Promise<number> {
const verb = rest[0];
if (verb !== "register" && verb !== "create") {
console.error(USAGE);
return 2;
}
const { values, positionals } = parseArgs({
args: rest.slice(1),
allowPositionals: true,
options: {
state: { type: "string" },
env: { type: "string" },
instance: { type: "string" },
name: { type: "string" },
force: { type: "boolean" },
// create
port: { type: "string" },
// register
"app-id": { type: "string" },
"installation-id": { type: "string" },
"client-id": { type: "string" },
"client-secret-stdin": { type: "boolean" },
"private-key": { type: "string" },
"webhook-secret": { type: "string" },
},
});
const orgRepo = positionals[0];
// --env is required for the same reason `server add` requires it: this
// writes to a live Coolify, and every write first asserts that the token
// belongs to the environment's declared team.
if (!orgRepo || !orgRepo.includes("/") || !values.env) {
console.error(USAGE);
return 2;
}
const stateDir = stateDirFrom(values.state);
const bindingsPath = join(stateDir, "environments.yaml");
const bindings = loadBindings(bindingsPath);
const binding = bindings.environments[values.env];
if (!binding) {
console.error(`environment ${values.env} not in environments.yaml`);
return 2;
}
// Step 2, before anything reaches a network: the Coolify-facing name comes
// from state. See resolveAppName — this is #5's footgun 1, dissolved.
const { name, seed } = resolveAppName({
bindings,
orgRepo,
nameFlag: values.name,
});
const { instance, client } = openCoolify(stateDir, values.instance, binding);
assertWritable(instance, `github-app ${verb}`);
const team = await assertTeam(client, binding.team, values.env);
console.log(`team ${formatTeam(team)}`);
console.log(
`github app name: ${name}${seed ? " (from --name, not yet in environments.yaml)" : " (from environments.yaml)"}`,
);
const org = orgRepo.split("/")[0] ?? orgRepo;
if (verb === "create") {
await createGithubApp({
client,
orgRepo,
name,
stateDir,
force: values.force,
port: values.port ? Number(values.port) : undefined,
});
} else {
const appId = values["app-id"];
const installationId = values["installation-id"];
const clientId = values["client-id"];
const privateKey = values["private-key"];
if (!appId || !installationId || !clientId || !privateKey) {
console.error(USAGE);
return 2;
}
if (!values["client-secret-stdin"]) {
console.error(
"--client-secret-stdin is required: the client secret is read from stdin,\nnever from argv (which `ps` shows and shell history keeps).",
);
return 2;
}
const clientSecret = await readAllStdin();
if (!clientSecret) {
console.error("no client secret on stdin");
return 2;
}
// #5's footgun 3: a webhook-inactive App is the right configuration for a
// tailnet-only Coolify, and the old script still demanded a secret for it.
const webhookSecret = values["webhook-secret"] ?? generateWebhookSecret();
if (!values["webhook-secret"]) {
console.log(
"no --webhook-secret: generated one (fine for a webhook-inactive App)",
);
}
await registerGithubApp({
client,
name,
org,
orgRepo,
stateDir,
force: values.force,
creds: {
appId: Number(appId),
installationId: Number(installationId),
clientId,
clientSecret,
webhookSecret,
privateKeyPem: readFileSync(privateKey, "utf8"),
},
});
}
// Only after the App is registered AND verified: a state file that names an
// App which does not work is worse than one that names none.
if (seed) {
seedGithubAppBinding(bindingsPath, orgRepo, name);
console.log(
`environments.yaml: github_apps["${orgRepo}"] = ${name} (added)`,
);
}
return 0;
}
async function main(): Promise<number> { async function main(): Promise<number> {
const [command, ...rest] = process.argv.slice(2); const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") { if (command === "-h" || command === "--help" || command === "help") {
@ -2298,6 +2467,9 @@ async function main(): Promise<number> {
}); });
return 0; return 0;
} }
if (command === "github-app") {
return await githubAppCommand(rest);
}
if (command === "smoke") { if (command === "smoke") {
const { values, positionals } = parseArgs({ const { values, positionals } = parseArgs({
args: rest, args: rest,

1069
src/github-app.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,280 @@
import { spawn } from "node:child_process";
import { generateKeyPairSync } from "node:crypto";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
// `cast github-app register` through the real CLI: argv parsing, the stdin-only
// client secret, the team assert, the name resolved from state, the
// post-condition check, and WHEN environments.yaml is written.
//
// The Coolify here is a stub. Registration against a live instance is
// operator-only territory (#7's testability boundary) and nothing in this file
// pretends otherwise — what it proves is that cast sends the right things and
// reacts correctly to each answer.
let privateKeyPem: string;
beforeAll(() => {
privateKeyPem = generateKeyPairSync("rsa", { modulusLength: 2048 })
.privateKey.export({ type: "pkcs8", format: "pem" })
.toString();
});
type Stub = {
url: string;
hits: string[];
bodies: Record<string, Record<string, unknown>>;
close: () => Promise<void>;
};
const stubs: Stub[] = [];
async function stubCoolify(opts: { repositories: unknown }): Promise<Stub> {
const hits: string[] = [];
const bodies: Record<string, Record<string, unknown>> = {};
const server = createServer((req, res) => {
const path = new URL(req.url ?? "", "http://x").pathname.replace(
"/api/v1",
"",
);
const key = `${req.method} ${path}`;
hits.push(key);
let raw = "";
req.on("data", (d) => {
raw += String(d);
});
req.on("end", () => {
if (raw) bodies[key] = JSON.parse(raw);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/security/keys") return json({ uuid: "key-uuid-1" });
if (path === "/github-apps" && req.method === "POST")
return json({ id: 7, uuid: "app-uuid" });
if (path === "/github-apps/7/repositories")
return json({ repositories: opts.repositories });
res.writeHead(404);
res.end("{}");
});
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
bodies,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
function fixture(
url: string,
githubApps: string,
): { state: string; pem: string } {
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
writeFileSync(
join(state, "environments.yaml"),
[
"# hand-maintained",
"environments:",
" prod:",
" server: prod-box",
" team: { id: 0, name: Root Team }",
githubApps,
"",
].join("\n"),
);
const pem = join(state, "downloaded.pem");
writeFileSync(pem, privateKeyPem);
return { state, pem };
}
function run(
args: string[],
stdin: string | null,
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", ...args], {
stdio: [stdin === null ? "ignore" : "pipe", "pipe", "pipe"],
});
if (stdin !== null) {
child.stdin?.end(stdin);
}
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
const REGISTER = (state: string, pem: string) => [
"github-app",
"register",
"heavy-duty/incubator",
"--env",
"prod",
"--state",
state,
"--app-id",
"12345",
"--installation-id",
"99887766",
"--client-id",
"Iv23liABCDEF",
"--client-secret-stdin",
"--private-key",
pem,
];
describe("cast github-app register", () => {
it("registers against the name in state, verifies the repo, and never takes the secret from argv", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const r = await run(REGISTER(f.state, f.pem), "the-client-secret\n");
expect(r.code).toBe(0);
expect(r.output).toContain('team id=0 name="Root Team" ✓');
expect(r.output).toContain("(from environments.yaml)");
expect(r.output).toContain(
"verified: hdb-coolify-prod can clone heavy-duty/incubator ✓",
);
// The secret reached Coolify, and it came off stdin — it is nowhere in
// argv, which `ps` shows and shell history keeps.
expect(stub.bodies["POST /github-apps"].client_secret).toBe(
"the-client-secret",
);
expect(stub.bodies["POST /security/keys"].name).toBe(
"hdb-coolify-prod-key",
);
// A webhook-INACTIVE App is the right shape for a tailnet-only Coolify, so
// no operator has to invent a placeholder any more (#5 footgun 3).
expect(r.output).toContain("generated one");
expect(
String(stub.bodies["POST /github-apps"].webhook_secret).length,
).toBeGreaterThan(0);
// The credentials landed in the state dir, under a git-ignored directory.
expect(
readFileSync(
join(f.state, "github-apps", "hdb-coolify-prod.pem"),
"utf8",
),
).toBe(privateKeyPem);
expect(
readFileSync(join(f.state, "github-apps", ".gitignore"), "utf8"),
).toContain("*");
});
it("seeds an ABSENT binding from --name, keyed by the full slug, comments intact", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(stub.url, "github_apps: {}");
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "hdb-coolify-prod"],
"s\n",
);
expect(r.code).toBe(0);
const after = readFileSync(join(f.state, "environments.yaml"), "utf8");
expect(after).toContain("heavy-duty/incubator: hdb-coolify-prod");
expect(after).toContain("# hand-maintained");
});
it("REFUSES a --name that disagrees with the state file", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "My Cool App"],
"s\n",
);
expect(r.code).toBe(1);
expect(r.output).toContain("disagrees with environments.yaml");
// Refused before it touched Coolify at all — not even the team assert.
expect(stub.hits).toEqual([]);
});
it("refuses a client secret passed any way other than stdin", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const withoutFlag = REGISTER(f.state, f.pem).filter(
(a) => a !== "--client-secret-stdin",
);
const r = await run(withoutFlag, null);
expect(r.code).toBe(2);
expect(r.output).toContain("--client-secret-stdin is required");
});
it("fails, and does NOT seed state, when the App cannot see the repo", async () => {
// A state file naming an App that does not work is worse than one naming
// none: the next `cast apply` resolves it, uses it, and fails at clone time.
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/something-else" }],
});
const f = fixture(stub.url, "github_apps: {}");
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "hdb-coolify-prod"],
"s\n",
);
expect(r.code).toBe(1);
expect(r.output).toContain("cannot see heavy-duty/incubator");
expect(r.output).toContain("can see: heavy-duty/something-else");
expect(readFileSync(join(f.state, "environments.yaml"), "utf8")).toContain(
"github_apps: {}",
);
});
it("refuses a read-only instance before any write", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
writeFileSync(
join(f.state, ".coolify.env"),
`COOLIFY_BASE_URL="${stub.url}"\nCOOLIFY_ACCESS_TOKEN="t"\nCOOLIFY_READ_ONLY=true\n`,
);
const r = await run(REGISTER(f.state, f.pem), "s\n");
expect(r.code).toBe(1);
expect(r.output).toContain("refusing to github-app register");
expect(stub.hits).toEqual([]);
});
it("prints usage for an unknown subcommand", async () => {
const r = await run(["github-app", "wat"], null);
expect(r.code).toBe(2);
expect(r.output).toContain("cast github-app create");
expect(r.output).toContain("cast github-app register");
});
});

931
test/github-app.test.ts Normal file
View file

@ -0,0 +1,931 @@
import { createVerify, generateKeyPairSync } from "node:crypto";
import {
existsSync,
mkdtempSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { loadBindings } from "../src/bindings.js";
import { CoolifyClient } from "../src/coolify.js";
import {
type AppCredentials,
awaitInstallationId,
buildManifest,
convertManifestCode,
createGithubApp,
detectOwnerType,
findInstallationId,
manifestFormPage,
mintAppJwt,
newAppFormAction,
persistCredentials,
preflightOrgAdmin,
readAppRepositories,
registerGithubApp,
resolveAppName,
seedGithubAppBinding,
startManifestServer,
} from "../src/github-app.js";
// WHAT THIS FILE DOES NOT TEST, said out loud because the issue asks for it
// (#7, "Testability boundary"):
//
// - The browser form POST. It is authenticated by the operator's logged-in
// GitHub session and there is no headless path to it. Nothing here proves
// that GitHub accepts a `redirect_url` on http://127.0.0.1:<port> — that
// assumption is the load-bearing one, it is unvalidated, and the first real
// run is an operator's.
// - Registration against a live Coolify. Every Coolify call below is mocked.
//
// What IS proven here is everything on cast's side of that line: the JSON it
// builds, the server it serves, the JWT it signs, the requests it makes, and
// what it does with each answer.
let privateKeyPem: string;
let publicKeyPem: string;
beforeAll(() => {
const pair = generateKeyPairSync("rsa", { modulusLength: 2048 });
privateKeyPem = pair.privateKey.export({
type: "pkcs8",
format: "pem",
}) as string;
publicKeyPem = pair.publicKey.export({
type: "spki",
format: "pem",
}) as string;
});
function tmp(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
function creds(over: Partial<AppCredentials> = {}): AppCredentials {
return {
appId: 12345,
installationId: 99887766,
clientId: "Iv23liABCDEF",
clientSecret: "cs-secret",
webhookSecret: "wh-secret",
privateKeyPem: privateKeyPem ?? "PEM",
...over,
};
}
// A Coolify whose every route is declared by the test, and which records what
// it was asked — so a test can assert on the ABSENCE of a call as easily as on
// its presence.
function coolify(
routes: Record<string, (body: unknown) => [number, unknown]>,
): { client: CoolifyClient; hits: string[]; bodies: Record<string, unknown> } {
const hits: string[] = [];
const bodies: Record<string, unknown> = {};
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname.replace("/api/v1", "");
const key = `${init?.method ?? "GET"} ${path}`;
hits.push(key);
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
if (body !== undefined) bodies[key] = body;
const route = routes[key];
if (!route) return new Response("no such route", { status: 404 });
const [status, payload] = route(body);
return new Response(JSON.stringify(payload), { status });
}) as unknown as typeof fetch;
return {
client: new CoolifyClient("https://coolify.test", "tok", fetchImpl),
hits,
bodies,
};
}
describe("the manifest cast POSTs to GitHub", () => {
const manifest = buildManifest({
name: "hdb-coolify-prod",
orgRepo: "heavy-duty/incubator",
redirectUrl: "http://127.0.0.1:8765/callback",
});
it("declares clone-only permissions in snake_case", () => {
// Hyphenated keys (`pull-requests`, as the docs' reference page renders
// them) are silently wrong and cost an App you have to delete.
expect(manifest.default_permissions).toEqual({
contents: "read",
metadata: "read",
});
for (const key of Object.keys(
manifest.default_permissions as Record<string, string>,
)) {
expect(key).not.toContain("-");
}
});
it("subscribes to no events and keeps the webhook inactive on a dead url", () => {
expect(manifest.default_events).toEqual([]);
expect(manifest.hook_attributes).toEqual({
// Required by the schema even when inactive, so it points at a name that
// can never resolve (RFC 2606 reserves `.invalid`).
url: "https://example.invalid/unused",
active: false,
});
});
it("is private, points at the repo, and redirects to the loopback LITERAL", () => {
expect(manifest.public).toBe(false);
expect(manifest.url).toBe("https://github.com/heavy-duty/incubator");
expect(manifest.redirect_url).toBe("http://127.0.0.1:8765/callback");
// Never `localhost`: it resolves through the host's name resolution, which
// other software on the machine can change.
expect(String(manifest.redirect_url)).not.toContain("localhost");
});
it("targets the org form for an org and the personal form for a user", () => {
expect(newAppFormAction("heavy-duty", "Organization")).toBe(
"https://github.com/organizations/heavy-duty/settings/apps/new",
);
expect(newAppFormAction("danmt", "User")).toBe(
"https://github.com/settings/apps/new",
);
});
it("escapes the manifest into the form field rather than breaking out of it", () => {
const page = manifestFormPage({
manifest: { name: 'a"><script>x</script>' },
formAction: "https://github.com/settings/apps/new",
csrf: "tok/en",
appName: "x",
});
expect(page).not.toContain("<script>x</script>");
expect(page).toContain("&quot;");
// The csrf token rides the action as `state`, url-encoded.
expect(page).toContain("state=tok%2Fen");
});
});
describe("the loopback callback server", () => {
it("serves the auto-submitting form and captures the code from a real request", async () => {
// Driven with a real HTTP request against a real ephemeral server — the
// behaviour under test is an HTTP handshake, so nothing here is stubbed.
const server = await startManifestServer({
csrf: "csrf-value",
port: 0,
appName: "hdb-coolify-prod",
formAction:
"https://github.com/organizations/heavy-duty/settings/apps/new",
manifestFor: (redirectUrl) =>
buildManifest({
name: "hdb-coolify-prod",
orgRepo: "heavy-duty/incubator",
redirectUrl,
}),
});
try {
// The manifest could not have been built before listen(): with port 0 the
// port is only known afterwards, and it is inside redirect_url.
expect(server.manifest.redirect_url).toBe(
`http://127.0.0.1:${server.port}/callback`,
);
const page = await (await fetch(server.startUrl)).text();
expect(page).toContain('name="manifest"');
expect(page).toContain("hdb-coolify-prod");
expect(page).toContain("state=csrf-value");
const res = await fetch(
`${server.startUrl}callback?code=abc123&state=csrf-value`,
);
expect(res.status).toBe(200);
expect(await server.code).toBe("abc123");
} finally {
await server.close();
}
});
it("refuses a callback carrying the wrong state, and keeps serving the right one", async () => {
const server = await startManifestServer({
csrf: "the-real-token",
port: 0,
appName: "app",
formAction: "https://github.com/settings/apps/new",
manifestFor: (redirectUrl) =>
buildManifest({ name: "app", orgRepo: "o/r", redirectUrl }),
});
try {
const forged = await fetch(
`${server.startUrl}callback?code=attacker&state=guessed`,
);
expect(forged.status).toBe(400);
expect(await forged.text()).toContain("state mismatch");
// The load-bearing half: refusing a forgery must not also cancel the
// real callback the operator is still on their way to producing.
const real = await fetch(
`${server.startUrl}callback?code=genuine&state=the-real-token`,
);
expect(real.status).toBe(200);
expect(await server.code).toBe("genuine");
} finally {
await server.close();
}
});
it("400s a callback with no code at all", async () => {
const server = await startManifestServer({
csrf: "t",
port: 0,
appName: "app",
formAction: "https://github.com/settings/apps/new",
manifestFor: (redirectUrl) =>
buildManifest({ name: "app", orgRepo: "o/r", redirectUrl }),
});
try {
const res = await fetch(`${server.startUrl}callback?state=t`);
expect(res.status).toBe(400);
} finally {
await server.close();
}
});
it("rejects the pending code when it is closed without a callback", async () => {
const server = await startManifestServer({
csrf: "t",
port: 0,
appName: "app",
formAction: "https://github.com/settings/apps/new",
manifestFor: (redirectUrl) =>
buildManifest({ name: "app", orgRepo: "o/r", redirectUrl }),
});
const pending = server.code;
await server.close();
await expect(pending).rejects.toThrow(/callback never arrived/);
});
});
describe("the App JWT", () => {
// Verified independently: this test does not call cast's own code to check
// cast's signature. It re-derives the segments and verifies with the PUBLIC
// key, which is what GitHub does.
function decode(jwt: string) {
const [h, p, s] = jwt.split(".");
return {
header: JSON.parse(Buffer.from(h, "base64url").toString("utf8")),
payload: JSON.parse(Buffer.from(p, "base64url").toString("utf8")),
signingInput: `${h}.${p}`,
signature: Buffer.from(s, "base64url"),
};
}
it("is an RS256 signature over the two segments, verifiable with the public key", () => {
const jwt = mintAppJwt({ privateKeyPem, clientId: "Iv23liABCDEF" });
const { header, signingInput, signature } = decode(jwt);
expect(header).toEqual({ alg: "RS256", typ: "JWT" });
expect(
createVerify("RSA-SHA256")
.update(signingInput)
.verify(publicKeyPem, signature),
).toBe(true);
});
it("does not verify against a different key", () => {
const other = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwt = mintAppJwt({ privateKeyPem, clientId: "x" });
const { signingInput, signature } = decode(jwt);
expect(
createVerify("RSA-SHA256")
.update(signingInput)
.verify(
other.publicKey.export({ type: "spki", format: "pem" }) as string,
signature,
),
).toBe(false);
});
it("backdates iat, stays inside GitHub's 10-minute ceiling, and issues as the CLIENT id", () => {
const now = 1_770_000_000_000;
const nowSeconds = Math.floor(now / 1000);
const { payload } = decode(
mintAppJwt({ privateKeyPem, clientId: "Iv23liABCDEF", now }),
);
// Backdated against clock skew — GitHub's own documented advice.
expect(payload.iat).toBe(nowSeconds - 60);
expect(payload.iat).toBeLessThan(nowSeconds);
// "no more than 10 minutes into the future", measured from iat. Cast sits
// inside the ceiling rather than on it: a JWT rejected for being one second
// too long looks exactly like a bad key from the operator's side.
expect(payload.exp - payload.iat).toBeLessThanOrEqual(600);
expect(payload.exp).toBeGreaterThan(nowSeconds);
// `iss` is the client id, which GitHub now recommends over the app id.
expect(payload.iss).toBe("Iv23liABCDEF");
});
});
describe("the manifest code exchange", () => {
const conversionBody = {
id: 424242,
slug: "hdb-coolify-prod",
client_id: "Iv23liABCDEF",
client_secret: "cs",
webhook_secret: "wh",
pem: "-----BEGIN RSA PRIVATE KEY-----\nx\n-----END RSA PRIVATE KEY-----\n",
owner: { login: "heavy-duty", type: "Organization" },
};
it("POSTs to the conversions endpoint with NO Authorization header", async () => {
const fetchImpl = vi.fn(
async () => new Response(JSON.stringify(conversionBody), { status: 200 }),
) as unknown as typeof fetch;
const out = await convertManifestCode("the-code", fetchImpl);
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe(
"https://api.github.com/app-manifests/the-code/conversions",
);
expect(init.method).toBe("POST");
// The code IS the credential. Sending a token here is not merely
// unnecessary — the endpoint is documented as unauthenticated.
expect(Object.keys(init.headers)).not.toContain("Authorization");
expect(out.clientSecret).toBe("cs");
expect(out.ownerLogin).toBe("heavy-duty");
expect(out.ownerType).toBe("Organization");
});
it("turns a 404 into the remedy, because the code is spent and retrying the exchange cannot help", async () => {
const fetchImpl = vi.fn(
async () => new Response("Not Found", { status: 404 }),
) as unknown as typeof fetch;
await expect(convertManifestCode("c", fetchImpl)).rejects.toThrow(
/valid for one hour[\s\S]*run `cast github-app create` again/,
);
});
it("turns a 422 into the rate-limit remedy rather than 'bad manifest'", async () => {
const fetchImpl = vi.fn(
async () => new Response("Unprocessable", { status: 422 }),
) as unknown as typeof fetch;
await expect(convertManifestCode("c", fetchImpl)).rejects.toThrow(
/rate-limit[\s\S]*wait a few minutes/,
);
});
it("refuses a partial body instead of persisting half a credential", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ ...conversionBody, pem: undefined }), {
status: 200,
}),
) as unknown as typeof fetch;
await expect(convertManifestCode("c", fetchImpl)).rejects.toThrow(
/missing fields cast needs/,
);
});
it("tolerates an absent webhook secret by reporting it as absent, not empty", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({ ...conversionBody, webhook_secret: null }),
{ status: 200 },
),
) as unknown as typeof fetch;
expect(
(await convertManifestCode("c", fetchImpl)).webhookSecret,
).toBeNull();
});
});
describe("recovering the installation id from the App's own key", () => {
function githubFetch(
handler: (path: string, init?: RequestInit) => Response,
): { impl: typeof fetch; paths: string[] } {
const paths: string[] = [];
const impl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
paths.push(path);
return handler(path, init);
}) as unknown as typeof fetch;
return { impl, paths };
}
it("asks the org endpoint with the JWT as a bearer token", async () => {
const { impl, paths } = githubFetch(
() => new Response(JSON.stringify({ id: 5150 }), { status: 200 }),
);
const id = await findInstallationId({
owner: "heavy-duty",
ownerType: "Organization",
jwt: "the.jwt.here",
fetchImpl: impl,
});
expect(id).toBe(5150);
expect(paths[0]).toBe("/orgs/heavy-duty/installation");
const [, init] = (impl as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(init.headers.Authorization).toBe("Bearer the.jwt.here");
});
it("asks the user endpoint for a personal account", async () => {
const { impl, paths } = githubFetch(
() => new Response(JSON.stringify({ id: 1 }), { status: 200 }),
);
await findInstallationId({
owner: "danmt",
ownerType: "User",
jwt: "j",
fetchImpl: impl,
});
expect(paths[0]).toBe("/users/danmt/installation");
});
it("reads a 404 as 'not installed yet' and a 500 as an error — they are different facts", async () => {
const notInstalled = githubFetch(() => new Response("", { status: 404 }));
expect(
await findInstallationId({
owner: "o",
ownerType: "Organization",
jwt: "j",
fetchImpl: notInstalled.impl,
}),
).toBeUndefined();
const broken = githubFetch(() => new Response("boom", { status: 500 }));
await expect(
findInstallationId({
owner: "o",
ownerType: "Organization",
jwt: "j",
fetchImpl: broken.impl,
}),
).rejects.toThrow(/→ 500/);
});
it("polls while the operator clicks through the install screen", async () => {
let call = 0;
const impl = vi.fn(async () => {
call++;
return call < 3
? new Response("", { status: 404 })
: new Response(JSON.stringify({ id: 777 }), { status: 200 });
}) as unknown as typeof fetch;
const slept: number[] = [];
const id = await awaitInstallationId({
owner: "heavy-duty",
ownerType: "Organization",
privateKeyPem,
clientId: "Iv1",
fetchImpl: impl,
intervalMs: 5000,
sleep: async (ms) => {
slept.push(ms);
},
});
expect(id).toBe(777);
expect(call).toBe(3);
expect(slept).toEqual([5000, 5000]);
});
it("gives up with an error that says the App exists and only the install is missing", async () => {
const impl = vi.fn(
async () => new Response("", { status: 404 }),
) as unknown as typeof fetch;
await expect(
awaitInstallationId({
owner: "heavy-duty",
ownerType: "Organization",
privateKeyPem,
clientId: "Iv1",
fetchImpl: impl,
attempts: 2,
intervalMs: 1,
sleep: async () => {},
}),
).rejects.toThrow(
/never installed on heavy-duty[\s\S]*The App exists on GitHub/,
);
});
});
describe("the Coolify-facing name is resolved from state, not from a flag (#5 footgun 1)", () => {
const bindings = (apps: Record<string, string>) =>
loadBindings("", {
overrideText: [
"environments:",
" prod:",
" server: box",
" team: { id: 0, name: Root Team }",
`github_apps: ${JSON.stringify(apps)}`,
"",
].join("\n"),
});
it("uses the full-slug entry and ignores a matching --name", () => {
const b = bindings({ "heavy-duty/incubator": "hdb-coolify-prod" });
expect(
resolveAppName({ bindings: b, orgRepo: "heavy-duty/incubator" }),
).toEqual({ name: "hdb-coolify-prod", seed: false });
expect(
resolveAppName({
bindings: b,
orgRepo: "heavy-duty/incubator",
nameFlag: "hdb-coolify-prod",
}).name,
).toBe("hdb-coolify-prod");
});
it("still honours a legacy bare-repo key (#6's compatibility fallback)", () => {
expect(
resolveAppName({
bindings: bindings({ incubator: "legacy-name" }),
orgRepo: "heavy-duty/incubator",
}),
).toEqual({ name: "legacy-name", seed: false });
});
it("REFUSES a --name that disagrees with state — the footgun, dissolved", () => {
expect(() =>
resolveAppName({
bindings: bindings({ "heavy-duty/incubator": "hdb-coolify-prod" }),
orgRepo: "heavy-duty/incubator",
nameFlag: "My Cool App",
}),
).toThrow(
/disagrees with environments.yaml[\s\S]*state file is the authority/,
);
});
it("seeds from --name only when the entry is absent, and refuses when neither exists", () => {
expect(
resolveAppName({
bindings: bindings({}),
orgRepo: "heavy-duty/incubator",
nameFlag: "hdb-coolify-prod",
}),
).toEqual({ name: "hdb-coolify-prod", seed: true });
expect(() =>
resolveAppName({
bindings: bindings({}),
orgRepo: "heavy-duty/incubator",
}),
).toThrow(/no GitHub App name for heavy-duty\/incubator/);
});
it("writes the seeded entry by FULL slug, preserving the operator's comments", () => {
const dir = tmp("cast-bind-");
const path = join(dir, "environments.yaml");
const original = [
"# the control plane's bindings — hand maintained",
"environments:",
" prod:",
" server: box # the tailnet one",
" team: { id: 0, name: Root Team }",
"",
"github_apps: {}",
"",
].join("\n");
writeFileSync(path, original);
seedGithubAppBinding(path, "heavy-duty/incubator", "hdb-coolify-prod");
const after = readFileSync(path, "utf8");
expect(after).toContain("# the control plane's bindings — hand maintained");
expect(after).toContain("# the tailnet one");
expect(after).toContain("heavy-duty/incubator: hdb-coolify-prod");
// And it round-trips through the real schema.
expect(loadBindings(path).github_apps["heavy-duty/incubator"]).toBe(
"hdb-coolify-prod",
);
});
});
describe("where the secrets land", () => {
it("writes the PEM and the other two secrets 0600, under a directory git ignores by default", () => {
const state = tmp("cast-state-");
const { pemPath, secretsPath } = persistCredentials({
stateDir: state,
name: "hdb-coolify-prod",
creds: creds(),
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
});
expect(readFileSync(pemPath, "utf8")).toBe(creds().privateKeyPem);
const saved = JSON.parse(readFileSync(secretsPath, "utf8"));
// All three, because the conversion response is the ONLY time GitHub yields
// them and `register` needs the client secret to be re-runnable at all.
expect(saved.client_secret).toBe("cs-secret");
expect(saved.webhook_secret).toBe("wh-secret");
expect(saved.app_id).toBe(12345);
expect(saved.installation_id).toBe(99887766);
for (const p of [pemPath, secretsPath]) {
expect(statSync(p).mode & 0o777).toBe(0o600);
}
// The structural half of the "loud note": `git add -A` in the state repo
// cannot commit plaintext credentials by accident.
const ignore = join(state, "github-apps", ".gitignore");
expect(existsSync(ignore)).toBe(true);
expect(readFileSync(ignore, "utf8")).toContain("*");
});
it("is idempotent on identical content and REFUSES to overwrite different content", () => {
const state = tmp("cast-state-");
const args = {
stateDir: state,
name: "app",
creds: creds(),
org: "o",
orgRepo: "o/r",
};
persistCredentials(args);
expect(() => persistCredentials(args)).not.toThrow();
expect(() =>
persistCredentials({
...args,
creds: creds({ privateKeyPem: "a different key" }),
}),
).toThrow(/refusing to overwrite[\s\S]*only copy in existence/);
// --force is the deliberate escape hatch for a stale half-run.
expect(() =>
persistCredentials({
...args,
creds: creds({ privateKeyPem: "a different key" }),
force: true,
}),
).not.toThrow();
});
});
describe("registering with Coolify, and the post-condition that matters", () => {
const ok = (payload: unknown) => () => [200, payload] as [number, unknown];
it("uploads the key, creates the App, and PROVES it can reach the repo", async () => {
const c = coolify({
"POST /security/keys": ok({ uuid: "key-uuid-1" }),
"POST /github-apps": ok({ id: 7, uuid: "app-uuid" }),
"GET /github-apps/7/repositories": ok({
repositories: [
{ full_name: "heavy-duty/other" },
{ full_name: "heavy-duty/incubator" },
],
}),
});
const out = await registerGithubApp({
client: c.client,
name: "hdb-coolify-prod",
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
});
expect(c.hits).toEqual([
"POST /security/keys",
"POST /github-apps",
"GET /github-apps/7/repositories",
]);
expect(c.bodies["POST /security/keys"]).toEqual({
name: "hdb-coolify-prod-key",
private_key: creds().privateKeyPem,
});
expect(c.bodies["POST /github-apps"]).toEqual({
name: "hdb-coolify-prod",
organization: "heavy-duty",
api_url: "https://api.github.com",
html_url: "https://github.com",
app_id: 12345,
installation_id: 99887766,
client_id: "Iv23liABCDEF",
client_secret: "cs-secret",
// No invented placeholder any more (#5 footgun 3).
webhook_secret: "wh-secret",
private_key_uuid: "key-uuid-1",
});
expect(out.coolifyAppId).toBe(7);
expect(out.repositories).toContain("heavy-duty/incubator");
});
it("fails HARD when the App cannot see the repo, naming what it can see", async () => {
// The whole point of step 9. Without it this misconfiguration surfaces
// hours later, in a different command, as an unresolvable source.
const c = coolify({
"POST /security/keys": ok({ uuid: "k" }),
"POST /github-apps": ok({ id: 9 }),
"GET /github-apps/9/repositories": ok({
repositories: [{ full_name: "heavy-duty/something-else" }],
}),
});
await expect(
registerGithubApp({
client: c.client,
name: "hdb-coolify-prod",
org: "heavy-duty",
orgRepo: "heavy-duty/incubator",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
}),
).rejects.toThrow(
/cannot see heavy-duty\/incubator[\s\S]*can see: heavy-duty\/something-else/,
);
});
it("fails when the repo list is UNREADABLE, rather than reporting it as empty", async () => {
const c = coolify({
"POST /security/keys": ok({ uuid: "k" }),
"POST /github-apps": ok({ id: 9 }),
"GET /github-apps/9/repositories": ok({ repositories: "not a list" }),
});
await expect(
registerGithubApp({
client: c.client,
name: "n",
org: "o",
orgRepo: "o/r",
creds: creds(),
stateDir: tmp("cast-state-"),
log: () => {},
}),
).rejects.toThrow(/cannot verify that n can reach o\/r/);
});
it("persists the credentials BEFORE the Coolify calls, so a Coolify failure does not lose the key", async () => {
const c = coolify({}); // every route 404s
const state = tmp("cast-state-");
await expect(
registerGithubApp({
client: c.client,
name: "app",
org: "o",
orgRepo: "o/r",
creds: creds(),
stateDir: state,
log: () => {},
}),
).rejects.toThrow();
// GitHub shows the private key once. Losing it to a failed HTTP call would
// mean deleting the App and starting over.
expect(existsSync(join(state, "github-apps", "app.pem"))).toBe(true);
});
it("reads a bare array and an owner/name pair as well as full_name", async () => {
const c = coolify({
"GET /github-apps/3/repositories": ok([
{ owner: { login: "heavy-duty" }, name: "incubator" },
]),
});
expect(await readAppRepositories(c.client, 3)).toEqual([
"heavy-duty/incubator",
]);
});
it("treats ONE unreadable row as an unreadable list — a partial list reads exactly like a complete one", async () => {
const c = coolify({
"GET /github-apps/3/repositories": ok({
repositories: [{ full_name: "a/b" }, { nothing: "usable" }],
}),
});
expect(await readAppRepositories(c.client, 3)).toBeUndefined();
});
});
describe("the optional org-admin preflight", () => {
it("passes on admin and refuses on anything else", () => {
expect(preflightOrgAdmin("heavy-duty", () => '{"role":"admin"}')).toEqual({
kind: "admin",
});
expect(preflightOrgAdmin("heavy-duty", () => '{"role":"member"}')).toEqual({
kind: "not-admin",
role: "member",
});
});
it("skips silently when gh is absent — a nice-to-have must never become a dependency", () => {
const result = preflightOrgAdmin("heavy-duty", () => {
throw new Error("ENOENT");
});
expect(result.kind).toBe("skipped");
});
});
describe("detecting whether the owner is an org or a personal account", () => {
it("reads the type, and falls back to undefined rather than guessing on failure", async () => {
const okFetch = vi.fn(
async () =>
new Response(JSON.stringify({ type: "User" }), { status: 200 }),
) as unknown as typeof fetch;
expect(await detectOwnerType("danmt", okFetch)).toBe("User");
const badFetch = vi.fn(async () => {
throw new Error("offline");
}) as unknown as typeof fetch;
expect(await detectOwnerType("danmt", badFetch)).toBeUndefined();
});
});
describe("`create` falls through into `register` — one implementation, not two", () => {
it("ends in exactly the Coolify calls `register` makes, with GitHub's own secrets", async () => {
const conversion = {
id: 424242,
slug: "hdb-coolify-prod",
client_id: "Iv23liXYZ",
client_secret: "github-issued-secret",
webhook_secret: "github-issued-webhook",
pem: privateKeyPem,
owner: { login: "heavy-duty", type: "Organization" },
};
// GitHub, mocked: owner type, the conversion, then the installation.
const githubFetch = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.endsWith("/conversions"))
return new Response(JSON.stringify(conversion), { status: 200 });
if (u.includes("/installation"))
return new Response(JSON.stringify({ id: 5150 }), { status: 200 });
return new Response(JSON.stringify({ type: "Organization" }), {
status: 200,
});
}) as unknown as typeof fetch;
const c = coolify({
"POST /security/keys": () => [200, { uuid: "key-uuid-1" }],
"POST /github-apps": () => [200, { id: 11 }],
"GET /github-apps/11/repositories": () => [
200,
{ repositories: [{ full_name: "heavy-duty/incubator" }] },
],
});
const state = tmp("cast-state-");
// Drive the browser step: as soon as cast prints its start url, fetch the
// callback the way GitHub's redirect would.
const flow = createGithubApp({
client: c.client,
orgRepo: "heavy-duty/incubator",
name: "hdb-coolify-prod",
stateDir: state,
port: 0,
deps: {
fetchImpl: githubFetch,
openUrl: (url) => {
if (url.startsWith("http://127.0.0.1")) {
const u = new URL(url);
// The state parameter is not knowable from outside: read it off the
// page cast is serving, exactly as a browser would.
fetch(url)
.then((r) => r.text())
.then((page) => {
const state = /state=([^"&]+)/.exec(page)?.[1] ?? "";
return fetch(
`${u.origin}/callback?code=the-code&state=${state}`,
);
});
}
return false;
},
sleep: async () => {},
runGh: () => '{"role":"admin"}',
log: () => {},
},
});
const out = await flow;
// The fall-through, asserted as an identity of behaviour: the same three
// calls, in the same order, that the `register`-only test above pins.
expect(c.hits).toEqual([
"POST /security/keys",
"POST /github-apps",
"GET /github-apps/11/repositories",
]);
const body = c.bodies["POST /github-apps"] as Record<string, unknown>;
expect(body.app_id).toBe(424242);
// Recovered via the JWT path, NOT read off a setup_url redirect parameter
// (GitHub documents that one as a spoofable hint).
expect(body.installation_id).toBe(5150);
expect(body.client_secret).toBe("github-issued-secret");
expect(body.webhook_secret).toBe("github-issued-webhook");
expect(out.coolifyAppId).toBe(11);
// And the secrets landed, all three of them.
expect(
readFileSync(join(state, "github-apps", "hdb-coolify-prod.pem"), "utf8"),
).toBe(privateKeyPem);
});
it("refuses before the browser dance when gh says you are not an org admin", async () => {
const c = coolify({});
await expect(
createGithubApp({
client: c.client,
orgRepo: "heavy-duty/incubator",
name: "n",
stateDir: tmp("cast-state-"),
port: 0,
ownerType: "Organization",
deps: {
runGh: () => '{"role":"member"}',
log: () => {},
openUrl: () => false,
},
}),
).rejects.toThrow(/not an admin of heavy-duty/);
// Nothing was served, nothing was registered — the point of a preflight.
expect(c.hits).toEqual([]);
});
});