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