oficina / meet Public

Move Calls onto rastrillo fb806a2: refusal hook and confirm-on-click links open

On branch chore/rastrillo-confirm

Pins amadan.net/rastrillo/rastrillo at fb806a2 (was 29e4491, 2026-09-08), the same bump Sheets made in oficina/sheets 6d700d6 (#46). Head: a539db3. Server and web only; nothing under native/ is touched.

Changes

  • #63, the refusal hook. auth_refusal.go (doorOnRefusal, authRefusal, the buffering writers, refusedHere) is deleted. app.refused is Config.Refused: the sign-in door with "That address can't sign in to Calls…" inside auth's own 403, no session, and the remembered return_to neither set nor forgotten. localSignin's door construction moved into app.door, shared by all three.
  • #64, confirm on click. Config.RenderConfirm is app.renderConfirm: Calls' own door (estilo door-open/close, mark, sky, heading, a lede, one autofocused Sign in button posting the token back, no address field), drawn by signin.html's new .Confirm branch since estilo has no confirm variant. auth's no-store, Referrer-Policy: strict-origin, X-Frame-Options DENY and extra frame-ancestors 'none' survive; tested through the production stack.
  • Routes. POST /auth/verify is mounted in both modes, wrapped like GET (LocalGuard in Home mode). Callback and Verify are mounted bare: the hook does what the wrapper did.
  • ErrProofUnset. ProofOff: true. Calls never had pow on sign-in; gateBegin, ruling 12's budgets and mailQueue's admission check are the front door.
  • CSP: no exception needed. Calls serves rastrillo's new default unchanged (style-src 'self' 'unsafe-hashes' <pow hash>). Inventory: no style= in Calls' templates, nor in the estilo/ui partials they call (ui at fb806a2 emits none); every JS style write (call-layout tile geometry and drag translate, call-window popover/title vars, room.js speaking-rim --lvl/--on vars, the new select.js display/maxBlockSize) is CSSOM, which CSP does not govern; no setAttribute('style'), cssText or markup with style. connect-src/media: the web client's fetches and SSE are same-origin, media is getUserMedia on srcObject, TURN/STUN go through RTCPeerConnection; none is covered by connect-src or media-src, and default-src was already 'self'. LiveKit is Mac-native only, not in the web client.
  • Home mode used to get connect's FormPolicy, which still allows inline styles. It now gets homeFormPolicy(issuer) = rastrillo's default + issuer in form-action. The one style attribute connect renders (home_link.html's overflow-wrap on the linked address) moved to calls-shell.css.
  • calls.ServeOptions sets mux, NextDue and ErrorPage; main, the CSP tests and the browser rig all use it.
  • Vendored assets. tokens.css and select.js re-copied from the new ui (vendored_test flagged both). rastrillo doctor --fix was not used: it would also overwrite rastrillo.js/busy.js, which are pinned to estilo's copies. Credits regenerated with make credits.

Tests

  • Every sign-in that GETs a link now opens it, asserts the confirm door, reads action+token off the page and POSTs with Origin and Sec-Fetch-Site: same-origin (client.open/press/redeem/follow, Home fixture followLink, the browser rig's Go sign-in, call.mjs's guest, native-auth.mjs).
  • New TestEmailedLinkOpensTheConfirmDoor: two GETs (fresh browsers) mint nothing and leave the auth_links row; the door's markup and headers; a cross-site POST is 403, mints nothing, spends nothing; the proper POST lands on /continue → / with the list; a replay gives ?err=expired.
  • New TestProductionServesRastrillosDefaultCSP (sign-in, list, holding, room, settings, credits through rastrillo.Handler: exactly the default, no style=" in any body) and TestTheConfirmDoorKeepsEveryPolicy.
  • TestHomeCSPPreservesPinnedFrameworkDirectives now also pins frameworkCSP to the live default and covers /auth/verify; /auth/verify added to the Home cross-origin POST refusals.
  • Refusal door: TestLinkReplacedBetweenMailAndClickAdmitsNobody and TestExpiredPendingRowAdmitsNobody now press Sign in and get the 403 door; TestKeymailRefusalIsTheDoor (both modes) now also follows the member's Keymail continuation to the meeting list. Invitation joins (TestEmailedLinkWorksInAnotherBrowser, the guest browser scenario) and native hand-off (TestNativeHandoffEmailContinuationAndRevocation, Home native tests) pass through the confirm step.
  • Browser gate now serves through the production stack and fails on any console CSP violation: all 46 scenarios passed, each with the no-violation check.
  • Local, GOTOOLCHAIN=go1.26.5: make ci, make race, make browser green.

Stored times (UTC)

  • rastrillo's auth_links/sessions still store RFC3339 UTC text, so rastrilloTextExpiry is unaffected.
  • Calls' SQL comparisons: pendingjoins `expiresat > ? (pendingLive/latestPending) and expiresat <= ? (hourly sweep), ORDER BY expiresat (nextExpiry), ORDER BY createdat (agents), ORDER BY startsat, createdat (meeting list). Parameters were already .UTC(), but rows were written from time.Now() in the machine's zone, so off-UTC machines compared wrongly; timezone=UTC fixes that. New TestPendingRowExpiryIsByInstantWhateverTheClocksZone` (UTC+9 and UTC-9 clocks) pins it.
  • Old rows in the old … +0000 UTC layout still order correctly against the new …+00:00 layout except on exact-instant ties, and pending rows live 15 minutes.
  • Guest-grant expiry is compared in Go (ExpiresAt.After), not SQL. Budgets and quality reports are in memory or logs only, never stored. Invitation links have no expiry column, only revoked_at IS NULL.
  • Displays convert explicitly (fmtTime uses .In(zone), the API uses .UTC(), auto titles use now.In(loc)), so UTC read-back changes nothing shown.

Not done / for the native session

  • native/ios/UITests/CallsPhoneUITests.swift testEmailBrowserHandoff opens the verification link and expects "Sign in to Calls app" straight away; it now needs to tap the confirm door's "Sign in" first. Left alone (native code). make native-browser / iphone-browser were not run; native-auth.mjs was updated but is unrun.
  • Not merged or deployed. Needs the review pass.

Hub CI passed on a539db3a22637186901e6233f9d31bbb2a9804d6. Not merged or deployed; waiting for review.

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

Reviewed the whole branch at a539db3a22637186901e6233f9d31bbb2a9804d6 against origin/main 38ad0b767e37eadc788ed93a4e2bb42ea1608c4f, after reading #26 and the house brief, in a detached scratch worktree under /tmp. No source edits, commits, pushes or merges.

VERDICT: APPROVE-WITH-NITS — ready to merge.

Findings: none. No P0, P1 or P2 defects found.

Verification:

  • GOTOOLCHAIN=go1.26.5 CGO_ENABLED=0 make ci race passed, including migration checks, refusal/Keymail/invitation/native-handoff tests and the process-level SIGTERM mail-drain tests from #25. The defer order still drains accepted mail before closing the database.
  • GOTOOLCHAIN=go1.26.5 CGO_ENABLED=0 make browser passed with the sandbox off: all 46 Chromium scenarios passed, each with the CSP assertion, plus the Node checks. The browser gate took 765.9 seconds.
  • Strict CSP is retained. The script writes use individual CSSOM properties, including select.js's display/maxBlockSize; I found no style-attribute string writes. A separate Chromium probe exercised the vendored combobox's search, keyboard selection, empty results, Escape and dynamic options with zero CSP violations. Calls' own device and screen-mode selects have no data-rst-select attribute and remain native controls. tokens.css/select.js match the pinned upstream assets; rastrillo.js/busy.js retain the estilo pins.
  • GET verification renders the escaped token without spending it; the same-origin POST is mounted in both modes, with Home's LocalGuard preserved. The production-stack tests cover the confirm page's restrictive headers, and cross-site redemption is refused. The refusal hook retains the intended 403 door without minting a session or overwriting the remembered return destination.
  • The non-UTC expiry claim checks out for new writes. An independent SQL probe using the old versus new DSNs reproduced a UTC+9 row remaining live after 16 minutes and a UTC-9 row already dead at 14 minutes under the old settings. Both zones correctly remained live at 14 minutes and expired at 16 with the new settings. The branch regression passes too.
  • The real native flow remains coherent: opening the email shows Sign in; pressing it preserves the pending native handoff and reaches the existing Sign in to Calls app consent. The server handoff tests pass through that confirmation. Native browser/UI gates were not run in this review.

Nonblocking notes:

  1. internal/calls/static/calls-shell.css:27 / internal/calls/serving.go:65: the Home wrapping behavior was copied into CSS, but the pinned estilo homeconnect/templates/home_link.html still emits its original style attribute on the address paragraph. Chromium reports style-src-attr when an address is rendered. I rendered the actual pinned Home partial under the strict policy at 390px with a long address: the new rule supplies overflow-wrap:anywhere and prevents horizontal overflow. This is console noise, not a rendering blocker. Remove the redundant attribute upstream and add Home account/link pages to browser CSP coverage; the 46-scenario standalone rig does not establish zero violations in Home mode.
  2. native/ios/UITests/CallsPhoneUITests.swift:96: the acknowledged testEmailBrowserHandoff update is still needed before the native gate can pass: tap the confirm door's Sign in before waiting for Sign in to Calls app. This is the explicitly deferred native-test follow-up, not evidence that the real user flow is broken.