oficina / home Public

Send /apps/<slug> to the app root, and follow preview origins there

The single-team redirect and the chooser's continuation went to the
team's /auth/start, so every switch re-ran the assertion exchange and its
interstitial even with a live app session. They now go to the team's app
root and let the app's own guard decide (spec shared-chrome §6.2(a)).

In a preview deployment /apps/<slug> ignored the preview override the
catalog honours and ended on "No teams available". It now redirects to
the preview's root, without needing a directory session (§6.2(b)).
TestDirectoryCatalogPreviewKeepsDocsChooserPolicy pinned the old entry
and is updated on purpose.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev ce6f0a1fc76661580571b41aca30c893561df820 parent 6083b70
6 files changed, +83 −15
  • internal/home/app_chooser_test.go +2 −2
  • internal/home/directory_catalog_test.go +46 −2
  • internal/home/directory_runtime_test.go +2 −2
  • internal/home/docs_chooser.go +28 −4
  • internal/home/docs_chooser_test.go +3 −3
  • internal/home/docs_continue_test.go +2 −2
diff --git a/internal/home/app_chooser_test.go b/internal/home/app_chooser_test.go
index 89d82d0..fc2bf1d 100644
--- a/internal/home/app_chooser_test.go
+++ b/internal/home/app_chooser_test.go
@@ -39,7 +39,7 @@ func TestNativeAppChoicesKeepTeamAndAppBoundaries(t *testing.T) {
t.Fatal("choice boundaries", w.Code, w.Body.String())
}
picked := f.request("POST", path+"/team", directoryOrigin, "team=A1", cookie)
- assertContinuationPage(t, picked, "https://alpha."+app+".oficina.works/auth/start?return_to=%2F")
+ assertContinuationPage(t, picked, "https://alpha."+app+".oficina.works/")
if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil {
t.Fatal(err)
}
@@ -47,7 +47,7 @@ func TestNativeAppChoicesKeepTeamAndAppBoundaries(t *testing.T) {
t.Fatal("stale team selection accepted", w.Code)
}
single := f.request("GET", path, "", "", cookie)
- if single.Code != 303 || single.Header().Get("Location") != "https://beta."+app+".oficina.works/auth/start?return_to=%2F" {
+ if single.Code != 303 || single.Header().Get("Location") != "https://beta."+app+".oficina.works/" {
t.Fatal("one-team shortcut", single.Code, single.Header())
}
if w := f.request("POST", path+"/team", "https://attacker.example", "team=B2", cookie); w.Code != 403 {
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go
index 99c6693..02c17c8 100644
--- a/internal/home/directory_catalog_test.go
+++ b/internal/home/directory_catalog_test.go
@@ -78,9 +78,12 @@ func TestDirectoryCatalogPreviewKeepsDocsChooserPolicy(t *testing.T) {
t.Fatal("preview navigation incorrect", page.Code, body)
}
noStore(t, page)
+ // Spec shared-chrome §6.2(b): /apps/docs follows the preview too, to
+ // its root, rather than ending on "No teams available" (this test
+ // asserted the chooser until round 2).
entry := f.request("GET", docsEntryPath, "", "", cookie)
- if entry.Code != 200 || entry.Header().Get("Location") != "" || !strings.Contains(entry.Body.String(), "This account has no team with Docs available.") {
- t.Fatal("preview changed team entry", entry.Code)
+ if entry.Code != 303 || entry.Header().Get("Location") != "https://overnight-docs.bds.oncarlos.com/" {
+ t.Fatal("preview entry", entry.Code, entry.Header().Get("Location"))
}
cfg.docsPreviewOrigin = "https://evil.example/path"
if _, err := NewRuntime(cfg, f.p.directory.db, quietLogger()); err != errExchangeConfig {
@@ -177,3 +180,44 @@ func TestConfiguredAppsLaunchOnlyOperatorDestinations(t *testing.T) {
t.Fatal("constructor bypassed app manifest validation", err)
}
}
+
+// TestAppEntryFollowsPreviewOrigins is spec shared-chrome §6.2(b): the
+// switcher's tiles link to /apps/<slug>, so in a preview deployment that
+// entry must reach the preview, as the catalog's own link does, instead
+// of "No teams available". It goes to the preview's root, like the
+// single-team redirect: the app's own guard decides whether to sign in.
+// A preview is an operator choice, not a team route, so no directory
+// session or team is needed to follow it.
+func TestAppEntryFollowsPreviewOrigins(t *testing.T) {
+ f := newMountedChooser(t)
+ cfg := *f.p.directory.runtime
+ cfg.docsPreviewOrigin = "https://overnight-docs.bds.oncarlos.com"
+ cfg.appPreviewOrigins = map[string]string{"sheets": "https://sheets.example", "calls": "https://calls-preview.example"}
+ app, err := NewRuntime(cfg, f.p.directory.db, quietLogger())
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.handler = app.Router()
+ cookie := f.session(t, chooserPerson)
+ for path, want := range map[string]string{
+ "/apps/docs": "https://overnight-docs.bds.oncarlos.com/",
+ "/apps/sheets": "https://sheets.example/",
+ "/apps/calls": "https://calls-preview.example/",
+ } {
+ for _, c := range []*http.Cookie{cookie, nil} {
+ r := f.request("GET", path, "", "", c)
+ noStore(t, r)
+ if r.Code != 303 || r.Header().Get("Location") != want {
+ t.Fatal(path, c != nil, r.Code, r.Header().Get("Location"))
+ }
+ }
+ // The entry still refuses any query, so nothing rides the hop.
+ if r := f.request("GET", path+"?return_to=/secret", "", "", cookie); r.Code != 404 {
+ t.Fatal(path, "query accepted", r.Code)
+ }
+ }
+ // An app without a preview keeps its team entry.
+ if r := f.request("GET", "/apps/slides", "", "", cookie); r.Code != 200 || !strings.Contains(r.Body.String(), "No teams available") {
+ t.Fatal("non-preview app changed", r.Code, r.Header().Get("Location"))
+ }
+}
diff --git a/internal/home/directory_runtime_test.go b/internal/home/directory_runtime_test.go
index ff240d7..f4e4d20 100644
--- a/internal/home/directory_runtime_test.go
+++ b/internal/home/directory_runtime_test.go
@@ -25,7 +25,7 @@ func TestDirectoryRuntimeMountUsesCanonicalRegistryAndCurrentMembership(t *testi
id := f.team(t, "A1", "alpha", "Alpha", chooserPerson)
f.team(t, "B2", "beta", "Foreign team", chooserOther)
r = f.request("GET", docsEntryPath, "", "", cookie)
- if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" {
+ if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/" {
t.Fatal(r.Code, r.Header())
}
noStore(t, r)
@@ -148,7 +148,7 @@ func TestDirectoryRuntimeExpiredChoiceReturnsToFreshTeamSelection(t *testing.T)
req.Header.Set("Sec-Fetch-Site", metadata)
rec := httptest.NewRecorder()
f.handler.ServeHTTP(rec, req)
- assertDocsContinuation(t, rec, "https://beta.docs.oficina.works/auth/start?return_to=%2F")
+ assertDocsContinuation(t, rec, "https://beta.docs.oficina.works/")
}
if _, err := f.p.directory.db.Writer().Exec(`UPDATE sessions SET expires_at='2000-01-01T00:00:00Z'`); err != nil {
t.Fatal(err)
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go
index bfaf180..06672bd 100644
--- a/internal/home/docs_chooser.go
+++ b/internal/home/docs_chooser.go
@@ -64,6 +64,16 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ // A preview deployment replaces the team routes for this app, as it
+ // does on the catalog page, so the entry follows it too (spec
+ // shared-chrome §6.2(b)): otherwise a link here from an app's switcher
+ // ends on "No teams available". A preview is an operator choice, not a
+ // team, so it needs no directory session; it goes to the preview's
+ // root, like the single-team redirect below.
+ if preview := c.previewOrigin(); preview != "" {
+ http.Redirect(w, r, preview+"/", http.StatusSeeOther)
+ return
+ }
subject, ok := c.subject(w, r)
if !ok {
return
@@ -150,10 +160,25 @@ func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, b
func (c *docsChooser) unavailable(w http.ResponseWriter) {
c.app.render(w, http.StatusServiceUnavailable, "docs_unavailable.html", nil)
}
+
+// previewOrigin is the operator's preview deployment for this app, or "".
+// Both settings were validated at construction (validAppPreviews).
+func (c *docsChooser) previewOrigin() string {
+ if c.appID == "docs" && c.app.runtime.docsPreviewOrigin != "" {
+ return c.app.runtime.docsPreviewOrigin
+ }
+ return c.app.runtime.appPreviewOrigins[c.appID]
+}
+
+// redirect and continueToDocs send the browser to the chosen team's app
+// root, not to its /auth/start. The app's own guard starts an assertion
+// exchange only when it has no session of its own, so a switch into an
+// app you are already signed in to costs no exchange and no interstitial
+// (spec shared-chrome §6.2(a)).
func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) {
// The directorydata store has already applied the configured destination
// policy. Generic entry carries only the app root, never a document path.
- http.Redirect(w, r, choice.Origin+handoff.StartPath+"?"+url.Values{"return_to": {"/"}}.Encode(), http.StatusSeeOther)
+ http.Redirect(w, r, choice.Origin+"/", http.StatusSeeOther)
}
func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata.Choice) {
@@ -161,11 +186,10 @@ func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata
c.unavailable(w)
return
}
- destination := choice.Origin + handoff.StartPath + "?" + url.Values{"return_to": {"/"}}.Encode()
- c.renderContinuation(w, destination)
+ c.renderContinuation(w, choice.Origin+"/")
}
-// Callers supply either a validated stored app origin plus the fixed app path,
+// Callers supply either a validated stored app origin plus the app root,
// or the validated directory origin plus the fixed sign-in continuation path.
func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) {
c.app.renderContinuation(w, destination, "Continue to "+c.appName)
diff --git a/internal/home/docs_chooser_test.go b/internal/home/docs_chooser_test.go
index 3bfc673..f9e4b94 100644
--- a/internal/home/docs_chooser_test.go
+++ b/internal/home/docs_chooser_test.go
@@ -127,7 +127,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) {
a := f.team(t, "A1", "alpha", "Alpha", chooserPerson)
r = f.request("GET", docsEntryPath, "", "", cookie)
noStore(t, r)
- if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" {
+ if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/" {
t.Fatal(r.Code, r.Header())
}
f.team(t, "B2", "beta", `<script>alert("team")</script>`, chooserPerson)
@@ -144,7 +144,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) {
}
r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie)
noStore(t, r)
- assertDocsContinuation(t, r, "https://beta.docs.oficina.works/auth/start?return_to=%2F")
+ assertDocsContinuation(t, r, "https://beta.docs.oficina.works/")
if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil {
t.Fatal(err)
}
@@ -156,7 +156,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) {
}
}
r = f.request("GET", docsEntryPath, "", "", cookie)
- if r.Header().Get("Location") != "https://beta.docs.oficina.works/auth/start?return_to=%2F" {
+ if r.Header().Get("Location") != "https://beta.docs.oficina.works/" {
t.Fatal(r.Header())
}
}
diff --git a/internal/home/docs_continue_test.go b/internal/home/docs_continue_test.go
index 17aaae3..e8f5589 100644
--- a/internal/home/docs_continue_test.go
+++ b/internal/home/docs_continue_test.go
@@ -74,7 +74,7 @@ func TestDocsContinueCanonicalResponseBoundary(t *testing.T) {
for _, origin := range []string{"https://alpha.docs.oficina.works", "https://approved.fixture.example"} {
r := httptest.NewRecorder()
c.continueToDocs(r, directorydata.Choice{Origin: origin})
- assertDocsContinuation(t, r, origin+"/auth/start?return_to=%2F")
+ assertDocsContinuation(t, r, origin+"/")
}
for _, origin := range []string{"", "http://alpha.example", "https://Alpha.example", "https://alpha.example/", "https://alpha.example:443", "https://user@alpha.example", "https://alpha.example?x=1", "https://alpha.example#f", "https://alpha.example?", "https://alpha.example\n", " https://alpha.example", "https://alpha.example\";url=https://bad.example", "javascript:alert(1)", "//alpha.example", "https://[::1]", "https://alpha.example/%22"} {
r := httptest.NewRecorder()
@@ -119,7 +119,7 @@ func TestDocsContinueRequeriesCurrentOrigin(t *testing.T) {
if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://changed.docs.oficina.works' WHERE team_id=?`, id); err != nil {
t.Fatal(err)
}
- assertDocsContinuation(t, f.request("POST", docsChoicePath, directoryOrigin, "team=A1", cookie), "https://changed.docs.oficina.works/auth/start?return_to=%2F")
+ assertDocsContinuation(t, f.request("POST", docsChoicePath, directoryOrigin, "team=A1", cookie), "https://changed.docs.oficina.works/")
}
func TestDocsContinueExactHostAndExpiredSession(t *testing.T) {
f := newChooserFixture(t)