more

47f1c44 · gmorell · 2026-05-13 22:24

138 files +8277 -1479

Files changed

modified .gitignore
+10 −0
@@ -35,3 +35,13 @@ git_gud-*.tar
35 35 npm-debug.log
36 36 /assets/node_modules/
37 37
38 +# Generated git host directories for bare repos.
39 +/priv/repos/
40 +
41 +# SSH host keys generated on first daemon boot.
42 +/priv/ssh/
43 +
44 +# Disk-backed object storage (LFS objects, CI artifacts) when running
45 +# with `GitGud.Storage.Disk`.
46 +/priv/storage/
47 +
modified PROGRESS.md
+190 −3
@@ -12,6 +12,181 @@ intermediate "I was here when I stopped" state) lives in the
12 12
13 13 ---
14 14
15 +## Slice 31 — Unified MFA: recovery codes for any factor
16 +
17 +Cleanup pass over the two-factor surface so recovery codes are tied to "MFA" generally, not specifically to TOTP. With WebAuthn-only users now possible (slice 30 only minted codes on TOTP enroll), they would have had no recovery path on key loss.
18 +
19 +- **`TwoFactor.ensure_recovery_codes/1`** — mints codes only if the user has none unused. Called from both TOTP enable + first WebAuthn enrollment so codes are issued exactly once across MFA setup methods, no matter which factor the user adds first.
20 +- **`Webauthn.register_credential/3`** return type widened to `{:ok, cred, recovery_codes | nil}`. Controller forwards the codes to the JS client; the JS hook renders them inline (via a `[data-recovery-codes-target]` sink in the page) with an "I've saved these" button to dismiss + reload. Existing TOTP flow unchanged — recovery codes still appear on the enrollment page after a successful first code.
21 +- **`TwoFactor.disable/1`** — keeps recovery codes if the user has any WebAuthn credentials remaining (they're still useful as a fallback for the other factor). Only wipes them when 2FA is fully off.
22 +- **`UserAuth.log_in_user/3`** — gates on `mfa_enabled?/1` (TOTP **or** WebAuthn), so a WebAuthn-only user no longer slips past the challenge.
23 +- **`TwoFactorChallenge`** LiveView — reads `has_totp?` + `has_webauthn?` separately. WebAuthn-only users default to `:recovery` code entry (since there's no TOTP to fall back to) and the "Back to authenticator code" link is hidden. Security-key button stays visible for both states.
24 +- 3 new tests; 320 pass overall.
25 +
26 +## Slice 30 — WebAuthn / security keys
27 +
28 +Second-factor option alongside TOTP. Hardware keys (YubiKey, Titan), platform authenticators (Touch ID / Windows Hello), and browser passkeys all work — anything implementing FIDO2.
29 +
30 +- **Dep**: `:wax_` for attestation + assertion verification (handles CBOR + COSE + multiple attestation formats + clock-skew bounds).
31 +- **Schema**: new `user_webauthn_credentials` table — `credential_id`, COSE `public_key`, `sign_count` (clone-detection counter), `aaguid` (authenticator model), `name`, `last_used_at`. Unique on `(user_id, credential_id)` so re-registering the same physical key replaces rather than duplicates.
32 +- **`GitGud.Accounts.Webauthn`** context — `registration_challenge/1`, `serialize_registration_options/2`, `register_credential/3`, `authentication_challenge/1`, `serialize_authentication_options/1`, `verify_assertion/3` plus CRUD (`list_credentials`, `delete_credential`, `rename_credential`, `count`, `has_credentials?`). Pre-existing credentials populate `excludeCredentials` so the browser doesn't try to enroll an already-paired key.
33 +- **Endpoints**:
34 + - `POST /api/webauthn/register/{start,finish}` — authed; the start handler stashes the `Wax.Challenge` in the session
35 + - `POST /api/webauthn/auth/{start,finish}` — anonymous (mid-2FA); reads `:two_factor_user_id` from session, on success writes `:webauthn_verified_user_id` and returns the JSON redirect target
36 +- **JS hooks** (`assets/js/webauthn.js`) — two LiveView hooks (`WebauthnRegister`, `WebauthnAuthenticate`) handle base64url ↔ Uint8Array conversion + `navigator.credentials.create / .get`. CSRF token forwarded via `x-csrf-token` header.
37 +- **UI integration**:
38 + - `TwoFactorSetup` LiveView gets a "Security keys" section with an **Add security key** button + a list of registered keys (name, AAGUID, added / last-used timestamps, remove button)
39 + - `TwoFactorChallenge` LiveView (login second factor) shows a **Use security key** button when the user has any registered — visible alongside the TOTP / recovery code paths
40 +- 7 new tests cover challenge generation, serialization shape, and CRUD. The cryptographic round-trip is covered upstream in Wax's own test suite + manual browser testing. 317 tests pass overall.
41 +
42 +Notes:
43 +- `config :git_gud, GitGud.Accounts.Webauthn` accepts `:rp_id` (defaults to the endpoint host) and `:origin` (defaults to `GitGudWeb.Endpoint.url()`). For a multi-origin deployment (e.g. dev + prod under different hostnames), set these explicitly.
44 +- Recovery flow when the user loses every security key + their TOTP secret: recovery codes from slice 29 still work.
45 +
46 +## Slice 29 — TOTP + recovery codes + instance enforcement
47 +
48 +- **Deps**: `:nimble_totp` for RFC 6238 codes, `:eqrcode` for the enrollment QR.
49 +- **Schema**: `users` gains `totp_key_id`, `totp_ciphertext`, `totp_iv`, `totp_tag`, `totp_enabled_at`. Secrets encrypted via the existing AES-256-GCM helpers (same primitive as CI secrets, same key rotation story). New `user_recovery_codes` table — bcrypt-hashed, one row per code, `used_at` marks consumption.
50 +- **`GitGud.Accounts.TwoFactor`** — context covering enroll / verify / disable / recovery codes / instance enforcement. Three user states: disabled / pending / enabled. A half-enrolled secret never gates login.
51 +- **Login flow**`UserAuth.log_in_user/3` detects `TwoFactor.enabled?/1` and stashes `:two_factor_user_id` in session, then redirects to `/users/log-in/two-factor`. The challenge LiveView accepts a 6-digit TOTP **or** a recovery code (the latter consumed single-use). On success, a small controller endpoint clears the stash and runs `complete_two_factor/2`, which is the only place where `log_in_user` gets to call `create_or_extend_session`.
52 +- **Enrollment LiveView** at `/users/settings/two-factor` — three views in one module: disabled → pending (QR + manual key + confirmation field) → enabled (recovery codes shown once + regenerate / disable controls). Pending secrets live in socket state only; they touch the DB only after a valid first code.
53 +- **Recovery codes** — 10 codes per user, lowercase base32, hashed with bcrypt (each row its own salt). Verification scans the user's unused codes; on miss runs `Bcrypt.no_user_verify/0` so timing doesn't leak validity.
54 +- **Instance enforcement**`config :git_gud, GitGud.Accounts.TwoFactor, required: true, grace_period_days: 14`. `enforcement_status/1` returns `:not_required | :enrolled | {:grace, days_left} | :enforced`. The `:require_authenticated` on_mount hook redirects users in the `:enforced` state to the enrollment LiveView (excluding the enrollment LiveView itself to avoid a loop). Default ships off so existing deployments aren't surprised.
55 +- **Settings page** gets a Two-factor section with a status-aware enable/manage button.
56 +- **Runner UUID fix** — Forgejo runner v12+ validates the `uuid` field on the `Register` response as a real UUID; we were sending stringified `runner.id` ("4", "5") and the runner failed config save with "invalid UUID length: 1". Added `runners.uuid` column (Ecto.UUID, backfilled with `gen_random_uuid()`), generated on registration, returned in the proto.
57 +- 13 new TwoFactor tests; 310 tests pass overall.
58 +
59 +## Slice 28 — Per-scope runners + org secrets UI
60 +
61 +- **Runner scope** — migration `20260513200000` adds `runners.repository_id` + `runners.organization_id` (nullable, with a CHECK constraint enforcing at most one set) plus `runner_reg_token_hash` columns on repositories + organizations.
62 +- **`Workflows.Runners`** rewritten around scope:
63 + - `mint_registration_token(repo_or_org)` stores SHA-256 hash, returns bare token once
64 + - `consume_registration_token/1` resolves a token to `:global` / `%Repository{}` / `%Organization{}` or `:error`
65 + - `register/2` takes a scope, sets the right FK
66 + - `list_for_scope/1` returns repo-eligible runners (repo + parent org + global), org-eligible (org + global), or just global
67 + - `delete_runner/1` for revocation
68 + - Old `valid_registration_token?/1` removed (callers use `consume_registration_token/1`)
69 +- **`claim_next_job/1`** widened — joins through `workflow_runs` (and `repositories` for org scope) so a repo runner only sees its repo's jobs, an org runner sees jobs from any of its repos, and a global runner sees everything. Label match unchanged.
70 +- **`RunnerController.register`** consumes the token to determine scope and threads it into `Runners.register/2`. Legacy global env token still works.
71 +- **`RepoLive.SettingsRunners`** at `/r/:owner/:name/settings/runners` — mint / regenerate the repo token (with copy-paste `forgejo-runner register` snippet), list eligible runners with scope badges, remove repo-scoped runners.
72 +- **`OrgLive.SettingsRunners`** at `/orgs/:handle/settings/runners` — same pattern for orgs.
73 +- **`SecretsLive.Org`** at `/orgs/:handle/settings/secrets` — mirror of `SecretsLive.Repo`. The org secrets/vars backend was already shipped slice 16; this was the missing UI.
74 +- **Settings nav** — repo settings page gets a sub-nav linking to Secrets / Runners / Branches / Webhooks / Deploy keys. Org show page gets an admin-only Secrets + Runners nav.
75 +- 6 new tests covering token mint/consume/regen, scope-aware `register/2`, and `list_for_scope/1` aggregation. 297 tests pass overall.
76 +
77 +## Slice 27 — Expand repo CI UI
78 +
79 +Fills out the workflow LiveViews so the existing CI backend is actually visible.
80 +
81 +- **`Workflows.latest_run/1`** and **`Workflows.list_artifacts/1`** context helpers — the controller had inline queries, but the UI needed straightforward functions.
82 +- **Repo show header** gets an **Actions** link with a status badge showing the most recent run's state (queued / running / success / failure). Currently sits next to Compare and is visible to anyone with read access.
83 +- **Workflow index** lists each run with **start time + duration** ("started 2026-05-13 11:00 · in 1m 42s") alongside trigger/ref/sha.
84 +- **Workflow show**:
85 + - Run-level **started / finished / duration** under the badge
86 + - Per-job **duration** in the job header
87 + - Per-step **duration** in the toggle row
88 + - New **Artifacts panel** below jobs: name, size, content-type, upload time + per-artifact Download button
89 +- **`GitGudWeb.WorkflowArtifactController`** — browser-side download path at `/r/:owner/:name/actions/:number/artifacts/:id`. Gates on repo read access from `current_scope`, sets `Content-Disposition: attachment`, sanitizes filenames. The runner-side endpoint (`/api/actions/_apis/...`) keeps its JWT gate intact.
90 +- 3 new tests covering `latest_run/1` + `list_artifacts/1` ordering. 291 tests pass overall.
91 +
92 +Still left from the CI roadmap (separate slices when wanted):
93 +- Re-run / cancel actions
94 +- Cache-hit path on the v1 Actions cache server
95 +- Org-scoped secrets/vars LiveView (the repo equivalent shipped slice 16)
96 +
97 +## Slice 26 — Markdown styling + per-user editor theme
98 +
99 +Followup to slice 25 — the cache + highlighter were rendering correctly but two visible gaps remained: no typography styles for the `prose` selector (Tailwind v4 has no built-in typography), and no way for a user to pick a syntax theme.
100 +
101 +- **Hand-rolled `.prose` CSS** in `assets/css/app.css` covering headings (h1/h2 get separator rules like GitHub), paragraphs, lists with disc/decimal markers, GFM task lists (with the checkbox prefix), blockquotes, GitHub-style alerts (`> [!NOTE]` etc), inline + fenced code, tables, images, hr, heading anchors. Plain `.markdown-fallback` / `.lumis-fallback` styled too. All built on daisyUI's `--color-base-*` variables so it tracks the active page theme.
102 +- **`users.editor_theme` column** (migration `20260513180000`) plus `User.editor_theme_changeset/2`.
103 +- **`GitGud.Themes.Editor`** — curated Lumis subset surfaced in UI: OneDark, Dracula, Solarized Dark/Light, Monokai, GitHub Dark/Light, Gruvbox Dark/Light, Nord, Tokyo Night, Catppuccin Mocha/Latte, Rose Pine. `for_scope/1` resolves from `current_scope.user.editor_theme`, falling back to the app default when unset or invalid.
104 +- **`assign_editor_theme/1`** hook in `UserAuth.on_mount/*` puts `@editor_theme` into every LiveView's assigns automatically (anon + authed). No per-view boilerplate.
105 +- **`GitGud.Markdown.to_html/2`** + **`GitGud.SyntaxHighlight.highlight/2`** both accept `:theme`; cache keys include it so changing themes doesn't poison prior entries.
106 +- **All call sites updated** — issue / PR / wiki / repo show / blob views now pass `@editor_theme` through.
107 +- **User settings page** — new "Editor theme" form between email and password with a dropdown of the curated set. Empty selection means "use app default."
108 +- 4 new tests for `GitGud.Themes.Editor`. 288 tests pass overall.
109 +
110 +## Slice 25 — Nebulex cache + syntax highlighting + markdown overhaul
111 +
112 +- **`GitGud.Cache`** — Nebulex local cache (ETS, partitioned) started under the app supervisor. Configured with `max_size: 50_000`, `allocated_memory: 200MB`, `gc_interval: 12h`. Adapter swap (replicated / partitioned) is a config change, not a code change.
113 +- **`GitGud.SyntaxHighlight`** — Rust-backed highlighter via [Lumis](https://lumis.sh), the tree-sitter highlighter pulled in transitively through MDEx. (User asked for Syntect; Lumis is functionally equivalent and already in the dep graph — same Rust-NIF latency profile.) Language resolved from explicit `:language` or `:filename` (extension + basename map for `Dockerfile`, `Makefile`, etc.). Results cached by sha256(content) + language + theme; never go stale because the key includes the bytes. 250KB cap with HTML-escaped fallback above that.
114 +- **`GitGud.Markdown`** — single entry point for all markdown rendering in the codebase. Backed by MDEx with the full GFM feature set: tables, strikethrough, task lists, autolink, footnotes, GitHub-style alerts (`> [!NOTE]`), heading anchors (`h-` prefix so deep links work), smart punctuation, relaxed task-list matching. Fenced code blocks get Lumis-powered syntax highlighting via MDEx's `:syntax_highlight` formatter. Sha256-keyed cache with 500KB input cap.
115 +- **Duplicated `render_md` helpers eliminated**`WikiLive.Show`, `PrLive.Show`, `IssueLive.Show`, `RepoLive.Show`, `RepoLive.Blob` all delegated to `GitGud.Markdown.to_html/1`. Each was previously its own copy of the same MDEx call.
116 +- **`RepoLive.Blob` now syntax-highlights source files** — non-markdown text files render through `SyntaxHighlight.highlight/2`, picking up the filename-based language hint. Markdown files keep the markdown render path. Binary / oversize fallbacks unchanged.
117 +- 12 new tests (8 markdown + 4 highlight) covering GFM features, cache hits, oversize fallback, language resolution. 284 tests pass overall.
118 +
119 +Notes for ops:
120 +- Cache is per-node ETS; restart drops it. Acceptable since regen is cheap and miss path returns the right answer.
121 +- To partition across a cluster, swap `Nebulex.Adapters.Local` for `Nebulex.Adapters.Partitioned` in `GitGud.Cache`. No call-site changes required.
122 +
123 +## Slice 24 — Disk storage backend + LFS
124 +
125 +Pluggable object storage with a Disk impl for dev / single-node prod / NFS-shared multi-node, plus the Git LFS Batch API on top.
126 +
127 +- **`GitGud.Storage.Disk`**`@behaviour GitGud.Storage` impl reading/writing under a configured `:root` directory. `presign_url/4` returns a token-signed URL routed through a new `GitGudWeb.StorageController`. Token via `Phoenix.Token`, embeds bucket+key+method+expiry. Path components sanitized against traversal (`..`, `/`, `\`).
128 +- **`GitGudWeb.StorageController`**`GET` and `PUT` at `/_storage/:bucket/*key` verify the token and stream bytes to/from the on-disk path via `send_file` / chunked `read_body`. New `:raw` router pipeline so Plug.Parsers doesn't swallow the upload body.
129 +- **`lfs_objects` table** (migration `20260513170000`) — `repository_id`, `oid` (64-hex SHA-256), `size`, `storage_bucket`, `storage_key`, `uploaded_at`. Unique on `(repository_id, oid)`.
130 +- **`GitGud.Lfs` context**`batch/3` for the LFS Batch API ("download" / "upload"), `verify/3` for the post-upload completion call, `get/2` lookup. Storage key layout `<repo_id>/<oid[0..1]>/<oid[2..]>` to keep fan-out reasonable on filesystem backends. Placeholder rows are created at batch time so concurrent uploads of the same oid don't race; `uploaded_at` is only set after `verify`.
131 +- **`GitSmartHttp` plug** gets two new dispatch clauses: `POST /:owner/:name.git/info/lfs/objects/batch` and `POST /:owner/:name.git/info/lfs/verify`. JSON body parsed inline (Plug.Parsers is bypassed at this point in the pipeline). Auth reuses the existing basic-auth helpers — download maps to read, upload maps to write.
132 +- **Config**`config :git_gud, GitGud.Storage, impl: GitGud.Storage.Disk` is the new default; `config :git_gud, GitGud.Storage.Disk, root: "priv/storage"`. Tests + dev use Disk under `tmp/test/storage`. Prod can swap to `GitGud.Storage.Garage` with one config line.
133 +- **Tests** — 11 unit tests (Storage.Disk roundtrip + token verification + LFS context lifecycle) and 3 HTTP-level tests against the LFS endpoints. 272 tests pass overall.
134 +
135 +Notes for ops:
136 +- Multi-node Disk requires every node to mount the same shared filesystem at the configured `:root`. NFS or any POSIX-shared FS works; no node-local writes.
137 +- For S3-flavored deployments, set `impl: GitGud.Storage.Garage` and configure `ex_aws` as described in that module's docstring.
138 +- The `/_storage` routes only matter for the Disk backend; with Garage the presigned URLs go directly to S3.
139 +
140 +## Slice 23 — Federation follow-up UI
141 +
142 +The three small UI gaps left by slice 22.
143 +
144 +- **Admin reports queue — Forward to source button**`AdminFederationLive.Index` reports tab now has a "Forward to source" action next to Dismiss/Mark actioned. Calls `Reports.forward_to_source/2`; surfaced only when `target_type ∈ {pull_request, pr_comment, actor}`. Backend gates the actual federation eligibility, button is just a heuristic gate.
145 +- **Remote actor profile pages**`GitGudWeb.ActorLive.Show` at `/actors/:id` renders a remote (or local) actor's cached profile: name, handle@host, summary, last_fetched_at, status, canonical URL link. Includes a "Report this actor" button that opens an `actor` target-type report. Federated-PR show page links the "Offered by" line to `/actors/:id` so reading users can reach the report path in one click.
146 +- **"Send PR to remote repo" UI**`PrLive.New` gains a toggle for "Send to a remote repository (ForgeFed Offer)". When on, the target-branch input becomes a free-text field (we don't know the remote's branches), an AP URL field appears, and Submit calls `Federation.Outbound.offer_pull_request/4` instead of `create_pull_request/3`. No local PR row is created — the user is told "Offer queued for delivery."
147 +- 258 tests still pass; UI-only wiring with no new tests.
148 +
149 +ROADMAP federation section now empty except for the bigger discretionary items (federated reviews shape, `mix gitgud.federation.suggest_blocks`, RFC 9421, reputation aging).
150 +
151 +## Slice 22 — Federation completion
152 +
153 +Closes the remaining federation gaps. Covers the federated-PR lifecycle (Update/Reject + comments), outbound delivery hardening, remote-actor freshness, and abuse-report plumbing.
154 +
155 +- **Outbound dead-letter**`DeliverOutbound` now distinguishes terminal vs in-flight failures: on the final attempt the row is written with `status: "dead_letter"` (new status enum value) and the job is `:discard`ed cleanly instead of bouncing as a retryable error. Status enum: `pending|success|failure|dropped|dead_letter`.
156 +- **Inbound `Update`**`ProcessInbox.dispatch("Update", …)` matches a Ticket with merge intent against the source actor's open federated PRs, calls `PullRequests.refresh_federated_head/2` to re-fetch the source branch, re-snapshot `head_sha` + `base_sha`, re-pin `refs/pull/<N>/head`, and clear `mergeable` for re-probing.
157 +- **Inbound `Reject`** — Reject of a Ticket (by id or as a bare URL) closes the matching federated PR via `PullRequests.set_state/2`.
158 +- **Inbound `Create(Note)`**`Create` with a Note `inReplyTo` a local PR's Ticket URL lands as a federated PR comment with `source_actor_id` set and `author_id: nil`. Idempotent via unique partial index on `pr_comments.activity_url`. Migration `20260513160000_add_federated_pr_comment_origin` adds `source_actor_id`, `activity_url` columns; `PrComment.federated_changeset/2` + `federated?/1` helper.
159 +- **Remote actor refresh worker**`Federation.Workers.RefreshRemoteActors` walks 50 stalest active remote actors per hour (cron `@hourly`), re-fetching them via `RemoteActors.fetch/1`. 404/410 responses mark the actor `status: "suspended"` so we stop wasting refresh cycles on tombstones.
160 +- **PR + actor reporting (UI wiring)** — PR show page now has a flag button on the header and on each comment. Handler mirrors the issue path: `GitGud.Reports.open(user, {type, id}, reason)`. `pr_comments` preload extended with `:source_actor` so federated comments render with origin handle + `federated` badge.
161 +- **Cross-instance report forwarding**`Reports.forward_to_source/2` resolves the report's target to its originating remote actor (PR / pr_comment / actor target types), builds an AP `Flag` activity authored by the admin's local actor, and queues `DeliverOutbound` to the remote shared / actor inbox. New `ActivityBuilders.flag/3`. Refuses non-federated targets with `{:error, :not_federated}`.
162 +- **Outbound `Offer`**`GitGud.Federation.Outbound.offer_pull_request/4` resolves a target repository's AP actor URL via `RemoteActors.fetch/1`, validates the target is a `Repository`-typed actor, builds an `Offer(Ticket)` via the new `ActivityBuilders.offer_pull_request/3`, inserts a local activity row, and queues `DeliverOutbound`. No local PR is created — the canonical state lives on the target instance. Operator-callable today; UI integration for "send to remote repo" is a follow-up.
163 +- 7 new tests (`pull_requests_federated_test.exs` grows from 3 to 6 covering Update/Reject/Note; new `federation/outbound_test.exs` covers offer + Flag forwarding + non-federated refusal). 258 tests pass overall.
164 +
165 +Deliberately out of scope for the slice:
166 +- Admin LiveView for the reports queue (with dismiss/action/forward buttons) — backend exists, UI is a separate small task
167 +- Remote actor profile pages with their own "Report this actor" button — needs the actor browser first
168 +- RFC 9421 signature support — current draft-cavage-12 still works against the federation universe
169 +
170 +## Slice 21 — Federated PRs (inbound `Offer`)
171 +
172 +- Migration `20260513150000_add_federated_pr_origin` adds three columns to `pull_requests`: `source_actor_id` (FK to `actors`), `activity_url` (unique partial index for idempotency), `source_clone_url`. Plus index on `source_actor_id`.
173 +- `PullRequest` schema gains `belongs_to :source_actor`, `field :activity_url`, `field :source_clone_url`, and `federated?/1`.
174 +- `PullRequests.create_federated_pull_request/3` — opens a PR whose source is a remote AP actor. Fetches the source branch directly from the supplied `source_clone_url` into the target repo (`refs/pull-tmp/...`), verifies the claimed head SHA is reachable, computes the merge base, inserts a PR with `source_repository_id=nil`, `author_id=nil`, `source_actor_id` set, and pins head into `refs/pull/<N>/head`.
175 +- `ProcessInbox.dispatch("Offer", …)` — recognizes a ForgeFed `Ticket` with `isMerge: true` (or `sourceBranch`/`targetBranch` heuristics), resolves the source clone URL from `object.source.cloneUri` then `actor.endpoints.cloneUri` then the actor URL + `.git` as a last resort. Idempotent on `activity_url`.
176 +- `PrLive.Show` and `PrLive.Index` render a "federated" badge for `source_actor_id`-bearing PRs; the show page also names the offering actor and the source clone URL.
177 +- 3 new tests in `test/git_gud/pull_requests_federated_test.exs`: direct `create_federated_pull_request` path with head pinning, end-to-end through `ProcessInbox` with re-delivery idempotency, and non-merge Ticket rejection. 251 tests pass overall.
178 +- Outbound `Offer` (local user opens a PR against a remote repo) is **not** in this slice — that needs a target-resolution flow (webfinger or AP id) and the existing `ActivityBuilders` has no `pr_offered` builder yet. Tracked in ROADMAP under outbound federation.
179 +
180 +## Slice 20 — Embedded SSH daemon
181 +
182 +- `GitGud.Ssh.Server` GenServer boots Erlang's `:ssh.daemon/2` under the app supervisor when `config :git_gud, GitGud.Ssh.Server, enabled: true`. Default port 2222. Host key auto-generated to `priv/ssh/ssh_host_ed25519_key` on first boot via `ssh-keygen` (gitignored).
183 +- `GitGud.Ssh.KeyApi` (`:ssh_server_key_api`): looks up presented public key by SHA-256 fingerprint in `user_ssh_keys`, requires the SSH username to equal the user's email local-part (Gerrit pattern). Fingerprint parity with `SshKey.compute_fingerprint/1` via `:ssh_message.ssh2_pubkey_encode/1` with manual SSH wire-format fallback for ed25519 + RSA.
184 +- `GitGud.Ssh.Channel` (`:ssh_server_channel`): parses `git-{upload,receive}-pack '<owner>/<repo>.git'`, authorizes against repo visibility / ownership / org admin, spawns git via Port using the subcommand form (`git upload-pack <path>`, not the dashed binary — wrong invocation reported "is not a git command"). Stderr stays on BEAM's stderr, **not** merged into stdout, so the pkt-line protocol stream isn't corrupted. Deny messages go via SSH extended-data type 1 (renders as `remote:` on the client).
185 +- Empty-repo page's `ssh_clone_url/2` now uses the configured SSH port + optional `external_host`.
186 +- `HookReceiver.receive/2` self-heals `Repository.default_branch` from disk HEAD when the configured branch doesn't resolve — closes the "first push to master while default_branch=main shows empty repo" gap.
187 +- 4 fingerprint-parity tests in `test/git_gud/ssh/key_api_test.exs`. 248 tests pass.
188 +- Deploy keys over the embedded daemon: not in v1 (still work via the external SSH wrapper). Documented in module doc.
189 +
15 190 ## Slice 19 — Fork sync, compare view, empty-repo onboarding
16 191
17 192 - `Repositories.fork_status/2` — fetches upstream into the fork under `refs/upstream/<branch>` and returns `{behind, ahead, fast_forward?, parent_sha, fork_sha}`
@@ -226,7 +401,19 @@ Closed slices 14, 15, 16, 17 in this session. Final task graph:
226 401 | 65–70 | done | Forks + cross-repo PRs (slice 17) |
227 402 | 71–74 | done | Layout + nav + themes + landing (slice 18) |
228 403 | 75–77, 79 | done | Fork sync, compare, empty-repo onboarding (slice 19) |
229 | 78 | pending | Federated PRs (next slice) |
230
231 End-of-session: 244 tests pass. Federated PRs (#78) is the open follow-up.
404 +| 80–83 | done | Embedded SSH daemon + self-healing default_branch (slice 20) |
405 +| 78 | done | Federated PRs — inbound `Offer` (slice 21) |
406 +| 84 | done | Federation completion: outbound dead-letter, Update/Reject/Note, refresh worker, PR reporting, forward Flag, outbound Offer (slice 22) |
407 +| 85 | done | Federation follow-up UI: admin Forward button, actor profile page, Send-PR-to-remote toggle (slice 23) |
408 +| 86 | done | Disk storage backend + LFS Batch API (slice 24) |
409 +| 87 | done | Nebulex cache + Lumis syntax highlight + Markdown overhaul (slice 25) |
410 +| 88 | done | Markdown styling + per-user editor theme (slice 26) |
411 +| 89 | done | Repo CI UI: artifacts panel, timings, latest-run badge (slice 27) |
412 +| 90 | done | Per-scope runners + org secrets UI (slice 28) |
413 +| 91 | done | TOTP + recovery codes + instance enforcement + runner UUID fix (slice 29) |
414 +| 92 | done | WebAuthn / security keys (slice 30) |
415 +| 93 | done | Unified MFA: recovery codes for any factor (slice 31) |
416 +
417 +End-of-session: 320 tests pass. Next likely: outbound `Offer` (local PR against remote repo) — needs target-resolution (webfinger or AP id) and an `Offer(Ticket)` builder. Or LFS, the other "now" item.
418 +Embedded SSH daemon verified end-to-end against `git push --set-upstream origin master` from a real client (after fixing two bugs: spawning git with the dashed subcommand name failed; `:stderr_to_stdout` on the Port corrupted the pkt-line stream).
232 419
modified README.md
+221 −11

Click to load diff…

modified ROADMAP.md
+7 −9

Click to load diff…

modified assets/css/app.css
+147 −0

Click to load diff…

modified assets/js/app.js
+2 −1

Click to load diff…

added assets/js/webauthn.js
+181 −0

Click to load diff…

modified config/config.exs
+39 −1

Click to load diff…

modified config/dev.exs
+11 −3

Click to load diff…

modified config/test.exs
+5 −0

Click to load diff…

modified ?
+0 −0

Click to load diff…

added lib/git_gud/accounts/recovery_code.ex
+27 −0

Click to load diff…

added lib/git_gud/accounts/two_factor.ex
+302 −0

Click to load diff…

modified lib/git_gud/accounts/user.ex
+15 −0

Click to load diff…

added lib/git_gud/accounts/webauthn.ex
+257 −0

Click to load diff…

added lib/git_gud/accounts/webauthn_credential.ex
+42 −0

Click to load diff…

modified lib/git_gud/application.ex
+2 −0

Click to load diff…

added lib/git_gud/cache.ex
+30 −0

Click to load diff…

modified lib/git_gud/federation/activity_builders.ex
+63 −0

Click to load diff…

added lib/git_gud/federation/outbound.ex
+117 −0

Click to load diff…

modified lib/git_gud/federation/outbound_delivery.ex
+1 −1

Click to load diff…

modified lib/git_gud/federation/workers/deliver_outbound.ex
+13 −4

Click to load diff…

modified lib/git_gud/federation/workers/process_inbox.ex
+279 −0

Click to load diff…

added lib/git_gud/federation/workers/refresh_remote_actors.ex
+57 −0

Click to load diff…

added lib/git_gud/lfs.ex
+177 −0

Click to load diff…

added lib/git_gud/lfs/lfs_object.ex
+34 −0

Click to load diff…

added lib/git_gud/markdown.ex
+126 −0

Click to load diff…

modified lib/git_gud/organizations/organization.ex
+2 −0

Click to load diff…

modified lib/git_gud/pull_requests.ex
+176 −1

Click to load diff…

modified lib/git_gud/pull_requests/pr_comment.ex
+17 −0

Click to load diff…

modified lib/git_gud/pull_requests/pull_request.ex
+11 −0

Click to load diff…

modified lib/git_gud/reports.ex
+90 −0

Click to load diff…

modified lib/git_gud/repositories/hook_receiver.ex
+67 −5

Click to load diff…

modified lib/git_gud/repositories/repository.ex
+4 −0

Click to load diff…

added lib/git_gud/ssh/channel.ex
+278 −0

Click to load diff…

added lib/git_gud/ssh/key_api.ex
+102 −0

Click to load diff…

added lib/git_gud/ssh/server.ex
+112 −0

Click to load diff…

added lib/git_gud/storage/disk.ex
+144 −0

Click to load diff…

added lib/git_gud/syntax_highlight.ex
+167 −0

Click to load diff…

added lib/git_gud/themes/editor.ex
+86 −0

Click to load diff…

modified lib/git_gud/workflows.ex
+62 −9

Click to load diff…

modified lib/git_gud/workflows/runner.ex
+15 −1

Click to load diff…

modified lib/git_gud/workflows/runners.ex
+183 −21

Click to load diff…

modified lib/git_gud_web/components/core_components.ex
+260 −0

Click to load diff…

modified lib/git_gud_web/controllers/runner_controller.ex
+41 −27

Click to load diff…

added lib/git_gud_web/controllers/storage_controller.ex
+92 −0

Click to load diff…

added lib/git_gud_web/controllers/webauthn_controller.ex
+139 −0

Click to load diff…

added lib/git_gud_web/controllers/workflow_artifact_controller.ex
+75 −0

Click to load diff…

added lib/git_gud_web/live/actor_live/show.ex
+116 −0

Click to load diff…

modified lib/git_gud_web/live/admin_federation_live/index.ex
+36 −0

Click to load diff…

modified lib/git_gud_web/live/branch_protection_live/index.ex
+1 −6

Click to load diff…

modified lib/git_gud_web/live/deploy_key_live/index.ex
+1 −6

Click to load diff…

modified lib/git_gud_web/live/interaction_policy_live/edit.ex
+1 −6

Click to load diff…

modified lib/git_gud_web/live/issue_live/show.ex
+3 −13

Click to load diff…

added lib/git_gud_web/live/org_live/settings_runners.ex
+167 −0

Click to load diff…

modified lib/git_gud_web/live/org_live/show.ex
+14 −0

Click to load diff…

modified lib/git_gud_web/live/pr_live/index.ex
+3 −0

Click to load diff…

modified lib/git_gud_web/live/pr_live/new.ex
+116 −9

Click to load diff…

modified lib/git_gud_web/live/pr_live/show.ex
+90 −19

Click to load diff…

modified lib/git_gud_web/live/repo_live/blob.ex
+14 −22

Click to load diff…

added lib/git_gud_web/live/repo_live/settings.ex
+162 −0

Click to load diff…

added lib/git_gud_web/live/repo_live/settings_runners.ex
+198 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/show.ex
+56 −19

Click to load diff…

modified lib/git_gud_web/live/repo_live/tree.ex
+11 −8

Click to load diff…

added lib/git_gud_web/live/secrets_live/org.ex
+177 −0

Click to load diff…

modified lib/git_gud_web/live/secrets_live/repo.ex
+1 −6

Click to load diff…

modified lib/git_gud_web/live/user_live/settings.ex
+59 −0

Click to load diff…

added lib/git_gud_web/live/user_live/two_factor_challenge.ex
+177 −0

Click to load diff…

added lib/git_gud_web/live/user_live/two_factor_setup.ex
+289 −0

Click to load diff…

modified lib/git_gud_web/live/webhook_live/index.ex
+1 −6

Click to load diff…

modified lib/git_gud_web/live/wiki_live/show.ex
+2 −12

Click to load diff…

modified lib/git_gud_web/live/workflow_live/index.ex
+18 −0

Click to load diff…

modified lib/git_gud_web/live/workflow_live/show.ex
+65 −3

Click to load diff…

modified lib/git_gud_web/plugs/git_smart_http.ex
+70 −0

Click to load diff…

modified lib/git_gud_web/router.ex
+82 −3

Click to load diff…

modified lib/git_gud_web/user_auth.ex
+89 −7

Click to load diff…

modified mix.exs
+15 −2

Click to load diff…

modified mix.lock
+8 −0

Click to load diff…

added priv/repo/migrations/20260513150000_add_federated_pr_origin.exs
+25 −0

Click to load diff…

added priv/repo/migrations/20260513160000_add_federated_pr_comment_origin.exs
+17 −0

Click to load diff…

added priv/repo/migrations/20260513170000_create_lfs_objects.exs
+26 −0

Click to load diff…

added priv/repo/migrations/20260513180000_add_user_editor_theme.exs
+11 −0

Click to load diff…

added priv/repo/migrations/20260513190000_fix_editor_theme_ids.exs
+31 −0

Click to load diff…

added priv/repo/migrations/20260513200000_scope_runners.exs
+41 −0

Click to load diff…

added priv/repo/migrations/20260513210000_create_user_two_factor.exs
+32 −0

Click to load diff…

added priv/repo/migrations/20260513220000_add_runner_uuid.exs
+26 −0

Click to load diff…

added priv/repo/migrations/20260513230000_create_user_webauthn_credentials.exs
+42 −0

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/HEAD
+0 −1

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/config
+0 −6

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/description
+0 −1

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/applypatch-msg.sample
+0 −15

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/commit-msg.sample
+0 −74

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/fsmonitor-watchman.sample
+0 −168

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/post-receive
+0 −3

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/post-update.sample
+0 −8

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/pre-applypatch.sample
+0 −14

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/pre-commit.sample
+0 −49

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/pre-merge-commit.sample
+0 −13

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/pre-push.sample
+0 −53

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/pre-rebase.sample
+0 −169

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/pre-receive
+0 −3

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/pre-receive.sample
+0 −24

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/prepare-commit-msg.sample
+0 −42

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/push-to-checkout.sample
+0 −78

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/sendemail-validate.sample
+0 −77

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/hooks/update.sample
+0 −128

Click to load diff…

deleted priv/repos/gmp/gitgud-2.git/info/exclude
+0 −6

Click to load diff…

deleted priv/repos/gmp/gitgud.git/HEAD
+0 −1

Click to load diff…

deleted priv/repos/gmp/gitgud.git/config
+0 −8

Click to load diff…

deleted priv/repos/gmp/gitgud.git/description
+0 −1

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/applypatch-msg.sample
+0 −25

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/commit-msg.sample
+0 −25

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/docs.url
+0 −1

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/fsmonitor-watchman.sample
+0 −16

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/post-receive
+0 −3

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/post-update.sample
+0 −12

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/pre-applypatch.sample
+0 −27

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/pre-commit.sample
+0 −19

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/pre-merge-commit.sample
+0 −16

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/pre-push.sample
+0 −46

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/pre-rebase.sample
+0 −40

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/pre-receive
+0 −3

Click to load diff…

deleted priv/repos/gmp/gitgud.git/hooks/prepare-commit-msg.sample
+0 −54

Click to load diff…

deleted priv/repos/gmp/gitgud.git/info/exclude
+0 −5

Click to load diff…

added test/git_gud/accounts/two_factor_test.exs
+180 −0

Click to load diff…

added test/git_gud/accounts/webauthn_test.exs
+114 −0

Click to load diff…

added test/git_gud/federation/outbound_test.exs
+224 −0

Click to load diff…

added test/git_gud/lfs_test.exs
+69 −0

Click to load diff…

added test/git_gud/markdown_test.exs
+81 −0

Click to load diff…

added test/git_gud/pull_requests_federated_test.exs
+405 −0

Click to load diff…

added test/git_gud/ssh/key_api_test.exs
+61 −0

Click to load diff…

added test/git_gud/storage/disk_test.exs
+58 −0

Click to load diff…

added test/git_gud/syntax_highlight_test.exs
+38 −0

Click to load diff…

added test/git_gud/themes/editor_test.exs
+28 −0

Click to load diff…

added test/git_gud/workflows/list_helpers_test.exs
+66 −0

Click to load diff…

added test/git_gud/workflows/scoped_runners_test.exs
+82 −0

Click to load diff…

added test/git_gud_web/lfs_http_test.exs
+98 −0

Click to load diff…

modified test/support/fixtures/forge_fixtures.ex
+2 −1

Click to load diff…

Parents: cfa4a8b