Hephaestus docs
A code forge you sign in to with a Bitcoin Cash wallet. Repos on a fast local disk, cold blobs on Sia, container registry baked in. Open source, self-hostable. This page is the map — every section is a one-shot recipe.
1. What Hephaestus is
Hephaestus is a Forgejo instance with a Silent Mode auth-proxy in front: instead of email + password, sign-in uses a Bitcoin Cash wallet signature (BIP-137 "Bitcoin Signed Message"). Everything else — git, PRs, issues, releases, container/npm registries, the API — is stock Forgejo. Nothing custom for you to learn if you have used Gitea or Forgejo before.
- runs on
- Docker Compose · Caddy + Postgres + Forgejo 10 + auth-proxy
- sign-in
- BCH wallet signature → OIDC callback → Forgejo session
- usernames
- Auto:
bch_<first-20-of-your-cashaddr>. Renameable to anything else. - storage
- Live git + Postgres on SSD; LFS/releases/packages spill to Sia via s3.silentmode.st
- source
- silentmode/hephaestus — MIT, open
2. Two hostnames, one instance
The same Forgejo is reachable at two addresses. Same account, same repos, same session cookie — differs only in how you got there:
| Host | How to reach it | Cert |
|---|---|---|
| hephaestus.x | A BCNR-aware browser (Theseus, Ariadne) resolves it directly, or through the public gateway at navigate.st/bns/hephaestus.x/ | Silent Mode Argonautica CA |
| code.silentmode.st | Regular DNS + Let's Encrypt. Works in any browser, no resolver setup | Let's Encrypt (public) |
code.silentmode.st. It works everywhere. Use hephaestus.x when the whole path is BCNR-aware.3. Sign in with a wallet
The sign-in button gives you three routes:
- Sign up — creates a fresh BIP-39 wallet in your browser, shows you the recovery phrase, and encrypts the wallet in
localStoragewith a passphrase you set. easiest - Sign in — you paste a 12- or 24-word recovery phrase you already have. Same wallet as anywhere else that speaks BIP-39.
- WizardConnect — sign with an external wallet (Cashonize / Paytaca) via an encrypted Nostr relay. Keys never leave the wallet. most private
What happens on the wire
- Forgejo redirects you to the auth-proxy's
/auth/authorize. - Your browser POSTs
{cashaddr, state}to/auth/challengeand gets back a nonce + message. - The wallet signs the message. Standard "Bitcoin Signed Message" (varint-prefixed magic + double-SHA256 + secp256k1 recoverable-compact + base64).
- Browser POSTs
{nonce, signature, state}to/auth/verify. The auth-proxy recovers the pubkey, re-hashes to a cashaddr, matches, mints an Ed25519-signedid_token, and hands back a Forgejo callback URL. - Following the callback lands you in the dashboard with a session cookie.
If you use the flow programmatically (no browser)
Every step is a JSON POST. The wallet-signing bit is the only crypto: sign the exact message string the server returned — no trailing newline. See auth-proxy/src/verify.ts for the byte-perfect reference. A working sample lives in PROTOCOL.md.
4. Rename your username
The auto-generated bch_<addr-prefix> is a placeholder. Rename yourself to anything else — BitcoinCash, alice, whatever's not taken.
From the UI
Settings → Profile → Username. Change the field, save. All your repo URLs move to /<new-name>/… and the old URLs 307-redirect for a while so bookmarks survive.
Allowed characters
Forgejo names — usernames, org names, repo names — accept letters (mixed case), digits, hyphens, underscores, and dots. So all of these are valid:
alicegame.xdots OKGame.Xgame-xSilent_Modebch_qp3hvzy09lzcm46qm4cd
Rejected: whitespace, slashes, and reserved routes like assets, login, api. If you're picking a name for an on-chain .x project, the natural mapping (game.x repo owner) works — no need to substitute a hyphen.
From the admin API (if you're the operator)
# The endpoint expects FORM-encoded, not JSON. If you send JSON it silently returns 422 "NewName required".
curl -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "new_name=BitcoinCash" \
https://code.silentmode.st/api/v1/admin/users/<old-name>/rename
5. Create + push a repo
Standard Forgejo. Nothing custom.
Create
- UI: + menu top-right → New Repository
- API:
POST /api/v1/user/reposwith a token
Clone + push
# HTTPS — always works, no key setup git clone https://code.silentmode.st/<you>/<repo>.git # SSH — needs your key uploaded under Settings → SSH keys. Port is 2222, not 22. git clone ssh://git@code.silentmode.st:2222/<you>/<repo>.git
6. Personal access tokens
Settings → Applications → Generate New Token. Pick the scopes you actually need — the token that pushes commits does not need write:organization.
| Scope | What it does |
|---|---|
read:repository, write:repository | Clone / push git over HTTPS, use the repo API |
read:package, write:package | Docker login + push/pull on the container registry |
write:organization | Create repos under an org you belong to |
read:user, write:user | List and revoke your own tokens via API |
7. Container registry (OCI)
Forgejo speaks the standard OCI Distribution API at code.silentmode.st/v2/. Docker + skopeo + buildah + Kubernetes all work as-is.
Log in
docker login code.silentmode.st -u <your-username>
# password: a Personal Access Token with write:package scope
Push
docker build -t code.silentmode.st/<owner>/<image>:<tag> . docker push code.silentmode.st/<owner>/<image>:<tag>
Owner is a username or an org you belong to. Owner-org pushes need you to be an Owners team member; add via the admin API or the org settings page.
Pull
docker pull code.silentmode.st/<owner>/<image>:<tag>
Anonymous pull works for public repos. The /v2/ endpoint returns 401 to guide clients through the Bearer-challenge flow, but the token endpoint issues an anonymous token for public content. This means external systems like Flux can pull from Hephaestus without any credential setup.
List packages
curl https://code.silentmode.st/api/v1/packages/<owner>?type=container
Worked example: publish an app's backend as an image
Any backend that runs in a container publishes the same way. Node example — Dockerfile at the repo root:
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]
Build + push in one pass:
docker login code.silentmode.st -u <you>
# password: PAT with write:package scope (see §6)
docker build -t code.silentmode.st/<owner>/<app>:latest .
docker push code.silentmode.st/<owner>/<app>:latest
The image is now pullable at code.silentmode.st/<owner>/<app>:latest. If the repo is public, Flux + friends can pull it anonymously (§7 above). Verify it in /-/packages or via the list-packages API.
8. API basics
Full Swagger at code.silentmode.st/api/swagger. Auth is a token in a header:
curl -H "Authorization: token $TOKEN" https://code.silentmode.st/api/v1/user
# or a Bearer if you prefer:
curl -H "Authorization: Bearer $TOKEN" https://code.silentmode.st/api/v1/user
A few endpoints you'll want early:
| Endpoint | What |
|---|---|
GET /api/v1/user | Whoami — verify token works |
POST /api/v1/user/repos | Create a repo under yourself |
POST /api/v1/orgs/<org>/repos | Create a repo under an org |
PATCH /api/v1/repos/<o>/<r> | Change description / website / private flag |
DELETE /api/v1/repos/<o>/<r> | Delete a repo (destructive, no undo) |
POST /api/v1/repos/<o>/<r>/branches | Copy a branch (rename via copy + delete) |
Content-Type: application/json; charset=utf-8 AND a properly UTF-8-encoded body. Shell interpolation on Windows Git Bash silently CP1252-encodes and the char lands as �. If that happens, PATCH again with a Python urllib caller or plain curl --data-binary @body.json.9. Where files live
- git repos
- VPS SSD at
/data/git/repositories/<owner>/<repo>.gitinside the Forgejo container - Postgres
- SSD at
/var/lib/postgresql/data— issues, PRs, users, sessions, tokens - LFS objects
- Sia via S3 at
s3.silentmode.st:8600, buckethephaestus-lfs - Release attachments, packages
- Same Sia bucket, different key prefixes
- Container images
- Same Sia bucket, prefix
packages/container/ - Nightly backup
resticencrypts the whole tree + Postgres dump to a separate Sia bucket, retained 30 days- DR mirror
- Static landing (this page) also mirrored to Sia bucket
bns/hephaestus.x/, updated on redeploy
10. Self-hosting
You can run your own Hephaestus on any Linux box with Docker. Nothing about it depends on Silent Mode's infrastructure — the operator's role is just to own the domain and hold the wallet secrets.
# 1. clone git clone https://code.silentmode.st/silentmode/hephaestus.git cd hephaestus # 2. configure — copy .env.example, fill in your domain, Sia S3 creds, OIDC secret cp .env.example .env $EDITOR .env # 3. run docker compose up -d # 4. first wallet signs in and takes the admin seat open https://your-domain.example/user/oauth2/hephaestus-wallet
Full deploy notes: README, and PROTOCOL.md for the wire format.
11. Known gotchas
- Push over HTTPS asks for a password. That password is a Personal Access Token, not your wallet passphrase. Wallet passphrases decrypt the wallet in your browser's
localStorage; they have nothing to do with Forgejo's git auth. See §5 for the git-remote URL and §6 for how to generate the PAT. - Descriptions show as
�when shell → curl → API re-encodes UTF-8 as CP1252 (Windows Git Bash is the usual culprit). Use a PythonurllibPATCH orcurl --data-binary @file. Full recipe with the correctContent-Typeheader is in §8 → "Encoding gotcha". - Signature mismatch on
/auth/verifyalmost always means your message string got a trailing newline (Pythonprint, Bash heredoc). Write the message to a file withnewline=''and hash the file bytes directly. - OIDC callback URL uses port 3000 if Forgejo's
ROOT_URLis unset. Setting[server].ROOT_URL = https://code.silentmode.st/inapp.inifixes it; browsers can't reach internal port 3000. - Cross-origin session cookies. Starting OAuth on hephaestus.x and finishing on code.silentmode.st loses the session cookie mid-flow. The landing dropdown always kicks off on code.silentmode.st to avoid this.
- Renaming a user via admin API takes
new_nameas a form field, not JSON. JSON returns 422 "NewName required". Full command in §4 → "From the admin API". - SSH port is 2222, not 22, so the compose stack doesn't fight the host's sshd. Your git remote URL must include
:2222. - Docker Hub rate limits can bite
docker compose buildif you're unauthenticated. Log in once withdocker loginagainst Docker Hub before running the stack build.
12. CLI — Command Line Interface and GUI — Graphical User Interface
Every task in this section works two ways: through the GUI (the Forgejo web pages at code.silentmode.st) or through the CLI (curl + git + docker against the same API). Humans usually reach for the GUI first; parallel sessions, CI, and scripts live in the CLI. Once you internalize the wallet-vs-PAT split, both paths are one call at a time.
Wallet vs Personal Access Token — when to use each
The wallet is for bootstrapping the account once, and for recovering if a PAT ever leaks. Nothing else. Every git operation, every API call, every docker push, from every session, from every day after the first — runs on a PAT.
| Task | Auth |
|---|---|
| Create the user account for the first time | Wallet sign-in |
| Push, pull, clone, edit repo settings | PAT (write:repository) |
| Docker push / pull to the OCI registry | PAT (write:package) |
| Create repos under an org, manage members | PAT (write:organization) |
| Revoke a compromised PAT | Any PAT with write:user, or admin |
| Rotate ownership (transfer everything to a new wallet) | Wallet sign-in on the new address + admin transfer |
Bootstrapping a new namespace for another session
Say a parallel session needs to push code to game.x/checkers. Full recipe, five steps, no browser required for the human at all:
# 1. Admin creates the org (one API call, no wallet involvement) curl -X POST -H "Authorization: token $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"username":"game.x","visibility":"public"}' \ https://code.silentmode.st/api/v1/orgs # 2. Whoever owns the game.x wallet signs in ONCE to bootstrap their user. # Either via browser (open /user/oauth2/hephaestus-wallet) # or programmatically (see next subsection — mnemonic never touches a browser). # Result: a user account named `bch_<first-20-of-cashaddr>`. # 3. Admin adds that user to game.x/Owners OWNERS_ID=$(curl -sk -H "Authorization: token $ADMIN_TOKEN" \ https://code.silentmode.st/api/v1/orgs/game.x/teams | \ python -c "import sys,json; \ print(next(t['id'] for t in json.load(sys.stdin) if t['name']=='Owners'))") curl -X PUT -H "Authorization: token $ADMIN_TOKEN" \ https://code.silentmode.st/api/v1/teams/$OWNERS_ID/members/bch_<addr> # 4. That user generates a PAT with write:repository scope # at Settings → Applications → Generate New Token. # Hand only the PAT to the parallel session. # 5. Session clones + pushes using the PAT as HTTPS password git clone https://code.silentmode.st/game.x/checkers.git cd checkers && ... && git push
Programmatic wallet sign-in (mnemonic stays local)
If the wallet owner wants to bootstrap without ever opening a browser — for a headless workstation, a CI job, or an AI session that reads its wallet from disk — the whole sign-in flow is scriptable. The mnemonic sits in a local wallets.json, never gets pasted anywhere, never enters a chat log.
Sketch (Node + @bitauth/libauth):
import { deriveHdPath, deriveHdPrivateNodeFromSeed, deriveSeedFromBip39Mnemonic,
encodeCashAddress, hash160, hash256, secp256k1, utf8ToBin, binToBase64,
CashAddressType } from "@bitauth/libauth";
import { readFileSync } from "node:fs";
const mnemonic = JSON.parse(readFileSync("wallets.json", "utf8")).main.seed;
const seed = deriveSeedFromBip39Mnemonic(mnemonic);
const root = deriveHdPrivateNodeFromSeed(seed);
const child = deriveHdPath(root, "m/44'/145'/0'/0/0");
const pub = secp256k1.derivePublicKeyCompressed(child.privateKey);
const cashaddr = encodeCashAddress({ prefix: "bchtest",
type: CashAddressType.p2pkh, payload: hash160(pub) }).address;
// 1. kick off OIDC → get state + redirect_uri
const kick = await fetch("https://code.silentmode.st/user/oauth2/hephaestus-wallet",
{ redirect: "manual" });
const url = new URL(kick.headers.get("location"));
const state = url.searchParams.get("state");
const redirect_uri = url.searchParams.get("redirect_uri");
// 2. challenge → nonce + message
const { nonce, message } = await (await fetch(
"https://code.silentmode.st/auth/challenge",
{ method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ cashaddr, state, redirect_uri }) })).json();
// 3. sign — Bitcoin Signed Message (see PROTOCOL.md for the exact bytes)
const signature = signBitcoinMessage(child.privateKey, message); // see PROTOCOL.md
// 4. verify → callback URL
const { redirect } = await (await fetch(
"https://code.silentmode.st/auth/verify",
{ method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce, signature, state }) })).json();
// 5. follow callback with a cookie-jar client → session cookie in hand
// → GET /user/settings/applications to mint a PAT for future use
Reference implementations in the tree: auth-proxy/src/verify.ts for byte-perfect signing, and PROTOCOL.md for the full wire format. Adapt for bitcoincash: / bchreg: by swapping the prefix.
Sensible PAT scopes per session shape
| Session shape | Scopes | Rationale |
|---|---|---|
| CI job pushing container images | write:package | Nothing else needed |
| Docs / content writer | write:repository | Push commits, edit metadata |
| Ops session managing an org | write:organization, write:repository | Create repos, adjust members |
| Analytics / read-only reader | read:repository, read:package | Zero write surface |
| Dedicated admin session | Admin account + write:organization, write:user | Full backend |
Rotation habit: issue one PAT per session, scope narrowly, revoke the moment the session ends or a machine changes hands. Every token has a name — use it (ci-fly-x-deploy, session-2026-09-20) so you know which one to revoke when things move.
13. Help + source
- Source: silentmode/hephaestus (MIT)
- Wire-format spec: PROTOCOL.md
- Auth-proxy verify reference: auth-proxy/src/verify.ts
- Silent Mode: silentmode.st
- Get a
.xname for your fork: sirius.x