Commits
- Dashboard: a mirror with no live sync error now renders the series' full
distinct-event set (real cells where it logged the push itself, synthetic
elsewhere), so mirrors genuinely holding the same content look aligned
instead of one appearing behind due to which host happened to get logged.
A mirror with a real, current sync error skips that fill and shows only
what it has confirmed, plus a small red badge (tooltip carries the error).
- admin: .row (paired form fields) now wraps on narrow viewports instead of
overflowing horizontally — fixes New repo's layout on mobile.
- Comments: the mirror-loop guard now checks an invisible marker
(state.MarkMirrored/IsMirroredComment) instead of matching the visible
"**via Forgejo:**" etc. prefix text, so a real comment that happens to
start with the same phrase is never mistaken for graft's own mirror.
The migration adding dynamic_repo.approved defaulted every row to 0,
including rows that were already materialized (live) under the old
model — federation-x-f3 among them. AllDynamicRepos now filters on
approved=1, so on the very next restart after this deploy, f3 silently
dropped out of live.syncers entirely: no error, no log line, just gone.
Caught live: its README stayed stuck for ~35 minutes across a restart
while f1/f2/bimr kept moving, and it sat invisible in /admin/pending
(a peer that had been live for hours suddenly asking to be
"re-approved" is exactly the tell).
Fixed with a startup backfill — UPDATE dynamic_repo SET approved = 1
WHERE materialized = 1 AND approved = 0 — idempotent (materialized
never reverts to 0), so safe to run unconditionally every startup, not
gated on "is this the first run after the column was added." Verified
with a test that closes and reopens a store with a pre-existing
materialized row and confirms it survives migration approved; verified
live: f3 reloaded on restart, dropped out of the pending queue, and
resumed picking up new commits.
Also: dropped the Radicle-side sentence from /add-peer's lead
paragraph (redundant with the field-level hint already there).
Documents the optional-password behavior on all three onboarding forms
(blank = pending request, correct = instant activation, wrong =
rejected outright), the new /admin/pending review page, and the third
dashboard button (+ Add Radicle peer) added alongside it. Also
corrects an inaccuracy the rewrite would otherwise have kept: the
share-claim passcode was described as an admin_password check: it
isn't one, it's a separate secret verified by AES-GCM decryption, not
a stored comparison.
Two mechanisms now coexist on the same forms, chosen by whether the
optional admin password field is filled in:
- Left blank: the submission becomes a pending request (dynamic_repo /
pending_radicle_peer, approved=0) that does nothing on its own — no
sync, no rad node connect, nothing touches f1/f2 or any Radicle node.
A stranger can propose a peer without ever knowing the admin password.
- Filled in and correct: unchanged from before — goes live immediately
(approved right away for a Forgejo peer/new repo; rad node connect +
rad seed run immediately for a Radicle peer). Still the right tool for
handing the password to a trusted group (a class, a workshop) who
should self-serve without a review step.
- Filled in and wrong: rejected outright, never silently downgraded to
a pending request — an oracle for guessing the password would defeat
the point of the existing rate limiter.
New /admin/pending: the only place the password gates anything now
*for the request path* — a stateless, no-session review list (every
Approve/Reject is its own password-carrying POST, consistent with how
/share and the rest of the admin surface already work) covering both
dynamic_repo requests and pending Radicle peer requests in one list.
Rejecting a Forgejo/repo request also deletes its already-written token
file, so a rejected submission leaves nothing behind.
dynamic_repo gets an `approved` column; UnmaterializedDynamicRepos and
the startup reload (AllDynamicRepos) now both filter on it, so a
pending-but-unapproved row can never be picked up by the sync loop by
accident. New pending_radicle_peer table for the Radicle-peer request
path, which needed no ongoing-pair storage at all — approval just runs
the two rad commands once and the row is gone.
Verified: 9 new functional tests (submission needs no password; wrong
password rejects outright rather than queuing; the pending review page
requires the password and never leaks data without it; approve/reject
only take effect with the correct password; approving activates a row;
rejecting removes it and its token file; the optional-password instant
path still works exactly as before; a Radicle approval fails gracefully
with no real node reachable) plus a live round trip against production
(submit with no password, confirm it's queued and invisible without
the admin password, reject it, confirm it's gone) and a Playwright pass
confirming zero CSP violations on the new review page.
New /add-radicle-peer: connects a new Radicle node to the mesh and
seeds an existing federation's RID (rad node connect + rad seed on
graft's own node), gated by the same admin password + rate limiter as
the Forgejo onboarding forms. No DB row needed — it's a one-time
operational action, not an ongoing sync pair. Cross-linked with
/add-peer; dashboard gets a third button.
Fixes a real bug caught live: federation-x-f3 (added via /add-peer
earlier) silently dropped out of the sync loop on every graft restart
since then — main() only rebuilt live RepoSyncers for dynamic_repo rows
still unmaterialized, never for ones a previous run had already
materialized, so a dynamic peer's syncer simply ceased to exist after
any deploy. Confirmed via f3's README staying stale for ~50 minutes
across several restarts while f2/bimr updated normally. Fixed with
loadAllDynamicRepos, called once at startup regardless of the
materialized flag — deliberately non-fatal on a load failure (unlike a
bad static config.yaml pair), since the self-service path exists
specifically to tolerate transient failures, not turn one peer's
hiccup into a full outage. Verified live: f3 reloaded on restart,
caught up on a real README change within one sync pass.
Also: dropped three redundant/stale sentences from the onboarding page
copy (a leftover "not entered here" clause, a since-superseded
auto-init warning now covered by the Adding-a-peer docs instead, and a
"no restart" hint repeated between the form and its own success page),
and "Pair name" -> "Peer name" for consistency with the rest of the UI.
Left over from testing the push-github failure-notification path.
Expanded description to include web-UI and additional integrations.
Dashboard: removed the separate "source"/"replicated" cell colors and
legend entries. Cells now always color by event kind (commit, issue,
patch, comment) — the fact that a side holds content without a
graft-logged push of its own (Radicle gossip, an Authorized Integration,
or simply being the origin) is now a tooltip note only, never a visual
category of its own.
New "comment" kind, tracked from four origins, each recorded in a new
activity_log.origin column and shown as a tooltip note (never a color):
- Forgejo <-> Radicle, native: internal/sync/comments.go polls both
sides' comments each pass and cross-mirrors new ones, prefixed
"**via Forgejo:**" / "**via Radicle:**". Dedup via a new comment_seen
table (content hash, scoped per repo_pair+kind+item) plus a prefix
guard against re-mirroring graft's own output — verified live: a
native Forgejo comment mirrored to Radicl<e exactly once, confirmed
stable (no duplicate) across multiple subsequent sync passes.
- ActivityPub: handleReply now logs its existing comment bridge as a
dashboard activity (previously invisible on the dashboard).
- AT Proto: new inbound reply polling (pollBlueskyReplies in
cmd/sync/bluesky_replies.go), using app.bsky.notification.listNotifications.
NOT verified against a live account — none is configured in this
deployment; built directly from AT Proto's published lexicon. Required
extending atproto.Client.Post and sync.BlueskyPoster.Post to return
the new post's AT-URI (new atproto_post table) so a later reply's
parent can be traced back to the activity it was about, and
state.Store.LogActivity to return the new row's id.
Bug caught by a unit test before deploy: the three new comment/reply
truncation helpers sliced on byte index, which both overshot the byte
bound (a 3-byte "…" past a 200-byte cutoff) and could cut a multi-byte
UTF-8 rune in half — fixed to slice on runes, matching the pattern
atproto.Client.Post already used correctly.
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.
Documents the two new dashboard buttons and the one-time share feature
shipped in the previous commit, and splits the former "menu of options"
into what's actually built (share page + QR) vs. still just an idea
(PAKE exchange, delegated Forgejo token minting).
New internal/admin package, wired into the dashboard:
- POST /share/new + GET/POST /share/<id>: a one-time secret share.
AES-256-GCM key derived from the share's own high-entropy id plus a
6-digit passcode, neither of which is ever stored together — only the
id's hash goes in the database, so a full DB dump alone can never
derive the decryption key. Destroyed on first successful claim, after
5 wrong passcodes, or after 15 minutes, whichever comes first. QR code
(pure-Go, github.com/skip2/go-qrcode) encodes the claim link only —
the passcode is still meant to travel over a separate channel.
- GET/POST /add-peer: join an existing federation. Picks up the
series' Radicle side (RID, seed, explorer) automatically — nothing
Radicle-related is ever user-supplied for this flow, since Radicle
already replicates to whatever's seeding that RID.
- GET/POST /new-repo: start a repo graft has never seen, with the
manual prerequisite steps (create the Forgejo repo + token, rad init,
note the RID) spelled out inline before the form.
Both onboarding forms are gated by a single shared admin password
(config.yaml's admin_password, defaulting to "graft" with a startup log
warning when left at that default) checked in constant time, backed by
a rate limiter — 5 failures per IP or 20 globally per 15-minute window,
independent of X-Forwarded-For accuracy. A submitted token is written to
its own chmod-600 file under the same directory static pairs already
use, following the existing token_file convention exactly; only the
path is ever persisted to the database.
Submissions land in a new dynamic_repo table and go live on the very
next sync pass — no restart. cmd/sync/main.go now tracks all runtime
registration (syncers, topology, AI-detection state) behind a mutex,
since it's written from the ticker goroutine and read from HTTP handler
goroutines; each pass checks for newly-approved dynamic repos, builds a
RepoSyncer for each, and rebuilds the dashboard's topology and
Authorized-Integration detection to include them.
CSP's form-action tightened from 'none' to 'self' — the new pages are
plain HTML forms posting back to graft itself; 'none' would have
silently blocked every one of them.
Verified: 9 functional tests (share round-trip, one-time destruction,
max-attempts destruction, wrong-password rejection, rate limiting
blocking even a correct password once tripped, successful add-peer/
new-repo with real DB row + token file content/permissions, QR
generation, clean GET rendering) plus a live production round trip
(share create/claim/QR, wrong-password rejection leaving no trace) and
a Playwright pass confirming zero CSP violations on all three new pages.
Three additions to TUTORIAL.md, none of them code:
- "Sharing a token safely": scope it down, never paste into chat/email/
a ticket, prefer the peer writing it themselves over their own SSH
session, use a one-time-secret tool when a third party must relay it,
rotate on any doubt.
- "Adding a peer to an existing federation": the actual steps behind
what's already running in production (a second Forgejo pair joining
an existing series, reusing the same RID/seed rather than standing up
new Radicle infra) — plus what the dashboard's source/replicated cells
mean when a newly-joined side shows them.
- "Adding a brand-new Radicle node to the mesh": rad node connect (the
one-time introduction dynamic peer discovery needs) and rad seed
(seeding policy defaults to block — connected doesn't imply seeded).
- "Future: built-in token exchange": a menu of options graft could
implement instead of leaning on outside tools — a one-time-secret
page with a 6-digit passcode, a QR-code variant for live handoffs, a
magic-wormhole-style PAKE exchange, or skipping the handoff entirely
via a delegated Forgejo token-minting flow. None built; recorded for
when it's worth picking one.
CSP: default-src 'none'; style-src 'unsafe-inline'; img-src 'self';
connect-src 'self'; base-uri 'none'; form-action 'none';
frame-ancestors 'none'. Validated with Playwright against the actual
dashboard/social HTML before shipping: a negative control (no style-src
allowance) reproduced the predicted breakage — transparent background,
no card borders, a genuine CSP console violation — proving the test
methodology catches what it's meant to; the shipped policy renders
identically to the no-CSP baseline, confirmed both locally and against
the live production response. style-src allows inline because both
pages' entire stylesheet is one static <style> block nothing is ever
injected into — no practical downside to allowing it specifically,
versus a per-page hash that would silently break on the next CSS edit.
Dashboard: the "source"/"replicated" states no longer render as a
separate pill that breaks the heatmap's visual language. Each side that
holds content without a graft-logged push of its own now gets real
cells — same shape as a commit/issue/patch cell, same hover tooltip,
just a different color (new --kind-source/--kind-replicated cell
colors, previously only used in the legend). Built from a dedup map of
every distinct event logged anywhere in the series (keyed by commit SHA
where content-addressed, kind+summary otherwise), so a side sees exactly
the same commits/issues its siblings logged — with a same-host link
reconstructed from the commit SHA where possible, and a tooltip note
naming which side actually logged the push and when.
X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Permissions-Policy, Cross-Origin-Opener-Policy, and a conservative HSTS
(180 days, no includeSubDomains, no preload) — the nuclei/nikto findings
that carry no realistic regression risk for a session-less, form-less,
auto-escaped dashboard.
CSP and Cross-Origin-Embedder-Policy are deliberately left out: the
dashboard's stylesheet is one inline <style> block that a naive CSP would
break outright, and COEP protects cross-origin-isolated contexts graft
has no use for. Both need real testing before shipping, not a blanket
header.
Addresses every finding from the full-codebase security audit:
- CRITICAL: persistent SSRF via ActivityPub actor/inbox URLs. New
internal/activitypub/ssrf.go validates every externally-supplied URL
(https only, rejects loopback/private/link-local/metadata addresses)
and re-validates the resolved IP at dial time to close the DNS-rebinding
gap a resolve-then-connect check would leave open. Wired into
FetchActor, NewHandler's client, and DeliverNewActivity's per-follower
delivery loop.
- CRITICAL: state.db (holds every series' ActivityPub private key in
plaintext) was world-readable (644) on the live deployment. Open() now
hard-fails if it can't chmod the file to 600.
- HIGH: no replay protection on inbound HTTP Signatures. VerifyRequest
now rejects requests whose Date header is more than 5 minutes stale.
- MEDIUM: unbounded upstream response bodies embedded whole into error
messages/logs (Forgejo, AT Proto, ActivityPub clients) — capped at
4 MiB/1 MiB read limits with logged bodies truncated to 500 bytes.
- MEDIUM: subprocess calls (git, rad) had no timeout, so one hung command
could wedge the whole serial sync loop — bounded to 5 minutes via
context.WithTimeout/exec.CommandContext.
- MEDIUM: Authorized Integrations detection silently disables graft's own
push forever once triggered, with no bound — now falls back to pushing
directly after 10 consecutive skipped passes.
Also fixes a dashboard accuracy issue: a mirror side with zero
graft-logged events read as "nothing synced" even when it plainly held
the content — the Forgejo side changes originate on (graft only logs the
pushes it makes, never the pushes it reads from), or a side that received
content via a path graft doesn't log itself (Radicle's own peer-to-peer
gossip, or a Forgejo-to-Forgejo Authorized Integration). Those sides now
show a distinct "source" or "replicated" badge instead, with a legend
entry for each; "nothing synced" is reserved for a series with truly zero
activity anywhere. Also: renamed the dashboard subtitle to "Forgejos &
Radicles syncs", dropped the redundant "Activity by repo" heading, moved
the "last 3 days" window label to the Recent Activity header, and renamed
"Social bridges" to "Social IDs" throughout.
authint.go's HasAuthorizedIntegration scans a Forgejo repo's
.forgejo/workflows/*.yml for a job with enable-openid-connect: true that
references another pair's host — the only signal available for this
without admin API access to the trust relationship itself.
At startup, detectAuthorizedIntegrations checks every pair of Forgejo
hosts sharing a series. When one side already has a workflow pushing
directly into the other, GitSyncer.AuthorizedIntegrationSource tells the
receiving pair to stop relaying that content via Radicle itself — the
direct CI path delivers it faster anyway. Genuine divergences still get
reported exactly as before; only the two unambiguous "the other side is
simply behind" cases are skipped. No Forgejo host in the current config
has such a workflow, so this is detected-but-inactive today, exactly as
it should be.
Also: dashboard label "N mirrored sides" -> "N mirrors".
That section described a planned feature that was never actually
requested — removing it rather than leaving unrequested scope in the
docs. A real mechanism (detect + rely on Authorized Integrations when
configured, fall back to the normal Radicle-hub sync otherwise) is
being built as a follow-up.
internal/atproto is a minimal client: Login (app password, never the
real account password) via com.atproto.server.createSession, Post via
com.atproto.repo.createRecord (app.bsky.feed.post), truncated to
Bluesky's 300-rune post limit.
A repo pair can now declare an optional bluesky block (handle +
app_password_file, same token-file convention as Forgejo). When set,
GitSyncer and PatchSyncer post a short note whenever they mirror a new
commit or open a patch/PR, alongside the existing activity_log entry.
Absent config is a true no-op everywhere (BlueskyPoster.Post is nil-safe)
— no account configured, no behavior change, verified live: deployed
with zero bluesky config present, service healthy, /social still
correctly shows "not configured" for every series.
Genuinely untestable further without a real account, as flagged in the
v2 plan from the start — the code is complete and defensive (a failed
Bluesky call never blocks or fails the sync loop), but live posting is
unverified until a dedicated Bluesky handle + app password exists to
test against.
RepoSyncer.RadicleDID() resolves the local node identity the same way
GitSyncer already does for remote setup (rad self --nid), and the actor
object now carries it as a PropertyValue attachment — so anyone following
from the fediverse can see, right on the profile, which Radicle node
actually signs this repo's history.
Deliberately scoped down from "AT Proto DID bridging" as discussed: this
surfaces the DID as verifiable profile metadata, not a cross-protocol
identity resolution (that would need a real DID method resolver and a
PDS to test against, neither of which exist here).
Verified live: https://graft.cyberwild.org/actors/federation-x now
carries the correct did:key: attachment.
activity_log now records the forgejo_id/radicle_id of the issue/patch
each entry is about, so a reply's inReplyTo can be resolved back to the
exact item it's a comment on.
Forgejo: CreateIssueComment (POST .../issues/{index}/comments — verified
live, and confirmed the same endpoint takes PR comments too). Radicle:
CommentIssue and CommentPatch via the rad CLI (verified against
`rad issue/patch comment --help` on a live node first, rather than
guessing the syntax — patch comments take a revision id, not the patch's
own id, which the Patch/Revision struct now also carries).
The inbox now handles Create{Note} whose inReplyTo points at one of our
own Notes: strips the reply's HTML, prefixes it with the replying
account's handle, and posts it as a comment on both the Forgejo and
Radicle sides of whichever specific pair logged that entry.
Verified live end-to-end without a real fediverse account, using one of
graft's own already-published actors as the signed sender (its key never
leaves the container — a small Go program run via `pct exec` reads it
locally and signs the request there): the reply landed as a comment on
both f2's issue and the Radicle issue, correctly attributed.
- httpsig.go: draft-cavage HTTP Signatures (sign outbound, verify inbound)
— the fiddliest part of ActivityPub federation, so it ships with a
round-trip test (sign -> receive -> verify) plus a tamper-detection
test, rather than trusting it by inspection.
- Inbox accepts Follow (verifies the signature against the follower's own
published key, records them, replies with a signed Accept) and
Undo{Follow} (removes them). Anything else (replies included) is
accepted and ignored until the comment bridge, M3.
- After each sync pass, DeliverNewActivity signs and POSTs a Create{Note}
to every follower's inbox for whatever landed in activity_log since
that series' last delivery — tracked per-series so a restart never
redelivers history.
- /social now shows each series' live follower count.
Verified live against real malformed/unreachable-actor requests (400 on
bad JSON, 502 on an actor that fails to resolve) with no crash — a real
follow from an actual fediverse account is still the outstanding
end-to-end check, not yet done.
Every series becomes a followable ActivityPub actor
(series@graft.cyberwild.org): WebFinger resolution, an Actor object with
a lazily-generated RSA-2048 keypair, an outbox wrapping activity_log
entries as Create{Note} activities, and per-entry Note objects at stable
URIs (/actors/{series}/notes/{id}) so future replies can address a
specific mirrored commit/issue/patch.
New /social page lists every series' ActivityPub endpoints, linked from
the main dashboard. AT Proto/Bluesky status shows as "not configured"
for now (M5).
Inbox/follow/delivery (M2), the comment bridge (M3), the Radicle DID
profile attachment (M4), and the AT Proto client (M5) are not built yet
— see the v2 plan.
Previously a series' heatmap only listed sides the activity log happened
to mention, so a Radicle node nothing had been pushed to yet (or a
Forgejo side that only ever received, never originated) was invisible —
looked like only part of the mesh existed. Now every side declared in
config.yaml gets its own row, marked with no events if it hasn't done
anything in the current window yet.
Dropping the TLD (f1.cyberwild instead of f1.cyberwild.org) saved
little space and just made hosts harder to recognize at a glance.
repoRootURL only recognized Forgejo's singular /commit/, /issues/,
/pulls/, /patches/ — Radicle Explorer's plural /commits/ never matched,
so every Radicle-hosted commit sub-row silently fell back to a
non-clickable label instead of a link.
hostLabel also only read a URL's host, which for Radicle Explorer is
always the shared frontend domain regardless of which underlying node
served it — the actual node lives in the /nodes/<host>/<rid>/... path.
Two different Radicle nodes mirroring the same repo would have collapsed
into one indistinguishable sub-row; now each gets its own.
Leftover from testing graft-source's own sync mesh; not meant to live in
the real codebase.
Each repo pair now declares an optional series, grouping every side that
mirrors the same underlying repository (its Forgejo instances and Radicle
node) under one row. The dashboard heatmap becomes one event per cell
(never aggregated behind a single tooltip) inside a collapsible group per
repo, expanding to one sub-row per actual mirrored server with its own
link and its own events - instead of one ambiguous shared link.
Also fixes git commit activity always linking to Forgejo regardless of
which side the content actually landed on, so Radicle now shows up as a
real destination instead of being invisible in the activity feed.
- Always light (Atlassian-style), no more automatic dark mode
- Each calendar tooltip entry and each recent commit links directly to
the forge issue/PR/commit or the Radicle explorer page it produced
- Fixed the tooltip being clipped by the calendar's horizontal scroll
container (overflow-y: visible instead of the inherited overflow: auto)
- Footer with the source repository link and license
- activity_log now stores a URL per entry; new optional config fields
source_url (footer link) and radicle.explorer_url (Radicle links)
rad issue open --quiet never prints the created issue's id (only a sync
summary), which silently corrupted the stored radicle_id and caused every
issue to be re-mirrored on each pass. Fixed by dropping --quiet and parsing
the id out of the normal output box instead.
Also adds an append-only activity_log table and a small HTTP dashboard
(GitHub-style activity calendar + recent commits list) served alongside
the existing /healthz and /api/status endpoints.
Reworded sentences for clarity and conciseness in the README.
- Dashboard: a mirror with no live sync error now renders the series' full
distinct-event set (real cells where it logged the push itself, synthetic
elsewhere), so mirrors genuinely holding the same content look aligned
instead of one appearing behind due to which host happened to get logged.
A mirror with a real, current sync error skips that fill and shows only
what it has confirmed, plus a small red badge (tooltip carries the error).
- admin: .row (paired form fields) now wraps on narrow viewports instead of
overflowing horizontally — fixes New repo's layout on mobile.
- Comments: the mirror-loop guard now checks an invisible marker
(state.MarkMirrored/IsMirroredComment) instead of matching the visible
"**via Forgejo:**" etc. prefix text, so a real comment that happens to
start with the same phrase is never mistaken for graft's own mirror.
The migration adding dynamic_repo.approved defaulted every row to 0,
including rows that were already materialized (live) under the old
model — federation-x-f3 among them. AllDynamicRepos now filters on
approved=1, so on the very next restart after this deploy, f3 silently
dropped out of live.syncers entirely: no error, no log line, just gone.
Caught live: its README stayed stuck for ~35 minutes across a restart
while f1/f2/bimr kept moving, and it sat invisible in /admin/pending
(a peer that had been live for hours suddenly asking to be
"re-approved" is exactly the tell).
Fixed with a startup backfill — UPDATE dynamic_repo SET approved = 1
WHERE materialized = 1 AND approved = 0 — idempotent (materialized
never reverts to 0), so safe to run unconditionally every startup, not
gated on "is this the first run after the column was added." Verified
with a test that closes and reopens a store with a pre-existing
materialized row and confirms it survives migration approved; verified
live: f3 reloaded on restart, dropped out of the pending queue, and
resumed picking up new commits.
Also: dropped the Radicle-side sentence from /add-peer's lead
paragraph (redundant with the field-level hint already there).
Documents the optional-password behavior on all three onboarding forms
(blank = pending request, correct = instant activation, wrong =
rejected outright), the new /admin/pending review page, and the third
dashboard button (+ Add Radicle peer) added alongside it. Also
corrects an inaccuracy the rewrite would otherwise have kept: the
share-claim passcode was described as an admin_password check: it
isn't one, it's a separate secret verified by AES-GCM decryption, not
a stored comparison.
Two mechanisms now coexist on the same forms, chosen by whether the
optional admin password field is filled in:
- Left blank: the submission becomes a pending request (dynamic_repo /
pending_radicle_peer, approved=0) that does nothing on its own — no
sync, no rad node connect, nothing touches f1/f2 or any Radicle node.
A stranger can propose a peer without ever knowing the admin password.
- Filled in and correct: unchanged from before — goes live immediately
(approved right away for a Forgejo peer/new repo; rad node connect +
rad seed run immediately for a Radicle peer). Still the right tool for
handing the password to a trusted group (a class, a workshop) who
should self-serve without a review step.
- Filled in and wrong: rejected outright, never silently downgraded to
a pending request — an oracle for guessing the password would defeat
the point of the existing rate limiter.
New /admin/pending: the only place the password gates anything now
*for the request path* — a stateless, no-session review list (every
Approve/Reject is its own password-carrying POST, consistent with how
/share and the rest of the admin surface already work) covering both
dynamic_repo requests and pending Radicle peer requests in one list.
Rejecting a Forgejo/repo request also deletes its already-written token
file, so a rejected submission leaves nothing behind.
dynamic_repo gets an `approved` column; UnmaterializedDynamicRepos and
the startup reload (AllDynamicRepos) now both filter on it, so a
pending-but-unapproved row can never be picked up by the sync loop by
accident. New pending_radicle_peer table for the Radicle-peer request
path, which needed no ongoing-pair storage at all — approval just runs
the two rad commands once and the row is gone.
Verified: 9 new functional tests (submission needs no password; wrong
password rejects outright rather than queuing; the pending review page
requires the password and never leaks data without it; approve/reject
only take effect with the correct password; approving activates a row;
rejecting removes it and its token file; the optional-password instant
path still works exactly as before; a Radicle approval fails gracefully
with no real node reachable) plus a live round trip against production
(submit with no password, confirm it's queued and invisible without
the admin password, reject it, confirm it's gone) and a Playwright pass
confirming zero CSP violations on the new review page.
New /add-radicle-peer: connects a new Radicle node to the mesh and
seeds an existing federation's RID (rad node connect + rad seed on
graft's own node), gated by the same admin password + rate limiter as
the Forgejo onboarding forms. No DB row needed — it's a one-time
operational action, not an ongoing sync pair. Cross-linked with
/add-peer; dashboard gets a third button.
Fixes a real bug caught live: federation-x-f3 (added via /add-peer
earlier) silently dropped out of the sync loop on every graft restart
since then — main() only rebuilt live RepoSyncers for dynamic_repo rows
still unmaterialized, never for ones a previous run had already
materialized, so a dynamic peer's syncer simply ceased to exist after
any deploy. Confirmed via f3's README staying stale for ~50 minutes
across several restarts while f2/bimr updated normally. Fixed with
loadAllDynamicRepos, called once at startup regardless of the
materialized flag — deliberately non-fatal on a load failure (unlike a
bad static config.yaml pair), since the self-service path exists
specifically to tolerate transient failures, not turn one peer's
hiccup into a full outage. Verified live: f3 reloaded on restart,
caught up on a real README change within one sync pass.
Also: dropped three redundant/stale sentences from the onboarding page
copy (a leftover "not entered here" clause, a since-superseded
auto-init warning now covered by the Adding-a-peer docs instead, and a
"no restart" hint repeated between the form and its own success page),
and "Pair name" -> "Peer name" for consistency with the rest of the UI.
Dashboard: removed the separate "source"/"replicated" cell colors and
legend entries. Cells now always color by event kind (commit, issue,
patch, comment) — the fact that a side holds content without a
graft-logged push of its own (Radicle gossip, an Authorized Integration,
or simply being the origin) is now a tooltip note only, never a visual
category of its own.
New "comment" kind, tracked from four origins, each recorded in a new
activity_log.origin column and shown as a tooltip note (never a color):
- Forgejo <-> Radicle, native: internal/sync/comments.go polls both
sides' comments each pass and cross-mirrors new ones, prefixed
"**via Forgejo:**" / "**via Radicle:**". Dedup via a new comment_seen
table (content hash, scoped per repo_pair+kind+item) plus a prefix
guard against re-mirroring graft's own output — verified live: a
native Forgejo comment mirrored to Radicl<e exactly once, confirmed
stable (no duplicate) across multiple subsequent sync passes.
- ActivityPub: handleReply now logs its existing comment bridge as a
dashboard activity (previously invisible on the dashboard).
- AT Proto: new inbound reply polling (pollBlueskyReplies in
cmd/sync/bluesky_replies.go), using app.bsky.notification.listNotifications.
NOT verified against a live account — none is configured in this
deployment; built directly from AT Proto's published lexicon. Required
extending atproto.Client.Post and sync.BlueskyPoster.Post to return
the new post's AT-URI (new atproto_post table) so a later reply's
parent can be traced back to the activity it was about, and
state.Store.LogActivity to return the new row's id.
Bug caught by a unit test before deploy: the three new comment/reply
truncation helpers sliced on byte index, which both overshot the byte
bound (a 3-byte "…" past a 200-byte cutoff) and could cut a multi-byte
UTF-8 rune in half — fixed to slice on runes, matching the pattern
atproto.Client.Post already used correctly.
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.
New internal/admin package, wired into the dashboard:
- POST /share/new + GET/POST /share/<id>: a one-time secret share.
AES-256-GCM key derived from the share's own high-entropy id plus a
6-digit passcode, neither of which is ever stored together — only the
id's hash goes in the database, so a full DB dump alone can never
derive the decryption key. Destroyed on first successful claim, after
5 wrong passcodes, or after 15 minutes, whichever comes first. QR code
(pure-Go, github.com/skip2/go-qrcode) encodes the claim link only —
the passcode is still meant to travel over a separate channel.
- GET/POST /add-peer: join an existing federation. Picks up the
series' Radicle side (RID, seed, explorer) automatically — nothing
Radicle-related is ever user-supplied for this flow, since Radicle
already replicates to whatever's seeding that RID.
- GET/POST /new-repo: start a repo graft has never seen, with the
manual prerequisite steps (create the Forgejo repo + token, rad init,
note the RID) spelled out inline before the form.
Both onboarding forms are gated by a single shared admin password
(config.yaml's admin_password, defaulting to "graft" with a startup log
warning when left at that default) checked in constant time, backed by
a rate limiter — 5 failures per IP or 20 globally per 15-minute window,
independent of X-Forwarded-For accuracy. A submitted token is written to
its own chmod-600 file under the same directory static pairs already
use, following the existing token_file convention exactly; only the
path is ever persisted to the database.
Submissions land in a new dynamic_repo table and go live on the very
next sync pass — no restart. cmd/sync/main.go now tracks all runtime
registration (syncers, topology, AI-detection state) behind a mutex,
since it's written from the ticker goroutine and read from HTTP handler
goroutines; each pass checks for newly-approved dynamic repos, builds a
RepoSyncer for each, and rebuilds the dashboard's topology and
Authorized-Integration detection to include them.
CSP's form-action tightened from 'none' to 'self' — the new pages are
plain HTML forms posting back to graft itself; 'none' would have
silently blocked every one of them.
Verified: 9 functional tests (share round-trip, one-time destruction,
max-attempts destruction, wrong-password rejection, rate limiting
blocking even a correct password once tripped, successful add-peer/
new-repo with real DB row + token file content/permissions, QR
generation, clean GET rendering) plus a live production round trip
(share create/claim/QR, wrong-password rejection leaving no trace) and
a Playwright pass confirming zero CSP violations on all three new pages.
Three additions to TUTORIAL.md, none of them code:
- "Sharing a token safely": scope it down, never paste into chat/email/
a ticket, prefer the peer writing it themselves over their own SSH
session, use a one-time-secret tool when a third party must relay it,
rotate on any doubt.
- "Adding a peer to an existing federation": the actual steps behind
what's already running in production (a second Forgejo pair joining
an existing series, reusing the same RID/seed rather than standing up
new Radicle infra) — plus what the dashboard's source/replicated cells
mean when a newly-joined side shows them.
- "Adding a brand-new Radicle node to the mesh": rad node connect (the
one-time introduction dynamic peer discovery needs) and rad seed
(seeding policy defaults to block — connected doesn't imply seeded).
- "Future: built-in token exchange": a menu of options graft could
implement instead of leaning on outside tools — a one-time-secret
page with a 6-digit passcode, a QR-code variant for live handoffs, a
magic-wormhole-style PAKE exchange, or skipping the handoff entirely
via a delegated Forgejo token-minting flow. None built; recorded for
when it's worth picking one.
CSP: default-src 'none'; style-src 'unsafe-inline'; img-src 'self';
connect-src 'self'; base-uri 'none'; form-action 'none';
frame-ancestors 'none'. Validated with Playwright against the actual
dashboard/social HTML before shipping: a negative control (no style-src
allowance) reproduced the predicted breakage — transparent background,
no card borders, a genuine CSP console violation — proving the test
methodology catches what it's meant to; the shipped policy renders
identically to the no-CSP baseline, confirmed both locally and against
the live production response. style-src allows inline because both
pages' entire stylesheet is one static <style> block nothing is ever
injected into — no practical downside to allowing it specifically,
versus a per-page hash that would silently break on the next CSS edit.
Dashboard: the "source"/"replicated" states no longer render as a
separate pill that breaks the heatmap's visual language. Each side that
holds content without a graft-logged push of its own now gets real
cells — same shape as a commit/issue/patch cell, same hover tooltip,
just a different color (new --kind-source/--kind-replicated cell
colors, previously only used in the legend). Built from a dedup map of
every distinct event logged anywhere in the series (keyed by commit SHA
where content-addressed, kind+summary otherwise), so a side sees exactly
the same commits/issues its siblings logged — with a same-host link
reconstructed from the commit SHA where possible, and a tooltip note
naming which side actually logged the push and when.
X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Permissions-Policy, Cross-Origin-Opener-Policy, and a conservative HSTS
(180 days, no includeSubDomains, no preload) — the nuclei/nikto findings
that carry no realistic regression risk for a session-less, form-less,
auto-escaped dashboard.
CSP and Cross-Origin-Embedder-Policy are deliberately left out: the
dashboard's stylesheet is one inline <style> block that a naive CSP would
break outright, and COEP protects cross-origin-isolated contexts graft
has no use for. Both need real testing before shipping, not a blanket
header.
Addresses every finding from the full-codebase security audit:
- CRITICAL: persistent SSRF via ActivityPub actor/inbox URLs. New
internal/activitypub/ssrf.go validates every externally-supplied URL
(https only, rejects loopback/private/link-local/metadata addresses)
and re-validates the resolved IP at dial time to close the DNS-rebinding
gap a resolve-then-connect check would leave open. Wired into
FetchActor, NewHandler's client, and DeliverNewActivity's per-follower
delivery loop.
- CRITICAL: state.db (holds every series' ActivityPub private key in
plaintext) was world-readable (644) on the live deployment. Open() now
hard-fails if it can't chmod the file to 600.
- HIGH: no replay protection on inbound HTTP Signatures. VerifyRequest
now rejects requests whose Date header is more than 5 minutes stale.
- MEDIUM: unbounded upstream response bodies embedded whole into error
messages/logs (Forgejo, AT Proto, ActivityPub clients) — capped at
4 MiB/1 MiB read limits with logged bodies truncated to 500 bytes.
- MEDIUM: subprocess calls (git, rad) had no timeout, so one hung command
could wedge the whole serial sync loop — bounded to 5 minutes via
context.WithTimeout/exec.CommandContext.
- MEDIUM: Authorized Integrations detection silently disables graft's own
push forever once triggered, with no bound — now falls back to pushing
directly after 10 consecutive skipped passes.
Also fixes a dashboard accuracy issue: a mirror side with zero
graft-logged events read as "nothing synced" even when it plainly held
the content — the Forgejo side changes originate on (graft only logs the
pushes it makes, never the pushes it reads from), or a side that received
content via a path graft doesn't log itself (Radicle's own peer-to-peer
gossip, or a Forgejo-to-Forgejo Authorized Integration). Those sides now
show a distinct "source" or "replicated" badge instead, with a legend
entry for each; "nothing synced" is reserved for a series with truly zero
activity anywhere. Also: renamed the dashboard subtitle to "Forgejos &
Radicles syncs", dropped the redundant "Activity by repo" heading, moved
the "last 3 days" window label to the Recent Activity header, and renamed
"Social bridges" to "Social IDs" throughout.
authint.go's HasAuthorizedIntegration scans a Forgejo repo's
.forgejo/workflows/*.yml for a job with enable-openid-connect: true that
references another pair's host — the only signal available for this
without admin API access to the trust relationship itself.
At startup, detectAuthorizedIntegrations checks every pair of Forgejo
hosts sharing a series. When one side already has a workflow pushing
directly into the other, GitSyncer.AuthorizedIntegrationSource tells the
receiving pair to stop relaying that content via Radicle itself — the
direct CI path delivers it faster anyway. Genuine divergences still get
reported exactly as before; only the two unambiguous "the other side is
simply behind" cases are skipped. No Forgejo host in the current config
has such a workflow, so this is detected-but-inactive today, exactly as
it should be.
Also: dashboard label "N mirrored sides" -> "N mirrors".
internal/atproto is a minimal client: Login (app password, never the
real account password) via com.atproto.server.createSession, Post via
com.atproto.repo.createRecord (app.bsky.feed.post), truncated to
Bluesky's 300-rune post limit.
A repo pair can now declare an optional bluesky block (handle +
app_password_file, same token-file convention as Forgejo). When set,
GitSyncer and PatchSyncer post a short note whenever they mirror a new
commit or open a patch/PR, alongside the existing activity_log entry.
Absent config is a true no-op everywhere (BlueskyPoster.Post is nil-safe)
— no account configured, no behavior change, verified live: deployed
with zero bluesky config present, service healthy, /social still
correctly shows "not configured" for every series.
Genuinely untestable further without a real account, as flagged in the
v2 plan from the start — the code is complete and defensive (a failed
Bluesky call never blocks or fails the sync loop), but live posting is
unverified until a dedicated Bluesky handle + app password exists to
test against.
RepoSyncer.RadicleDID() resolves the local node identity the same way
GitSyncer already does for remote setup (rad self --nid), and the actor
object now carries it as a PropertyValue attachment — so anyone following
from the fediverse can see, right on the profile, which Radicle node
actually signs this repo's history.
Deliberately scoped down from "AT Proto DID bridging" as discussed: this
surfaces the DID as verifiable profile metadata, not a cross-protocol
identity resolution (that would need a real DID method resolver and a
PDS to test against, neither of which exist here).
Verified live: https://graft.cyberwild.org/actors/federation-x now
carries the correct did:key: attachment.
activity_log now records the forgejo_id/radicle_id of the issue/patch
each entry is about, so a reply's inReplyTo can be resolved back to the
exact item it's a comment on.
Forgejo: CreateIssueComment (POST .../issues/{index}/comments — verified
live, and confirmed the same endpoint takes PR comments too). Radicle:
CommentIssue and CommentPatch via the rad CLI (verified against
`rad issue/patch comment --help` on a live node first, rather than
guessing the syntax — patch comments take a revision id, not the patch's
own id, which the Patch/Revision struct now also carries).
The inbox now handles Create{Note} whose inReplyTo points at one of our
own Notes: strips the reply's HTML, prefixes it with the replying
account's handle, and posts it as a comment on both the Forgejo and
Radicle sides of whichever specific pair logged that entry.
Verified live end-to-end without a real fediverse account, using one of
graft's own already-published actors as the signed sender (its key never
leaves the container — a small Go program run via `pct exec` reads it
locally and signs the request there): the reply landed as a comment on
both f2's issue and the Radicle issue, correctly attributed.
- httpsig.go: draft-cavage HTTP Signatures (sign outbound, verify inbound)
— the fiddliest part of ActivityPub federation, so it ships with a
round-trip test (sign -> receive -> verify) plus a tamper-detection
test, rather than trusting it by inspection.
- Inbox accepts Follow (verifies the signature against the follower's own
published key, records them, replies with a signed Accept) and
Undo{Follow} (removes them). Anything else (replies included) is
accepted and ignored until the comment bridge, M3.
- After each sync pass, DeliverNewActivity signs and POSTs a Create{Note}
to every follower's inbox for whatever landed in activity_log since
that series' last delivery — tracked per-series so a restart never
redelivers history.
- /social now shows each series' live follower count.
Verified live against real malformed/unreachable-actor requests (400 on
bad JSON, 502 on an actor that fails to resolve) with no crash — a real
follow from an actual fediverse account is still the outstanding
end-to-end check, not yet done.
Every series becomes a followable ActivityPub actor
(series@graft.cyberwild.org): WebFinger resolution, an Actor object with
a lazily-generated RSA-2048 keypair, an outbox wrapping activity_log
entries as Create{Note} activities, and per-entry Note objects at stable
URIs (/actors/{series}/notes/{id}) so future replies can address a
specific mirrored commit/issue/patch.
New /social page lists every series' ActivityPub endpoints, linked from
the main dashboard. AT Proto/Bluesky status shows as "not configured"
for now (M5).
Inbox/follow/delivery (M2), the comment bridge (M3), the Radicle DID
profile attachment (M4), and the AT Proto client (M5) are not built yet
— see the v2 plan.
Previously a series' heatmap only listed sides the activity log happened
to mention, so a Radicle node nothing had been pushed to yet (or a
Forgejo side that only ever received, never originated) was invisible —
looked like only part of the mesh existed. Now every side declared in
config.yaml gets its own row, marked with no events if it hasn't done
anything in the current window yet.
repoRootURL only recognized Forgejo's singular /commit/, /issues/,
/pulls/, /patches/ — Radicle Explorer's plural /commits/ never matched,
so every Radicle-hosted commit sub-row silently fell back to a
non-clickable label instead of a link.
hostLabel also only read a URL's host, which for Radicle Explorer is
always the shared frontend domain regardless of which underlying node
served it — the actual node lives in the /nodes/<host>/<rid>/... path.
Two different Radicle nodes mirroring the same repo would have collapsed
into one indistinguishable sub-row; now each gets its own.
Each repo pair now declares an optional series, grouping every side that
mirrors the same underlying repository (its Forgejo instances and Radicle
node) under one row. The dashboard heatmap becomes one event per cell
(never aggregated behind a single tooltip) inside a collapsible group per
repo, expanding to one sub-row per actual mirrored server with its own
link and its own events - instead of one ambiguous shared link.
Also fixes git commit activity always linking to Forgejo regardless of
which side the content actually landed on, so Radicle now shows up as a
real destination instead of being invisible in the activity feed.
- Always light (Atlassian-style), no more automatic dark mode
- Each calendar tooltip entry and each recent commit links directly to
the forge issue/PR/commit or the Radicle explorer page it produced
- Fixed the tooltip being clipped by the calendar's horizontal scroll
container (overflow-y: visible instead of the inherited overflow: auto)
- Footer with the source repository link and license
- activity_log now stores a URL per entry; new optional config fields
source_url (footer link) and radicle.explorer_url (Radicle links)
rad issue open --quiet never prints the created issue's id (only a sync
summary), which silently corrupted the stored radicle_id and caused every
issue to be re-mirrored on each pass. Fixed by dropping --quiet and parsing
the id out of the normal output box instead.
Also adds an append-only activity_log table and a small HTTP dashboard
(GitHub-style activity calendar + recent commits list) served alongside
the existing /healthz and /api/status endpoints.