Merge branch 'feat/home-app-launcher' into main
7 files changed,
+171
−12
docs/superpowers/specs/2026-08-30-home-design.md+13 −0internal/home/directory.go+2 −2internal/home/directory_catalog.go+34 −0internal/home/directory_catalog_test.go+94 −0internal/home/runtime.go+14 −9internal/home/runtime_test.go+1 −1internal/home/templates/directory.html+13 −0
CI passed — run details
diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md| index bd7b00f..40265f7 100644 |
| --- a/docs/superpowers/specs/2026-08-30-home-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-home-design.md |
| @@ -1342,6 +1342,19 @@ registry is not invented for this screen — §4's `<team>.<app>.oficina.works` |
| has to resolve per app and §7 has to know what can be provisioned, and |
| without it that knowledge is scattered across templates. |
| +**Preview navigation exception, 2026-09-09 — approved by Paul for the |
| +current preview connection.** The directory may read the operator-only |
| +`HOME_DOCS_PREVIEW_ORIGIN` setting and link the Docs catalog entry to that |
| +canonical HTTPS origin's fixed `/signin` path, labelled **Open preview**. |
| +An empty setting keeps the existing `/apps/docs` team chooser. This is an |
| +opt-in preview launcher, not a team instance or a change to the canonical |
| +`<team>.<app>.oficina.works` route policy. It does not register a consumer |
| +or grant access; the destination must already implement its own Home |
| +handshake. The setting is rejected outside the directory role, for a |
| +noncanonical origin, or for the credential/directory origins themselves. |
| +The current catalog is a staged list with explicit unavailable states; |
| +the full team-scoped grid and its switcher remain to be implemented. |
| + |
| **Mail and Files are `integrated: false`** — Keymail and Slopbox are |
| separate products in separate repositories with their own auth. They render |
| as plain outbound links, never as team-scoped tiles, until each verifies |
diff --git a/internal/home/directory.go b/internal/home/directory.go| index fe25f09..a84aeac 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -17,7 +17,7 @@ import ( |
| ) |
| func newDirectory(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) { |
| - if cfg.role != "directory" || cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil || !cfg.roster.valid(cfg.role, cfg.keys) { |
| + if !cfg.validDocsPreview() || cfg.role != "directory" || cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil || !cfg.roster.valid(cfg.role, cfg.keys) { |
| return nil, errExchangeConfig |
| } |
| if logger == nil { |
| @@ -79,7 +79,7 @@ func (a *App) directoryRouter() http.Handler { |
| }) |
| r.With(a.sessions.Require).Get("/", func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Cache-Control", "no-store") |
| - a.render(w, 200, "directory.html", map[string]any{}) |
| + a.render(w, 200, "directory.html", map[string]any{"Apps": directoryCatalog(a.runtime.docsPreviewOrigin)}) |
| }) |
| r.Post("/signout", func(w http.ResponseWriter, r *http.Request) { |
| a.sessions.SignOut(w, r) |
diff --git a/internal/home/directory_catalog.go b/internal/home/directory_catalog.go| new file mode 100644 |
| index 0000000..a4f6006 |
| --- /dev/null |
| +++ b/internal/home/directory_catalog.go |
| @@ -0,0 +1,34 @@ |
| +package home |
| + |
| +type catalogApp struct { |
| + Name, Href, Status string |
| +} |
| + |
| +// This display catalog grants no integration or provisioning capability. |
| +// Those remain governed by directoryApps and the destination checks. |
| +func directoryCatalog(previewOrigin string) []catalogApp { |
| + docs := catalogApp{Name: "Docs", Href: docsEntryPath, Status: "Choose a team"} |
| + if previewOrigin != "" { |
| + docs.Href = previewOrigin + "/signin" |
| + docs.Status = "Open preview" |
| + } |
| + return []catalogApp{ |
| + docs, |
| + {Name: "Sheets", Status: "Not connected to Home yet"}, |
| + {Name: "Slides", Status: "Not connected to Home yet"}, |
| + {Name: "Calendar", Status: "Not connected to Home yet"}, |
| + {Name: "Meet", Status: "Coming soon"}, |
| + {Name: "Mail", Status: "Separate sign-in"}, |
| + {Name: "Files", Status: "Separate sign-in"}, |
| + } |
| +} |
| + |
| +// Preview navigation is an operator choice, not an additional team route. |
| +// The destination must already have its own Home consumer registration. |
| +func (cfg Runtime) validDocsPreview() bool { |
| + if cfg.docsPreviewOrigin == "" { |
| + return true |
| + } |
| + _, err := originHost(cfg.docsPreviewOrigin) |
| + return cfg.role == "directory" && err == nil && cfg.docsPreviewOrigin != cfg.origins.Credential && cfg.docsPreviewOrigin != cfg.origins.Directory |
| +} |
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go| new file mode 100644 |
| index 0000000..f11d7b9 |
| --- /dev/null |
| +++ b/internal/home/directory_catalog_test.go |
| @@ -0,0 +1,94 @@ |
| +package home |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *testing.T) { |
| + f := newMountedChooser(t) |
| + anonymous := f.request("GET", "/", "", "", nil) |
| + if anonymous.Code != 303 || !strings.HasPrefix(anonymous.Header().Get("Location"), "/auth/start") || strings.Contains(anonymous.Body.String(), "Choose a team") { |
| + t.Fatal("anonymous directory exposed catalog", anonymous.Code, anonymous.Header()) |
| + } |
| + cookie := f.session(t, chooserPerson) |
| + page := f.request("GET", "/", "", "", cookie) |
| + if page.Code != 200 { |
| + t.Fatal(page.Code) |
| + } |
| + noStore(t, page) |
| + body := page.Body.String() |
| + for _, name := range []string{"Docs", "Sheets", "Slides", "Calendar", "Meet", "Mail", "Files"} { |
| + if !strings.Contains(body, ">"+name+"<") { |
| + t.Fatal("missing app", name) |
| + } |
| + } |
| + if strings.Count(body, "<a ") != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || strings.Contains(body, "<script") { |
| + t.Fatal("catalog advertised unsupported launch", body) |
| + } |
| + if strings.Count(body, "Not connected to Home yet") != 3 || strings.Count(body, "Coming soon") != 1 || strings.Count(body, "Separate sign-in") != 2 { |
| + t.Fatal("catalog readiness missing", body) |
| + } |
| + entry := f.request("GET", docsEntryPath, "", "", cookie) |
| + if entry.Code != 200 || !strings.Contains(entry.Body.String(), "This account has no team with Docs available.") { |
| + t.Fatal("catalog bypassed team selection", entry.Code) |
| + } |
| +} |
| + |
| +func TestDirectoryCatalogPreviewKeepsDocsChooserPolicy(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cfg := *f.p.directory.runtime |
| + cfg.docsPreviewOrigin = "https://overnight-docs.bds.oncarlos.com" |
| + app, err := NewRuntime(cfg, f.p.directory.db, quietLogger()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.handler = app.Router() |
| + cookie := f.session(t, chooserPerson) |
| + page := f.request("GET", "/?return_to=https://evil.example", "", "", cookie) |
| + body := page.Body.String() |
| + if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Contains(body, "Choose a team") || strings.Contains(body, "evil.example") || strings.Count(body, "<a ") != 1 { |
| + t.Fatal("preview navigation incorrect", page.Code, body) |
| + } |
| + noStore(t, page) |
| + 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) |
| + } |
| + cfg.docsPreviewOrigin = "https://evil.example/path" |
| + if _, err := NewRuntime(cfg, f.p.directory.db, quietLogger()); err != errExchangeConfig { |
| + t.Fatal("constructor bypassed preview config validation", err) |
| + } |
| +} |
| + |
| +func TestRuntimeDocsPreviewOriginIsOptionalAndStrict(t *testing.T) { |
| + _, keys := runtimeEnv(t) |
| + t.Setenv("HOME_ROLE", "directory") |
| + t.Setenv("HOME_ASSERTION_KEYS", keys) |
| + for _, origin := range []string{"", "https://overnight-docs.bds.oncarlos.com"} { |
| + t.Setenv("HOME_DOCS_PREVIEW_ORIGIN", origin) |
| + cfg, err := LoadRuntime("directory.db", quietLogger()) |
| + if err != nil || cfg.docsPreviewOrigin != origin { |
| + t.Fatal("valid preview config refused", err) |
| + } |
| + } |
| + for _, origin := range []string{ |
| + "http://docs.example", "//docs.example", "https://user@docs.example", "https://docs.example/", "https://docs.example/signin", |
| + "https://docs.example?x=1", "https://docs.example#fragment", "https://docs.example?", "https://docs.example#", |
| + "https://docs.example:443", "https://docs.example:8443", "https://DOCS.example", "HTTPS://docs.example", |
| + "https://docs.example.", "https://127.0.0.1", "https://[::1]", " https://docs.example", "https://docs.example\n", |
| + "https://docs%2eexample", "https://docs_example", "https://-docs.example", "https://docs..example", Origin, directoryOrigin, |
| + } { |
| + t.Setenv("HOME_DOCS_PREVIEW_ORIGIN", origin) |
| + if _, err := LoadRuntime("directory.db", quietLogger()); err != errExchangeConfig { |
| + t.Fatalf("invalid preview config accepted %q: %v", origin, err) |
| + } |
| + } |
| + t.Setenv("HOME_DOCS_PREVIEW_ORIGIN", "https://overnight-docs.bds.oncarlos.com") |
| + for _, role := range []string{"", "credential"} { |
| + t.Setenv("HOME_ROLE", role) |
| + if _, err := LoadRuntime("home.db", quietLogger()); err != errExchangeConfig { |
| + t.Fatal("preview setting accepted outside directory", role, err) |
| + } |
| + } |
| +} |
diff --git a/internal/home/runtime.go b/internal/home/runtime.go| index 1da805b..dcc8fa2 100644 |
| --- a/internal/home/runtime.go |
| +++ b/internal/home/runtime.go |
| @@ -23,14 +23,15 @@ var errExchangeConfig = errors.New("home: invalid assertion configuration") |
| var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), directorydata.Schema, directorydata.RosterSchema) |
| type Runtime struct { |
| - roster rosterRuntime |
| - role string |
| - origins Origins |
| - secrets Secrets |
| - signer *assertioncrypto.Keypair |
| - keyID string |
| - keys map[string][]byte |
| - consumers map[string]string |
| + docsPreviewOrigin string |
| + roster rosterRuntime |
| + role string |
| + origins Origins |
| + secrets Secrets |
| + signer *assertioncrypto.Keypair |
| + keyID string |
| + keys map[string][]byte |
| + consumers map[string]string |
| } |
| // LoadRuntime keeps existing deployments on the legacy path until a role is |
| @@ -43,6 +44,10 @@ func LoadRuntime(dbPath string, logger *slog.Logger) (Runtime, error) { |
| return Runtime{}, err |
| } |
| cfg.role = os.Getenv("HOME_ROLE") |
| + cfg.docsPreviewOrigin = os.Getenv("HOME_DOCS_PREVIEW_ORIGIN") |
| + if !cfg.validDocsPreview() { |
| + return Runtime{}, errExchangeConfig |
| + } |
| if cfg.role != "directory" { |
| cfg.roster, err = loadRosterRuntime(cfg.role, nil) |
| if err != nil { |
| @@ -151,7 +156,7 @@ func (cfg Runtime) Schema() *migrate.Set { |
| return BootSchema |
| } |
| func NewRuntime(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) { |
| - if !cfg.roster.valid(cfg.role, cfg.keys) { |
| + if !cfg.roster.valid(cfg.role, cfg.keys) || !cfg.validDocsPreview() { |
| return nil, errExchangeConfig |
| } |
| if cfg.role == "directory" { |
diff --git a/internal/home/runtime_test.go b/internal/home/runtime_test.go| index 05894fe..5686723 100644 |
| --- a/internal/home/runtime_test.go |
| +++ b/internal/home/runtime_test.go |
| @@ -18,7 +18,7 @@ func runtimeEnv(t *testing.T) (string, string) { |
| t.Setenv(k, "") |
| os.Unsetenv(k) |
| } |
| - for _, k := range []string{"HOME_CONSUMERS_BASE64", "HOME_ASSERTION_KEYS_BASE64", "HOME_ROLE", "HOME_ASSERTION_KEY", "HOME_ASSERTION_KEYS", "HOME_CONSUMERS", "HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} { |
| + for _, k := range []string{"HOME_DOCS_PREVIEW_ORIGIN", "HOME_CONSUMERS_BASE64", "HOME_ASSERTION_KEYS_BASE64", "HOME_ROLE", "HOME_ASSERTION_KEY", "HOME_ASSERTION_KEYS", "HOME_CONSUMERS", "HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} { |
| t.Setenv(k, "") |
| } |
| t.Setenv("HOME_CREDENTIAL_ORIGIN", Origin) |
diff --git a/internal/home/templates/directory.html b/internal/home/templates/directory.html| index 7c27538..10bebfb 100644 |
| --- a/internal/home/templates/directory.html |
| +++ b/internal/home/templates/directory.html |
| @@ -2,5 +2,18 @@ |
| {{define "content"}} |
| <h1>Oficina</h1> |
| <p>You are signed in.</p> |
| +<section aria-labelledby="apps-heading"> |
| +<h2 id="apps-heading">Apps</h2> |
| +<div rst-list> |
| +{{range .Apps}} |
| +<div rst-row> |
| +<div rst-row-main> |
| +{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}<strong>{{.Name}}</strong>{{end}} |
| +<span rst-row-sub>{{.Status}}</span> |
| +</div> |
| +</div> |
| +{{end}} |
| +</div> |
| +</section> |
| <form method="post" action="/signout" rst-form><button type="submit" rst-btn>Sign out</button></form> |
| {{end}} |