forge-dashboard

module
v0.98.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2026 License: AGPL-3.0

README

forge-dashboard

CI release licence coverage Go Reference

A single-page dashboard of open pull requests, issues, and CI status across every repository you have write access to on GitHub and a Forgejo instance — one page instead of two forge UIs. See #1 for the v1 acceptance criteria this was built against, and #3 for the passwordless login, per-user tokens, sharing, and an admin role this document now describes the first slice of.

forge-dashboard, light mode forge-dashboard, dark mode

Fixture data, not a real account's actual repositories — regenerated automatically by the release workflow on every release (see scripts/capture-screenshots.js), so it's never more than one release stale.

Design

  • Passkey login, no passwords anywhere. Registration and sign-in are real WebAuthn ceremonies — works with Proton Pass, 1Password, iCloud Keychain, a platform authenticator, or a hardware key, anything that speaks passkeys. Nothing password-shaped is ever stored or transmitted.
  • Every user configures their own forges. Each signed-in user has their own GitHub/Forgejo tokens, set from the Settings page, and sees only their own dashboard — nobody else's tokens, nobody else's repos.
  • Credentials never reach the browser. The Go backend holds every user's tokens server-side, encrypted at rest, and only ever hands the frontend its own already-filtered JSON. Confirm this yourself: open the browser's network tab and watch it call nothing but /api/dashboard, /api/settings, /api/auth/* and /healthz — and that /api/settings never echoes a token back, only whether one is set.
  • An MCP endpoint for agents, /api/mcp, read-only like the rest of this app. Authenticated the same way any other /api/* route already is — a personal API token (Settings → API tokens), Authorization: Bearer <token> — not a separate credential system. Its one tool, get_dashboard, mirrors GET /api/dashboard exactly (including its own ?owner=/owner semantics for a dashboard someone else has shared with you) rather than adding a second, parallel path to the same data. Built on the official MCP Go SDK, over the Streamable HTTP transport.
  • One Go binary. The frontend — SvelteKit throughout, migrated incrementally one page at a time (see #326) — ends up embedded into the binary with //go:embed; a released binary needs nothing but go build to run. Building from source needs one extra step first, bun run build:web, to produce the pages internal/api/static embeds — run automatically in CI and the Docker build's own web-build stage, a manual step before a local go build otherwise. See CONTRIBUTING.md for the toolchain either half needs.
  • Read-only against both forges, with one caveat. Nothing here writes back to GitHub or Forgejo — it aggregates and displays, and every row links out to the real thing. The caveat: checking whether a repo's webhook is already pointed at this dashboard (the Webhooks card, and the dashboard's own coverage summary) needs a token scope that includes write access on Forgejo, since its hooks API has no separate read-only scope — this app still only reads that list, and never creates or edits a hook itself.
  • A WebMCP tool in the frontend itself, get_dashboard. Experimental — as of this writing it's an Origin Trial in Chrome 149+/Edge 150+ only, with no stable spec yet — so this is a no-op everywhere else rather than a real dependency the page needs. Where it's present, it lets an in-browser AI agent fetch the same data the page itself renders by calling the page's own already-authenticated fetch layer (fetchDashboardData()) directly — the same request /api/dashboard already answers, riding on the browser's own session cookie exactly like the page's own poll does. Nothing new crosses to the browser that the preceding "Credentials never reach the browser" bullet doesn't already allow: the tool never sees a token, only the same already-filtered JSON the page always got.
Where this stands against #3

This ships every piece of #3: passkey registration and login gating the dashboard, per-user GitHub/Forgejo tokens (every signed-in user configures their own forges from the Settings page and sees only their own dashboard by default), an admin area for whoever registers first (list every registered user, generate and manage the single-use invite links every registration after the first now requires, revoke a user's passkeys and sessions without deleting their account, or remove one outright), and dashboard sharing (grant another registered user read-only access to your own dashboard, from Settings).

Requirements

  • Go 1.27 or newer, to build.
  • bun, to build from source — bun run build:web has to run before go build picks up its output (see the preceding Design section). Not needed to run a pre-built release binary or the Docker image, both of which already have that step baked in.
  • A passkey-capable browser to sign in at all — any current Chrome, Safari, Firefox or Edge; a phone counts too. Nothing else to install.
  • Somewhere writable for the database (DB_PATH, default /data/forge-dashboard.db) that survives a restart — registered passkeys, sessions, and every user's encrypted forge credentials live there. In Docker that means a mounted volume at /data; see Docker below.
  • ENCRYPTION_KEY, a base64-encoded 32-byte key the process refuses to start without — see Configuration below for how to generate one. It's what encrypts every user's forge tokens at rest; there's no sensible default for a secret like this one.
  • Each signed-in user configures their own forge credentials from the Settings page after registering — see Credentials below for exactly which permissions to grant. Nothing forge-related needs setting before the process starts.

Authentication

The first visit registers a passkey and becomes the instance's admin; every registration after that needs an invite link the admin generates — no password anywhere, and no separate account system. POST /api/auth/register/begin and .../finish run the WebAuthn registration ceremony, .../login/begin/.../finish run the login ceremony, and a signed session cookie (HttpOnly, SameSite=Lax, Secure whenever the request arrived over HTTPS) is what actually gates / and /api/dashboard afterward — see internal/auth and api/openapi.yaml's auth tag.

  • Whoever registers first becomes admin, and self-registration closes the moment they do. No environment variable to set or get wrong, and no reliance on a deployment's own network boundary either — the first completed registration is what Service.BeginRegistration checks server-side, and every registration after that is rejected unless it presents a valid invite. New accounts only ever exist because the admin decided to add one, regardless of who else can reach the login page. See Admin area below for generating, listing, and revoking invites.
  • An invite is a single-use, one-hour link. The admin picks the username and display name up front from the admin area's own Invites card; the generated /login.html?invite=<token>&username=<username> link is copied out of band (no email system exists here) and handed to the invitee, whose own visit to it shows nothing left to fill in but the passkey prompt. A token that expires or completes one registration can never be used again.
  • Losing every admin passkey with no other admin and no outstanding invite is a hard lockout. There's no email/SMS channel here to build a self-service account-recovery flow on top of — the only way back in is restoring the SQLite database (DB_PATH) from a backup taken before the loss.
  • RP_ID / RP_ORIGIN configure the WebAuthn relying party. They default to localhost / http://localhost:8080 for a local run; a real deployment must set both to its real domain, or every passkey registered against the default refuses to work there — a passkey is cryptographically bound to the origin it was created for, not something this service can paper over after the fact.
API tokens

A script can authenticate the same way a signed-in browser does, without a WebAuthn ceremony of its own to perform: generate a personal API token from Settings' "API tokens" card, then send it as a Bearer credential —

curl -H "Authorization: Bearer fdb_<the-generated-token>" \
  http://localhost:8080/api/dashboard

against any endpoint the frontend itself calls, RequireAuth accepts either credential (session cookie or a live token) the same way. The raw value is shown exactly once, right after generating it — only its hash is stored, so losing it means generating a new one, same as any other bearer credential. Settings lists every live token (label, created date, last-used date) with a one-click Revoke that stops it authenticating immediately.

Admin area

Whoever registers first gets an Admin link in the dashboard header, leading to /admin.html: a list of every registered user, with two actions per row, and an Invites card for generating and managing the links every registration past the first now requires.

  • Revoke signs a user out everywhere — clears every passkey and every API token they've generated — without touching their account or saved forge credentials. They have to register a new passkey from scratch to get back in, through a fresh invite the same as any other post-bootstrap registration. Useful for a lost device, a compromised passkey manager, or a leaked API token, short of removing the person entirely.
  • Remove deletes the account outright — passkeys, sessions, API tokens, and saved forge credentials all go with it, and the username becomes available for a fresh invite. Irreversible.

Neither action works on the admin's own account (the backend refuses it with a 400, and the frontend disables both buttons on that row) — there's no recovery path for locking yourself out this way.

Invites

The Invites card generates a single-use registration link: enter the username and display name up front — the invitee only ever completes the WebAuthn ceremony — and the generated link is shown exactly once with a copy button, right after creation. Only the token's hash is stored server-side, so losing it before copying means generating a new one, the same "show once" handling a personal API token already gets. A table of outstanding invites (username, expiry) lists everything not yet used or expired, each with its own Revoke to stop a link that shouldn't be honoured any more — a revoked invite can never complete a registration. Every invite expires after one hour, fixed and not configurable: long enough to hand a link off and have the invitee act on it in one sitting, short enough that a forgotten, unconsumed invite isn't a standing credential.

Requests

The Admin page's Requests card is a log of every outbound call this instance has made to GitHub or Forgejo — across every account, an admin's own included — newest first: when it happened, which forge, which account made it, the method and endpoint, the status code, the outcome (success, or why it failed), and the rate-limit budget left afterward where the response carried one. It exists for the same reason server access logs do anywhere else: correlating a shared-credential problem — a shared GitHub token running two accounts into the same rate limit being the case that actually motivated it — needs a cross-account view no single account's own dashboard could give.

Filter by forge or by account (the account filter is a username, not the internal account id, so it stays something you can actually type), and Export CSV downloads the same filtered rows as a file — useful for attaching to a bug report or pulling into a spreadsheet rather than scrolling a table.

Sharing

From Settings, share your own dashboard with another registered user, read-only — they need no GitHub/Forgejo credentials of their own configured. Once shared, a selector appears in their dashboard header so they can switch between their own dashboard and yours; Settings also lists everyone who's shared their dashboard with you, and everyone you've shared yours with, with a one-click way to stop.

Enforced server-side (GET /api/dashboard?owner=<username> answers 403 without an active share, not just a hidden frontend control) — see internal/sharing and api/openapi.yaml's sharing tag.

Credentials

Set from the Settings page (/settings.html, linked from the dashboard header) once you're signed in — nothing forge-related is configured by the process itself. Each forge takes either a token (sees private repos too) or a bare username (public repos only, no credential at all) — never both purposes at once. A token always wins over a username if you set both, and leaving a token field blank on save keeps whatever was saved before, so updating your username doesn't mean re-pasting the token too.

GitHub
  • Token: a personal access token with read access to the repositories you want tracked, plus write access to their webhooks if you ever plan to use the Webhooks page's "Add a webhook" button. The exact scope, classic and fine-grained, lives in Settings' own field hint next to the token field — one place, kept current with what the app actually requests, rather than a second copy here that can drift out of sync with it (and did: an earlier version of this section named a Contents scope Settings' own hint has never actually required).
  • Username only (token left blank): shows that account's public repositories, fully unauthenticated — the same data anyone gets landing on github.com/<username>?tab=repositories. Verified live against a real 105-public-repo account. Unauthenticated GitHub API calls are capped at 60 requests/hour, so this mode only sees everything on an account small enough to fit that budget — a token (5,000/hour) is what an account this size actually needs.
Forgejo
  • Token: Settings → Applications → Generate New Token. Same "see Settings' own field hint" pointer the GitHub token entry gives — it lists every scope, including read:user, easy to miss since it isn't obviously related to repos, but GET /user/repos (how repository discovery works) refuses a token without it: confirmed live against a real instance, 403 token does not have at least one of required scope(s): [read:user], before it was added to the token.
  • Username only (token left blank, instance URL still set): shows that account's public repositories on the configured instance, unauthenticated — same trade-off as GitHub's username mode. Verified against a real Forgejo instance's GET /users/<username>/repos.

Pull request actions

Merge and Update branch appear on a pull request row when the forge reports it's actually possible — a merge conflict, a token missing the right permission, or the forge being unreachable all hide or lock the button instead of letting the click fail. Merge asks for confirmation first; Update branch doesn't, since a merge from the base branch is easy to reverse and a merge itself isn't. Close is always available on an open pull request, regardless of mergeability — for one that turns out not to need merging at all (a duplicate, or one whose content already landed another way), it's the action that actually applies — and asks for confirmation the same way Merge does.

A GitHub pull request that isn't already auto-merging gets an "Enable auto-merge" action in the row's "More actions" menu, arming the forge's own native auto-merge (GitHub's enablePullRequestAutoMerge mutation) instead of switching to GitHub's own UI just to turn it on. It picks a merge method the repo actually allows, the same merge > squash > rebase precedence Merge already uses, with no override of its own. Available whenever the pull request isn't a genuine conflict — unlike Merge, a pending or not-yet-required check doesn't hide it, since arming auto-merge ahead of CI finishing is the whole point. Forgejo has no separate "enable auto-merge" endpoint of its own — merge_when_checks_succeed is a flag on the same merge call, which would merge right now rather than arming a standing intent — different enough framing that it's left for a follow-up rather than folded into this one.

A pull request opened by release-please, Dependabot, or Renovate keeps itself current on its own schedule — for release-please specifically, a manual Update branch click can fight its own next run, since it regenerates the branch and changelog together on every push to the base branch. Update branch stays hidden on a PR any of them opened unless "Allow updating bot-managed PR branches" is turned on in Settings, which also reveals a field for the label Renovate's own rebase/retry trigger listens for on your repos (its own rebaseLabel config option, genuinely per-repo configurable — leave blank for Renovate's own default, rebase).

A Dependabot-authored pull request on GitHub instead gets its own pair of buttons — Dependabot: Rebase and Dependabot: Recreate — that trigger Dependabot's own comment commands (@dependabot rebase / @dependabot recreate) instead of you switching to the forge to type them yourself. GitHub only — Dependabot doesn't run on Forgejo.

A repo with a behind pull request can also be brought up to date without a click at all, by enabling auto-update-branch for it in Settings. For a Dependabot pull request specifically, once "Allow updating bot-managed PR branches" is also on, that background pass drives the same @dependabot rebase command the manual button does rather than a generic branch update — and if that rebase leaves the pull request's CI failing, follows up with @dependabot recreate on its own, the same way you'd notice the failure and click Recreate yourself. A Renovate pull request gets the same treatment as the manual Renovate: Rebase button below it — the configured rebase label added instead of a generic branch update, since that's Renovate's own rebase/retry trigger, not a comment command.

A Renovate-authored pull request, on either forge, gets a Renovate: Rebase button that adds the configured rebase label (Settings' own Renovate rebase label field, described earlier) instead of you finding and adding it yourself — Renovate's own rebase/retry trigger is a label, not a comment command. On Forgejo the label has to already exist on the repo (its labels API takes an existing label's ID, not an arbitrary name); the button reports that plainly instead of silently doing nothing if it doesn't.

A pull request with any CI reported at all gets a View pipeline button, opening a panel that lists every individual job/check against its head commit — status, name, and a link to that job's own page on the forge — instead of only the row's own collapsed CI pill. Fetched fresh every time the panel opens, not cached, or part of the regular dashboard refresh. Falls back to the legacy combined commit-status API's own per-check entries when a repo (or an older Forgejo instance) reports no Actions/Workflow runs at all for that commit.

Webhooks

Optional. Without one, the dashboard still refreshes on its own schedule and the browser tab polls it every 30 seconds; a webhook just means a new pull request, a closed issue, or a CI status change on a tracked repo shows up within seconds instead of at the next poll. Set up from the Settings page, one forge at a time — full copy-pasteable steps, exactly which events each part of the dashboard needs (CI status specifically needs its own, easy-to-miss event), and how delivery verification works: docs/webhooks.md.

A delivery refreshes just the repository it names, not the account's whole tracked-repo set — a much smaller GraphQL query on GitHub than a full refresh runs. A payload with no repository (the initial "ping" delivery a new webhook sends, say) falls back to a full refresh instead.

Configuration

Everything the process itself needs is environment variables — no config file, and nothing forge-related, since that's per-user now (see the preceding Credentials section):

Variable Required Default Meaning
ADDR no :8080 Listen address.
DB_PATH no /data/forge-dashboard.db Where passkeys, sessions, and every user's encrypted forge credentials live.
RP_ID no localhost The WebAuthn relying party ID — set to your real domain in any real deployment.
RP_ORIGIN no http://localhost:8080 The WebAuthn relying party origin — set to the real https:// origin users reach this at.
ENCRYPTION_KEY yes — Base64-encoded 32-byte key for encrypting forge tokens at rest. Generate with openssl rand -base64 32.
REFRESH_INTERVAL no 20m How often the backend re-polls a signed-in user's forges, as a Go duration (2m30s, 10m). A safety net now that every tracked repo gets a webhook — lower it if you're not relying on those.
CI_POLL_INTERVAL no 1m How often the backend re-checks just the open pull requests whose CI is still pending, as a Go duration. A fallback for real Forgejo instances that silently drop the webhook event CI status rides on (see docs/webhooks.md) — set to 0s to turn it off if you don't need it.
LOG_LEVEL no info debug, info, warn, or error. debug logs every outbound request to GitHub/Forgejo (method, URL) — turn it on to diagnose a request-volume spike from the process's own logs instead of reasoning about the code from the outside.

Repository discovery is automatic per user. With a token, the dashboard lists every repository it has push access to — on GitHub, one GraphQL query returns that whole list along with every repo's open pull requests, open issues, and CI status in a single request (drawing from GraphQL's own separate rate-limit pool, not the REST budget everything else on the account shares); on Forgejo, GET /user/repos. With only a username, it lists that account's public repositories (GET /users/<username>/repos on both) with no credential in play at all — GitHub's GraphQL API allows no anonymous access, so this mode stays REST-based there too. Either way there's no per-repo allowlist to maintain — archived and forked repositories are excluded automatically, on both forges, in either mode. On Forgejo, a mirrored repository is excluded too — a pull mirror has no pull requests or issues of its own to poll, and its canonical home is whichever forge it's mirrored from.

Running it

go build -o forge-dashboard ./cmd/forge-dashboard
mkdir -p data
DB_PATH=./data/forge-dashboard.db \
  ENCRYPTION_KEY="$(openssl rand -base64 32)" \
  ./forge-dashboard

Then open http://localhost:8080, register a passkey — RP_ID/ RP_ORIGIN default to localhost/http://localhost:8080, which matches this local run with nothing extra to set — and add your GitHub/Forgejo credentials from the Settings page.

Docker

The /data directory the image ships is where the database goes — mount a volume there, or every passkey and every saved credential is gone the moment the container is recreated:

docker build -t forge-dashboard .
docker run --rm -p 8080:8080 \
  --cap-drop=ALL --security-opt=no-new-privileges --read-only \
  --memory=64m --cpus=0.5 \
  -v forge-dashboard-data:/data \
  -e RP_ID=localhost -e RP_ORIGIN=http://localhost:8080 \
  -e ENCRYPTION_KEY="$(openssl rand -base64 32)" \
  forge-dashboard

Generate ENCRYPTION_KEY once and keep it — losing it makes every saved credential unrecoverable, and every user has to re-enter theirs from the Settings page.

The published image is ghcr.io/alrayyes/forge-dashboard, built and pushed on every release by .goreleaser.yml's dockers: block — tagged by version and :latest, linux/amd64 and linux/arm64.

Deployment

Deployed on a homelab, reachable only over Tailscale. Passkey login is now the actual access control — the tailnet is defence in depth on top of it, not the only thing standing between a visitor and the dashboard the way v1 originally had it. The image is pulled into that homelab's existing compose setup as its own service directory, routed through the Traefik instance already running there; there's no per-service Tailscale sidecar, since every service on that host reaches the tailnet the same way. That deployment config — including the /data volume this now needs, and RP_ID/RP_ORIGIN set to the real deployed domain rather than the localhost defaults — lives in that private deployment repo, not here.

API

api/openapi.yaml is the contract: GET /healthz for liveness, GET /api/dashboard for the signed-in user's aggregated snapshot (or, with ?owner=<username>, one shared with them), GET/PUT /api/settings for that user's own GitHub/Forgejo configuration — the PUT response never echoes a token back, only whether one is now set — GET/PUT/DELETE /api/sharing(/{username}) for managing who can see your dashboard, and GET /api/admin/users plus the revoke/delete endpoints, alongside GET/POST /api/admin/invites and POST /api/admin/invites/{token}/revoke for generating, listing, and revoking registration invites, every one of them refusing anyone but the designated admin. redocly lint validates it; nothing yet asserts the handlers still match it (see CONTRIBUTING.md). Rendered docs live at docs/api/index.html (Scalar, zero-build), published to https://alrayyes.github.io/forge-dashboard/docs/api/ on every push to main that passes lint.

GET/POST/DELETE /api/mcp sits outside this contract — it's the MCP Streamable HTTP transport (JSON-RPC over HTTP, not a REST resource redocly/OpenAPI describes), not a plain request/response endpoint. See the preceding Design section for what it exposes and how it authenticates.

Contributing

See CONTRIBUTING.md for the toolchain, the hooks, and how a change gets reviewed and released.

Licence

AGPL-3.0. Chosen over the plain GPL-3.0 this project started under because AGPL's network-use clause also covers running a modified copy as a hosted service, which GPL alone doesn't.

Directories

Path Synopsis
cmd
forge-dashboard command
Command forge-dashboard is the composition root: it wires the SQLite database, the passkey auth service, and the per-user dashboard.Manager, then serves the API and static frontend.
Command forge-dashboard is the composition root: it wires the SQLite database, the passkey auth service, and the per-user dashboard.Manager, then serves the API and static frontend.
internal
api
Package api is the composition root's one dependency: an http.Handler satisfying api/openapi.yaml, serving the aggregated dashboard snapshot and the static frontend.
Package api is the composition root's one dependency: an http.Handler satisfying api/openapi.yaml, serving the aggregated dashboard snapshot and the static frontend.
auth
Package auth is passkey (WebAuthn) registration and login: no password ever stored or transmitted, a session cookie once a ceremony succeeds, and the store both ceremonies and sessions persist to (SQLite).
Package auth is passkey (WebAuthn) registration and login: no password ever stored or transmitted, a session cookie once a ceremony succeeds, and the store both ceremonies and sessions persist to (SQLite).
dashboard
Package dashboard holds the domain model both forge clients produce and the aggregator that merges them into one snapshot.
Package dashboard holds the domain model both forge clients produce and the aggregator that merges them into one snapshot.
forgejo
Package forgejo is a client for the pieces of the Forgejo API (Gitea-compatible) forge-dashboard needs — the same shape as internal/github, against a different API under a self-hosted host.
Package forgejo is a client for the pieces of the Forgejo API (Gitea-compatible) forge-dashboard needs — the same shape as internal/github, against a different API under a self-hosted host.
github
Package github is a client for the pieces of the GitHub API forge-dashboard needs: which repositories the credential can write to, their open pull requests and issues, and combined CI status per pull request.
Package github is a client for the pieces of the GitHub API forge-dashboard needs: which repositories the credential can write to, their open pull requests and issues, and combined CI status per pull request.
requestlog
Package requestlog persists a queryable record of every outbound request forge-dashboard makes to GitHub or Forgejo (#482), so a rate-limit or connectivity incident can be debugged from what actually happened on the wire instead of from stdout logs alone.
Package requestlog persists a queryable record of every outbound request forge-dashboard makes to GitHub or Forgejo (#482), so a rate-limit or connectivity incident can be debugged from what actually happened on the wire instead of from stdout logs alone.
settings
Package settings is each user's own GitHub/Forgejo credentials — saved through the Settings page, encrypted at rest, and what dashboard.Manager builds that user's forge sources from.
Package settings is each user's own GitHub/Forgejo credentials — saved through the Settings page, encrypted at rest, and what dashboard.Manager builds that user's forge sources from.
sharing
Package sharing tracks which users have granted which other users read-only access to their aggregated dashboard.
Package sharing tracks which users have granted which other users read-only access to their aggregated dashboard.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL