site/hephaestus.x/docs/index.html
Local Dev c5353e3e47 hephaestus.x: overlay side nav on home and docs
Home page: How it works, Source and Pantheon were in the topnav next
to cross-site tabs. Moved them plus Sign in and Verify into an overlay
sidebar on the left edge — same shape as Sirius.X and Theseus.X —
pointer-events:none, opacity 0.35 by default, hover brightens with a
blurred backdrop, active section painted acid green,
IntersectionObserver replaced with scroll-position tracking so the
final section still activates when the reader hits the page end.
Topnav collapses to the site tabs the header still owns: Hephaestus.X ·
Docs · Explore repos · Sign in.

Docs page: the two-column grid with an inline .toc left column has
been swapped for the same overlay rail. The 13 chapter titles now sit
at the left edge, keep their numbered mono prefix via CSS counter,
and stay legible under the semi-transparent hover backdrop. Content
column is centred at 820px so the reading axis is on the middle of
the viewport rather than shifted right by a sidebar. Icon rail at
64px on medium; sidebar hidden altogether on mobile because there is
no room for a 13-item rail.
2026-09-20 20:40:39 +02:00

555 lines
37 KiB
HTML
Raw Blame History

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hephaestus docs — how to sign in, push, pull, self-host</title>
<meta name="description" content="Hephaestus documentation: wallet sign-in, creating repos, the OCI container registry, personal access tokens, and self-hosting the whole stack from silentmode/hephaestus.">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="mask-icon" href="/favicon.svg" color="#d6ff3d">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600;9..144,700&family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Ubuntu:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#050810; --panel:#141a24; --panel2:#0a0f1c; --line:rgba(255,255,255,.06);
--sky-mid:#0a0f1c; --sky-rim:#131a2a;
--ink:#f1f4fa; --mut:#b8c2d4; --dim:#6a7488; --acid:#d6ff3d;
--chain-native:#0AC18E;
--forge:#ff8c00;
--serif:'Fraunces',Georgia,serif;
--sans:'DM Sans','Inter',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;
background:radial-gradient(ellipse 1200px 600px at 50% -5%,var(--sky-rim) 0%,transparent 60%),
radial-gradient(ellipse 900px 500px at 100% 100%,var(--sky-mid) 0%,transparent 55%),
radial-gradient(ellipse 900px 500px at 0% 100%,#0c1424 0%,transparent 55%),
var(--bg);
background-attachment:fixed;
color:var(--ink);font:16px/1.6 var(--sans);min-height:100vh}
h1,h2,h3,h4{font-family:var(--serif);font-feature-settings:"ss01","ss02"}
code,.mono{font-family:var(--mono)}
html[data-font-cmp="ubuntu"] body,
html[data-font-cmp="ubuntu"] h1, html[data-font-cmp="ubuntu"] h2, html[data-font-cmp="ubuntu"] h3, html[data-font-cmp="ubuntu"] h4,
html[data-font-cmp="ubuntu"] p, html[data-font-cmp="ubuntu"] a, html[data-font-cmp="ubuntu"] button,
html[data-font-cmp="ubuntu"] span, html[data-font-cmp="ubuntu"] div,
html[data-font-cmp="ubuntu"] label, html[data-font-cmp="ubuntu"] input, html[data-font-cmp="ubuntu"] li{font-family:'Ubuntu',system-ui,sans-serif !important}
html[data-font-cmp="ubuntu"] code, html[data-font-cmp="ubuntu"] .mono{font-family:var(--mono) !important}
.font-picker{display:inline-flex;align-items:center;gap:2px;padding:2px;margin:0 10px;
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:999px;
font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase}
.font-picker button{background:transparent;border:none;color:var(--dim);
padding:4px 10px;border-radius:999px;cursor:pointer;font-family:inherit;font-size:inherit;
letter-spacing:inherit;text-transform:inherit;transition:color .12s}
.font-picker button:hover{color:var(--ink)}
.font-picker button.active{background:var(--acid);color:var(--bg);font-weight:600}
a{color:var(--acid)}
.topnav{position:sticky;top:0;z-index:20;display:flex;gap:2px;align-items:center;flex-wrap:wrap;
padding:10px 1.2rem;background:rgba(5,8,16,.82);backdrop-filter:blur(10px)}
.topnav .brand{font-weight:600;color:var(--ink);text-decoration:none;margin-right:14px;font-size:15px;letter-spacing:.2px;display:inline-flex;align-items:center;gap:6px}
.topnav .brand .g{color:var(--forge)}
.topnav a{color:var(--mut);text-decoration:none;padding:6px 12px;border-radius:8px;font-size:14px}
.topnav a:hover{color:var(--ink);background:var(--panel)}
.topnav a.here{color:var(--acid);background:rgba(214,255,61,.08)}
.topnav .spacer{margin-left:auto}
/* Docs layout: single-column centred; the section rail is an overlay. */
.doc{max-width:820px;margin:0 auto;padding:2.4rem 1.2rem 4rem}
.content{min-width:0}
/* Adaptive side nav — overlay pattern shared with the rest of Silent Mode. */
:root{--sn-w:220px}
.sidenav{position:fixed;top:56px;left:0;bottom:0;width:var(--sn-w);z-index:15;
background:transparent;padding:32px 12px 22px 18px;
overflow-y:auto;pointer-events:none;transition:width .15s ease}
.sidenav a{display:flex;align-items:center;gap:10px;color:var(--mut);text-decoration:none;
padding:6px 10px;border-radius:8px;
font-size:13.5px;font-weight:500;opacity:.35;pointer-events:auto;line-height:1.35;
transition:opacity .12s,color .12s,background .12s,backdrop-filter .12s;counter-increment:t}
.sidenav a::before{content:counter(t) ". ";color:currentColor;font-family:var(--mono);font-size:11px;opacity:.7;flex:none}
.sidenav a .lb{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sidenav a:hover{opacity:1;color:var(--ink);background:rgba(5,8,16,.72);backdrop-filter:blur(8px)}
.sidenav a.active{opacity:.9;color:var(--acid)}
.sidenav a.active:hover{opacity:1}
.sidenav > .list{counter-reset:t;display:contents}
h2{scroll-margin-top:70px}
@media (max-width:1180px){
:root{--sn-w:52px}
.sidenav{padding:22px 4px}
.sidenav a{justify-content:center;padding:8px 2px;gap:0}
.sidenav a .lb{display:none}
}
@media (max-width:720px){
:root{--sn-w:0}
.sidenav{display:none}
}
h1{font-size:clamp(2rem,4vw,2.5rem);margin:.4rem 0 .3rem;line-height:1.05}
h1 .g{color:var(--forge)}
h2{font-size:1.35rem;margin:2.4rem 0 .6rem;padding-top:1rem;border-top:1px solid var(--line)}
h2:first-of-type{border-top:none;padding-top:0;margin-top:1.6rem}
h3{font-size:1.05rem;margin:1.4rem 0 .4rem;color:var(--ink);font-family:var(--sans);font-weight:600}
h4{font-size:.95rem;margin:1.1rem 0 .3rem;color:var(--ink);font-family:var(--sans);font-weight:600}
p,li{color:var(--mut);line-height:1.65}
.content b{color:var(--ink)}
.content strong{color:var(--ink)}
ul,ol{padding-left:1.2rem}
ul li,ol li{margin:.3rem 0}
hr{border:0;border-top:1px solid var(--line);margin:2rem 0}
code{background:#0e131b;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:13px;color:var(--ink)}
pre{background:#0e131b;border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto;
font-family:var(--mono);font-size:13px;color:var(--ink);margin:.9rem 0;line-height:1.55}
pre .cm{color:var(--dim)}
pre .k{color:var(--acid)}
pre .s{color:var(--chain-native)}
.note{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--acid);
border-radius:10px;padding:12px 16px;color:var(--mut);font-size:14px;margin:1rem 0}
.note.warn{border-left-color:#ffc75f}
.note.info{border-left-color:var(--forge)}
.note b{color:var(--ink)}
.note p{margin:.3rem 0}
dl.spec{display:grid;grid-template-columns:auto 1fr;gap:6px 22px;margin:.6rem 0;font-size:13.5px}
dl.spec dt{color:var(--dim);white-space:nowrap;font-family:var(--mono)}
dl.spec dd{margin:0;color:var(--mut)}
dl.spec dd b{color:var(--ink)}
table{width:100%;border-collapse:collapse;margin:1rem 0;font-size:13.5px}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--dim);font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;font-weight:500}
td{color:var(--mut)}
td b{color:var(--ink)}
td code{font-size:12px}
.kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
border-radius:4px;padding:0 6px;font-family:var(--mono);font-size:11.5px;color:var(--ink)}
.pill{display:inline-block;font-size:10.5px;padding:1px 8px;border-radius:999px;
background:rgba(255,140,0,.14);color:var(--forge);font-weight:500;text-transform:lowercase;letter-spacing:.02em;margin-left:6px;vertical-align:middle}
/* Multi-column footer (shared with landing — same look) */
footer.deep{border-top:1px solid var(--line);margin-top:4rem;padding:2.4rem 1.2rem 1.6rem;background:rgba(10,15,28,.4)}
footer.deep .fwrap{max-width:1080px;margin:0 auto;display:grid;grid-template-columns:1.4fr 1fr 1fr 1fr;gap:32px}
footer.deep h5{font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:0 0 .8rem;font-weight:500}
footer.deep .brand{font-family:var(--serif);font-size:20px;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:6px}
footer.deep .brand .g{color:var(--forge)}
footer.deep p{color:var(--dim);font-size:13px;line-height:1.55;margin:.6rem 0 0;max-width:280px}
footer.deep ul{list-style:none;padding:0;margin:0;display:grid;gap:6px;font-size:13.5px}
footer.deep a{color:var(--mut);text-decoration:none;transition:color .1s}
footer.deep a:hover{color:var(--ink)}
footer.deep .bar{max-width:1080px;margin:2rem auto 0;padding-top:1rem;border-top:1px solid var(--line);
display:flex;justify-content:space-between;gap:16px;font-size:12px;color:var(--dim);flex-wrap:wrap}
footer.deep .bar .mono{font-family:var(--mono);letter-spacing:.03em}
@media (max-width:820px){
footer.deep .fwrap{grid-template-columns:1fr 1fr;gap:24px}
}
@media (max-width:520px){
footer.deep .fwrap{grid-template-columns:1fr}
}
</style>
</head>
<body>
<nav class="topnav">
<a class="brand" href="../"><span>⚒️</span><span><span class="g">Hephaestus</span>.X</span></a>
<a href="./" class="here">Docs</a>
<a href="https://code.silentmode.st/explore/repos" rel="noopener">Explore repos</a>
<span class="spacer"></span>
<a href="https://code.silentmode.st/user/oauth2/hephaestus-wallet" style="color:var(--acid)">🔑 Sign in</a>
</nav>
<aside class="sidenav" aria-label="On this page">
<div class="list">
<a href="#what" ><span class="lb">What Hephaestus is</span></a>
<a href="#hosts" ><span class="lb">Two hostnames</span></a>
<a href="#signin" ><span class="lb">Sign in with a wallet</span></a>
<a href="#rename" ><span class="lb">Rename your username</span></a>
<a href="#repos" ><span class="lb">Create + push a repo</span></a>
<a href="#tokens" ><span class="lb">Personal access tokens</span></a>
<a href="#registry"><span class="lb">Container registry</span></a>
<a href="#api" ><span class="lb">API basics</span></a>
<a href="#storage" ><span class="lb">Where files live</span></a>
<a href="#selfhost"><span class="lb">Self-hosting</span></a>
<a href="#gotchas" ><span class="lb">Known gotchas</span></a>
<a href="#sessions"><span class="lb">CLI + GUI access</span></a>
<a href="#help" ><span class="lb">Help + source</span></a>
</div>
</aside>
<div class="doc">
<main class="content">
<h1>Hephaestus <span class="g">docs</span></h1>
<p style="font-size:1.05rem">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.</p>
<h2 id="what">1. What Hephaestus is</h2>
<p>Hephaestus is a <a href="https://forgejo.org/">Forgejo</a> 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.</p>
<dl class="spec">
<dt>runs on</dt> <dd>Docker Compose · Caddy + Postgres + Forgejo 10 + auth-proxy</dd>
<dt>sign-in</dt> <dd>BCH wallet signature → OIDC callback → Forgejo session</dd>
<dt>usernames</dt> <dd>Auto: <code>bch_&lt;first-20-of-your-cashaddr&gt;</code>. Renameable to anything else.</dd>
<dt>storage</dt> <dd>Live git + Postgres on SSD; LFS/releases/packages spill to Sia via s3.silentmode.st</dd>
<dt>source</dt> <dd><a href="https://code.silentmode.st/silentmode/hephaestus">silentmode/hephaestus</a> — MIT, open</dd>
</dl>
<h2 id="hosts">2. Two hostnames, one instance</h2>
<p>The same Forgejo is reachable at two addresses. Same account, same repos, same session cookie — differs only in how you got there:</p>
<table>
<tr><th>Host</th><th>How to reach it</th><th>Cert</th></tr>
<tr><td><b>hephaestus.x</b></td><td>A BCNR-aware browser (Theseus, Ariadne) resolves it directly, or through the public gateway at <a href="https://navigate.st/bns/hephaestus.x/"><code>navigate.st/bns/hephaestus.x/</code></a></td><td>Silent Mode Argonautica CA</td></tr>
<tr><td><b>code.silentmode.st</b></td><td>Regular DNS + Let's Encrypt. Works in any browser, no resolver setup</td><td>Let's Encrypt (public)</td></tr>
</table>
<div class="note info"><b>Which one to use?</b> If you're setting up an OAuth callback, a docker registry, or a git remote — use <code>code.silentmode.st</code>. It works everywhere. Use <code>hephaestus.x</code> when the whole path is BCNR-aware.</div>
<h2 id="signin">3. Sign in with a wallet</h2>
<p>The sign-in button gives you three routes:</p>
<ul>
<li><b>Sign up</b> — creates a fresh BIP-39 wallet in your browser, shows you the recovery phrase, and encrypts the wallet in <code>localStorage</code> with a passphrase you set. <span class="pill">easiest</span></li>
<li><b>Sign in</b> — you paste a 12- or 24-word recovery phrase you already have. Same wallet as anywhere else that speaks BIP-39.</li>
<li><b>WizardConnect</b> — sign with an external wallet (Cashonize / Paytaca) via an encrypted Nostr relay. Keys never leave the wallet. <span class="pill">most private</span></li>
</ul>
<h3>What happens on the wire</h3>
<ol>
<li>Forgejo redirects you to the auth-proxy's <code>/auth/authorize</code>.</li>
<li>Your browser POSTs <code>{cashaddr, state}</code> to <code>/auth/challenge</code> and gets back a nonce + message.</li>
<li>The wallet signs the message. Standard "Bitcoin Signed Message" (varint-prefixed magic + double-SHA256 + secp256k1 recoverable-compact + base64).</li>
<li>Browser POSTs <code>{nonce, signature, state}</code> to <code>/auth/verify</code>. The auth-proxy recovers the pubkey, re-hashes to a cashaddr, matches, mints an Ed25519-signed <code>id_token</code>, and hands back a Forgejo callback URL.</li>
<li>Following the callback lands you in the dashboard with a session cookie.</li>
</ol>
<h3>If you use the flow programmatically (no browser)</h3>
<p>Every step is a JSON POST. The wallet-signing bit is the only crypto: sign the exact <code>message</code> string the server returned — <b>no trailing newline</b>. See <a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/auth-proxy/src/verify.ts">auth-proxy/src/verify.ts</a> for the byte-perfect reference. A working sample lives in <a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/PROTOCOL.md">PROTOCOL.md</a>.</p>
<h2 id="rename">4. Rename your username</h2>
<p>The auto-generated <code>bch_&lt;addr-prefix&gt;</code> is a placeholder. Rename yourself to anything else — <code>BitcoinCash</code>, <code>alice</code>, whatever's not taken.</p>
<h4>From the UI</h4>
<p><a href="https://code.silentmode.st/user/settings">Settings → Profile → Username</a>. Change the field, save. All your repo URLs move to <code>/&lt;new-name&gt;/…</code> and the old URLs 307-redirect for a while so bookmarks survive.</p>
<h4>Allowed characters</h4>
<p>Forgejo names — usernames, org names, repo names — accept letters (mixed case), digits, hyphens, underscores, <b>and dots</b>. So all of these are valid:</p>
<ul>
<li><code>alice</code></li>
<li><code>game.x</code> <span class="pill">dots OK</span></li>
<li><code>Game.X</code></li>
<li><code>game-x</code></li>
<li><code>Silent_Mode</code></li>
<li><code>bch_qp3hvzy09lzcm46qm4cd</code></li>
</ul>
<p>Rejected: whitespace, slashes, and reserved routes like <code>assets</code>, <code>login</code>, <code>api</code>. If you're picking a name for an on-chain <code>.x</code> project, the natural mapping (<code>game.x</code> repo owner) works — no need to substitute a hyphen.</p>
<h4>From the admin API (if you're the operator)</h4>
<pre><span class="cm"># The endpoint expects FORM-encoded, not JSON. If you send JSON it silently returns 422 "NewName required".</span>
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/&lt;old-name&gt;/rename</pre>
<h2 id="repos">5. Create + push a repo</h2>
<p>Standard Forgejo. Nothing custom.</p>
<h4>Create</h4>
<ul>
<li>UI: <b>+</b> menu top-right → New Repository</li>
<li>API: <code>POST /api/v1/user/repos</code> with a token</li>
</ul>
<h4>Clone + push</h4>
<pre><span class="cm"># HTTPS — always works, no key setup</span>
git clone https://code.silentmode.st/&lt;you&gt;/&lt;repo&gt;.git
<span class="cm"># SSH — needs your key uploaded under Settings → SSH keys. Port is 2222, not 22.</span>
git clone ssh://git@code.silentmode.st:2222/&lt;you&gt;/&lt;repo&gt;.git</pre>
<div class="note"><b>Push over HTTPS</b> uses a Personal Access Token as the password (see §6). Do not paste a wallet passphrase there — it's a Forgejo token, not your wallet.</div>
<h2 id="tokens">6. Personal access tokens</h2>
<p><a href="https://code.silentmode.st/user/settings/applications">Settings → Applications → Generate New Token</a>. Pick the scopes you actually need — the token that pushes commits does not need <code>write:organization</code>.</p>
<table>
<tr><th>Scope</th><th>What it does</th></tr>
<tr><td><code>read:repository</code>, <code>write:repository</code></td><td>Clone / push git over HTTPS, use the repo API</td></tr>
<tr><td><code>read:package</code>, <code>write:package</code></td><td>Docker login + push/pull on the container registry</td></tr>
<tr><td><code>write:organization</code></td><td>Create repos under an org you belong to</td></tr>
<tr><td><code>read:user</code>, <code>write:user</code></td><td>List and revoke your own tokens via API</td></tr>
</table>
<div class="note warn"><b>Tokens are shown once.</b> 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.</div>
<h2 id="registry">7. Container registry (OCI)</h2>
<p>Forgejo speaks the standard OCI Distribution API at <code>code.silentmode.st/v2/</code>. Docker + <code>skopeo</code> + <code>buildah</code> + Kubernetes all work as-is.</p>
<h4>Log in</h4>
<pre>docker login code.silentmode.st -u &lt;your-username&gt;
<span class="cm"># password: a Personal Access Token with write:package scope</span></pre>
<h4>Push</h4>
<pre>docker build -t code.silentmode.st/&lt;owner&gt;/&lt;image&gt;:&lt;tag&gt; .
docker push code.silentmode.st/&lt;owner&gt;/&lt;image&gt;:&lt;tag&gt;</pre>
<p>Owner is a username or an org you belong to. Owner-org pushes need you to be an <b>Owners</b> team member; add via the admin API or the org settings page.</p>
<h4>Pull</h4>
<pre>docker pull code.silentmode.st/&lt;owner&gt;/&lt;image&gt;:&lt;tag&gt;</pre>
<p><b>Anonymous pull works for public repos.</b> The <code>/v2/</code> 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 <a href="https://home.runonflux.io/">Flux</a> can pull from Hephaestus without any credential setup.</p>
<h4>List packages</h4>
<pre>curl https://code.silentmode.st/api/v1/packages/&lt;owner&gt;?type=container</pre>
<h4>Worked example: publish an app's backend as an image</h4>
<p>Any backend that runs in a container publishes the same way. Node example — Dockerfile at the repo root:</p>
<pre><span class="cm"># Dockerfile</span>
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]</pre>
<p>Build + push in one pass:</p>
<pre>docker login code.silentmode.st -u &lt;you&gt;
<span class="cm"># password: PAT with write:package scope (see §6)</span>
docker build -t code.silentmode.st/&lt;owner&gt;/&lt;app&gt;:latest .
docker push code.silentmode.st/&lt;owner&gt;/&lt;app&gt;:latest</pre>
<p>The image is now pullable at <code>code.silentmode.st/&lt;owner&gt;/&lt;app&gt;:latest</code>. If the repo is public, Flux + friends can pull it anonymously (§7 above). Verify it in <a href="https://code.silentmode.st/-/packages">/-/packages</a> or via the list-packages API.</p>
<h2 id="api">8. API basics</h2>
<p>Full Swagger at <a href="https://code.silentmode.st/api/swagger">code.silentmode.st/api/swagger</a>. Auth is a token in a header:</p>
<pre>curl -H "Authorization: token $TOKEN" https://code.silentmode.st/api/v1/user
<span class="cm"># or a Bearer if you prefer:</span>
curl -H "Authorization: Bearer $TOKEN" https://code.silentmode.st/api/v1/user</pre>
<p>A few endpoints you'll want early:</p>
<table>
<tr><th>Endpoint</th><th>What</th></tr>
<tr><td><code>GET /api/v1/user</code></td><td>Whoami — verify token works</td></tr>
<tr><td><code>POST /api/v1/user/repos</code></td><td>Create a repo under yourself</td></tr>
<tr><td><code>POST /api/v1/orgs/&lt;org&gt;/repos</code></td><td>Create a repo under an org</td></tr>
<tr><td><code>PATCH /api/v1/repos/&lt;o&gt;/&lt;r&gt;</code></td><td>Change description / website / private flag</td></tr>
<tr><td><code>DELETE /api/v1/repos/&lt;o&gt;/&lt;r&gt;</code></td><td>Delete a repo (destructive, no undo)</td></tr>
<tr><td><code>POST /api/v1/repos/&lt;o&gt;/&lt;r&gt;/branches</code></td><td>Copy a branch (rename via copy + delete)</td></tr>
</table>
<div class="note info"><b>Encoding gotcha.</b> Descriptions with UTF-8 characters (em-dashes, curly quotes) need <code>Content-Type: application/json; charset=utf-8</code> AND a properly UTF-8-encoded body. Shell interpolation on Windows Git Bash silently CP1252-encodes and the char lands as <code><EFBFBD></code>. If that happens, PATCH again with a Python <code>urllib</code> caller or plain <code>curl --data-binary @body.json</code>.</div>
<h2 id="storage">9. Where files live</h2>
<dl class="spec">
<dt>git repos</dt> <dd>VPS SSD at <code>/data/git/repositories/&lt;owner&gt;/&lt;repo&gt;.git</code> inside the Forgejo container</dd>
<dt>Postgres</dt> <dd>SSD at <code>/var/lib/postgresql/data</code> — issues, PRs, users, sessions, tokens</dd>
<dt>LFS objects</dt> <dd>Sia via S3 at <code>s3.silentmode.st:8600</code>, bucket <code>hephaestus-lfs</code></dd>
<dt>Release attachments, packages</dt> <dd>Same Sia bucket, different key prefixes</dd>
<dt>Container images</dt><dd>Same Sia bucket, prefix <code>packages/container/</code></dd>
<dt>Nightly backup</dt> <dd><code>restic</code> encrypts the whole tree + Postgres dump to a separate Sia bucket, retained 30 days</dd>
<dt>DR mirror</dt> <dd>Static landing (this page) also mirrored to Sia bucket <code>bns/hephaestus.x/</code>, updated on redeploy</dd>
</dl>
<h2 id="selfhost">10. Self-hosting</h2>
<p>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.</p>
<pre><span class="cm"># 1. clone</span>
git clone https://code.silentmode.st/silentmode/hephaestus.git
cd hephaestus
<span class="cm"># 2. configure — copy .env.example, fill in your domain, Sia S3 creds, OIDC secret</span>
cp .env.example .env
$EDITOR .env
<span class="cm"># 3. run</span>
docker compose up -d
<span class="cm"># 4. first wallet signs in and takes the admin seat</span>
open https://your-domain.example/user/oauth2/hephaestus-wallet</pre>
<p>Full deploy notes: <a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/README.md">README</a>, and <a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/PROTOCOL.md">PROTOCOL.md</a> for the wire format.</p>
<h2 id="gotchas">11. Known gotchas</h2>
<ul>
<li><b>Push over HTTPS asks for a password.</b> That password is a <b>Personal Access Token</b>, not your wallet passphrase. Wallet passphrases decrypt the wallet in your browser's <code>localStorage</code>; they have nothing to do with Forgejo's git auth. See <a href="#repos">§5</a> for the git-remote URL and <a href="#tokens">§6</a> for how to generate the PAT.</li>
<li><b>Descriptions show as <code><EFBFBD></code></b> when shell → curl → API re-encodes UTF-8 as CP1252 (Windows Git Bash is the usual culprit). Use a Python <code>urllib</code> PATCH or <code>curl --data-binary @file</code>. Full recipe with the correct <code>Content-Type</code> header is in <a href="#api">§8 → "Encoding gotcha"</a>.</li>
<li><b>Signature mismatch on <code>/auth/verify</code></b> almost always means your message string got a trailing newline (Python <code>print</code>, Bash heredoc). Write the message to a file with <code>newline=''</code> and hash the file bytes directly.</li>
<li><b>OIDC callback URL uses port 3000</b> if Forgejo's <code>ROOT_URL</code> is unset. Setting <code>[server].ROOT_URL = https://code.silentmode.st/</code> in <code>app.ini</code> fixes it; browsers can't reach internal port 3000.</li>
<li><b>Cross-origin session cookies.</b> 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.</li>
<li><b>Renaming a user via admin API</b> takes <code>new_name</code> as a form field, not JSON. JSON returns 422 "NewName required". Full command in <a href="#rename">§4 → "From the admin API"</a>.</li>
<li><b>SSH port is 2222, not 22</b>, so the compose stack doesn't fight the host's sshd. Your git remote URL must include <code>:2222</code>.</li>
<li><b>Docker Hub rate limits</b> can bite <code>docker compose build</code> if you're unauthenticated. Log in once with <code>docker login</code> against Docker Hub before running the stack build.</li>
</ul>
<h2 id="sessions">12. CLI &mdash; Command Line Interface and GUI &mdash; Graphical User Interface</h2>
<p>Every task in this section works two ways: through the <b>GUI</b> (the Forgejo web pages at <code>code.silentmode.st</code>) or through the <b>CLI</b> (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.</p>
<h3>Wallet vs Personal Access Token — when to use each</h3>
<p>The wallet is for <b>bootstrapping the account</b> once, and for <b>recovering</b> 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.</p>
<table>
<tr><th>Task</th><th>Auth</th></tr>
<tr><td>Create the user account for the first time</td><td>Wallet sign-in</td></tr>
<tr><td>Push, pull, clone, edit repo settings</td><td>PAT (<code>write:repository</code>)</td></tr>
<tr><td>Docker push / pull to the OCI registry</td><td>PAT (<code>write:package</code>)</td></tr>
<tr><td>Create repos under an org, manage members</td><td>PAT (<code>write:organization</code>)</td></tr>
<tr><td>Revoke a compromised PAT</td><td>Any PAT with <code>write:user</code>, or admin</td></tr>
<tr><td>Rotate ownership (transfer everything to a new wallet)</td><td>Wallet sign-in on the new address + admin transfer</td></tr>
</table>
<div class="note info"><b>Never share a wallet mnemonic across sessions.</b> 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.</div>
<h3>Bootstrapping a new namespace for another session</h3>
<p>Say a parallel session needs to push code to <code>game.x/checkers</code>. Full recipe, five steps, no browser required for the human at all:</p>
<pre><span class="cm"># 1. Admin creates the org (one API call, no wallet involvement)</span>
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
<span class="cm"># 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_&lt;first-20-of-cashaddr&gt;`.</span>
<span class="cm"># 3. Admin adds that user to game.x/Owners</span>
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_&lt;addr&gt;
<span class="cm"># 4. That user generates a PAT with write:repository scope
# at Settings → Applications → Generate New Token.
# Hand only the PAT to the parallel session.</span>
<span class="cm"># 5. Session clones + pushes using the PAT as HTTPS password</span>
git clone https://code.silentmode.st/game.x/checkers.git
cd checkers && ... && git push</pre>
<h3>Programmatic wallet sign-in (mnemonic stays local)</h3>
<p>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 <code>wallets.json</code>, never gets pasted anywhere, never enters a chat log.</p>
<p>Sketch (Node + <a href="https://github.com/bitauth/libauth">@bitauth/libauth</a>):</p>
<pre>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;
<span class="cm">// 1. kick off OIDC → get state + redirect_uri</span>
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");
<span class="cm">// 2. challenge → nonce + message</span>
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();
<span class="cm">// 3. sign — Bitcoin Signed Message (see PROTOCOL.md for the exact bytes)</span>
const signature = signBitcoinMessage(child.privateKey, message); <span class="cm">// see PROTOCOL.md</span>
<span class="cm">// 4. verify → callback URL</span>
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();
<span class="cm">// 5. follow callback with a cookie-jar client → session cookie in hand
// → GET /user/settings/applications to mint a PAT for future use</span></pre>
<p>Reference implementations in the tree: <a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/auth-proxy/src/verify.ts"><code>auth-proxy/src/verify.ts</code></a> for byte-perfect signing, and <a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/PROTOCOL.md"><code>PROTOCOL.md</code></a> for the full wire format. Adapt for <code>bitcoincash:</code> / <code>bchreg:</code> by swapping the <code>prefix</code>.</p>
<h3>Sensible PAT scopes per session shape</h3>
<table>
<tr><th>Session shape</th><th>Scopes</th><th>Rationale</th></tr>
<tr><td>CI job pushing container images</td><td><code>write:package</code></td><td>Nothing else needed</td></tr>
<tr><td>Docs / content writer</td><td><code>write:repository</code></td><td>Push commits, edit metadata</td></tr>
<tr><td>Ops session managing an org</td><td><code>write:organization</code>, <code>write:repository</code></td><td>Create repos, adjust members</td></tr>
<tr><td>Analytics / read-only reader</td><td><code>read:repository</code>, <code>read:package</code></td><td>Zero write surface</td></tr>
<tr><td>Dedicated admin session</td><td>Admin account + <code>write:organization</code>, <code>write:user</code></td><td>Full backend</td></tr>
</table>
<p><b>Rotation habit:</b> 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 (<code>ci-fly-x-deploy</code>, <code>session-2026-09-20</code>) so you know which one to revoke when things move.</p>
<h2 id="help">13. Help + source</h2>
<ul>
<li>Source: <a href="https://code.silentmode.st/silentmode/hephaestus">silentmode/hephaestus</a> (MIT)</li>
<li>Wire-format spec: <a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/PROTOCOL.md">PROTOCOL.md</a></li>
<li>Auth-proxy verify reference: <a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/auth-proxy/src/verify.ts">auth-proxy/src/verify.ts</a></li>
<li>Silent Mode: <a href="https://silentmode.st/">silentmode.st</a></li>
<li>Get a <code>.x</code> name for your fork: <a href="https://silentmode.st/bns/sirius.x/">sirius.x</a></li>
</ul>
</main>
</div>
<footer class="deep">
<div class="fwrap">
<div>
<a class="brand" href="../"><span>⚒️</span><span><span class="g">Hephaestus</span>.X</span></a>
<p>A code forge you sign in to with a Bitcoin Cash wallet. Open source, self-hostable, backed by Sia.</p>
</div>
<div>
<h5>Product</h5>
<ul>
<li><a href="../">Home</a></li>
<li><a href="./">Docs</a></li>
<li><a href="https://code.silentmode.st/explore/repos" rel="noopener">Explore repos</a></li>
<li><a href="https://code.silentmode.st/user/oauth2/hephaestus-wallet">Sign in</a></li>
</ul>
</div>
<div>
<h5>Source + spec</h5>
<ul>
<li><a href="https://code.silentmode.st/silentmode/hephaestus">silentmode/hephaestus</a></li>
<li><a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/PROTOCOL.md">Wallet-auth PROTOCOL</a></li>
<li><a href="https://code.silentmode.st/silentmode/hephaestus/src/branch/master/auth-proxy">auth-proxy</a></li>
<li><a href="https://code.silentmode.st/api/swagger">API (Swagger)</a></li>
</ul>
</div>
<div>
<h5>Pantheon</h5>
<ul>
<li><a href="https://silentmode.st/bns/silentmode.x/" rel="noopener">Silentmode.X</a></li>
<li><a href="https://silentmode.st/bns/sirius.x/" rel="noopener">Sirius.X (names)</a></li>
<li><a href="https://silentmode.st/bns/theseus.x/" rel="noopener">Theseus.X (browser)</a></li>
<li><a href="https://silentmode.st/bns/fly.x/" rel="noopener">Fly.X</a></li>
</ul>
</div>
</div>
<div class="bar">
<span>A pillar of <a href="https://silentmode.st/">Silent Mode</a> · alpha on chipnet · one wallet, all of it</span>
<span class="mono">MIT · self-hostable</span>
</div>
</footer>
<script defer src="../js/theme.js"></script>
<script>
// Sidenav scroll-spy: highlight the section currently in view.
(() => {
const links = document.querySelectorAll(".sidenav a[href^='#']");
if (!links.length) return;
const entries = [];
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) entries.push({ el, link: a }); });
if (!entries.length) return;
const setActive = (link) => { links.forEach((l) => l.classList.remove("active")); link.classList.add("active"); };
const update = () => {
const doc = document.documentElement;
const scrollBottom = window.scrollY + window.innerHeight;
if (scrollBottom >= (doc.scrollHeight - 4)) { setActive(entries[entries.length - 1].link); return; }
const line = window.scrollY + window.innerHeight * 0.35;
let active = entries[0].link;
for (const e of entries) {
if (e.el.getBoundingClientRect().top + window.scrollY <= line) active = e.link;
else break;
}
setActive(active);
};
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
})();
</script>
</body>
</html>