Simplify dashboard to kind-only colors; add comment sync from 4 origins
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.
Add request-and-approve onboarding, alongside the instant self-service path
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.
Simplify dashboard to kind-only colors; add comment sync from 4 origins
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.
Self-service federation onboarding + one-time secret sharing
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.
Simplify dashboard to kind-only colors; add comment sync from 4 origins
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.
Simplify dashboard to kind-only colors; add comment sync from 4 origins
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.
Add request-and-approve onboarding, alongside the instant self-service path
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.
Add request-and-approve onboarding, alongside the instant self-service path
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.
Simplify dashboard to kind-only colors; add comment sync from 4 origins
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.