oficina / home Public

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

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev c76741f3143c9ae5ee9ef1426a0780358a56412b parent 66c6c56parent d09f554
6 files changed, +123 −16
  • docs/superpowers/specs/2026-08-30-home-design.md +13 −0
  • internal/home/directory.go +2 −2
  • internal/home/directory_catalog.go +34 −9
  • internal/home/directory_catalog_test.go +60 −2
  • internal/home/runtime.go +13 −2
  • internal/home/runtime_test.go +1 −1

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 40265f7..84c6b5c 100644
--- a/docs/superpowers/specs/2026-08-30-home-design.md
+++ b/docs/superpowers/specs/2026-08-30-home-design.md
@@ -1355,6 +1355,19 @@ 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.
+**Suite preview extension, 2026-09-10 — Paul requested the remaining apps
+use the working Home handoff.** `HOME_APP_PREVIEW_ORIGINS` (or its canonical
+base64 alternative) is an optional operator map from a compiled catalog
+slug to a canonical HTTPS origin. Each configured link opens the fixed
+`/signin` path. Unknown apps, empty destinations, self-origins, duplicate
+JSON keys and non-directory use are refused. The legacy Docs setting may
+coexist with other app entries, but specifying Docs in both settings is
+refused. Configuration changes navigation only; each destination must have
+its separately reviewed consumer and account-linking integration before
+its link is enabled. Memoria is included in the catalog. Mail and Files
+still require their own vault-unlock flow; this setting grants no keys or
+membership and changes none of the canonical team routing rules.
+
**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 9ce7ec9..20150fc 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.validDocsPreview() || 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.validAppPreviews() || 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{"Apps": directoryCatalog(a.runtime.docsPreviewOrigin)})
+ a.render(w, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)})
})
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
index a4f6006..cf1b8e1 100644
--- a/internal/home/directory_catalog.go
+++ b/internal/home/directory_catalog.go
@@ -1,34 +1,59 @@
package home
+import "strings"
+
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 {
+func directoryCatalog(cfg Runtime) []catalogApp {
docs := catalogApp{Name: "Docs", Href: docsEntryPath, Status: "Choose a team"}
- if previewOrigin != "" {
- docs.Href = previewOrigin + "/signin"
+ if cfg.docsPreviewOrigin != "" {
+ docs.Href = cfg.docsPreviewOrigin + "/signin"
docs.Status = "Open preview"
}
- return []catalogApp{
+ apps := []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: "Memoria", Status: "Not connected to Home yet"},
{Name: "Mail", Status: "Separate sign-in"},
{Name: "Files", Status: "Separate sign-in"},
}
+ for i := range apps {
+ if origin := cfg.appPreviewOrigins[strings.ToLower(apps[i].Name)]; origin != "" {
+ apps[i].Href = origin + "/signin"
+ apps[i].Status = "Open preview"
+ }
+ }
+ return apps
}
// 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
+func (cfg Runtime) validAppPreviews() bool {
+ if cfg.docsPreviewOrigin != "" {
+ if !cfg.validPreviewOrigin(cfg.docsPreviewOrigin) || cfg.appPreviewOrigins["docs"] != "" {
+ return false
+ }
}
- _, err := originHost(cfg.docsPreviewOrigin)
- return cfg.role == "directory" && err == nil && cfg.docsPreviewOrigin != cfg.origins.Credential && cfg.docsPreviewOrigin != cfg.origins.Directory
+ for app, origin := range cfg.appPreviewOrigins {
+ switch app {
+ case "docs", "sheets", "slides", "calendar", "meet", "memoria", "mail", "files":
+ default:
+ return false
+ }
+ if !cfg.validPreviewOrigin(origin) {
+ return false
+ }
+ }
+ return true
+}
+func (cfg Runtime) validPreviewOrigin(origin string) bool {
+ _, err := originHost(origin)
+ return cfg.role == "directory" && err == nil && origin != cfg.origins.Credential && origin != cfg.origins.Directory
}
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go
index f11d7b9..4194ce4 100644
--- a/internal/home/directory_catalog_test.go
+++ b/internal/home/directory_catalog_test.go
@@ -18,7 +18,7 @@ func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *tes
}
noStore(t, page)
body := page.Body.String()
- for _, name := range []string{"Docs", "Sheets", "Slides", "Calendar", "Meet", "Mail", "Files"} {
+ for _, name := range []string{"Docs", "Sheets", "Slides", "Calendar", "Meet", "Memoria", "Mail", "Files"} {
if !strings.Contains(body, ">"+name+"<") {
t.Fatal("missing app", name)
}
@@ -26,7 +26,7 @@ func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *tes
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 {
+ if strings.Count(body, "Not connected to Home yet") != 4 || 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)
@@ -92,3 +92,61 @@ func TestRuntimeDocsPreviewOriginIsOptionalAndStrict(t *testing.T) {
}
}
}
+
+func TestAppPreviewManifestIsClosedAndUnambiguous(t *testing.T) {
+ _, keys := runtimeEnv(t)
+ t.Setenv("HOME_ROLE", "directory")
+ t.Setenv("HOME_ASSERTION_KEYS", keys)
+ for _, raw := range []string{
+ `null`, `{}`, `[]`, `{"unknown":"https://unknown.example"}`, `{"Docs":"https://docs.example"}`,
+ `{"sheets":""}`, `{"sheets":"https://user@sheets.example"}`, `{"sheets":"https://sheets.example/path"}`,
+ `{"sheets":"https://sheets.example","sheets":"https://other.example"}`,
+ `{"sheets":"https://id.oficina.works"}`, `{"sheets":"https://oficina.works"}`,
+ } {
+ t.Setenv("HOME_APP_PREVIEW_ORIGINS", raw)
+ if _, err := LoadRuntime("directory.db", quietLogger()); err != errExchangeConfig {
+ t.Fatalf("accepted invalid app manifest %s: %v", raw, err)
+ }
+ }
+ t.Setenv("HOME_APP_PREVIEW_ORIGINS", `{"sheets":"https://sheets.example","memoria":"https://memoria.example"}`)
+ cfg, err := LoadRuntime("directory.db", quietLogger())
+ if err != nil || cfg.appPreviewOrigins["sheets"] != "https://sheets.example" {
+ t.Fatal("valid manifest refused", err)
+ }
+ t.Setenv("HOME_DOCS_PREVIEW_ORIGIN", "https://docs.example")
+ if _, err = LoadRuntime("directory.db", quietLogger()); err != nil {
+ t.Fatal("legacy Docs cannot coexist with other apps", err)
+ }
+ t.Setenv("HOME_APP_PREVIEW_ORIGINS", `{"docs":"https://docs.example"}`)
+ if _, err = LoadRuntime("directory.db", quietLogger()); err != errExchangeConfig {
+ t.Fatal("ambiguous Docs accepted", err)
+ }
+ t.Setenv("HOME_DOCS_PREVIEW_ORIGIN", "")
+ for _, role := range []string{"", "credential"} {
+ t.Setenv("HOME_ROLE", role)
+ if _, err = LoadRuntime("home.db", quietLogger()); err != errExchangeConfig {
+ t.Fatal("app manifest accepted on wrong role", role, err)
+ }
+ }
+}
+
+func TestConfiguredAppsLaunchOnlyOperatorDestinations(t *testing.T) {
+ f := newMountedChooser(t)
+ cfg := *f.p.directory.runtime
+ cfg.appPreviewOrigins = map[string]string{"sheets": "https://sheets.example", "memoria": "https://memoria.example"}
+ app, err := NewRuntime(cfg, f.p.directory.db, quietLogger())
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.handler = app.Router()
+ page := f.request("GET", "/?app=slides&origin=https://attacker.example", "", "", f.session(t, chooserPerson))
+ body := page.Body.String()
+ if page.Code != 200 || strings.Count(body, "<a ") != 3 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") {
+ t.Fatal(page.Code, body)
+ }
+ noStore(t, page)
+ cfg.appPreviewOrigins["unknown"] = "https://unknown.example"
+ if _, err := NewRuntime(cfg, f.p.directory.db, quietLogger()); err != errExchangeConfig {
+ t.Fatal("constructor bypassed app manifest validation", err)
+ }
+}
diff --git a/internal/home/runtime.go b/internal/home/runtime.go
index dcc8fa2..9bff597 100644
--- a/internal/home/runtime.go
+++ b/internal/home/runtime.go
@@ -24,6 +24,7 @@ var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.
type Runtime struct {
docsPreviewOrigin string
+ appPreviewOrigins map[string]string
roster rosterRuntime
role string
origins Origins
@@ -45,7 +46,17 @@ func LoadRuntime(dbPath string, logger *slog.Logger) (Runtime, error) {
}
cfg.role = os.Getenv("HOME_ROLE")
cfg.docsPreviewOrigin = os.Getenv("HOME_DOCS_PREVIEW_ORIGIN")
- if !cfg.validDocsPreview() {
+ rawPreviews, err := exchangeJSON("HOME_APP_PREVIEW_ORIGINS", 8192)
+ if err != nil {
+ return Runtime{}, err
+ }
+ if rawPreviews != "" {
+ if json.Unmarshal([]byte(rawPreviews), &cfg.appPreviewOrigins) != nil || cfg.appPreviewOrigins == nil || len(cfg.appPreviewOrigins) == 0 || cfg.role != "directory" {
+ return Runtime{}, errExchangeConfig
+ }
+ }
+
+ if !cfg.validAppPreviews() {
return Runtime{}, errExchangeConfig
}
if cfg.role != "directory" {
@@ -156,7 +167,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) || !cfg.validDocsPreview() {
+ if !cfg.roster.valid(cfg.role, cfg.keys) || !cfg.validAppPreviews() {
return nil, errExchangeConfig
}
if cfg.role == "directory" {
diff --git a/internal/home/runtime_test.go b/internal/home/runtime_test.go
index 5686723..7f2fefa 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_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"} {
+ for _, k := range []string{"HOME_APP_PREVIEW_ORIGINS", "HOME_APP_PREVIEW_ORIGINS_BASE64", "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)