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:

HostHow to reach itCert
hephaestus.xA 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.stRegular DNS + Let's Encrypt. Works in any browser, no resolver setupLet's Encrypt (public)
Which one to use? If you're setting up an OAuth callback, a docker registry, or a git remote — use 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:

What happens on the wire

  1. Forgejo redirects you to the auth-proxy's /auth/authorize.
  2. Your browser POSTs {cashaddr, state} to /auth/challenge and gets back a nonce + message.
  3. The wallet signs the message. Standard "Bitcoin Signed Message" (varint-prefixed magic + double-SHA256 + secp256k1 recoverable-compact + base64).
  4. Browser POSTs {nonce, signature, state} to /auth/verify. The auth-proxy recovers the pubkey, re-hashes to a cashaddr, matches, mints an Ed25519-signed id_token, and hands back a Forgejo callback URL.
  5. 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:

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

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
Push over HTTPS uses a Personal Access Token as the password (see §7). Do not paste a wallet passphrase there — it's a Forgejo token, not your wallet.

6. Private repos + access

Every repo has a private flag. Private repos are invisible to logged-out users and to signed-in users who aren't collaborators. Existing clones keep working until credentials rotate.

GUI

  1. Toggle privacy. Repo → Settings (top-right of the repo header) → General section → check "Make repository private"Save. Public → private is instant.
  2. Add a collaborator. Same Settings page → Collaborators & Teams in the left rail → type a Forgejo username → pick Read (clone, view issues), Write (push, close issues), or Admin (settings, delete) → Add.
  3. Team-based access (org repos only). Same page shows an Add team selector. Members inherit the team's role for that repo. Manage team membership from the org's Teams tab.

CLI

All of the above via API. PAT needs write:repository for the repo calls and write:organization for the team calls.

# flip a repo private (or public: false)
curl -X PATCH \
  -H "Authorization: token $HEPHAESTUS_PAT" \
  -H "Content-Type: application/json" \
  -d '{"private": true}' \
  https://code.silentmode.st/api/v1/repos/<owner>/<repo>

# add a collaborator with write access
curl -X PUT \
  -H "Authorization: token $HEPHAESTUS_PAT" \
  -H "Content-Type: application/json" \
  -d '{"permission": "write"}' \
  https://code.silentmode.st/api/v1/repos/<owner>/<repo>/collaborators/<username>

# create a team + add a member (org repos)
curl -X POST \
  -H "Authorization: token $HEPHAESTUS_PAT" \
  -H "Content-Type: application/json" \
  -d '{"name":"reviewers","permission":"write","includes_all_repositories":false,
       "units":["repo.code","repo.pulls","repo.issues"]}' \
  https://code.silentmode.st/api/v1/orgs/<org>/teams

curl -X PUT \
  -H "Authorization: token $HEPHAESTUS_PAT" \
  https://code.silentmode.st/api/v1/teams/<team_id>/members/<username>
Storage note. Private repos live on the VPS local disk (LFS/archive blobs spill to Sia). Repo size is not currently counted against Silent Storage private quota — so a private repo does not eat into your 500 MB/2 GB/50 GB tier. This may change if the pool starts filling; today it doesn't.

7. Personal access tokens

Settings → Applications → Generate New Token. Pick the scopes you actually need — the token that pushes commits does not need write:organization.

ScopeWhat it does
read:repository, write:repositoryClone / push git over HTTPS, use the repo API
read:package, write:packageDocker login + push/pull on the container registry
write:organizationCreate repos under an org you belong to
read:user, write:userList and revoke your own tokens via API
Tokens are shown once. Copy the value on the "generated" screen — after you leave the page, only the last 8 characters are shown. Lost tokens are revoked and re-issued, not recovered.

8. 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.

9. 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:

EndpointWhat
GET /api/v1/userWhoami — verify token works
POST /api/v1/user/reposCreate a repo under yourself
POST /api/v1/orgs/<org>/reposCreate 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>/branchesCopy a branch (rename via copy + delete)
Encoding gotcha. Descriptions with UTF-8 characters (em-dashes, curly quotes) need 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.

10. Where files live

git repos
VPS SSD at /data/git/repositories/<owner>/<repo>.git inside 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, bucket hephaestus-lfs
Release attachments, packages
Same Sia bucket, different key prefixes
Container images
Same Sia bucket, prefix packages/container/
Nightly backup
restic encrypts 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

11. 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.

12. Known gotchas

13. 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.

TaskAuth
Create the user account for the first timeWallet sign-in
Push, pull, clone, edit repo settingsPAT (write:repository)
Docker push / pull to the OCI registryPAT (write:package)
Create repos under an org, manage membersPAT (write:organization)
Revoke a compromised PATAny PAT with write:user, or admin
Rotate ownership (transfer everything to a new wallet)Wallet sign-in on the new address + admin transfer
Never share a wallet mnemonic across sessions. A parallel session doesn't need it. Give them a PAT with the narrowest scope that works, and revoke it independently when they're done.

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 shapeScopesRationale
CI job pushing container imageswrite:packageNothing else needed
Docs / content writerwrite:repositoryPush commits, edit metadata
Ops session managing an orgwrite:organization, write:repositoryCreate repos, adjust members
Analytics / read-only readerread:repository, read:packageZero write surface
Dedicated admin sessionAdmin account + write:organization, write:userFull 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.

14. Help + source