Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

Tighten admin page copy and TUTORIAL.md; fix auto_init divergence trap

Copy pass: cut restating the same point across a lead paragraph, a hint,
and a success message. Cut rhetorical framing ("the one thing worth
understanding first", "genuinely the right long-term direction") in
favor of flat statements.

Fixes a real bug found while adding f3.cyberwild.org live: the "add a
peer" instructions said to create the peer's repo with auto_init: true,
matching the very first repo-creation step earlier in the doc. For a
peer joining an already-active federation this is wrong — auto_init
gives the repo an unrelated first commit, which graft correctly reads
as a real divergence against the federation's existing history and
refuses to push. Corrected to auto_init: false / omitted, with the
failure mode spelled out, and added the same note as a hint on the
/add-peer form itself.

+40 -42
+23 -25
TUTORIAL.md
··· 46 46 47 47 ### Sharing a token safely 48 48 49 - The token never belongs in `config.yaml` — only its file path does (`token_file`). The token itself lives in its own `chmod 600` file, owned by the user `graft` runs as. That much is enforced by `graft` itself (`config.Load` requires `token_file`, never accepts an inline value). Getting the token *into* that file safely is on you — a few rules: 49 + `config.yaml` never holds the token itself, only `token_file`. `graft` requires `token_file` and rejects an inline value. Getting the token into that file is on you: 50 50 51 - - **Scope it down.** `write:repository` + `write:issue` only — never an admin or org-wide token. A leaked scoped token can only touch the one repo it was made for. 52 - - **Never paste it into chat, email, or a ticket.** All three are permanently searchable logs the moment the secret lands in them, long after anyone remembers it's still live there. 53 - - **Prefer the peer's own hands on the keyboard.** The person who owns the Forgejo instance generates the token and writes it directly into `/etc/graft/<name>.token` themselves, over their own SSH session — it never transits through a third party (including you) at all. 54 - - **When a third party must relay it, use something that dies after one read** — a password manager's one-time-share feature (Bitwarden Send, 1Password Psst), or a self-hosted equivalent (`onetimesecret.com` is open source and self-hostable). Never a plain link with no expiry. 55 - - **Rotate it if you're ever unsure it stayed private** — regenerating a Forgejo token is free; guessing whether a six-month-old paste is still up is not. 51 + - Scope it to `write:repository` + `write:issue`. Never admin or org-wide. 52 + - Never paste it into chat, email, or a ticket. 53 + - Prefer the token owner writing it directly into `/etc/graft/<name>.token` over their own SSH session — no third party involved. 54 + - If a third party must relay it, use a one-time-secret tool (Bitwarden Send, 1Password Psst, self-hosted `onetimesecret.com`), not a plain link. 55 + - Rotate it if its exposure is ever in doubt. 56 56 57 - See [Built-in token exchange](#built-in-token-exchange) at the bottom — `graft` now has a one-time secret-share page for exactly this. 57 + See [Built-in token exchange](#built-in-token-exchange) — `graft` has a one-time secret-share page for this. 58 58 59 59 ## 3. Create the repo on Forgejo 60 60 ··· 138 138 139 139 ## Adding a peer to an existing federation 140 140 141 - Once `my-project` is mirrored between one Forgejo and one Radicle node, adding a second Forgejo instance to the same repo — a genuine federation, not just a pair — needs neither a new Radicle node nor any change to the two pairs already running. 141 + Adding a second Forgejo instance to a repo already mirrored between one Forgejo and one Radicle node needs no new Radicle node and no change to the pairs already running. 142 142 143 143 | Placeholder | Stands for | 144 144 |---|---| 145 145 | `git2.example.org` | the second Forgejo instance | 146 146 | `bob` | the account on it that owns the mirrored repo | 147 147 148 - **The one thing worth understanding first**: a Radicle repository (a RID) already replicates to every Radicle node that seeds it, automatically, regardless of `graft`. Pointing a second Forgejo pair's `radicle.http_base_url` at the *same* seed you're already using doesn't create a new relationship — that seed already has the content. Nothing to do on the Radicle side at all for this case; skip straight to the config. 148 + A Radicle RID replicates to every node that seeds it, independent of `graft`. Pointing a second pair's `radicle.http_base_url` at the same seed already in use doesn't create a new relationship — that seed already has the content. No Radicle-side step for this case. 149 149 150 - 1. On `git2.example.org`, create the repo (`auto_init: true`, same default branch) and a token, exactly as in step 2–3 above, scoped `write:repository` + `write:issue`, saved to its own file: 150 + 1. On `git2.example.org`, create the repo **without** `auto_init` (`auto_init: false` or omitted) and a token scoped `write:repository` + `write:issue`. `auto_init: true` gives the repo an unrelated first commit; `graft` sees that as a real divergence against the federation's existing history and refuses to push, logging "forgejo and rad diverged since last sync" (see Troubleshooting). An empty repo has no history to diverge from. 151 151 152 152 ```sh 153 153 echo "$TOKEN2" > /etc/graft/my-project-git2.token ··· 191 191 192 192 3. Restart: `sudo systemctl restart graft`. `config.Load` validates every field on startup — a typo surfaces immediately in the logs, not as a silent no-op. 193 193 194 - 4. Confirm: the status dashboard now shows `git2.example.org` as a second row under `my-project`. If it joined an already-active federation, its side may show cells colored differently from a normal commit/issue/patch — "source" (this is where content first entered the federation) or "replicated" (this side holds it without `graft` ever having logged a push here itself, because Radicle's own gossip — or a Forgejo Authorized Integration — got there first). Hover one for the date, the original side, and a link. 194 + 4. Confirm: the dashboard shows `git2.example.org` as a second row under `my-project`. A side with content but no direct push logged by `graft` shows colored cells instead of a normal commit/issue/patch cell — "source" (content originated here) or "replicated" (arrived via Radicle gossip or a Forgejo Authorized Integration, not a `graft`-logged push). Hover for date, origin, link. 195 195 196 196 ## Adding a brand-new Radicle node to the mesh 197 197 198 - Reusing an existing seed (above) needs nothing extra. Standing up a node nobody in the mesh has ever talked to is a two-step manual bootstrap — `graft`'s own Radicle node (`rad_home`) doesn't discover new peers on its own: 198 + Reusing an existing seed needs nothing extra. A node nobody in the mesh has talked to needs two manual steps — `graft`'s own Radicle node doesn't discover new peers on its own: 199 199 200 200 ```sh 201 201 rad node connect <new-node-id>@<new-node-address>:8776 202 202 rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 203 203 ``` 204 204 205 - Both matter. `rad node connect` is the first handshake — Radicle's peer discovery (`"peers": {"type": "dynamic"}` in `~/.radicle/config.json`) spreads pairs it already knows about, but a node nobody is yet connected to needs one explicit introduction. `rad seed` matters separately: a Radicle node's default seeding policy is to replicate *nothing* it isn't explicitly told to — being connected doesn't imply seeding. Skip either step and the new node stays either unreachable or silently empty. 205 + `rad node connect` is the first handshake — Radicle's peer discovery (`"peers": {"type": "dynamic"}` in `~/.radicle/config.json`) spreads pairs it already knows, but a never-connected node needs one explicit introduction. `rad seed` is separate: default seeding policy replicates nothing not explicitly told to. Skip either step and the node stays unreachable or empty. 206 206 207 207 ## Troubleshooting 208 208 ··· 216 216 217 217 ## Self-service dashboard actions 218 218 219 - The dashboard (next to the federation heatmap) has two buttons and a footer link that do the manual work above through a browser instead: 219 + Two buttons next to the federation heatmap, plus a footer link, do the above through a browser: 220 220 221 - - **`+ Add peer`** (`/add-peer`) — join an existing series. Pick the federation from a dropdown; its Radicle side (RID, seed, explorer) is filled in automatically, never asked for — see "Adding a peer to an existing federation" above for why that's always safe. Paste the new peer's Forgejo details and token, confirm with the admin password, done. Live within one `sync_interval`, no restart. 222 - - **`+ New repo`** (`/new-repo`) — the "Adding a brand-new Radicle node" + fresh-repo path, as an inline onboarding page: it spells out the manual prerequisites (create the Forgejo repo, generate a scoped token, `rad init`, note the RID) and then takes a form with everything once you've done them. 223 - - **`Share a secret, once`** (`/share/new`, footer link) — the one-time-secret page from the list below. Anyone can create a share (it's the mechanism *for* someone who isn't the admin to safely hand a token over); only claiming requires the passcode. 221 + - **`+ Add peer`** (`/add-peer`) — join an existing series. Radicle side (RID, seed, explorer) is filled in from the chosen series, not asked for. Repo must exist on the peer, empty (no auto-init — see above). Submit Forgejo details, token, admin password. Live within one `sync_interval`. 222 + - **`+ New repo`** (`/new-repo`) — prerequisites listed inline (create the Forgejo repo, generate a scoped token, `rad init`, note the RID), then a form. 223 + - **`Share a secret, once`** (`/share/new`, footer) — the one-time-secret page below. Creating a share needs no password; claiming needs the passcode. 224 224 225 - Both onboarding forms are gated by `admin_password` in `config.yaml` (defaults to `graft` — `graft` logs a warning at startup if it's still set to that; change it). Checked in constant time, rate-limited (5 wrong attempts per IP or 20 total across all IPs, per 15 minutes) — a wrong guess never reveals which field was wrong, and the limiter, not the password's strength, is what actually stands between a stranger and brute-forcing a short shared secret. 225 + Both onboarding forms check `admin_password` from `config.yaml` (default `graft`; `graft` logs a startup warning if unchanged). Constant-time comparison, rate-limited: 5 wrong attempts per IP or 20 total, per 15 minutes. 226 226 227 227 ## Built-in token exchange 228 228 229 - Two of the ideas from the token-sharing menu below are live; the rest are recorded for when they're worth it. 230 - 231 - **Built**, both reachable from the dashboard (see above): 229 + **Built**, reachable from the dashboard: 232 230 233 - - **One-time secret share.** `POST /share/new` encrypts whatever's pasted (AES-256-GCM), keyed from a passcode *and* the share's own random id together — the id is never stored in reversible form, only its hash, so a full database dump alone can never derive the key. `GET/POST /share/<id>` claims it: right passcode reveals it once and destroys the record immediately; five wrong passcodes destroy it too, fail-closed; 15 minutes unclaimed and it's gone regardless. 234 - - **QR code.** `/share/<id>/qr.png` — encodes the claim link only, not the passcode (still meant to travel over a separate channel); a convenience for scanning off someone's screen on a call rather than copy-pasting a link. 231 + - **One-time secret share.** `POST /share/new` encrypts the input with AES-256-GCM, keyed from a passcode and the share's own random id together. Only the id's hash is stored — a database dump alone can't derive the key. `GET/POST /share/<id>` claims it: right passcode reveals it once and deletes the record; 5 wrong passcodes deletes it too; 15 minutes unclaimed and it's gone. 232 + - **QR code.** `/share/<id>/qr.png` encodes the claim link only, not the passcode. 235 233 236 234 **Not built:** 237 235 238 - - **A wormhole-style PAKE exchange** (the protocol behind [`magic-wormhole`](https://github.com/magic-wormhole/magic-wormhole)): both admins type the same short human-readable code into a small CLI, and a key exchange derives a shared secret from the code without either the code or the token ever crossing the wire in the clear — `graft`'s own server, even if fully compromised, never sees the plaintext token at any point. The strongest guarantee here, and the most to build (a relay endpoint speaking the handshake, and both sides need shell access rather than just a browser). Deliberately skipped for now: the one-time-share page above already closes the gap it was meant to close, at a fraction of the engineering cost, and re-litigating that trade only makes sense if token handoffs become frequent enough that the share page's residual risk (a compromised link *and* a brute-forced passcode, however rate-limited) stops being acceptable. 236 + - **Wormhole-style PAKE exchange** (the protocol behind [`magic-wormhole`](https://github.com/magic-wormhole/magic-wormhole)): both sides type the same short code into a CLI; the key exchange derives a shared secret without the code or the token crossing the wire in the clear. `graft`'s server never sees the plaintext token. Most to build — a relay endpoint for the handshake, shell access on both sides. Skipped: the share page above covers the same case at a fraction of the cost. 239 237 240 - - **Skip the handoff entirely.** The most elegant fix doesn't share a secret better — it avoids minting a long-lived one a human has to move at all. If Forgejo ever exposes a delegated token-creation flow (the peer's admin clicks a link, logs into *their own* Forgejo, and a token scoped to exactly `write:repository` + `write:issue` on exactly that repo is minted and handed to `graft` server-to-server), no plaintext secret ever needs to touch a clipboard, a chat window, or a QR code in the first place. This is genuinely the right long-term direction — everything built above, share page included, is a workaround for a token having to exist as a string a human copies, and this is the only option that makes that string never exist. It isn't built because it depends on Forgejo's own OAuth2/application-token APIs supporting a delegated, scope-limited minting flow that a third party can drive — worth checking directly against a current Forgejo instance before investing in it, since the answer decides whether this is a small integration or a real feature request upstream. 238 + - **Skip the handoff entirely.** If Forgejo exposes a delegated token-creation flow (peer admin clicks a link, logs into their own Forgejo, a token scoped to exactly `write:repository` + `write:issue` on exactly that repo is minted and handed to `graft` server-to-server), no plaintext secret touches a clipboard, a chat window, or a QR code. Depends on Forgejo's OAuth2/application-token APIs supporting delegated, scope-limited minting driven by a third party — unverified against a current Forgejo instance.
+10 -10
internal/admin/onboard.go
··· 20 20 21 21 var addPeerTmpl = template.Must(template.New("add-peer").Parse(` 22 22 <h1>Add a peer to an existing federation</h1> 23 - <p class="lead">A new Forgejo instance joining a repo that's already mirrored elsewhere. It reuses the federation's existing Radicle side automatically — Radicle already replicates to it — so nothing about Radicle needs to be entered here.</p> 23 + <p class="lead">Joins a Forgejo instance to a repo already mirrored elsewhere. Radicle side (RID, node, explorer) is inherited from the chosen series — not entered here.</p> 24 24 {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 25 25 <div class="card"> 26 26 <form method="POST" action="/add-peer"> ··· 29 29 <option value="">— choose —</option> 30 30 {{range .Series}}<option value="{{.}}" {{if eq . $.SelectedSeries}}selected{{end}}>{{.}}</option>{{end}} 31 31 </select> 32 - <div class="hint">The RID, Radicle node and explorer this peer joins are inherited from the series you pick.</div> 33 32 34 33 <label for="name">Pair name</label> 35 34 <input type="text" id="name" name="name" required maxlength="80" pattern="[a-zA-Z0-9._-]+" value="{{.Name}}" placeholder="e.g. federation-x-gitadmin"> ··· 46 45 </div> 47 46 <label for="repo">Repository</label> 48 47 <input type="text" id="repo" name="repo" required value="{{.Repo}}"> 48 + <div class="hint">Create this repo empty on the peer — no auto-init. An auto-init commit diverges from the federation's history and blocks the sync.</div> 49 49 50 50 <label for="token">Forgejo token</label> 51 51 <textarea id="token" name="token" required placeholder="scoped to write:repository + write:issue"></textarea> 52 - <div class="hint">Written to its own chmod-600 file on save — never kept in this form or logged. Got it from someone else safely? See <a href="/share/new">one-time share</a>.</div> 52 + <div class="hint">Written to its own chmod-600 file on save. Not kept in this form, not logged. From a third party: use <a href="/share/new">one-time share</a>.</div> 53 53 54 54 <label>Sync scope</label> 55 55 <div class="checks"> ··· 60 60 61 61 <label for="password">Admin password</label> 62 62 <input type="password" id="password" name="password" required autocomplete="off"> 63 - <div class="hint">Confirms this addition is authorized. Active within one sync interval once accepted.</div> 63 + <div class="hint">Active within one sync interval. No restart.</div> 64 64 65 65 <button type="submit">Add peer</button> 66 66 </form> ··· 69 69 70 70 var newRepoTmpl = template.Must(template.New("new-repo").Parse(` 71 71 <h1>Start a new repo</h1> 72 - <p class="lead">Mirroring a repo graft has never seen before, on both a Forgejo instance and a Radicle node.</p> 72 + <p class="lead">Mirrors a repo on a new Forgejo instance and a new Radicle node.</p> 73 73 <ol class="steps"> 74 - <li>On your Forgejo instance, create the repo (give it one initial commit) and generate a token scoped to <code>write:repository</code> + <code>write:issue</code>.</li> 75 - <li>Clone it locally and run <code>rad init --name &lt;repo&gt; --default-branch &lt;branch&gt; --public</code> — note the RID it prints (<code>rad:z...</code>).</li> 76 - <li>If other Radicle nodes should also carry it, tell them to <code>rad seed &lt;rid&gt;</code>.</li> 77 - <li>Fill in what you just created below.</li> 74 + <li>On the Forgejo instance: create the repo with one initial commit. Generate a token scoped to <code>write:repository</code> + <code>write:issue</code>.</li> 75 + <li>Clone it locally. Run <code>rad init --name &lt;repo&gt; --default-branch &lt;branch&gt; --public</code>. Note the RID (<code>rad:z...</code>).</li> 76 + <li>To replicate to other Radicle nodes: <code>rad seed &lt;rid&gt;</code> on each.</li> 77 + <li>Fill in the form below.</li> 78 78 </ol> 79 79 {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 80 80 <div class="card"> ··· 128 128 129 129 var onboardOKTmpl = template.Must(template.New("onboard-ok").Parse(` 130 130 <h1>{{.Title}}</h1> 131 - <div class="notice good">{{.Name}} is saved and will start mirroring within the next sync interval — no restart needed.</div> 131 + <div class="notice good">{{.Name}} saved. Active within one sync interval. No restart.</div> 132 132 <p><a class="nav-link" href="/">&larr; Back to the dashboard</a></p> 133 133 `)) 134 134
+7 -7
internal/admin/share.go
··· 115 115 116 116 var shareNewTmpl = template.Must(template.New("share-new").Parse(` 117 117 <h1>Share a secret, once</h1> 118 - <p class="lead">Paste a token or anything else sensitive below. You'll get back a one-time link and a separate 6-digit passcode — hand the link and the passcode to the recipient over two <em>different</em> channels (a link over chat, the passcode read aloud on a call, say), so intercepting one alone is useless. The secret is encrypted, shown to the recipient exactly once, and then destroyed — including if they enter the wrong passcode five times.</p> 118 + <p class="lead">Encrypts the text below. Returns a one-time link and a separate 6-digit passcode. Send them over two different channels. Destroyed on first successful claim, after 5 wrong passcodes, or after 15 minutes.</p> 119 119 {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 120 120 <div class="card"> 121 121 <form method="POST" action="/share/new"> 122 122 <label for="secret">Secret</label> 123 123 <textarea id="secret" name="secret" required maxlength="8000" placeholder="the token, pasted as-is"></textarea> 124 - <div class="hint">Never stored in reversible form without a passcode only the recipient will have.</div> 124 + <div class="hint">Stored encrypted. The passcode required to decrypt it is never stored.</div> 125 125 <button type="submit">Create one-time link</button> 126 126 </form> 127 127 </div> ··· 129 129 130 130 var shareCreatedTmpl = template.Must(template.New("share-created").Parse(` 131 131 <h1>Share created</h1> 132 - <div class="notice good">This is the only time both the link and passcode are shown together. Send them to the recipient now, over two separate channels.</div> 132 + <div class="notice good">Link and passcode shown together only this once. Send them over two separate channels.</div> 133 133 <div class="card"> 134 134 <label>One-time link</label> 135 135 <div class="secret-box"><a href="{{.URL}}">{{.URL}}</a></div> 136 136 <label>Passcode</label> 137 137 <div class="passcode">{{.Passcode}}</div> 138 - <label>QR code (opens the link — the recipient still enters the passcode by hand)</label> 138 + <label>QR code (link only — passcode still entered by hand)</label> 139 139 <div style="text-align:center;margin-top:.5rem;"><img src="/share/{{.ID}}/qr.png" width="200" height="200" alt="QR code for the one-time link"></div> 140 - <div class="hint">Expires in 15 minutes if never claimed. Claimed exactly once — a second visit shows nothing.</div> 140 + <div class="hint">Expires in 15 minutes if unclaimed. One claim only.</div> 141 141 </div> 142 142 `)) 143 143 144 144 var shareClaimTmpl = template.Must(template.New("share-claim").Parse(` 145 145 <h1>Claim a shared secret</h1> 146 - <p class="lead">Enter the 6-digit passcode you were given separately from this link. It can only be tried a handful of times before the secret is destroyed.</p> 146 + <p class="lead">Enter the 6-digit passcode. Limited attempts before the secret is destroyed.</p> 147 147 {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 148 148 <div class="card"> 149 149 <form method="POST" action="/share/{{.ID}}"> ··· 156 156 157 157 var shareRevealedTmpl = template.Must(template.New("share-revealed").Parse(` 158 158 <h1>Secret</h1> 159 - <div class="notice good">Shown once. It's already deleted — copy it now, this page won't show it again on refresh.</div> 159 + <div class="notice good">Shown once. Copy it now.</div> 160 160 <div class="card"> 161 161 <div class="secret-box">{{.Secret}}</div> 162 162 </div>