oficina / meet Public

Merge origin/main (f5a1254, door copy) into feat/r2-shared-chrome; estilo main 530cb93, connect 2bec942

The door's words now come from estilo's one copy set: chrome.SignIn
loses Heading, AddressLabel, Help and Submit; the sent state is
SignInNotice.Sent(a.signinAgain(r, "")) so "Use a different address"
keeps return_to; titles take .Page() under the suffixing layout; the
signed-out page is .SignedOut(href), standalone and with Home. Main's
email subject (Sign in to Calls) is kept.

Astra's batch nits: the Home signed-out test asserts the retired
"Sign in with Home" rather than connect.English's action, which Home
#79 made "Sign in"; and the account link is "Connect your Oficina
account" in the account menu (TestTheAccountMenuOffersToConnectYour-
OficinaAccount, red on main f5a1254).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 346ea0ecfbe9736620ea6e323959435550265bf9 parent bd56680parent f5a1254
8 files changed, +126 −59
  • go.mod +2 −2
  • go.sum +4 −6
  • internal/calls/auth.go +24 −40
  • internal/calls/home.go +3 −2
  • internal/calls/home_router_test.go +29 −2
  • internal/calls/templates/signedout.html +3 −3
  • internal/callstest/harness_test.go +15 −3
  • internal/callstest/signin_test.go +46 −1

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768
go: downloading amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c
=== fmt ===
=== test ===
go test ./...
ok  	amadan.net/oficina/calls/callscore	(cached)
?   	amadan.net/oficina/calls/cmd/calls	[no test files]
ok  	amadan.net/oficina/calls/internal/calls	1.515s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	8.748s
?   	amadan.net/oficina/calls/protocol	[no test files]
?   	amadan.net/oficina/calls/scripts/writeestilo	[no test files]
=== migration-check ===
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
=== race ===
CGO_ENABLED=1 go test -race ./internal/...
ok  	amadan.net/oficina/calls/internal/calls	10.964s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	26.847s
diff --git a/go.mod b/go.mod
index 9f8c78d..1cd97b6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,8 @@ module amadan.net/oficina/calls
go 1.26.0
require (
- amadan.net/oficina/estilo.git v0.0.0-20261001081853-759a8ff71c5a
- amadan.net/oficina/home/connect v0.0.0-20260930200947-7a0f19c3a8a7
+ amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768
+ amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/go-chi/chi/v5 v5.3.2
diff --git a/go.sum b/go.sum
index 0f8bbb5..da3dcea 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,7 @@
-amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 h1:Qww+xGIYJaj+6TIX4Knr71ghxeYJjtgFm6WukX6NkGg=
-amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/estilo.git v0.0.0-20261001081853-759a8ff71c5a h1:7eP/rgQjU0KAtYbhiaFSplK5VF2s49NeUKjki/li3zg=
-amadan.net/oficina/estilo.git v0.0.0-20261001081853-759a8ff71c5a/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/home/connect v0.0.0-20260930200947-7a0f19c3a8a7 h1:FVzfiViGRUtl/enMtf34LL2ECLCLJx5V2JJmsOxuOEI=
-amadan.net/oficina/home/connect v0.0.0-20260930200947-7a0f19c3a8a7/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
+amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768 h1:bVscoa9fH5LdLDjlyecZhrX7JzWPT0blb57MH0FIm2U=
+amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c h1:3yxO2D8BxDLMGfFV2o6Sp9Z91yLnjDCIuRKHVWORa8A=
+amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
diff --git a/internal/calls/auth.go b/internal/calls/auth.go
index ddfdea7..8a29ba1 100644
--- a/internal/calls/auth.go
+++ b/internal/calls/auth.go
@@ -33,6 +33,7 @@ func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) {
Origin: a.cfg.Origin,
InstanceKey: a.cfg.instanceKey,
Mailer: a.cfg.mailer,
+ Subject: chrome.EmailSubject("Calls"),
Authorize: a.authorize,
SigninPath: signinPath,
SignedInPath: landing,
@@ -241,34 +242,24 @@ func (a *app) localSignin(w http.ResponseWriter, r *http.Request) {
}
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"},
- },
- }))
+ // 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
}
+ // 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.
door := chrome.SignIn{
- Icon: assets.Path("static/brand/icon.svg"),
- 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: "Sign in",
- OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"},
+ 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"},
}
if hasReturn {
door.Return = ret
@@ -287,7 +278,7 @@ func (a *app) localSignin(w http.ResponseWriter, r *http.Request) {
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{"Sign in"}, signinPage: true}, map[string]any{"Door": door}))
+ render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true}, map[string]any{"Door": door}))
}
// signinAgain is the form's own address, keeping whatever page the
@@ -374,23 +365,16 @@ func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
// 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)
+ a.renderSignedOut(w, r, signinPath, 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) {
+// 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{"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},
- }}))
+ render(w, "signedout", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Notice": notice}))
}
diff --git a/internal/calls/home.go b/internal/calls/home.go
index 2501a5f..38cf510 100644
--- a/internal/calls/home.go
+++ b/internal/calls/home.go
@@ -163,8 +163,9 @@ func (a *app) endRemovedSession(w http.ResponseWriter, r *http.Request) {
func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) {
home := connect.Page(state, label)
if state == "signedout" {
- // Where Sign out lands: the Oficina door, in connect's own words.
- a.renderSignedOut(w, r, home.Title, home.Body, home.Action, home.ActionLabel, status)
+ // Where Sign out lands: the Oficina door, in the door's words
+ // (connect's name the hub); its Sign in goes to connect's action.
+ a.renderSignedOut(w, r, home.Action, status)
return
}
var body bytes.Buffer
diff --git a/internal/calls/home_router_test.go b/internal/calls/home_router_test.go
index e1feb68..c1eb08f 100644
--- a/internal/calls/home_router_test.go
+++ b/internal/calls/home_router_test.go
@@ -355,7 +355,8 @@ func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) {
}
// With Home connected, Sign out lands on the Oficina door's signed-out
-// page, in connect's own words, and its one link goes back to /signin.
+// page, in the door's own words (not connect's, which name the hub), and
+// its one link, "Sign in", goes back to connect's sign-in action.
func TestHomeSignedOutIsTheOficinaDoor(t *testing.T) {
f := newHomeRouterFixture(t)
w := httptest.NewRecorder()
@@ -364,11 +365,21 @@ func TestHomeSignedOutIsTheOficinaDoor(t *testing.T) {
if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("GET /signed-out: %d %q", w.Code, w.Header().Get("Cache-Control"))
}
- for _, want := range []string{"<div estilo-door", "<span>Calls</span>", "estilo-sky-layer", connect.English["signedout_title"], `<a rst-btn="primary" href="/signin">` + connect.English["signedout_action"]} {
+ for _, want := range []string{"<div estilo-door", "<span>Calls</span>", "estilo-sky-layer",
+ "<title>Signed out · Calls</title>", "<h1>You’re signed out</h1>", "You’ve signed out of Calls on this device.",
+ `<a rst-btn="primary" href="/signin">Sign in</a>`} {
if !strings.Contains(body, want) {
t.Errorf("signed-out page is missing %q:\n%s", want, body)
}
}
+ // "Sign in with Home" is the retired label: connect.English now says
+ // "Sign in" itself (Home #79), so asserting its absence by key would
+ // contradict the "Sign in" button required above.
+ for _, gone := range []string{connect.English["signedout_body"], "Sign in with Home", "Home"} {
+ if strings.Contains(body, gone) {
+ t.Errorf("signed-out page still carries %q:\n%s", gone, body)
+ }
+ }
}
// The revoked-user loop Astra found in Docs (#65, 1) and Sheets (#39, 2):
@@ -388,3 +399,19 @@ func TestARemovedHomeMemberIsNotBouncedBetweenSigninAndThePage(t *testing.T) {
t.Fatalf("after the notice, /signin did not start a sign-in: %q", next)
}
}
+
+// The account link is "Connect your Oficina account" (spec §13 Q3: the
+// hub is never named), in the account menu, offered to a local session
+// only: "Connect Home account" on the list page is retired.
+func TestTheAccountMenuOffersToConnectYourOficinaAccount(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ body := f.do("GET", "/", "").Body.String()
+ if !strings.Contains(body, `<a estilo-menu-item href="/account/home"><span estilo-menu-label>Connect your Oficina account</span></a>`) || strings.Contains(body, "Connect Home account") {
+ t.Fatalf("the list page's account link:\n%s", body)
+ }
+ f.link(t)
+ if body := f.do("GET", "/", "").Body.String(); strings.Contains(body, "/account/home") {
+ t.Fatal("a Home session is offered to connect the account it already is")
+ }
+}
diff --git a/internal/calls/templates/signedout.html b/internal/calls/templates/signedout.html
index 7ab02a1..abad39f 100644
--- a/internal/calls/templates/signedout.html
+++ b/internal/calls/templates/signedout.html
@@ -1,4 +1,4 @@
-{{/* Where Sign out lands with Home connected: the Oficina door's
- signed-out page. home.go's renderHome builds it from connect's own
- signed-out strings. */}}
+{{/* Where Sign out lands: the Oficina door's signed-out page, in the
+ door's own words. auth.go's renderSignedOut builds it, standalone
+ and with Home. */}}
{{define "content"}}{{template "estilo-signed-out" .Notice}}{{end}}
diff --git a/internal/callstest/harness_test.go b/internal/callstest/harness_test.go
index 22305b1..fc4aedf 100644
--- a/internal/callstest/harness_test.go
+++ b/internal/callstest/harness_test.go
@@ -159,9 +159,10 @@ func (x *harness) pendingJoins(t *testing.T) int {
}
type recordingMailer struct {
- mu sync.Mutex
- to []string
- links []string
+ mu sync.Mutex
+ to []string
+ subjects []string
+ links []string
}
var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`)
@@ -170,6 +171,7 @@ func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) er
m.mu.Lock()
defer m.mu.Unlock()
m.to = append(m.to, to)
+ m.subjects = append(m.subjects, subject)
m.links = append(m.links, magicLink.FindString(body))
return nil
}
@@ -183,6 +185,16 @@ func (m *recordingMailer) last() (string, string) {
return m.to[len(m.to)-1], m.links[len(m.links)-1]
}
+func (m *recordingMailer) lastSubject(t *testing.T) string {
+ t.Helper()
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if len(m.subjects) == 0 {
+ t.Fatal("no mail sent")
+ }
+ return m.subjects[len(m.subjects)-1]
+}
+
func (m *recordingMailer) count() int {
m.mu.Lock()
defer m.mu.Unlock()
diff --git a/internal/callstest/signin_test.go b/internal/callstest/signin_test.go
index 24f5808..9d67806 100644
--- a/internal/callstest/signin_test.go
+++ b/internal/callstest/signin_test.go
@@ -160,7 +160,7 @@ func TestSigninPageIsTheOficinaDoor(t *testing.T) {
body := rec.Body.String()
for _, want := range []string{
"<div estilo-door", "<span>Calls</span>", `<img estilo-sky-layer="placeholder"`,
- `<img estilo-sky-layer="grain"`, `action="/signin"`, `name="address"`, `type="submit">Sign in</button>`,
+ `<img estilo-sky-layer="grain"`, `action="/signin"`, `name="address"`,
} {
if !strings.Contains(body, want) {
t.Errorf("sign-in page is missing %q:\n%s", want, body)
@@ -196,3 +196,48 @@ func TestSigninPageIsTheOficinaDoor(t *testing.T) {
t.Errorf("err=rate is not a page error:\n%s", page)
}
}
+
+// The door's words are estilo's, the same in every Oficina app: the
+// submit reads "Sign in" (never "Email me a link", Paul 2026-10-01), one
+// help line says what happens, the sent state says the one body and links
+// back with "Use a different address", and each tab title is
+// "<Page> · Calls".
+func TestSigninDoorSpeaksTheSuitesCopy(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ form := c.get("/signin").Body.String()
+ for _, want := range []string{
+ "<title>Sign in · Calls</title>", "<h1>Sign in</h1>", ">Email address<",
+ "We’ll email you a link that signs you in. It works once and expires in 15 minutes.",
+ `<button rst-btn="primary" type="submit">Sign in</button>`,
+ } {
+ if !strings.Contains(form, want) {
+ t.Errorf("sign-in form is missing %q:\n%s", want, form)
+ }
+ }
+ if strings.Contains(form, "Email me a link") {
+ t.Errorf("sign-in form still says \"Email me a link\":\n%s", form)
+ }
+ sent := c.get("/signin?sent=1").Body.String()
+ for _, want := range []string{
+ "<title>Check your email · Calls</title>", "<h1>Check your email</h1>",
+ "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.",
+ `<a href="/signin">Use a different address</a>`,
+ } {
+ if !strings.Contains(sent, want) {
+ t.Errorf("sent page is missing %q:\n%s", want, sent)
+ }
+ }
+}
+
+// The sign-in email's subject names the app: "Sign in to Calls".
+func TestSigninEmailSubjectNamesCalls(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ x.browser(t).post("/signin", url.Values{"address": {"paul@example.com"}})
+ if x.mail.count() != 1 {
+ t.Fatalf("sent %d mails, want 1", x.mail.count())
+ }
+ if got := x.mail.lastSubject(t); got != "Sign in to Calls" {
+ t.Errorf("sign-in email subject %q, want %q", got, "Sign in to Calls")
+ }
+}