Merge branch 'feat/suite-app-launcher' into main
6 files changed,
+123
−16
docs/superpowers/specs/2026-08-30-home-design.md+13 −0internal/home/directory.go+2 −2internal/home/directory_catalog.go+34 −9internal/home/directory_catalog_test.go+60 −2internal/home/runtime.go+13 −2internal/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) |