oficina / home Public

Merge branch 'feat/home-app-launcher' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev cd476a280200fddfe02eae43c289c5426132d937 parent 60faf05parent 822a49b
7 files changed, +171 −12
  • docs/superpowers/specs/2026-08-30-home-design.md +13 −0
  • internal/home/directory.go +2 −2
  • internal/home/directory_catalog.go +34 −0
  • internal/home/directory_catalog_test.go +94 −0
  • internal/home/runtime.go +14 −9
  • internal/home/runtime_test.go +1 −1
  • internal/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}}