package calls import ( "context" "database/sql" "errors" "net/http" "net/url" "strings" "amadan.net/oficina/estilo.git/chrome" "amadan.net/oficina/estilo.git/sky" "amadan.net/oficina/home/connect" rauth "amadan.net/rastrillo/rastrillo/auth" "amadan.net/rastrillo/rastrillo/sessions" "github.com/keymaildev/signin" ) const signinPath = "/signin" const sentLocation = signinPath + "?sent=1" // newAuthPlugin wires rastrillo/auth. Authorize is Calls' one departure // from Docs (spec §3.2): an address is admitted at the emailed link when // it is on the allowlist, or when it holds an unexpired pending-join row // whose link is still live. The row was written before the mail went // out, so the click is admitted in whichever browser it lands. func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) { landing := "/continue" if a.cfg.home != nil { landing = "/auth/local/complete" } return rauth.New(rauth.Config{ DB: writer, Origin: a.cfg.Origin, InstanceKey: a.cfg.instanceKey, Mailer: a.signinMail, Subject: chrome.EmailSubject("Calls"), Authorize: a.authorize, // A verified address authorize refuses gets the door, with the // refusal on it, in auth's own 403 (refused). Refused: a.refused, // GET /auth/verify spends nothing: it opens the door with one // Sign in button that POSTs the link back, so a mail scanner's // fetch cannot spend it (renderConfirm). RenderConfirm: a.renderConfirm, // No proof-of-work on the sign-in form. Calls never had one: // gateBegin's syntax check, mayRequestLink's budgets (ruling 12) // and mailQueue's admission check are what stand between a // stranger and the mailer, and the door is estilo's form, which // renders no pow challenge. ProofOff keeps today's behaviour; // wiring a pow.Guard is a decision of its own (schema, assets, a // form that needs JavaScript). ProofOff: true, SigninPath: signinPath, SignedInPath: landing, Logger: a.logger, }) } func (a *app) authorize(address string) bool { if a.allowNow().allows(address) { return true } live, err := a.store.pendingLive(context.Background(), address) if err != nil { a.logger.Error("calls: authorize: pending lookup", "err", err) return false } return live } // gateBegin sits in front of rastrillo's Begin, which it reaches for // EVERY well-formed address, so whether an address signs in at Keymail // or by emailed link depends on its domain alone, never on membership. // // - A Keymail address (keymail.dev itself, or a domain delegating to // Keymail with a `_keymail` TXT record) goes to Keymail, member or // not. Begin answers with a 303 to Keymail's authorize URL, which // newBegin's continuation turns into a page: Safari applies // form-action 'self' to a form's whole redirect chain. Admission is // rastrillo's Authorize after Keymail has verified the address (a // member, or a guest holding a live pending-join row), and its // refusal is the door saying so (refused). // - Any other address is answered ?sent=1, and a link is mailed only // when mayRequestLink allowed it for this request (ruling 12's // budgets included) and authorize admits the address. mailQueue // decides that after the response, in a worker, so not even the // response time differs (signin_mail.go). // // Calls' own refusals are byte-identical to success; rastrillo's // address and rate outcomes are shown. Membership of a specific address // is not a secret Calls keeps at this form (spec §3.2), but the choice // of ceremony must still not depend on it: it used to set force=1 for a // permitted address only, which kept Keymail out of Calls altogether // (Paul, 2026-10-06: "I do want it to use keymail"). force now arrives // only from the form rastrillo lands a failed Keymail exchange on // (?force=1&err=keymail), and asks for an emailed link instead. func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() address := r.FormValue("address") // Syntax first. The gate below answers every refusal as "sent", so // a malformed address used to go straight to "Check your email" // and the field's own error could never show. Whether a string is // an email address says nothing about who is on the team, so this // answer is no oracle. It is keymail's own check, the one Begin // would make. if _, _, err := signin.SplitAddress(strings.TrimSpace(address)); err != nil { http.Redirect(w, r, a.signinAgain(r, "err=address"), http.StatusSeeOther) return } may := a.mayRequestLink(w, r, address) r = r.WithContext(context.WithValue(r.Context(), mayMailKey{}, may)) next(&sameAnswer{ResponseWriter: w, app: a}, r) } } // newBegin is POST /signin in both modes: gateBegin under // connect.OAuthContinuation, which ends the POST with a page // (renderKeymail) when Begin's answer is Keymail's authorize URL. func (a *app) newBegin(begin http.HandlerFunc) http.HandlerFunc { return connect.OAuthContinuation(a.cfg.Origin, a.gateBegin(begin), a.renderKeymail) } // renderKeymail is the page between the form and Keymail: the door's // card saying where the person is going, with the meta refresh that // goes there (home-oauth-head) and a link for when it is blocked. func (a *app) renderKeymail(w http.ResponseWriter, r *http.Request, destination string) { p := connect.OAuthPage(destination) notice := chrome.SignInNotice{ Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host), Heading: p.Title, Body: "Taking you to Keymail to finish signing in.", Link: &chrome.Link{Href: p.URL, Label: p.ActionLabel}, } render(w, "signin", a.page(r, frame{parts: []string{p.Title}, signinPage: true}, map[string]any{"Sent": true, "Notice": notice, "Continue": p})) } // mayMailKey carries gateBegin's mayRequestLink answer to mailQueue, // through Begin, on the request context. type mayMailKey struct{} // mayRequestLink decides whether a link may be mailed (ruling 12). A // member address always may. Any other address may only when the // browser carries a valid pending-join cookie for a live link, within // two budgets: five attempts per link and ten across all links per // fifteen minutes. // // It runs on the response path, so its WORK must not depend on who the // address is (Astra on #25, round 3, P1: over budget, a member wrote a // pending row while an outsider wrote a log line and returned — // 0.4–0.65 ms against 77–78 ms). Whoever the address names, a live // cookie costs the same: the link lookup, and the pending_joins row, // written for an over-budget outsider too. Only the in-memory budget // counters, charged for outsiders as before, differ. Nothing is logged // here: an over-budget attempt is counted (overBudget) and the hourly // sweep reports the count. // // The row an over-budget outsider gets admits nothing new by mail: the // answer this returns still says no, so no link is sent (the budgets are // about mail). It does let a Keymail sign-in through that link's guest // path, which the budgets never governed. func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool { address = normalizeAddress(address) member := a.allowNow().allows(address) ck, hasCookie := a.readJoinCookie(r) if !hasCookie { return member } link, _, live, err := a.store.liveLinkByHash(r.Context(), ck.TokenHash) if err != nil { a.logger.Error("calls: link for cookie", "err", err) return member } if !live { return member } may := true if !member { now := a.store.now() // The per-link budget is consulted first: a link already over // its own budget must not keep charging the shared pool. if !a.linkBudget.allow(ck.TokenHash, now) || !a.outsiderBudget.allow("all", now) { a.overBudget.Add(1) may = false } } if address == "" { return member } if err := a.store.writePending(r.Context(), address, link); err != nil { a.logger.Error("calls: write pending join", "err", err) return member } return may } // rastrillo's own Begin also same-origin-checks the request and // answers a bare 403 on failure — unreachable behind csrf.Protect // here, but a second gate worth knowing runs underneath this one. // // sameAnswer rewrites rastrillo's own refusal redirects to the sent // location. It hooks WriteHeader because http.Redirect sets Location // and then calls WriteHeader, so the Location is visible in time. // Calls' own refusals are byte-identical to success; rastrillo's // address and rate outcomes are shown; membership of a specific // address is not a secret Calls keeps at this form, per spec §3.2, and // the timing of a sent mail is a further channel the response cannot // hide. type sameAnswer struct { http.ResponseWriter app *app } func (s *sameAnswer) WriteHeader(status int) { loc := s.Header().Get("Location") rewrite := loc != "" && strings.HasPrefix(loc, signinPath+"?err=") && loc != signinPath+"?err=address" && loc != signinPath+"?err=rate" if rewrite { s.app.logger.Warn("calls: sign-in refused; answering as if sent", "outcome", loc) s.Header().Set("Location", sentLocation) } s.ResponseWriter.WriteHeader(status) } type personCtxKey struct{} // requirePerson runs behind RequireSession: it maps the session subject // (the verified address; sessions.UserID would return 0, false) to a // persons row, creating it on first sign-in. func (a *app) requirePerson(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s, ok := sessions.Current(r) if !ok || s.Subject == "" { a.logger.Error("calls: session with no subject reached requirePerson", "path", r.URL.Path) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } p, err := a.personForSession(r.Context(), s) if errors.Is(err, errHomeRemoved) { // A Home identity that is definitely no longer admitted: end // the session and say so, rather than a 404 whose "Sign in" // would pass straight back through the same live session // (the loop Astra found in Docs #65 and Sheets #39). a.endRemovedSession(w, r) return } if errors.Is(err, errHomeIdentity) { a.notFound(w, r) return } if err != nil { a.logger.Error("calls: ensure person", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p))) }) } func currentPerson(r *http.Request) (Person, bool) { p, ok := r.Context().Value(personCtxKey{}).(Person) return p, ok } // requireMember is spec §2.5's first row: current member standing. func (a *app) requireMember(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p, ok := currentPerson(r) if !ok || !a.isMember(p) { a.notFound(w, r) return } next.ServeHTTP(w, r) }) } // localSignin serves the sign-in form (GET /signin standalone, // /signin/local with Home) and the landing spot for every outcome // rastrillo redirects back to (?sent=1, ?err=rate|address|expired| // removed|1), in the suite's words (spec §9.2). // // Already signed in, it skips the form: 303 to return_to or the meeting // list. A guest is not signed in for this purpose — signing in is exactly // what a guest here is asking to do — and a session that no longer // resolves to a person has been ended by signin before this runs. // // return_to is remembered in a sealed cookie (signin_return.go), and a // plain /signin forgets one left by an abandoned attempt. func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() ret, hasReturn := safeReturn(q) if v := a.viewerOf(r); v.signedIn() && q.Get("sent") == "" && q.Get("err") == "" && r.URL.Path == signinPath { dest := "/" if hasReturn { dest = ret } w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, dest, http.StatusSeeOther) return } switch { case hasReturn: a.rememberReturn(w, ret) case len(q) == 0: a.forgetReturn(w) } w.Header().Set("Cache-Control", "no-store") if q.Get("sent") == "1" { // The door's own check-your-email state (estilo DoorCopy). "Use a // different address" goes back to the form keeping the return // (spec §9.2): the remembered page, not a bare /signin, which would // forget it (Astra on Docs #65, finding 2). rastrillo's Begin // redirects to ?sent=1 with no address, so the body cannot echo it. notice := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.Sent(a.signinAgain(r, "")) render(w, "signin", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Sent": true, "Notice": notice})) return } door := a.door(r) if hasReturn { door.Return = ret } if q.Get("force") != "" { // A failed Keymail exchange lands here as ?force=1&err=keymail. // Posting force back asks for an emailed link instead, so the // retry does not return to the Keymail that just failed. door.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}} } switch q.Get("err") { case "address": // An address error belongs to the field, not a banner. door.AddressError = "That doesn’t look like an email address." case "rate": door.Error = "Too many attempts. Wait a few minutes, then try again." case "expired": door.Error = "That link has expired or was already used. Enter your email to get a new one." case "removed": door.Error = "Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong." case "keymail": door.Error = "Your Keymail sign-in couldn’t be completed. Enter your email to get a link instead." case "": default: door.Error = "We couldn’t sign you in. Enter your email to get a new link." } render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true}, map[string]any{"Door": door})) } // door is the sign-in door for this instance: Calls' mark and name on // its sky, posting to the form's own address. The door's words // (heading, label, help, the "Sign in" submit) are estilo's one copy // set; Calls passes its name, mark, sky and destinations. func (a *app) door(r *http.Request) chrome.SignIn { return chrome.SignIn{ Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host), Action: signinPath, OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, } } // refusalText is what the door says to an address Keymail or an emailed // link has just verified and authorize refused. const refusalText = "That address can’t sign in to Calls. Use a different address, or ask whoever runs this team to add it." // refused is auth's Refused hook: Keymail or an emailed link verified // the address and authorize said no. It answers with the sign-in door, // carrying the refusal, in a 403. No session is minted, and auth has // already ended the attempt. // // Rendered, not redirected to ?err=refused: a query parameter is // something anybody can type, and the sentence means "you proved this // address and Calls does not admit it" (Astra on #25, round 3). Only // this hook can say it, and auth calls it only after the address is // proved, so saying it is no membership oracle. The remembered // return_to is neither set nor forgotten here: the form is the way on, // and a different address may still be going to the same page. func (a *app) refused(w http.ResponseWriter, r *http.Request, _ rauth.Identity) { door := a.door(r) door.Error = refusalText w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusForbidden) render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true}, map[string]any{"Door": door})) } // renderConfirm is auth's RenderConfirm: the page an emailed link opens // on. GET /auth/verify spends nothing, so a mail-security scanner that // fetches every link in an inbound message cannot spend this one; the // door's single Sign in button POSTs the token back to d.Action, and // that POST signs in. // // It is the door with the address field taken away (the same mark, // sky, heading and Sign in label), so the person who clicked the link // sees Calls, not rastrillo's plain English fallback. auth has already // set no-store, Referrer-Policy: strict-origin (the URL is a live // credential) and the framing refusals; nothing here replaces them. // d.Token is untrusted query input and reaches the page only through // html/template. func (a *app) renderConfirm(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) { door := a.door(r) door.Lede = "Select Sign in to finish signing in." render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true}, map[string]any{"Confirm": signinConfirm{Door: door, Action: d.Action, Token: d.Token}})) } // signinConfirm is the confirm step's door: the chrome, and the form // that posts the emailed token back. type signinConfirm struct { Door chrome.SignIn Action, Token string } // signinAgain is the form's own address, keeping whatever page the // person was on their way to: /signin/local with Home (a local sign-in // the person chose on purpose, which /signin would turn into the Home // exchange), /signin otherwise. extra is one more query parameter. func (a *app) signinAgain(r *http.Request, extra string) string { path := signinPath if a.home != nil { path = "/signin/local" } q := url.Values{} if ret, ok := a.rememberedReturn(r); ok { q.Set("return_to", ret) } out := path if enc := q.Encode(); enc != "" { out += "?" + enc } if extra != "" { if strings.Contains(out, "?") { out += "&" + extra } else { out += "?" + extra } } return out } // continueSignedIn is SignedInPath. Task 6 adds the pending-join branch // in front; this slice's first version routes members to the list and // everyone else to /welcome. func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) { p, ok := currentPerson(r) if !ok { a.ErrorPage(w, r, http.StatusInternalServerError, "") return } if dest, handled := a.continuePending(w, r, p); handled { http.Redirect(w, r, dest, http.StatusSeeOther) return } // Where the person was going when they were asked to sign in // (signin_return.go), spent either way. ret, remembered := a.rememberedReturn(r) a.forgetReturn(w) if remembered { http.Redirect(w, r, ret, http.StatusSeeOther) return } if a.isMember(p) { http.Redirect(w, r, "/", http.StatusSeeOther) return } http.Redirect(w, r, "/welcome", http.StatusSeeOther) } // continuePending is /continue's first branch (spec §3.2): the most // recent unexpired pending row for this person, re-checked against its // link and meeting in one transaction with the grant, then deleted. func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) { a.clearJoinCookie(w) pend, ok, err := a.store.latestPending(r.Context(), p.Email) if err != nil { a.logger.Error("calls: latest pending", "err", err) return "", false } if !ok { return "", false } dest, err := a.store.consumePending(r.Context(), p, pend, a.isMember(p)) if err != nil { a.logger.Error("calls: consume pending", "err", err) return "", false } return dest, dest != "" } func (a *app) welcome(w http.ResponseWriter, r *http.Request) { p, _ := currentPerson(r) render(w, "welcome", a.page(r, titled("You’re signed in"), map[string]any{"Email": p.Email})) } // signedOut serves GET /signed-out standalone: where Sign out lands. // With Home configured connect serves it, through renderHome. func (a *app) signedOut(w http.ResponseWriter, r *http.Request) { a.renderSignedOut(w, r, signinPath, http.StatusOK) } // renderSignedOut is the signed-out page: the door's own notice // (estilo's SignedOut, the same words in every app), whose primary Sign in // goes to signin — /signin standalone, connect's action with Home. func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, signin string, status int) { notice := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.SignedOut(signin) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) render(w, "signedout", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Notice": notice})) }