US-45.7: thread page with WebAuthn browser login - #912
Merged
Merged
Conversation
The tenant's human reads a story's change thread at /threads/:story_id and writes message and finding entries on it, signed in at /login with the WebAuthn credential enrolled at signup. - Login is Reauth's assertion ceremony under purpose browser_login (Loopctl.WebAuthn.BrowserLogin): stored single-use challenge, counter check, fail closed. The assertion is POSTed through the browser pipeline's CSRF check; the session is renewed, bound to the tenant and the asserting authenticator, lasts at most 8 hours, and is re-validated on every request, every mount, before every write and on a one-minute timer, so a revoked authenticator or an inactive tenant ends it. - The page reads under the tenant's RLS (Threads.page/3); entries render as escaped untrusted text; a checkpoint diff is fetched from the forge by SHA on demand in a start_async task, bounded in bytes and wall clock, never stored. - Writes carry a per-form nonce as the idempotency key, as human:webauthn with an empty lineage, through Threads.record_entry/4 and the new Threads.record_human_finding/3. A human finding binds to a current-claim checkpoint, can be answered by a fix, and counts toward the round-3 decision and the ceiling for the round in progress when it was written. The judgement-shape CHECK admits a review-less finding from that author only. - The intake issue gets one comment linking the thread page: intent recorded in the first checkpoint's transaction (thread_issue_links), posted by ThreadIssueLinkWorker.
- Login answers every slug alike: an unknown, inactive or unenrolled slug gets a decoy challenge of the same shape, its credential id an HMAC of the slug under the endpoint secret. The form carries only the challenge id; the tenant comes from the stored challenge. The per-tenant budgets are removed; both hops are limited per client with RemoteIp.bucket_key/1. - A session is a browser_sessions row. Logout revokes it on the server, and deleting the authenticator or the tenant cascades. It is validated under the tenant's RLS on every request, mount, write and timer tick. - A human finding counts in the round in progress when it was written, on any checkpoint of the claim. A material one written after the final verdict records a review_ceiling escalation against the final review, and the page enqueues the stage move and says so. - A checkpoint's diff is the placed base branch three-dot compare with the checkpoint; the adapter accumulates iodata with a running byte count. - The page opens on the newest entries and loads older ones upward; a write inserts its own entry without re-reading the ledger; a failed diff offers a retry; a page with no story refuses every event. - The issue link is derived by the worker from durable state, so threads that predate the worker are linked too; the checkpoint path no longer writes it. - The page tests and the link worker test run async.
…per-claim diffs - Login is usernameless with a discoverable credential. begin issues a tenantless challenge with empty allowCredentials and user verification required, stored in webauthn_login_challenges. complete identifies the authenticator by the assertion's credential id, globally unique by a new index, and the tenant by the authenticator. An unknown credential fails as a bad assertion does. The slug, the decoy and its key are gone. - User verification is required on the client and enforced on the server for this ceremony only. - A session insert that loses a race with an authenticator revoke is a clean refusal. - The link row is written in the first checkpoint's transaction again; the migration backfills once, for in-flight threads whose issue is not known closed. The worker only drains; the recurring sweep is gone. - A checkpoint's diff uses the placed base of its own claim (dispatch_route/3 on the claim_route_query/3 rule), falling back to the source's current base only when that claim recorded none, and the page says so. - One diff is open at a time; only this page's checkpoints are fetched, and an open or loading diff is not fetched again. - A human finding after the ceiling tells escalated from already escalated; only the former enqueues the stage move.
…ess login webauthn_login_challenges holds browser-login challenges only, BrowserLogin being its one writer, so the purpose column, the purpose argument of the two discoverable Reauth functions and the purpose filter in the consume query are removed. A test now pins that an assertion spends only the challenge it names. AC-45.7.1 now says login uses a discoverable WebAuthn credential enrolled for the tenant, usernameless, with user verification.
- ReauthChallengeCleanupWorker also purges expired or used login challenges. - The form nonce survives a reconnect: both forms have a phx-change handler that adopts the recovered values, a well-formed nonce included; the nonce rotates only after a recorded write. - A resent human finding answers what its first delivery recorded: the escalation at the finding's seq plus one, or nil, never a state inferred from the ceiling now. - The finding form offers only the current claim's checkpoints and is disabled with a reason when there are none; checkpoints, and the diff allow-list, are re-read on the revalidation tick and after every write. - load_older reads entries only (Threads.page_entries/3). - The outbox mechanics IssueClosures and IssueLinks share (attempt bound, backoff, claim, compare-and-set, error text) live in Loopctl.ForgeOutbox; IssueClosures keeps its behaviour and its public API. - The thread's halt check is Runners.custody_halted? everywhere; the second reader is gone. The halt tests move to a committed-tenant module. - One git object id rule, Loopctl.GitSha, used by Threads, Stages and both forge clients. - The migration's backfill doc names the real test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Story US-45.7 (Epic 45 change threads, PRD §6.1). The tenant's human reads a story's thread at
/threads/:story_idand writesmessageandfindingentries on it, after signing in at/loginwith the WebAuthn credential enrolled at signup. The review gate has not run yet; it runs next.What it does
Login (AC-45.7.1).
Loopctl.WebAuthn.BrowserLoginusesReauth's ceremony under the purposebrowser_login. The challenge is stored and single-use, its allow-list is the tenant's enrolledRootAuthenticators, the sign counter is checked, and every failure fails closed.LoginLiverunsnavigator.credentials.get()through theWebAuthnLoginhook. It fills a form with thetenant_idandchallenge_idthe server holds, never values the client sent. It then triggers a POST toBrowserSessionController, so the request goes through:browser's CSRF check and the controller can write the cookie.The session:
{tenant_id, authenticator_id, authenticated_at};live_socket_id, so logout disconnects any open LiveViews;BrowserLogin.validate/2checks the session:RequireBrowserSessionplug);on_mounton the:browser_threadslive_session);The session ends at the next check once the authenticator is revoked (revocation deletes its row) or the tenant is no longer active.
Rate limits fail closed:
Page (AC-45.7.2).
Threads.page/3reads the story, its thread and the project's repository in oneRepo.with_tenanttransaction, under the tenant's RLS and never throughAdminRepo. The page shows:gate_evidence["ci"]summary and jobs;<pre>as escaped text, each marked untrusted, with no Markdown and no raw HTML;"show diff" calls
Threads.checkpoint_diff/3insidestart_async. That function reads the checkpoint and its parent in a short RLS transaction and closes it before calling the new forge callbackPullRequestSource.checkpoint_diff/3:/compare/parent...head, or/commits/headfor a first checkpoint, using the diff media type.truncatedwhen either cap cuts it.Writes (AC-45.7.3). Each form carries a nonce minted when it is rendered, and the nonce is the entry's idempotency key. The key is read from the submitted form, not the socket. A double submit, a resubmit after a lost reply, or a reconnect therefore replays the same key and produces one entry.
Entries are written as
human:webauthnwith an empty lineage. That is the label the audit chain already gives WebAuthn-authenticated human acts, and no API key can produce it.Threads.record_entry/4.Threads.record_human_finding/3: under the story lock, secret-screened, and refusedtenant_haltedduring a halt. A resend is still answered from its row during a halt.Issue link (AC-45.7.4). When a thread records its first checkpoint, the same transaction writes a
thread_issue_linksrow, unique per story. That is when the page first has work on it, and every thread passes through it exactly once.ThreadIssueLinkWorkerruns on cron every 2 minutes. It posts one comment through the existingcomment_issuepath andGITHUB_TOKEN, using a compare-and-set claim and holding nothing across the forge call.Design decisions to check
thread_entries_judgement_shapeCHECK requiredreview_idon every finding. It now also admitsauthor_principal = 'human:webauthn', and no other author. A test proves an agent author is still refused.Reviews.round_findings/4). That window stops one finding counting in two rounds, and stops a finding written after a verdict from reopening that verdict. Within it, a human finding:answers_findings/4);introduced_byrule for the round in progress.CustodySurface. The page shows a halt banner.mark_commented, the comment is re-posted.IssueCloserdocuments and accepts the same cost for its own comment.browserinroute_coverage.test.js, and the route snapshot is regenerated.SOUL rule 9
Tests
New test files:
test/loopctl/web_authn/browser_login_test.exstest/loopctl_web/live/login_live_test.exs: TC-45.7.1, including the CSRF refusal throughEndpoint.calltest/loopctl_web/live/thread_live_test.exs: TC-45.7.2, TC-45.7.3, the session guard, tenant isolationtest/loopctl/threads/human_findings_test.exstest/loopctl/threads/issue_links_test.exstest/loopctl/workers/thread_issue_link_worker_test.exs: TC-45.7.4 end to end on committed data, plus the cron wiringThe adapter's
checkpoint_difftests are ingithub_pull_request_source_test.exs. Every new context function has a tenant isolation case. The commit hook ran the full gate: 11910 tests, 0 failures.Mutation table
Each row ran
~/workspace/claude-config/bin/mutate.sh <file> --no-baseline --old '<text>' --new '<broken>' -- mix test <file>, one at a time, after the check had passed unmutated. Exit 0 means the check went red under the mutation.:browser_sessionfrom the thread scope (plug wiring)on_mount(mount wiring)plug :protect_from_forgeryclear_session()on login:continstead of:halttenant_idthe client sentapi_key:pageinstead of the human principalraw/1status == :pendingNot covered
Process.send_aftercall is not.get()(seeauthenticator_enroll.js). The server side is covered with the mocked adapter.Screenshots
Taken from a dev server running on its own port against an isolated dev database, with a session cookie minted for a seeded tenant, at 1440 px and 390 px wide:
<script>and**markdown**shows as literal text, and the entries contain noscriptelements.acme/widgetsrepository reads "diff unavailable: the forge answered 404", and the ledger beside it is unaffected.Review round 1 (14 findings, fixed in 63ce0e4) — supersedes the design notes above where they differ
RemoteIp.bucket_key/1). An unknown, inactive or unenrolled slug gets a decoy challenge with the same shape (random id and bytes, a credential id derived as an HMAC of the slug), the form carries only thechallenge_id, and the tenant is resolved from the stored challenge; no tenant id is rendered. Residual timing difference (a real challenge costs a lookup and insert) is stated in the moduledoc.browser_sessionstable (RLS) backs the cookie{tenant_id, session_id}; logout revokes the row; every request, mount, write and 60s tick validates it unrevoked, unexpired and its tenant active; deleting the authenticator cascades to its sessions.review_ceilingescalation and the ceiling worker moves the story....checkpoint(what the gate judges), fetched on demand with a retry control after a failure; the body is accumulated linearly.async: true.Mutations: every cited one re-run at the new text (M09, M10 and M29 retired with the code they tested) plus N01-N18; all exit 0. Not covered by a test: the 10s diff deadline (only via a past-deadline unit check), that the 60s timer is scheduled, and logout's disconnect broadcast.
Review round 2 (10 findings, fixed in 8115f03 and 32dbca9) — supersedes the login notes above
allowCredentials; the authenticator is found by its credential id (new global unique indextenant_root_authenticators_credential_id_uidx) and the tenant comes from it; an unknown credential answers exactly like a bad signature. AC-45.7.1 reworded accordingly. The login challenge carries no purpose (a redundant filter my own mutation showed could not fail; removed).dispatch_route/3), falling back to the source's current base only when that claim has no ledger row, and saying so; one diff open at a time; only this page's checkpoints can be fetched.escalatedfromalready_escalatedand only the first moves the stage; a session insert racing a revoke refuses cleanly.Before deploy: the global credential-id index fails the migration if two tenants share a credential id; the CHANGELOG carries the check query. A security key enrolled without a resident credential cannot log in at /login until a passkey is enrolled.
Mutations: cited rows re-run (N01, N02, N15 retired with the deleted code), R01-R22 new, all exit 0 except R23 (no re-enqueue on
already_escalated), whose absence a test cannot observe because the enqueue is idempotent against an already-escalated stage.Review round 3 (the ceiling; 9 findings, fixed in place in 504f339, no round 4)
Contained edges, fixed with mutation proof as #910 and #911 were.
ReauthChallengeCleanupWorker.phx-changehandler adopts the client's nonce (shape-checked) from LiveView's form recovery; it rotates only after a recorded write. Tested by simulating the recovery event; not observed in a real browser.Threads.page_entries/3).Loopctl.ForgeOutboxholds the outbox mechanics IssueClosures and IssueLinks share (IssueClosures' behaviour and tests unchanged).Runners.custody_halted?vianot_halted/1) for every judgement and the page banner; the two halt tests run in their own async: false module on a committed tenant.Loopctl.GitSha.valid?/1is the one commit-SHA rule.Mutations: cited rows re-run at the current text, T01-T17 new, all exit 0 except R23 (as before). T07 (a released claim offers no checkpoints) has no page test; the server refusal of that case is proven (M21). Session check: removing the login-challenge purge fails its test.