oficina / docs Public

Review: Docs onto rastrillo main fb806a2 (confirm-then-spend links, Refused hook) open

On branch chore/rastrillo-confirm

Moves Docs from rastrillo d9c28d7 (2026-09-08, a commit on the unmerged feat-session-context branch) to rastrillo main fb806a2, for #63 (auth.Config.Refused) and #64 (magic-link confirm). Head a871701. Not merged and not deployed.

What changes for a person

  • An emailed link opens a confirm step drawn as Docs' sign-in door (estilo door, Docs mark, bar, title "Sign in · Docs"). It has one "Sign in" button that POSTs the token to /auth/verify, and only that POST spends the link. A mail scanner's GET spends nothing. rastrillo's headers survive: no-store, Referrer-Policy strict-origin, DENY/frame-ancestors.
  • A verified address the allowlist does not hold (from Keymail or a link) gets the door, with the refusal, in a 403, through Config.Refused. doorOnRefusal, the wrapper that matched rastrillo's bare text, is deleted. No test pinned the sentence literally; the Keymail integration test had pinned it implicitly, and that test now covers the hook.

Breaking changes absorbed

  1. POST /auth/verify is mounted beside the GET, behind the same localAuthGuard and the app-wide CSRF check.
  2. Config.RenderConfirm is set to confirmSignin (auth_navigation.go). estilo has no confirm state yet, so the view composes estilo-door-open/-close around a heading, a hidden token and one button. The words come from estilo's English copy.
  3. ErrProofUnset: set to ProofOff: true. Docs has never had a proof-of-work door; its abuse controls are rate limits plus the allowlist-gated mail queue. Also set in two test fixtures that call rauth.New directly.
  4. The default CSP dropped 'unsafe-inline' from style-src (rastrillo c7e1a09). Docs' list, show and share pages and the jump-to partial use <style> blocks, and home_link.html has a style attribute. Under the new default all of these render unstyled, and nothing reports an error. Docs now serves its own ContentSecurityPolicy (the old default) through a new docs.ServeOptions, which cmd/docs uses. homeFormPolicy derives from the same policy. TestServedPolicyAllowsThePagesInlineStyles serves real pages through rastrillo's production middleware, and it goes red if the old policy is lost. Moving the styles into stylesheets is a separate follow-up.
  5. sessions.FromContext never reached rastrillo main. The team stream's session reader now runs From in a goroutine and returns "no session" once its context ends. The query itself is not interrupted, but From only reads.
  6. Tests that signed in by GETting the link now POST its token with same-origin evidence (confirmRequest / App.TestConfirmRequest). The browser drive presses the confirm button.
  7. golang.org/x/text moved to v0.39.0 (pulled in by rastrillo).

Tests

  • TestEmailSignInConfirmsInTheDoorThenSpendsOnPost: begin → mail → GET twice (door, no cookie) → cross-site POST refused and nothing spent → same-origin POST → session → landing → list 200 → second POST is ?err=expired.
  • TestEmailSignInRefusedAfterConfirmGetsTheDoor: a guest is mailed a link, the share is revoked, then the POST gets the door in a 403 with no session.
  • TestKeymailOAuthActualIntegration covers the Keymail continuation and the OAuth refusal door through the hook.
  • Each new test was mutation-checked and fails for the right reason: no RenderConfirm, no Refused, no POST route, and the strict CSP all turn it red.
  • TMPDIR=/tmp .amadan/ci.d/10-gate passes locally on go1.26.5.
  • scripts/browser-drive.sh passes 43/44 with Chrome for Testing and the real confirm click. The one failure, 320px reflow on a document page (SVG paths at 562–622), fails the same way on origin/main and is not caused by this branch.

Not done / for Paul

  • Existing time rows: rastrillo now writes times in SQLite's UTC layout. Old rows still scan. ORDER BY updated_at stays correct to the second across the two layouts, so there is no back-fill migration.
  • The aliased old-path rastrillo import (github.com/carlosframework/rastrillo v0.23.0) is left alone because it still builds.

GPT-6-Astra (Codex), reviewing for session 21a32889.

Reviewed the whole branch at a871701d2b911f581be309d5fc09046fd11313e1 against freshly fetched origin/main 6fa77852776bf3a431ba31171368a393d5cc0bbb, in detached scratch worktrees under /tmp. Also compared rastrillo d9c28d7 with the pinned main revision fb806a2.

VERDICT: CHANGES-REQUIRED — needs changes.

  1. P2 — Preserve cancellation of the database work in the team session reader. internal/docs/team_session_reader.go:35–47 starts an unrestricted goroutine for each lookup, but cancelling its context only abandons the result. sessions.From ultimately calls DB.QueryRow with a background context on Docs' one-connection writer pool. If a transaction holds that connection, every cancelled/expired stream lookup remains queued, retaining its goroutine and request until the connection becomes available. SQLite's busy_timeout does not bound waiting for a Go connection-pool slot. Under concurrent streams and a slow or stalled writer, obsolete lookups accumulate and still compete for the writer when it recovers. The buffered answer channel prevents a blocked send; it does not cancel the queued query.

Reproduced against the actual reader and database: hold the writer in a transaction, queue a lookup, cancel after WaitCount advances, repeat 64 times. All 64 callers return “no session”, but the branch retains 64 session-lookup goroutines; main retains 0 with the same probe. Both have one open connection, so this is goroutine/pending-work buildup, not connection proliferation. The branch workers disappear only after releasing the writer. The existing TestTeamStreamActualSessionWriterWaitCannotExtendOriginalLease checks that the stream closes, not that its database work ends, so it remains green.

Restore a context-aware shared session lookup that carries the deadline into connection acquisition and QueryRowContext (preferably land that API on rastrillo main and pin it). Add a regression that cancels queued lookups while keeping the writer held and verifies their workers finish before releasing it. A bounded worker/admission mechanism would at least need an explicit bound on outstanding work; a goroutine per call is not such a bound. No session-authority bypass was found: cancellation correctly refuses the caller, and the abandoned lookup is read-only.

The other requested checks:

  • CSP: comparing the actual pinned defaults, the only directive difference is style-src: the new framework policy uses 'self' 'unsafe-hashes' plus the specific honeypot hash. Docs' 'self' 'unsafe-inline' is the intended relaxation; copying the old policy loses no additional CSP directive. The new HSTS header is set separately by rastrillo and survives Options.CSP. Home correctly adds only the validated issuer to the final form-action; confirm adds its framing CSP alongside the full policy. Minor hardening note: the revised policy test compares the app's constant with itself through serving middleware, so it no longer detects future framework hardening outside style-src. Keep an independent comparison against the actual framework default, allowing only the intentional style/form-action differences.
  • ProofOff: preserves the established no-PoW behavior. Rate limiting and admission-gated mail delivery remain. Classification still precedes admission and may make outbound requests; ProofOff does not eliminate that pre-existing abuse surface. No new blocker from making this choice explicit.
  • Confirm and CSRF: production-handler probes passed in standalone and Home mode: exactly one button; token occurs only in the hidden field; hostile token markup is escaped; no-store, strict-origin, noindex, nofollow, DENY and both CSP headers survive. Cross-site, same-site, hostile Origin/Referer, Origin: null, and missing-evidence POSTs are refused; a subsequent same-origin POST still spends the link. Origin-only fallback also succeeds. The GET does not consume the token. Sign-in chrome does not copy the token into a return URL.
  • Refusal: the hook renders the 403 door, preserves existing response cookies and sets no-store. The email-revocation and actual Keymail integration tests pass, including refusal without minting a session. Removing response-text matching is sound.
  • UTC/no backfill: Docs already writes its relevant document/grant timestamps in UTC. I found no SQLite date/datetime/julianday/strftime arithmetic over those legacy timestamp columns. List ordering uses their common date/time prefix; the link-flow expiry comparison uses integer Unix seconds, and session storage continues to use explicitly formatted RFC3339 strings. No date-maths blocker found from leaving old rows in place.

Verification: TMPDIR=/tmp GOTOOLCHAIN=go1.26.5 .amadan/ci.d/10-gate passed in the branch worktree; remote CI also reports passed at a871701. The browser drive independently returned 43/44 on both branch and main, with identical 320px SVG-path failures at 562/592/622px. For main, a temporary external launcher opened its GET sign-in link in the debugging browser, avoiding the known hanging screenshot bootstrap; the main browser checks were unchanged. The reflow failure is pre-existing and is not a finding against this branch.

Review probes were supplied through Go overlays from /tmp/docs-review-77-probes; no repository source files were edited. Both worktrees remain clean. No commit, push, merge or deployment performed.

New head: 1345bb93d08fac676e52fd7d4d7be818a5e236cb. This addresses Astra's review.

P2, cancellation (option b). I couldn't do the lookup inside Docs without copying rastrillo's session validation: sessions.Config.DB is a concrete *sql.DB, and its lookup and expiry check aren't exported. So I ported Sessions.FromContext from feat-session-context onto rastrillo main as its own branch: rastrillo feat/session-from-context at 98668096a56bbbf7266f634e65730eb5762a1105, discussion rastrillo/rastrillo #65. Docs now pins that commit, and team_session_reader.go calls reader.FromContext(ctx, r) again, so the context reaches connection acquisition and the query itself. Docs must not merge until rastrillo #65 is reviewed and merged, and Docs is re-pinned to rastrillo main.

Regression test. TestTeamSessionReaderCancelledLookupsLeaveNoWorkQueued holds the writer and queues 64 lookups one at a time; each is cancelled once WaitCount advances. While the writer is still held, it requires zero goroutines left with a frame in rastrillo/sessions. Against a871701's reader it fails with "64 of 64 cancelled session lookups are still queued for the writer". On this head it passes.

CSP minor note. TestHomeFormPolicyPreservesFrameworkAndStandalone now also compares Docs' policy with rastrillo's real default and allows a difference only in style-src. I checked that it fails when another directive is changed.

The gate (TMPDIR=/tmp .amadan/ci.d/10-gate, go1.26.5) passes locally. Hub CI is pending.

New head: 87a5fafc81e9809e805236648608c2761b85ce6d. Docs now pins rastrillo main 3585f5abff4ff3626ba0f0ba79a5fe5e56ae5a78, where rastrillo #65 (Sessions.FromContext) was merged. This replaces the branch-commit pin 9866809. The only changes are go.mod and go.sum. The gate (TMPDIR=/tmp .amadan/ci.d/10-gate, go1.26.5) passes locally, including TestTeamSessionReaderCancelledLookupsLeaveNoWorkQueued. Hub CI is pending.

GPT-6-Astra (Codex), reviewing for session 21a32889.

Re-reviewed the whole branch at 87a5fafc81e9809e805236648608c2761b85ce6d against freshly fetched origin/main 6fa77852776bf3a431ba31171368a393d5cc0bbb, in detached scratch worktree /tmp/docs-review-77-87a5faf. Confirmed the dependency resolves to rastrillo main 3585f5abff4ff3626ba0f0ba79a5fe5e56ae5a78.

VERDICT: APPROVE-WITH-NITS — ready to merge. No open P0, P1 or P2 findings.

Previous P2 resolved. newTeamSessionReader now calls reader.FromContext(ctx, r) directly. In the pinned module that context reaches resolveContext, lookupContext, and DB.QueryRowContext, including connection acquisition. The new TestTeamSessionReaderCancelledLookupsLeaveNoWorkQueued passes with the writer still held. I also reran the earlier independent review probe: 64 cancelled calls leave 0 session lookup workers and one open connection. With an external Go overlay restoring a871701's goroutine-wrapped reader, the new regression fails for the intended reason: 64 of 64 cancelled session lookups are still queued for the writer. Session parity, revocation/database-error handling and the original stream-lease test pass.

CSP: the comparison now obtains the real rastrillo default from an unconfigured rastrillo.Handler. For the policies actually shipped, only style-src differs, and Home adds only its configured issuer to form-action. An external mutation changing base-uri makes the comparison fail at the framework-versus-Docs assertion. This addresses the earlier self-comparison concern, with one nonblocking hardening note below.

  1. Note — match the exact CSP directive name. internal/docs/home_security_test.go:86 uses HasPrefix(d, "style-src"), which also removes the distinct style-src-elem and style-src-attr directives. I added style-src-elem 'none' to Docs' policy through an external overlay: both the drift comparison and TestServedPolicyAllowsThePagesInlineStyles still pass. The latter helper (internal/docs/served_policy_test.go:88) only interprets style-src/default-src. Prefer comparing the first field exactly with style-src; optionally teach the inline-style checker about the element/attribute overrides. Neither current policy contains these extra directives, so this is future regression-test hardening, not a current rendering or security blocker.

Regression checks: confirm-by-POST, repeated scanner GETs, one-use redemption, refusal after access revocation, and actual Keymail integration pass. I reran the production-handler security probes in standalone and Home modes: the confirm token is escaped and appears only in its field; security headers and both CSP headers survive; cross-site/same-site, hostile Origin/Referer, null Origin and missing-evidence POSTs are rejected; a subsequent valid Origin-only POST succeeds. No new regression found in the remaining branch diff or dependency integration.

Verification: TMPDIR=/tmp GOTOOLCHAIN=go1.26.5 .amadan/ci.d/10-gate passed. Hub CI reports passed at 87a5faf. The browser drive with Chrome for Testing completed 43/44, with only the documented baseline document reflow failure at 320px (SVG paths ending at 562/592/622px); no JavaScript errors. The baseline classification comes from the prior branch/main comparison recorded above and the supplied review brief; this re-review reran the new head's browser drive.

All review probes and mutations were supplied through Go overlays from /tmp; repository source files were not edited and the scratch worktree is clean. No commit, push, merge or deployment performed.