oficina / meet Public

WIP: implementer stopped mid-task (session model limit); untested

Saved as-is by the coordinating session so the work isn't lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 6d30ba6d83fe39874be48084c6c5f39df39b93d8 parent 84f381f
7 files changed, +877 −84
  • cmd/calls/main.go +3 −0
  • internal/calls/app.go +17 −2
  • internal/calls/auth.go +144 −21
  • internal/calls/chrome.go +262 −0
  • internal/calls/errors.go +214 −0
  • internal/calls/render.go +33 −61
  • internal/calls/signin_return.go +204 −0

CI failed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/oficina/home/connect v0.0.0-20260930200947-7a0f19c3a8a7
# amadan.net/oficina/calls/internal/calls
internal/calls/auth.go:174:21: undefined: errHomeRemoved
internal/calls/auth.go:179:6: a.endRemovedSession undefined (type *app has no field or method endRemovedSession)
internal/calls/credits.go:32:33: not enough arguments in call to a.page
	have (*http.Request, map[string]any)
	want (*http.Request, frame, map[string]any)
internal/calls/handlers_join.go:87:30: not enough arguments in call to a.page
	have (*http.Request, map[string]any)
	want (*http.Request, frame, map[string]any)
internal/calls/handlers_meetings.go:95:31: not enough arguments in call to a.page
	have (*http.Request, map[string]any)
	want (*http.Request, frame, map[string]any)
internal/calls/handlers_meetings.go:154:35: not enough arguments in call to a.page
	have (*http.Request, map[string]any)
	want (*http.Request, frame, map[string]any)
internal/calls/handlers_meetings.go:183:31: not enough arguments in call to a.page
	have (*http.Request, map[string]any)
	want (*http.Request, frame, map[string]any)
internal/calls/handlers_meetings.go:206:33: not enough arguments in call to a.page
	have (*http.Request, map[string]any)
	want (*http.Request, frame, map[string]any)
internal/calls/handlers_meetings.go:246:33: not enough arguments in call to a.page
	have (*http.Request, map[string]any)
	want (*http.Request, frame, map[string]any)
internal/calls/handlers_room.go:71:30: not enough arguments in call to a.page
	have (*http.Request, map[string]any)
	want (*http.Request, frame, map[string]any)
internal/calls/handlers_room.go:71:30: too many errors
# amadan.net/oficina/calls/internal/calls
# [amadan.net/oficina/calls/internal/calls]
vet: internal/calls/auth.go:174:21: undefined: errHomeRemoved
make: *** [Makefile:66: vet] Error 1

amadan: step vet failed: exit status 2

amadan: job failed at step vet — its output is above, not at the tail of this log
diff --git a/cmd/calls/main.go b/cmd/calls/main.go
index 864e85c..7988d12 100644
--- a/cmd/calls/main.go
+++ b/cmd/calls/main.go
@@ -88,6 +88,9 @@ func main() {
// without this the platform has no way to know it should wake the
// app to clear one (review C2-2).
opts.NextDue = rt.NextDue
+ // A panic outside the router gets the app's own error page, not
+ // rastrillo's plain "Something went wrong." (shared chrome §3.3).
+ opts.ErrorPage = rt.ErrorPage
if err := rastrillo.Serve(opts); err != nil {
logger.Error("serve failed", "err", err)
os.Exit(1)
diff --git a/internal/calls/app.go b/internal/calls/app.go
index ed1cefe..816ce16 100644
--- a/internal/calls/app.go
+++ b/internal/calls/app.go
@@ -1,6 +1,7 @@
package calls
import (
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
"context"
@@ -436,6 +437,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
cancel()
r := chi.NewRouter()
+ // Every panic inside the router is the 500 page with a reference and
+ // the viewer's own chrome (errors.go); an unknown route is the 404
+ // page, not chi's plain text (shared chrome spec §3.3).
+ r.Use(a.recoverToErrorPage)
+ r.NotFound(a.notFound)
+ r.MethodNotAllowed(a.methodNotAllowed(r))
// The drive surface (spec §8.2) is mounted at the router root,
// outside csrf.Protect and outside the session middlewares: an
@@ -457,12 +464,17 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Use(a.nativeResume)
r.Get("/native/start", a.nativeStart)
r.Get("/help/open-source", a.credits)
+ // Appearance is inside the CSRF check and outside the session
+ // group: a session that expired mid-page must not turn the click
+ // into a sign-in loop (spec §8.3).
+ r.Post("/appearance", chrome.HandleAppearance)
r.Get(signinPath, a.signin)
if a.home == nil {
r.Post(signinPath, a.gateBegin(authPlugin.Begin))
r.Get("/auth/callback", authPlugin.Callback)
r.Get("/auth/verify", authPlugin.Verify)
- r.Post("/signout", authPlugin.Signout)
+ r.Post("/signout", signedOutAfter(authPlugin.Signout))
+ r.Get("/signed-out", a.signedOut)
} else {
r.Post(signinPath, a.home.routes.LocalGuard(a.gateBegin(authPlugin.Begin)))
r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback))
@@ -546,6 +558,9 @@ type Runtime struct {
// NextDue answers rastrillo.Options.NextDue: the next moment this
// app has work, or the zero time when nothing is scheduled.
NextDue func() time.Time
+ // ErrorPage answers rastrillo.Options.ErrorPage: the app's own error
+ // page for a panic outside the router.
+ ErrorPage func(w http.ResponseWriter, r *http.Request, status int, ref string)
}
// nextDue is Runtime.NextDue's implementation: the earliest of the next
@@ -598,7 +613,7 @@ func App(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *Runtime, e
}
go a.sweepForever(ctx)
go a.tickForever(ctx)
- return mux, &Runtime{NextDue: a.nextDue}, nil
+ return mux, &Runtime{NextDue: a.nextDue, ErrorPage: a.ErrorPage}, nil
}
// NewForTest builds the app and returns the handler and its test
diff --git a/internal/calls/auth.go b/internal/calls/auth.go
index 60804e6..dc36afc 100644
--- a/internal/calls/auth.go
+++ b/internal/calls/auth.go
@@ -12,6 +12,7 @@ import (
"amadan.net/oficina/estilo.git/sky"
rauth "amadan.net/rastrillo/rastrillo/auth"
"amadan.net/rastrillo/rastrillo/sessions"
+ "github.com/keymaildev/signin"
)
const signinPath = "/signin"
@@ -61,6 +62,16 @@ 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
+ }
if !a.mayRequestLink(w, r, address) {
http.Redirect(w, r, sentLocation, http.StatusSeeOther)
return
@@ -160,8 +171,16 @@ func (a *app) requirePerson(next http.Handler) http.Handler {
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.ErrorPage(w, r, http.StatusNotFound, "")
+ a.notFound(w, r)
return
}
if err != nil {
@@ -183,46 +202,119 @@ 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.ErrorPage(w, r, http.StatusNotFound, "")
+ 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" {
+ render(w, "signin", a.page(r, frame{parts: []string{"Check your email"}, signinPage: true}, map[string]any{
+ "Sent": true,
+ "Notice": chrome.SignInNotice{
+ Icon: assets.Path("static/brand/icon.svg"),
+ App: appName,
+ Sky: sky.For(r.Host),
+ Heading: "Check your email",
+ // rastrillo's Begin redirects to ?sent=1 with no address, so
+ // this cannot echo it, as Docs, Sheets and Slides cannot.
+ Body: "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.",
+ // 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).
+ Link: &chrome.Link{Href: a.signinAgain(r, ""), Label: "Use a different address"},
+ },
+ }))
+ return
+ }
door := chrome.SignIn{
Icon: assets.Path("static/brand/icon.svg"),
- App: "Calls",
+ App: appName,
Sky: sky.For(r.Host),
Heading: "Sign in",
Action: signinPath,
AddressLabel: "Email address",
+ Help: "We’ll email you a link that signs you in. It works once and expires in 15 minutes.",
Submit: "Email me a link",
+ OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"},
+ }
+ if hasReturn {
+ door.Return = ret
}
- switch r.URL.Query().Get("err") {
+ switch q.Get("err") {
case "address":
- door.AddressError = "That does not look like an email 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 sign-in attempts just now. Wait a few minutes and try again."
+ door.Error = "Too many attempts. Wait a few minutes, then try again."
case "expired":
- door.Error = "That sign-in link has expired. Ask for a new one."
+ 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 "":
default:
- door.Error = "Sign-in did not work. Try again."
+ door.Error = "We couldn’t sign you in. Enter your email to get a new link."
}
- render(w, "signin", map[string]any{
- "Sent": r.URL.Query().Get("sent") == "1",
- "Door": door,
- "Notice": chrome.SignInNotice{
- Icon: door.Icon,
- App: door.App,
- Sky: door.Sky,
- Heading: "Check your email",
- Body: "A sign in link should be on its way with a link to click.",
- Link: &chrome.Link{Href: signinPath, Label: "Use a different email address"},
- },
- })
+ render(w, "signin", a.page(r, frame{parts: []string{"Sign in"}, signinPage: true}, map[string]any{"Door": door}))
+}
+
+// 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
@@ -238,6 +330,14 @@ func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) {
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
@@ -268,5 +368,28 @@ func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person)
func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
p, _ := currentPerson(r)
- render(w, "welcome", a.page(r, map[string]any{"Email": p.Email}))
+ 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) {
+ w.Header().Set("Cache-Control", "no-store")
+ a.renderSignedOut(w, r, "You’re signed out", "You’ve signed out of Calls on this device.", signinPath, "Sign in", http.StatusOK)
+}
+
+// renderSignedOut is the signed-out page, the Oficina door's notice with
+// a primary Sign in: standalone in the suite's words (spec §9.2), with
+// Home in connect's.
+func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, heading, body, href, label string, status int) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ render(w, "signedout", a.page(r, frame{parts: []string{"Signed out"}, signinPage: true}, map[string]any{"Notice": chrome.SignInNotice{
+ Icon: assets.Path("static/brand/icon.svg"),
+ App: appName,
+ Sky: sky.For(r.Host),
+ Heading: heading,
+ Body: body,
+ Link: &chrome.Link{Href: href, Label: label},
+ }}))
}
diff --git a/internal/calls/chrome.go b/internal/calls/chrome.go
new file mode 100644
index 0000000..781d314
--- /dev/null
+++ b/internal/calls/chrome.go
@@ -0,0 +1,262 @@
+package calls
+
+// The shared chrome (estilo chrome, round 2): every page's bar, account
+// menu and <title> are built here, once, so no page can drift from
+// another. Before this the shell was rastrillo's topbar at 81px: a raw
+// address, a blue underlined "Settings" and a Sign out button that
+// wrapped under it, a separate one-item Help dropdown, no nav and no
+// current-page marker, and a <title> that named the app on one page out
+// of fifteen.
+//
+// Spec: estilo spec/shared-chrome, docs/superpowers/specs/
+// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account),
+// §10 (titles). Calls adopts after Docs, Sheets and Slides.
+//
+// The call room keeps no bar: since 2026-09-28 a call is one window in
+// the viewport (room.html). Its title menu carries the same items, in
+// the same order and words, as the account menu built here (roomMenu).
+
+import (
+ "net/http"
+ "net/url"
+
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+// appName is the brand and the last part of every <title>.
+const appName = "Calls"
+
+// Frame is what layout.html reads from every page: the bar, the parts of
+// the <title> before the app's name, and the Appearance preference for
+// <html data-theme>.
+type Frame struct {
+ Chrome chrome.Bar
+ TitleParts []string
+ Theme string
+}
+
+// frame describes one page's chrome. The zero value is a page whose
+// <title> is just "Calls" (the meeting list, the app root).
+type frame struct {
+ // parts precede "Calls" in <title>, most specific first. The first
+ // is the page's h1 (spec §10).
+ parts []string
+ // context is the open meeting's title, shown in the bar.
+ context string
+ // signinPage is one of the sign-in, sent and signed-out pages: the
+ // corner is empty and the brand is plain text, since everything it
+ // could link to is behind the session gate.
+ signinPage bool
+ // public is a page reached through a capability (the invitation
+ // link /j/{token}): "Sign in" carries no return path, because the
+ // path is the capability and must not be copied into another URL.
+ public bool
+}
+
+// titled is the frame for a page whose <title> is these parts.
+func titled(parts ...string) frame { return frame{parts: parts} }
+
+// onMeeting is the frame for one of a meeting's pages: the meeting's
+// title is the bar's context, and it follows page in the <title>. With
+// page empty the meeting is the page (the holding screen, the room).
+func onMeeting(page, meeting string) frame {
+ if page == "" {
+ return frame{parts: []string{meeting}, context: meeting}
+ }
+ return frame{parts: []string{page, meeting}, context: meeting}
+}
+
+// viewer is who the chrome is for: nobody, a guest, or a signed-in
+// person, and how they signed in.
+type viewer struct {
+ person Person
+ method string
+ ok bool
+}
+
+// signedIn is a person who signed in, as opposed to a per-meeting guest.
+func (v viewer) signedIn() bool { return v.ok && !v.person.Anonymous }
+
+// viewerOf resolves whoever the request is for without requiring
+// anybody. Behind requireParticipant that is the person already in the
+// context. Anywhere else (an error page, Open source, the invitation
+// page) the session is read optionally and without side effects: an
+// error page must never create a persons row or end a session, so a
+// session that no longer resolves is simply signed out here, and the
+// next guarded page is where it is dealt with.
+func (a *app) viewerOf(r *http.Request) viewer {
+ if p, ok := currentPerson(r); ok {
+ s, _ := sessions.Current(r)
+ return viewer{person: p, method: s.Method, ok: true}
+ }
+ id, ok := a.auth.SessionFrom(r)
+ if !ok {
+ return viewer{}
+ }
+ p, err := a.lookupPerson(r, sessions.Session{Subject: id.Address, Method: string(id.Method)})
+ if err != nil {
+ return viewer{}
+ }
+ return viewer{person: p, method: string(id.Method), ok: true}
+}
+
+// lookupPerson is personForSession without its one write: a first
+// sign-in's persons row is created by the guarded pages, never by chrome.
+func (a *app) lookupPerson(r *http.Request, s sessions.Session) (Person, error) {
+ switch s.Method {
+ case "magiclink", "keymail":
+ return a.store.existingHomePerson(r.Context(), s.Subject)
+ }
+ return a.personForSession(r.Context(), s)
+}
+
+// page builds render's data for a page: data plus "Frame", the chrome,
+// and "Viewer", which the room's own menu reads.
+func (a *app) page(r *http.Request, f frame, data map[string]any) map[string]any {
+ if data == nil {
+ data = map[string]any{}
+ }
+ var v viewer
+ if !f.signinPage {
+ v = a.viewerOf(r)
+ }
+ if v.signedIn() {
+ data["Viewer"] = Viewer{Email: v.person.Email, Member: a.isMember(v.person)}
+ }
+ data["Frame"] = a.frameFor(r, v, f)
+ return data
+}
+
+// Viewer is the signed-in person, for the room's title menu: who they
+// are, and whether the team's pages (Meetings, Settings) are theirs.
+type Viewer struct {
+ Email string
+ Member bool
+}
+
+// frameFor builds a page's Frame for viewer v.
+func (a *app) frameFor(r *http.Request, v viewer, f frame) Frame {
+ bar := chrome.Bar{
+ App: chrome.App{
+ Name: appName,
+ Slug: "calls",
+ Home: "/",
+ Icon: assets.Path("static/brand/icon.svg"),
+ },
+ Context: f.context,
+ Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"},
+ }
+ switch {
+ case f.signinPage:
+ bar.App.Home = ""
+ case v.signedIn():
+ bar.Account = a.account(r, v)
+ bar.Switcher = a.switcher()
+ if a.isMember(v.person) {
+ bar.Nav = a.nav(r)
+ bar.NavLabel = "Calls"
+ }
+ case f.public:
+ // A guest, or nobody, on the invitation page.
+ bar.App.Home = ""
+ bar.SignIn = &chrome.Link{Href: signinPath, Label: "Sign in"}
+ default:
+ // Nobody, or a guest (spec §8.4: guests get the same corner).
+ bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"}
+ }
+ return Frame{
+ Chrome: bar,
+ TitleParts: f.parts,
+ Theme: chrome.ThemeAttr(chrome.Appearance(r)),
+ }
+}
+
+// nav is the team's two pages, with the one being shown marked.
+func (a *app) nav(r *http.Request) []chrome.Link {
+ return []chrome.Link{
+ {Href: "/", Label: "Meetings", Current: r.URL.Path == "/"},
+ {Href: "/settings", Label: "Settings", Current: r.URL.Path == "/settings"},
+ }
+}
+
+// returnTarget is where a person should come back to: this page, if it
+// is a page (a GET), otherwise the meeting list.
+func returnTarget(r *http.Request) string {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ return "/"
+ }
+ return r.URL.RequestURI()
+}
+
+// account is the account menu (spec §8.2): who is signed in, "Connect
+// your Oficina account" when that means something, Appearance, Help
+// (Open source, last), and the one Sign out in the app. Settings is in
+// the bar's nav rather than here: it is the team's meeting defaults and
+// agents, not this person's account, and a second copy of the link would
+// be a second label to keep in step.
+func (a *app) account(r *http.Request, v viewer) *chrome.Account {
+ acc := &chrome.Account{
+ TriggerLabel: "Account: " + v.person.Email,
+ Initials: chrome.Initials("", v.person.Email),
+ Address: v.person.Email,
+ Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"},
+ Appearance: &chrome.AppearanceMenu{
+ Action: "/appearance",
+ Return: returnTarget(r),
+ Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"),
+ },
+ Help: []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}},
+ SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"},
+ }
+ if a.offersHomeLink(v) {
+ acc.Items = []estilomenu.Item{{Href: "/account/home", Label: "Connect your Oficina account"}}
+ }
+ return acc
+}
+
+// offersHomeLink is whether the account menu offers to connect the
+// person's Oficina account: only with Home configured, only to a member
+// (the page is behind requireMember), and never to somebody who signed
+// in through Home, whose account already is the connection.
+func (a *app) offersHomeLink(v viewer) bool {
+ return a.home != nil && v.signedIn() && a.isMember(v.person) && (v.method == "magiclink" || v.method == "keymail")
+}
+
+// switcher is the app switcher, or nil when no hub directory is
+// configured. It shows "All apps" alone and no tiles (Paul's §13 answer
+// 2), as Docs, Sheets and Slides do.
+func (a *app) switcher() *chrome.Switcher {
+ if a.cfg.home == nil || a.cfg.home.Directory == "" {
+ return nil
+ }
+ return &chrome.Switcher{
+ Label: "Oficina apps",
+ CurrentLabel: "(this app)",
+ AllApps: estilomenu.Item{Href: a.cfg.home.Directory + "/", Label: "All apps"},
+ }
+}
+
+// roomMenu is the call window's title menu: the account menu's items in
+// its order and its words, for a page that has no bar. Standing is the
+// one line only the room has.
+type roomMenu struct {
+ Identity string
+ Nav []chrome.Link
+ Help []estilomenu.Item
+ SignOut *chrome.SignOut
+}
+
+func (a *app) roomMenuFor(fr Frame) roomMenu {
+ m := roomMenu{Nav: fr.Chrome.Nav}
+ if acc := fr.Chrome.Account; acc != nil {
+ m.Identity = acc.Address
+ m.Help = acc.Help
+ m.SignOut = &acc.SignOut
+ } else {
+ m.Help = []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}}
+ }
+ return m
+}
+
diff --git a/internal/calls/errors.go b/internal/calls/errors.go
new file mode 100644
index 0000000..a3c778f
--- /dev/null
+++ b/internal/calls/errors.go
@@ -0,0 +1,214 @@
+package calls
+
+// HTML error pages (shared chrome spec §3). Before this an unknown URL
+// answered chi's plain-text "404 page not found", with or without
+// Accept: text/html, and the handled error pages were titled "Something
+// went wrong" whatever had happened, a 404 included.
+//
+// Which answers are pages is decided by the request, not the route: a
+// page is rendered only when Accept names text/html, as every browser
+// navigation and form post does. The room's own fetches (the stream,
+// signal, ICE and info), the native app's JSON and the share form's JSON
+// answer never ask for HTML, so they keep plain answers. room.js reads
+// those by status (readRefusal), and the statuses are unchanged.
+//
+// A meeting that is missing, ended, or not yours is the same 404
+// (AGENTS.md: a row that isn't yours 404s, never 403s).
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "runtime/debug"
+ "strings"
+
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/rastrillo/rastrillo"
+ "github.com/go-chi/chi/v5"
+)
+
+// errorKind says which row of spec §3.2 a failure is.
+type errorKind int
+
+const (
+ // kindRoute: nothing here for you — an unknown address, or a page
+ // this session may not open (a guest outside /m/, a non-member on
+ // Settings). Both are "we can't find that page": which of the two it
+ // is must not be readable from outside.
+ kindRoute errorKind = iota
+ // kindMeeting: the meeting (or its invitation, or a peer in it) is
+ // missing, ended, or not shared with you.
+ kindMeeting
+ // kindStatus: the page for the status itself (5xx).
+ kindStatus
+)
+
+// errorWords is one row of §3.2: the title part, the heading, the body.
+type errorWords struct {
+ title, heading, body string
+}
+
+func errorCopy(status int, kind errorKind) errorWords {
+ switch {
+ case status == http.StatusNotFound && kind == kindMeeting:
+ return errorWords{"Meeting not available", "This meeting isn’t available", "It may have ended, or it hasn’t been shared with you."}
+ case status == http.StatusNotFound:
+ return errorWords{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."}
+ case status == http.StatusForbidden:
+ return errorWords{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."}
+ case status == http.StatusServiceUnavailable:
+ return errorWords{"Calls is unavailable", "Calls is unavailable right now", "Try again in a few minutes."}
+ }
+ return errorWords{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."}
+}
+
+// wantsHTML reports whether the request asked for a page.
+func wantsHTML(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "text/html")
+}
+
+// plainError is the answer to a request that did not ask for a page.
+func plainError(w http.ResponseWriter, status int) {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, http.StatusText(status), status)
+}
+
+// ErrorPage answers status for a handler: a meeting-shaped 404 (almost
+// every handled 404 in Calls is about a meeting or its invitation), the
+// 5xx page with the reference rastrillo minted for the log line, or
+// plain text for a request that did not ask for a page. It is also
+// rastrillo.Options.ErrorPage, for panics outside the router.
+func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
+ a.errorPage(w, r, status, kindMeeting, ref)
+}
+
+// notFound is the route-shaped 404: the router's own, and the guards'.
+func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
+ a.errorPage(w, r, http.StatusNotFound, kindRoute, "")
+}
+
+// errorPage renders the page inside the normal layout, with the
+// viewer's own chrome (read optionally: an error page never signs
+// anybody out or creates anything).
+func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) {
+ if !wantsHTML(r) {
+ plainError(w, status)
+ return
+ }
+ c := errorCopy(status, kind)
+ page := chrome.ErrorPage{
+ Status: status,
+ Title: c.heading,
+ Body: c.body,
+ Home: chrome.Link{Href: "/", Label: "Go to Calls"},
+ }
+ // Try again is the same URL, and only for a GET: re-sending a POST
+ // from an error page is the one thing it must never do.
+ if status >= 500 && r.Method == http.MethodGet {
+ page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
+ }
+ if ref != "" {
+ page.Ref, page.RefLabel = ref, "Reference:"
+ }
+ if back := sameOriginReferer(r, a.cfg.Origin); back != "" && status < 500 {
+ page.Back = &chrome.Link{Href: back, Label: "Back"}
+ }
+ var buf strings.Builder
+ data := a.page(r, titled(c.title), map[string]any{"Error": page})
+ if err := pages["errors"].ExecuteTemplate(&buf, "layout", data); err != nil {
+ a.logger.Error("calls: render error page", "err", err)
+ plainError(w, status)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(buf.String()))
+}
+
+// sameOriginReferer is the page a person came from, when it is this app
+// and not this page: rastrillo's rule for the error page's Back link. An
+// unchecked Referer is an open redirect with better manners.
+func sameOriginReferer(r *http.Request, origin string) string {
+ raw := r.Header.Get("Referer")
+ if raw == "" || origin == "" {
+ return ""
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin {
+ return ""
+ }
+ back := u.EscapedPath()
+ if u.RawQuery != "" {
+ back += "?" + u.RawQuery
+ }
+ // Never an invitation path: the Back link would copy the capability
+ // into this page.
+ if strings.HasPrefix(back, "/j/") {
+ return ""
+ }
+ if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() {
+ return ""
+ }
+ return back
+}
+
+// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a
+// path that only takes a POST (somebody opening /signout from history)
+// is the same 404 page as an unknown route: to a person there is no
+// page there. Any other method gets 405 in plain text, with the Allow
+// header set here — chi's default sets it, a custom handler does not.
+func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var allowed []string
+ for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
+ if routes.Match(chi.NewRouteContext(), m, r.URL.Path) {
+ allowed = append(allowed, m)
+ }
+ }
+ get := false
+ for _, m := range allowed {
+ get = get || m == http.MethodGet
+ }
+ if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get {
+ a.notFound(w, r)
+ return
+ }
+ w.Header().Set("Allow", strings.Join(allowed, ", "))
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ }
+}
+
+// routePattern is the matched route, "/m/{sqid}" rather than the path it
+// matched: safe to log whatever the path held (/j/ is a capability).
+func routePattern(r *http.Request) string {
+ if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" {
+ return rc.RoutePattern()
+ }
+ return "(unrouted)"
+}
+
+// recoverToErrorPage turns a panic in a handler into the 500 page with a
+// reference, logged with the stack under the same reference. The page
+// reads the session itself, so it shows the viewer's own chrome
+// wherever in the router the panic happened. Panics outside the router
+// reach rastrillo.Serve's own recoverer through Options.ErrorPage.
+func (a *app) recoverToErrorPage(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ v := recover()
+ if v == nil {
+ return
+ }
+ if v == http.ErrAbortHandler {
+ panic(v)
+ }
+ ref := rastrillo.NewRef()
+ // The value is logged as its type only: a panic raised with a
+ // meeting title in it would be a leak of its own.
+ a.logger.Error("calls: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack()))
+ a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref)
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/calls/render.go b/internal/calls/render.go
index 5a7a145..6bb220c 100644
--- a/internal/calls/render.go
+++ b/internal/calls/render.go
@@ -7,8 +7,10 @@ import (
"io/fs"
"testing/fstest"
+ "amadan.net/oficina/estilo.git/base"
"amadan.net/oficina/estilo.git/callui"
"amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/credits"
"amadan.net/oficina/estilo.git/sky"
"html/template"
@@ -26,13 +28,18 @@ var appFS embed.FS
var assets = rastrillo.NewAssets(callAssets())
func callAssets() fs.FS {
- // callui.css, chrome.css (the sign-in door) and sky.css (the sky
- // behind it) come straight from the estilo this module pins, so they
- // cannot drift from it.
+ // estilo's stylesheets and chrome.js come straight from the estilo
+ // this module pins, so they cannot drift from it: base.css (links),
+ // menu.css (every floating panel), chrome.css (the bar, the account
+ // menu and the sign-in door), sky.css (the sky behind the door),
+ // callui.css (the call's own pieces).
files := fstest.MapFS{
- "static/callui.css": &fstest.MapFile{Data: callui.CSS()},
- "static/chrome.css": &fstest.MapFile{Data: chrome.CSS()},
- "static/sky.css": &fstest.MapFile{Data: sky.CSS()},
+ "static/estilo/base.css": &fstest.MapFile{Data: base.CSS()},
+ "static/estilo/menu.css": &fstest.MapFile{Data: estilomenu.CSS()},
+ "static/estilo/chrome.css": &fstest.MapFile{Data: chrome.CSS()},
+ "static/estilo/chrome.js": &fstest.MapFile{Data: chrome.JS()},
+ "static/estilo/sky.css": &fstest.MapFile{Data: sky.CSS()},
+ "static/callui.css": &fstest.MapFile{Data: callui.CSS()},
}
if err := fs.WalkDir(appFS, "static", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
@@ -57,83 +64,48 @@ var pageNames = []string{"index", "signin", "signedout", "errors", "welcome", "l
var pages = map[string]*template.Template{}
-// doorPages are the pages drawn as the Oficina door: estilo's chrome
-// sign-in templates, parsed alongside rastrillo's field and callout
-// partials that they call.
-var doorPages = map[string]bool{"signin": true, "signedout": true}
-
func init() {
for _, name := range pageNames {
pages[name] = template.Must(buildPage(name).ParseFS(credits.Templates(), "templates/credits.html"))
- if doorPages[name] {
- pages[name] = template.Must(pages[name].ParseFS(chrome.Templates(), "templates/signin.html"))
- }
if name == "home" {
pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html"))
}
}
}
-// buildPage parses rastrillo's ui partials first, then this app's own
-// partials, then the layout and the page, so a page can call
-// confirm-form, field, empty-state and the rest by name with a dict —
-// and chat-panel, the one partial Calls owns, the same way.
+// buildPage parses rastrillo's ui partials first, then estilo's chrome
+// and menu templates (the bar, the account menu, the error page body and
+// the sign-in door, which call rastrillo's field and callout partials),
+// then this app's own partials, then the layout and the page, so a page
+// can call confirm-form, field, empty-state and the rest by name with a
+// dict — and chat-panel, the one partial Calls owns, the same way.
//
// templates/partials/ is parsed for every page rather than per page
// that uses it: a partial is defined-but-unused for free, and the
// alternative is a second list beside pageNames that has to be kept in
// step with the first.
func buildPage(name string) *template.Template {
- return template.Must(
- template.Must(
- template.Must(template.New("layout").
- Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
- Funcs(template.FuncMap{"asset": assets.Path}).
- ParseFS(ui.Templates(), "*.html")).
- ParseFS(appFS, "templates/partials/*.html")).
- ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
+ t := template.New("layout").
+ Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
+ Funcs(template.FuncMap{"asset": assets.Path})
+ t = template.Must(t.ParseFS(ui.Templates(), "*.html"))
+ t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html"))
+ t = template.Must(t.ParseFS(estilomenu.Templates(), "templates/*.html"))
+ t = template.Must(t.ParseFS(appFS, "templates/partials/*.html"))
+ return template.Must(t.ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
}
+// render writes a page. A template failure is logged by nobody and
+// answered in the plainest way left: nothing has been written yet, so
+// the status can still say so.
func render(w http.ResponseWriter, name string, data any) {
var buf bytes.Buffer
if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
http.Error(w, "something went wrong", http.StatusInternalServerError)
return
}
- buf.WriteTo(w)
-}
-
-// Viewer is the signed-in person the shell's account corner names, plus
-// whether they currently hold member standing — the layout's only use
-// for it is deciding whether the Settings link belongs beside sign-out
-// (spec's team-only surface: a guest has no agents to manage).
-type Viewer struct {
- Email string
- Member bool
-}
-
-// page builds render's data map for a request that may be signed in:
-// it adds "Viewer" when the request carries a signed-in person
-// (currentPerson, auth.go), so layout.html's account corner and
-// sign-out form appear; a request with no person in context (signin,
-// followLink before a session exists) gets data back unchanged, and
-// the layout's {{if .Viewer}} renders nothing. A page reached only
-// while signed out (templates/signin.html) has no need to call this
-// and renders with the plain render(w, name, data) instead.
-//
-// A method on *app, not a free function, because Viewer.Member needs
-// a.isMember — the team as it stands right now (standing.go) — which
-// a bare *http.Request cannot answer on its own.
-func (a *app) page(r *http.Request, data map[string]any) map[string]any {
- if p, ok := currentPerson(r); ok && !p.Anonymous {
- data["Viewer"] = Viewer{Email: p.Email, Member: a.isMember(p)}
+ if w.Header().Get("Content-Type") == "" {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
}
- return data
-}
-
-// ErrorPage renders the one error template with the status and the
-// reference rastrillo minted for the log line.
-func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
- w.WriteHeader(status)
- render(w, "errors", a.page(r, map[string]any{"Status": status, "Ref": ref}))
+ buf.WriteTo(w)
}
diff --git a/internal/calls/signin_return.go b/internal/calls/signin_return.go
new file mode 100644
index 0000000..e22b03c
--- /dev/null
+++ b/internal/calls/signin_return.go
@@ -0,0 +1,204 @@
+package calls
+
+// Back to where you were (shared chrome spec §9.4). Before this a
+// signed-out visit to /settings, a meeting's holding screen or any other
+// guarded page went to a bare /signin, and after the emailed link the
+// person landed on the meeting list: rastrillo/auth's RequireSession
+// drops the path, and its Verify always lands on a fixed SignedInPath.
+//
+// So the path rides a cookie instead of the link. GET /signin?return_to=
+// remembers it, sealed and short-lived, and /continue (SignedInPath)
+// goes there once a pending meeting join — which takes precedence, being
+// the reason the person signed in at all — has had its turn. With Home
+// configured, connect's own Signin remembers return_to for the Home
+// exchange; this cookie serves the local link there (/signin/local).
+//
+// This is a port of Slides' cookie (b19fbda), which Astra reviewed on
+// oficina/slides #14: sealed with a key of its own, bound to this origin
+// by the purpose, with an issued and an expiry time checked on read, one
+// cookie only, strict base64, and handoff.SafePath on the way in and the
+// way out, so it can never name another site or loop into /signin.
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "math"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/handoff/seal"
+)
+
+// returnCookieAge is how long a remembered page lasts: a link opened
+// after ten minutes, or in another browser, lands on the meeting list.
+const returnCookieAge int64 = 600
+const returnCookieLimit = 4096
+
+type returnIntent struct {
+ Path string `json:"path"`
+ Issued int64 `json:"issued"`
+ Expires int64 `json:"expires"`
+}
+
+// returnCookieName is host-only, and __Host- prefixed wherever the origin
+// is https.
+func (a *app) returnCookieName() string {
+ if a.secure() {
+ return "__Host-calls_return"
+ }
+ return "calls_return"
+}
+
+func (a *app) secure() bool { return strings.HasPrefix(a.cfg.Origin, "https://") }
+
+// returnSealer keeps the cookie independent of every other sealed value:
+// its key is a domain-bound hash of the instance key. A changed key
+// invalidates old return cookies, which is safe.
+func (a *app) returnSealer() *seal.Sealer {
+ key := sha256.Sum256([]byte("oficina/calls return v1\x00" + a.cfg.instanceKey))
+ s, err := seal.New(key[:], "oficina/calls")
+ if err != nil {
+ return nil
+ }
+ return s
+}
+
+func (a *app) returnPurpose() string { return "calls.return.v1:" + a.cfg.Origin }
+
+// safeReturn is the request's return_to, when there is exactly one and
+// it is a same-origin path (handoff.SafePath: no scheme, no host, no
+// "//", no control characters, not /signin or /auth/*) and not an
+// invitation, whose path is the capability itself.
+func safeReturn(q url.Values) (string, bool) {
+ values, ok := q["return_to"]
+ if !ok || len(values) != 1 || !safeReturnPath(values[0]) {
+ return "", false
+ }
+ return values[0], true
+}
+
+func safeReturnPath(p string) bool {
+ return handoff.SafePath(p) && !strings.HasPrefix(p, "/j/")
+}
+
+func (a *app) rememberReturn(w http.ResponseWriter, path string) {
+ s := a.returnSealer()
+ if !safeReturnPath(path) || s == nil {
+ return
+ }
+ now := a.store.now().Unix()
+ if now <= 0 || now > math.MaxInt64-returnCookieAge {
+ return
+ }
+ raw, err := json.Marshal(returnIntent{Path: path, Issued: now, Expires: now + returnCookieAge})
+ if err != nil {
+ return
+ }
+ sealed, err := s.Seal(a.returnPurpose(), raw)
+ if err != nil {
+ return
+ }
+ value := base64.RawURLEncoding.EncodeToString(sealed)
+ if len(value) > returnCookieLimit {
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: a.returnCookieName(), Value: value, Path: "/",
+ MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(),
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func (a *app) forgetReturn(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: a.returnCookieName(), Value: "", Path: "/",
+ MaxAge: -1, HttpOnly: true, Secure: a.secure(),
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+// rememberedReturn reads the remembered page back, validated again: the
+// cookie is a request header like any other.
+func (a *app) rememberedReturn(r *http.Request) (string, bool) {
+ s := a.returnSealer()
+ if s == nil {
+ return "", false
+ }
+ count := 0
+ for _, c := range r.Cookies() {
+ if c.Name == a.returnCookieName() {
+ count++
+ }
+ }
+ if count != 1 {
+ return "", false
+ }
+ c, err := r.Cookie(a.returnCookieName())
+ if err != nil || c.Value == "" || len(c.Value) > returnCookieLimit {
+ return "", false
+ }
+ sealed, err := base64.RawURLEncoding.Strict().DecodeString(c.Value)
+ if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != c.Value {
+ return "", false
+ }
+ raw, err := s.Open(a.returnPurpose(), sealed)
+ if err != nil || len(raw) > returnCookieLimit {
+ return "", false
+ }
+ var intent returnIntent
+ if json.Unmarshal(raw, &intent) != nil {
+ return "", false
+ }
+ canonical, _ := json.Marshal(intent)
+ now := a.store.now().Unix()
+ if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now ||
+ intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge || !safeReturnPath(intent.Path) {
+ return "", false
+ }
+ return intent.Path, true
+}
+
+// requireSignedInPage is rastrillo/auth's RequireSession with return_to
+// for a page: a signed-out GET or HEAD that asked for HTML is answered
+// 303 /signin?return_to=<the page>. Everything else — a POST, and the
+// room's stream, signal, ICE and info fetches, which never ask for HTML —
+// goes to the guard and gets exactly today's answer, which is what
+// room.js's readRefusal reads a sign-out from (spec §9.4).
+func (a *app) requireSignedInPage(next http.Handler) http.Handler {
+ guarded := a.auth.RequireSession(next)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if (r.Method == http.MethodGet || r.Method == http.MethodHead) && wantsHTML(r) {
+ if _, ok := a.auth.SessionFrom(r); !ok {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
+ return
+ }
+ }
+ guarded.ServeHTTP(w, r)
+ })
+}
+
+// signedOutAfter wraps rastrillo/auth's Signout, which ends the session
+// and redirects to the sign-in form: a page that, with no word about
+// what had just happened, read like an error. It goes to /signed-out,
+// which says so.
+func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ signout(&toSignedOut{ResponseWriter: w}, r)
+ }
+}
+
+// toSignedOut rewrites Signout's Location, at the last moment a header is
+// still settable (sameAnswer's technique).
+type toSignedOut struct{ http.ResponseWriter }
+
+func (t *toSignedOut) WriteHeader(status int) {
+ if t.Header().Get("Location") == signinPath {
+ t.Header().Set("Location", "/signed-out")
+ }
+ t.ResponseWriter.WriteHeader(status)
+}