diff --git a/.example.env b/.example.env index 5214e39656..05836470ca 100644 --- a/.example.env +++ b/.example.env @@ -7,6 +7,10 @@ JWT_SECRET=hsjl]W;&ZcHxT&FK;s%bgIQF:#ch=~#Al4:5]N;7V= 0 { + return url[index+3:] + } + return url +} diff --git a/app/handlers/portals_test.go b/app/handlers/portals_test.go new file mode 100644 index 0000000000..ea37532c46 --- /dev/null +++ b/app/handlers/portals_test.go @@ -0,0 +1,117 @@ +package handlers_test + +import ( + "context" + "net/http" + "testing" + + "github.com/getfider/fider/app/handlers" + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/enum" + "github.com/getfider/fider/app/models/query" + . "github.com/getfider/fider/app/pkg/assert" + "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/env" + "github.com/getfider/fider/app/pkg/mock" +) + +func TestPortalDirectory_Disabled(t *testing.T) { + RegisterT(t) + + server := mock.NewServer() + env.Config.PortalDirectoryEnabled = false + + code, _ := server. + WithURL("http://test.fider.io/"). + Execute(handlers.PortalDirectory()) + + Expect(code).Equals(http.StatusNotFound) +} + +func TestPortalDirectory_ListsPublicPortals(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetPublicTenants) error { + q.Result = []*entity.Tenant{ + {ID: 2, Name: "Avengers", Subdomain: "avengers", Status: enum.TenantActive, LogoBlobKey: "logos/avengers.png"}, + {ID: 1, Name: "Demonstration", Subdomain: "demo", Status: enum.TenantActive}, + } + return nil + }) + + server := mock.NewServer() + env.Config.PortalDirectoryEnabled = true + defer func() { env.Config.PortalDirectoryEnabled = false }() + + code, props := server. + WithURL("http://test.fider.io/"). + ExecuteAsPage(handlers.PortalDirectory()) + + Expect(code).Equals(http.StatusOK) + + portals, ok := props.Data["portals"].([]any) + Expect(ok).IsTrue() + Expect(portals).HasLen(2) + + avengers := portals[0].(map[string]any) + Expect(avengers["name"]).Equals("Avengers") + Expect(avengers["url"]).Equals("http://avengers.test.fider.io") + Expect(avengers["host"]).Equals("avengers.test.fider.io") + Expect(avengers["logoURL"]).Equals("http://avengers.test.fider.io/static/images/logos/avengers.png?size=200") + + demo := portals[1].(map[string]any) + Expect(demo["name"]).Equals("Demonstration") + Expect(demo["url"]).Equals("http://demo.test.fider.io") + Expect(demo["host"]).Equals("demo.test.fider.io") + Expect(demo["logoURL"]).IsNil() +} + +func TestPortalDirectory_NoPortals(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetPublicTenants) error { + q.Result = []*entity.Tenant{} + return nil + }) + + server := mock.NewServer() + env.Config.PortalDirectoryEnabled = true + defer func() { env.Config.PortalDirectoryEnabled = false }() + + code, props := server. + WithURL("http://test.fider.io/"). + ExecuteAsPage(handlers.PortalDirectory()) + + Expect(code).Equals(http.StatusOK) + + portals, ok := props.Data["portals"].([]any) + Expect(ok).IsTrue() + Expect(portals).HasLen(0) +} + +func TestPortalDirectory_ServerRendersForCrawlers(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetPublicTenants) error { + q.Result = []*entity.Tenant{ + {ID: 2, Name: "Avengers", Subdomain: "avengers", Status: enum.TenantActive}, + } + return nil + }) + + server := mock.NewServer() + env.Config.PortalDirectoryEnabled = true + defer func() { env.Config.PortalDirectoryEnabled = false }() + + code, response := server. + WithURL("http://test.fider.io/"). + AddHeader("User-Agent", "Googlebot/2.1"). + Execute(handlers.PortalDirectory()) + + Expect(code).Equals(http.StatusOK) + + // The card markup only appears when the page was rendered server-side: the client bundle + // never runs in a test. This is what a crawler indexing the root domain receives, and it + // only works while the page stays registered in public/ssr.tsx. + Expect(response.Body.String()).ContainsSubstring("c-portal-card") +} diff --git a/app/middlewares/tenant.go b/app/middlewares/tenant.go index 03beb5bcaf..f59a296e4a 100644 --- a/app/middlewares/tenant.go +++ b/app/middlewares/tenant.go @@ -90,6 +90,28 @@ func RequireTenant() web.MiddlewareFunc { } } +// RootDomainFallback serves the given handler on the root path of a multi-tenant instance's +// root domain, where no tenant resolves from the hostname and RequireTenant would otherwise +// return 404. Every other path, and every request that did resolve a tenant, is passed +// through untouched. +// +// The hostname must be exactly the configured root domain. A tenant-shaped host that +// resolved nothing — an unknown or disabled subdomain, or a custom domain matching no +// tenant — must keep its 404 instead of being handed the directory. +func RootDomainFallback(handler web.HandlerFunc) web.MiddlewareFunc { + return func(next web.HandlerFunc) web.HandlerFunc { + return func(c *web.Context) error { + isRootDomain := c.Request.URL.Hostname() == env.Config.HostDomain + + if c.Tenant() == nil && isRootDomain && c.Request.URL.Path == "/" { + return handler(c) + } + + return next(c) + } + } +} + // BlockPendingTenants blocks requests for pending tenants func BlockPendingTenants() web.MiddlewareFunc { return func(next web.HandlerFunc) web.HandlerFunc { diff --git a/app/middlewares/tenant_test.go b/app/middlewares/tenant_test.go index 8d938896e3..c23377e431 100644 --- a/app/middlewares/tenant_test.go +++ b/app/middlewares/tenant_test.go @@ -436,3 +436,128 @@ func TestBlockLockedTenants_LockedTenant(t *testing.T) { Expect(status).Equals(http.StatusPaymentRequired) } + +func TestRootDomainFallback_NoTenantOnRootPath(t *testing.T) { + RegisterT(t) + + fallbackServed := false + nextServed := false + + server := mock.NewServer() + server.Use(middlewares.RootDomainFallback(func(c *web.Context) error { + fallbackServed = true + return c.Ok(web.Map{}) + })) + + status, _ := server. + WithURL("http://test.fider.io/"). + Execute(func(c *web.Context) error { + nextServed = true + return c.Ok(web.Map{}) + }) + + Expect(status).Equals(http.StatusOK) + Expect(fallbackServed).IsTrue() + Expect(nextServed).IsFalse() +} + +func TestRootDomainFallback_NoTenantOnOtherPath(t *testing.T) { + RegisterT(t) + + fallbackServed := false + nextServed := false + + server := mock.NewServer() + server.Use(middlewares.RootDomainFallback(func(c *web.Context) error { + fallbackServed = true + return c.Ok(web.Map{}) + })) + + status, _ := server. + WithURL("http://test.fider.io/posts/1"). + Execute(func(c *web.Context) error { + nextServed = true + return c.Ok(web.Map{}) + }) + + Expect(status).Equals(http.StatusOK) + Expect(fallbackServed).IsFalse() + Expect(nextServed).IsTrue() +} + +func TestRootDomainFallback_TenantOnRootPath(t *testing.T) { + RegisterT(t) + + fallbackServed := false + nextServed := false + + server := mock.NewServer() + server.Use(middlewares.RootDomainFallback(func(c *web.Context) error { + fallbackServed = true + return c.Ok(web.Map{}) + })) + + status, _ := server. + WithURL("http://demo.test.fider.io/"). + OnTenant(mock.DemoTenant). + Execute(func(c *web.Context) error { + nextServed = true + return c.Ok(web.Map{}) + }) + + Expect(status).Equals(http.StatusOK) + Expect(fallbackServed).IsFalse() + Expect(nextServed).IsTrue() +} + +func TestRootDomainFallback_UnknownSubdomain(t *testing.T) { + RegisterT(t) + + fallbackServed := false + nextServed := false + + server := mock.NewServer() + server.Use(middlewares.RootDomainFallback(func(c *web.Context) error { + fallbackServed = true + return c.Ok(web.Map{}) + })) + + // A subdomain that resolves to no tenant must keep 404ing rather than being handed the + // portal directory: it is not the root domain. + status, _ := server. + WithURL("http://nosuchtenant.test.fider.io/"). + Execute(func(c *web.Context) error { + nextServed = true + return c.Ok(web.Map{}) + }) + + Expect(status).Equals(http.StatusOK) + Expect(fallbackServed).IsFalse() + Expect(nextServed).IsTrue() +} + +func TestRootDomainFallback_UnknownCustomDomain(t *testing.T) { + RegisterT(t) + + fallbackServed := false + nextServed := false + + server := mock.NewServer() + server.Use(middlewares.RootDomainFallback(func(c *web.Context) error { + fallbackServed = true + return c.Ok(web.Map{}) + })) + + // A custom domain pointed at this instance but matching no tenant is also not the root + // domain, so it must fall through. + status, _ := server. + WithURL("http://feedback.someoneelse.com/"). + Execute(func(c *web.Context) error { + nextServed = true + return c.Ok(web.Map{}) + }) + + Expect(status).Equals(http.StatusOK) + Expect(fallbackServed).IsFalse() + Expect(nextServed).IsTrue() +} diff --git a/app/models/dto/portal.go b/app/models/dto/portal.go new file mode 100644 index 0000000000..185a712adb --- /dev/null +++ b/app/models/dto/portal.go @@ -0,0 +1,11 @@ +package dto + +// PortalSummary is one entry in the public portal directory. URL and LogoURL are absolute and +// point at the portal's own host, because the directory is served from the root domain where +// tenant-scoped paths do not resolve. +type PortalSummary struct { + Name string `json:"name"` + URL string `json:"url"` + Host string `json:"host"` + LogoURL string `json:"logoURL,omitempty"` +} diff --git a/app/models/query/tenant.go b/app/models/query/tenant.go index 536dcb827b..7c54267abe 100644 --- a/app/models/query/tenant.go +++ b/app/models/query/tenant.go @@ -57,6 +57,14 @@ type GetTenantByDomain struct { Result *entity.Tenant } +// GetPublicTenants returns the tenants eligible for the public portal directory: active, +// non-private, and not inside a deletion grace window. Ordered by name. +type GetPublicTenants struct { + + // Output + Result []*entity.Tenant +} + type GetPendingSignUpVerification struct { // Output Result *entity.EmailVerification diff --git a/app/pkg/env/env.go b/app/pkg/env/env.go index 39b113ed41..79fd1eca95 100644 --- a/app/pkg/env/env.go +++ b/app/pkg/env/env.go @@ -55,6 +55,7 @@ type config struct { Locale string `env:"LOCALE,default=en"` JWTSecret string `env:"JWT_SECRET,required"` PostCreationWithTagsEnabled bool `env:"POST_CREATION_WITH_TAGS_ENABLED,default=false"` + PortalDirectoryEnabled bool `env:"PORTAL_DIRECTORY_ENABLED,default=false"` AllowAllowedSchemes bool `env:"ALLOW_ALLOWED_SCHEMES,default=true"` AllowPrivateNetworkTargets bool `env:"ALLOW_PRIVATE_NETWORK_TARGETS,default=false"` Stripe struct { @@ -256,6 +257,12 @@ func IsMultiHostMode() bool { return Config.HostMode == "multi" } +// IsPortalDirectoryEnabled returns true when the public portal directory should be served on +// the root domain. A single-host instance has exactly one portal, so it never applies there. +func IsPortalDirectoryEnabled() bool { + return IsMultiHostMode() && Config.PortalDirectoryEnabled +} + // IsProduction returns true on Fider production environment func IsProduction() bool { return Config.Environment == "production" || (!IsTest() && !IsDevelopment()) diff --git a/app/pkg/env/env_test.go b/app/pkg/env/env_test.go index a2f3ac91eb..3eec024556 100644 --- a/app/pkg/env/env_test.go +++ b/app/pkg/env/env_test.go @@ -66,3 +66,26 @@ func TestSubdomain(t *testing.T) { Expect(env.Subdomain("test.fidercdn.com")).Equals("") Expect(env.Subdomain("helloworld.com")).Equals("") } + +func TestIsPortalDirectoryEnabled(t *testing.T) { + RegisterT(t) + + originalHostMode := env.Config.HostMode + originalEnabled := env.Config.PortalDirectoryEnabled + defer func() { + env.Config.HostMode = originalHostMode + env.Config.PortalDirectoryEnabled = originalEnabled + }() + + env.Config.HostMode = "multi" + env.Config.PortalDirectoryEnabled = true + Expect(env.IsPortalDirectoryEnabled()).IsTrue() + + env.Config.PortalDirectoryEnabled = false + Expect(env.IsPortalDirectoryEnabled()).IsFalse() + + // A single-host instance has exactly one portal, so the directory never applies to it. + env.Config.HostMode = "single" + env.Config.PortalDirectoryEnabled = true + Expect(env.IsPortalDirectoryEnabled()).IsFalse() +} diff --git a/app/pkg/web/context.go b/app/pkg/web/context.go index 4857bcded3..eee9067ac7 100644 --- a/app/pkg/web/context.go +++ b/app/pkg/web/context.go @@ -604,6 +604,25 @@ func LogoURL(ctx context.Context) string { return "https://login.fider.io/static/assets/logo.png" } +// TenantLogoURL returns an absolute URL to the given tenant's logo, or an empty string when +// it has none. Unlike LogoURL it takes the tenant explicitly, so it also works on the root +// domain of a multi-tenant instance, where no tenant is in context. The URL always points at +// the tenant's own host, because /static/images is only served where a tenant resolves. +func TenantLogoURL(ctx context.Context, tenant *entity.Tenant) string { + if tenant.LogoBlobKey == "" { + return "" + } + + path := "/static/images/" + tenant.LogoBlobKey + "?size=200" + + if env.Config.CDN.Host != "" { + request := ctx.Value(app.RequestCtxKey).(Request) + return request.URL.Scheme + "://" + tenant.Subdomain + "." + env.Config.CDN.Host + path + } + + return TenantBaseURL(ctx, tenant) + path +} + // BaseURL return the base URL from given context func BaseURL(ctx context.Context) string { if env.IsSingleHostMode() { diff --git a/app/pkg/web/context_test.go b/app/pkg/web/context_test.go index 8bf097095f..e0675e1470 100644 --- a/app/pkg/web/context_test.go +++ b/app/pkg/web/context_test.go @@ -155,6 +155,68 @@ func TestAssetsURL_MultiHostMode(t *testing.T) { Expect(web.AssetsURL(ctx, "/assets/main.css")).Equals("http://theavengers.fidercdn.com/assets/main.css") } +func TestTenantLogoURL_PointsAtTenantOwnHost(t *testing.T) { + RegisterT(t) + + env.Config.HostMode = "multi" + env.Config.CDN.Host = "" + ctx := newGetContext("http://login.test.fider.io:3000", nil) + tenant := &entity.Tenant{ + ID: 1, + Subdomain: "theavengers", + LogoBlobKey: "logos/avengers.png", + } + + Expect(web.TenantLogoURL(ctx, tenant)).Equals("http://theavengers.test.fider.io:3000/static/images/logos/avengers.png?size=200") +} + +func TestTenantLogoURL_WithCNAME(t *testing.T) { + RegisterT(t) + + env.Config.HostMode = "multi" + env.Config.CDN.Host = "" + ctx := newGetContext("http://login.test.fider.io:3000", nil) + tenant := &entity.Tenant{ + ID: 1, + Subdomain: "theavengers", + CNAME: "feedback.theavengers.com", + LogoBlobKey: "logos/avengers.png", + } + + Expect(web.TenantLogoURL(ctx, tenant)).Equals("http://feedback.theavengers.com:3000/static/images/logos/avengers.png?size=200") +} + +func TestTenantLogoURL_WithCDN(t *testing.T) { + RegisterT(t) + + env.Config.HostMode = "multi" + env.Config.CDN.Host = "fidercdn.com" + defer func() { env.Config.CDN.Host = "" }() + + ctx := newGetContext("http://login.test.fider.io:3000", nil) + tenant := &entity.Tenant{ + ID: 1, + Subdomain: "theavengers", + LogoBlobKey: "logos/avengers.png", + } + + Expect(web.TenantLogoURL(ctx, tenant)).Equals("http://theavengers.fidercdn.com/static/images/logos/avengers.png?size=200") +} + +func TestTenantLogoURL_NoLogo(t *testing.T) { + RegisterT(t) + + env.Config.HostMode = "multi" + env.Config.CDN.Host = "" + ctx := newGetContext("http://login.test.fider.io:3000", nil) + tenant := &entity.Tenant{ + ID: 1, + Subdomain: "theavengers", + } + + Expect(web.TenantLogoURL(ctx, tenant)).Equals("") +} + func TestCanonicalURL_SameDomain(t *testing.T) { RegisterT(t) diff --git a/app/services/sqlstore/postgres/postgres.go b/app/services/sqlstore/postgres/postgres.go index 580b14c7a4..ccc82242d6 100644 --- a/app/services/sqlstore/postgres/postgres.go +++ b/app/services/sqlstore/postgres/postgres.go @@ -108,6 +108,7 @@ func (s Service) Init() { bus.AddHandler(createTenant) bus.AddHandler(getFirstTenant) bus.AddHandler(getTenantByDomain) + bus.AddHandler(getPublicTenants) bus.AddHandler(activateTenant) bus.AddHandler(isSubdomainAvailable) bus.AddHandler(isCNAMEAvailable) diff --git a/app/services/sqlstore/postgres/tenant.go b/app/services/sqlstore/postgres/tenant.go index 39a6313f91..457bafd5fe 100644 --- a/app/services/sqlstore/postgres/tenant.go +++ b/app/services/sqlstore/postgres/tenant.go @@ -275,6 +275,33 @@ func getFirstTenant(ctx context.Context, q *query.GetFirstTenant) error { }) } +func getPublicTenants(ctx context.Context, q *query.GetPublicTenants) error { + return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { + tenants := []*dbEntities.Tenant{} + + err := trx.Select(&tenants, ` + SELECT t.id, t.name, t.subdomain, t.cname, t.invitation, t.locale, t.welcome_message, t.welcome_header, t.description_template, t.status, t.is_private, t.logo_bkey, t.custom_css, t.allowed_schemes, t.is_email_auth_allowed, t.is_feed_enabled, t.is_moderation_enabled, t.prevent_indexing, t.is_pro, t.scheduled_deletion_at, + (b.paddle_subscription_id IS NOT NULL AND b.stripe_subscription_id IS NULL) AS has_paddle_subscription + FROM tenants t + LEFT JOIN tenants_billing b ON b.tenant_id = t.id + WHERE t.status = $1 + AND t.is_private = false + AND t.scheduled_deletion_at IS NULL + ORDER BY t.name + `, enum.TenantActive) + if err != nil { + return errors.Wrap(err, "failed to get public tenants") + } + + q.Result = make([]*entity.Tenant, 0, len(tenants)) + for _, tenant := range tenants { + q.Result = append(q.Result, tenant.ToModel()) + } + + return nil + }) +} + func getTenantByDomain(ctx context.Context, q *query.GetTenantByDomain) error { return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { tenant := dbEntities.Tenant{} diff --git a/app/services/sqlstore/postgres/tenant_test.go b/app/services/sqlstore/postgres/tenant_test.go index 96506236ee..67567c8128 100644 --- a/app/services/sqlstore/postgres/tenant_test.go +++ b/app/services/sqlstore/postgres/tenant_test.go @@ -438,3 +438,57 @@ func TestTenantStorage_Save_Get_ListOAuthConfig(t *testing.T) { Expect(customConfigs.Result[0].JSONUserNamePath).Equals("New user.name") Expect(customConfigs.Result[0].JSONUserEmailPath).Equals("New user.email") } + +func TestTenantStorage_GetPublicTenants(t *testing.T) { + ctx := SetupDatabaseTest(t) + defer TeardownDatabaseTest() + + publicTenants := &query.GetPublicTenants{} + err := bus.Dispatch(ctx, publicTenants) + Expect(err).IsNil() + + names := make([]string, 0) + for _, tenant := range publicTenants.Result { + names = append(names, tenant.Name) + } + + Expect(names).Equals([]string{"Avengers", "Demonstration", "Demonstration German", "Orange Inc"}) +} + +func TestTenantStorage_GetPublicTenants_ExcludesPrivateAndInactive(t *testing.T) { + ctx := SetupDatabaseTest(t) + defer TeardownDatabaseTest() + + _, err := trx.Execute("UPDATE tenants SET is_private = true WHERE subdomain = 'orange'") + Expect(err).IsNil() + _, err = trx.Execute("UPDATE tenants SET status = $1 WHERE subdomain = 'german'", enum.TenantDisabled) + Expect(err).IsNil() + _, err = trx.Execute("UPDATE tenants SET status = $1 WHERE subdomain = 'avengers'", enum.TenantPending) + Expect(err).IsNil() + _, err = trx.Execute("UPDATE tenants SET status = $1 WHERE subdomain = 'demo'", enum.TenantLocked) + Expect(err).IsNil() + + publicTenants := &query.GetPublicTenants{} + err = bus.Dispatch(ctx, publicTenants) + Expect(err).IsNil() + Expect(publicTenants.Result).HasLen(0) +} + +func TestTenantStorage_GetPublicTenants_ExcludesTenantsPendingDeletion(t *testing.T) { + ctx := SetupDatabaseTest(t) + defer TeardownDatabaseTest() + + _, err := trx.Execute("UPDATE tenants SET scheduled_deletion_at = $1 WHERE subdomain = 'demo'", time.Now().Add(72*time.Hour)) + Expect(err).IsNil() + + publicTenants := &query.GetPublicTenants{} + err = bus.Dispatch(ctx, publicTenants) + Expect(err).IsNil() + + names := make([]string, 0) + for _, tenant := range publicTenants.Result { + names = append(names, tenant.Name) + } + + Expect(names).Equals([]string{"Avengers", "Demonstration German", "Orange Inc"}) +} diff --git a/docs/superpowers/specs/2026-08-21-portal-directory-design.md b/docs/superpowers/specs/2026-08-21-portal-directory-design.md new file mode 100644 index 0000000000..06c50b3136 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-portal-directory-design.md @@ -0,0 +1,424 @@ +# Portal Directory — Design + +Date: 2026-08-21 +Status: approved, ready for implementation planning + +## Problem + +In multi-tenant mode (`HOST_MODE=multi`) a Fider instance resolves tenants purely from the +request hostname (`app/middlewares/tenant.go:48`). Hitting the root domain with no subdomain +leaves `c.Tenant()` nil, so `middlewares.RequireTenant` returns 404 for every route except +`/signup`. There is no query that lists tenants — only `GetFirstTenant` and +`GetTenantByDomain` in `app/models/query/tenant.go` — and no page that lists them. + +The consequence: the only way to reach a portal is to already know its subdomain. Portals on +the same instance are undiscoverable. + +## Solution + +Serve a public portal directory at the root domain of a multi-tenant instance: a browsable, +client-filterable list of every active, public portal, each entry linking to that portal's own +URL. + +The directory is off by default behind a new environment flag, so upgrading an existing +instance never starts enumerating its tenants without the operator opting in. + +## Scope decisions + +Settled during brainstorming; each rejected option is additive and can be revisited later. + +| Decision | Choice | Rejected | +| --- | --- | --- | +| Audience | Public — anyone hitting the root domain | Signed-in "my portals"; operator-only view | +| Eligibility | Automatic: active + non-private | Per-portal opt-in setting (needs a migration + admin UI) | +| Placement | Root `/` of the root domain | Dedicated `/portals` path | +| Default | Off, behind an env flag | Always on | +| Entry content | Logo, name, host | Activity stats (post counts, last activity) | +| Scale | Whole list in page props + client-side filter | Server-side search and pagination | + +Explicitly out of scope: opt-in setting, activity stats, pagination, a JSON API endpoint, a +signup call to action on the directory. + +## Architecture + +Five units, each independently testable: + +1. **Config flag** (`app/pkg/env`) — is the directory enabled at all? +2. **Query + store** (`app/models/query`, `app/services/sqlstore/postgres`) — which portals are + eligible? +3. **Route wiring** (`app/middlewares`, `app/cmd/routes.go`) — when does the root domain serve + the directory instead of 404ing? +4. **Handler** (`app/handlers`) — assemble the view data. +5. **Page** (`public/pages/PortalDirectory`) — render and filter. + +### 1. Config flag + +Add to the `config` struct in `app/pkg/env/env.go`, alongside the other feature flags: + +```go +PortalDirectoryEnabled bool `env:"PORTAL_DIRECTORY_ENABLED,default=false"` +``` + +And a helper beside `IsMultiHostMode` (`env.go:255`): + +```go +// IsPortalDirectoryEnabled returns true when the public portal directory should be served +// on the root domain. Single-host instances have exactly one portal, so it never applies. +func IsPortalDirectoryEnabled() bool { + return IsMultiHostMode() && Config.PortalDirectoryEnabled +} +``` + +Document the variable in `.example.env`, following the commented-out-with-explanation style +already used there for `ALLOW_PRIVATE_NETWORK_TARGETS`: + +``` +# PORTAL_DIRECTORY_ENABLED=true +# Serves a public directory of this instance's portals at the root domain. Multi-tenant +# (HOST_MODE=multi) only. Lists every active, non-private portal by name and links to it. +``` + +Folding the host-mode check into the helper means no caller has to remember it, and a +single-tenant instance behaves identically whether the flag is set or not. + +### 2. Query and store + +`app/models/query/tenant.go`: + +```go +// GetPublicTenants returns the tenants eligible for the public portal directory: +// active, non-private, and not inside a deletion grace window. Ordered by name. +type GetPublicTenants struct { + // Output + Result []*entity.Tenant +} +``` + +`app/services/sqlstore/postgres/tenant.go` — `getPublicTenants`, reusing the column list and +`tenants_billing` join from `getFirstTenant` (`tenant.go:258`) so `dbEntities.Tenant.ToModel` +is fully populated: + +```sql +-- Column list is copied verbatim from getFirstTenant, ending with +-- t.scheduled_deletion_at and the computed has_paddle_subscription. +SELECT t.id, t.name, t.subdomain, ... , t.scheduled_deletion_at, + (b.paddle_subscription_id IS NOT NULL AND b.stripe_subscription_id IS NULL) AS has_paddle_subscription +FROM tenants t +LEFT JOIN tenants_billing b ON b.tenant_id = t.id +WHERE t.status = $1 + AND t.is_private = false + AND t.scheduled_deletion_at IS NULL +ORDER BY t.name +``` + +The full column list is not restated here to avoid two copies drifting apart; take it from +`getFirstTenant` and change only the `WHERE` and `ORDER BY`. Note `trx.Select` (plural) rather +than `trx.Get`, and an empty result is not an error — unlike `getFirstTenant`, zero rows is a +valid answer. + +`$1` is `enum.TenantActive`. Registered with `bus.AddHandler(getPublicTenants)` in +`postgres.go` next to the existing tenant handlers (`postgres.go:109`). + +**Eligibility rationale.** `status = TenantActive` excludes pending signups, disabled tenants, +and locked (payment-lapsed) tenants. `scheduled_deletion_at IS NULL` excludes sites the owner +has asked to delete but whose grace window has not elapsed. `is_private = false` matters most: +`CheckTenantPrivacy` already blocks anonymous access to private portals, and listing their +names would leak exactly what that setting protects. + +`prevent_indexing` is deliberately **not** part of the filter. `CreateTenant` inserts it as +`true` for every new tenant (`tenant.go:246`), so honouring it would leave the directory +permanently empty. It governs search-engine indexing, not membership of an on-instance list. + +### 3. Route wiring + +`app/cmd/routes.go:141` registers `r.Get("/", handlers.Index())` after +`r.Use(middlewares.RequireTenant())`. The engine is `julienschmidt/httprouter` +(`app/pkg/web/engine.go:80`), which panics on a duplicate method+path registration, so a +second `"/"` route is not an option. + +Instead, a fallthrough middleware inserted immediately before `RequireTenant` — the exact +point where the root-domain 404 originates today: + +```go +// RootDomainFallback serves the given handler for the root path of a multi-tenant +// instance's root domain, where no tenant resolves and RequireTenant would 404. +func RootDomainFallback(handler web.HandlerFunc) web.MiddlewareFunc { + return func(next web.HandlerFunc) web.HandlerFunc { + return func(c *web.Context) error { + isRootDomain := c.Request.URL.Hostname() == env.Config.HostDomain + + if c.Tenant() == nil && isRootDomain && c.Request.URL.Path == "/" { + return handler(c) + } + return next(c) + } + } +} +``` + +**The host check is load-bearing.** "No tenant resolved" is not the same as "this is the root +domain": it is equally true of an unknown subdomain, a *disabled* tenant's subdomain (the +`MultiTenant` middleware deliberately leaves the tenant unset for those, +`middlewares/tenant.go:63`), and a custom domain pointed here that matches no tenant. Guarding +only on `tenant == nil && path == "/"` turns every one of those 404s into a 200 serving the +directory — confirmed by running the app, where `disabled.localhost/` and `nosuch.localhost/` +both rendered `PortalDirectory/PortalDirectory.page`. + +Comparing against `env.Config.HostDomain` is the exact test. `env.Subdomain(host) == ""` is +*not* a valid substitute: it also returns `""` for any host that does not end in the +multi-tenant domain, so unmatched custom domains would still slip through +(`env.go:306-324`). + +In `routes.go`, immediately above `r.Use(middlewares.RequireTenant())`: + +```go +// The root domain of a multi-tenant instance resolves no tenant, so RequireTenant would +// 404. Serve the portal directory there instead when the operator has enabled it. +r.Use(middlewares.RootDomainFallback(handlers.PortalDirectory())) +r.Use(middlewares.RequireTenant()) +``` + +The middleware takes the handler as a `web.HandlerFunc` parameter, so `middlewares` does not +import `handlers`; `routes.go` wires the two, as it already does for everything else. + +The flag check lives in the handler rather than the middleware, so the middleware stays a +pure "is this the root domain's root path" decision and the handler owns its own +enable/disable behaviour. With the flag off the handler returns 404 — the same response as +today. + +Alternatives rejected: + +- **Move `"/"` into an early group** — the group would have to re-apply `RequireTenant`, + `BlockPendingTenants`, and `CheckTenantPrivacy` for the tenant case. Duplicated middleware + wiring that will drift from the real chain. +- **Teach `RequireTenant` to render the directory** — smallest diff, but it forces + `middlewares` to import `handlers` and overloads a middleware whose single job is a guard. + +### 4. Handler and view data + +`app/models/dto/portal.go`: + +```go +// PortalSummary is one entry in the public portal directory. +type PortalSummary struct { + Name string `json:"name"` + URL string `json:"url"` + Host string `json:"host"` + LogoURL string `json:"logoURL,omitempty"` +} +``` + +`app/handlers/portals.go`: + +```go +// PortalDirectory lists the public portals hosted on this instance. Only reachable on the +// root domain of a multi-tenant instance, and only when the operator has enabled it. +func PortalDirectory() web.HandlerFunc { + return func(c *web.Context) error { + if !env.IsPortalDirectoryEnabled() { + return c.NotFound() + } + + q := &query.GetPublicTenants{} + if err := bus.Dispatch(c, q); err != nil { + return c.Failure(err) + } + + portals := make([]dto.PortalSummary, 0, len(q.Result)) + for _, tenant := range q.Result { + url := web.TenantBaseURL(c, tenant) + portals = append(portals, dto.PortalSummary{ + Name: tenant.Name, + URL: url, + Host: strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://"), + LogoURL: web.TenantLogoURL(c, tenant), + }) + } + + return c.Page(http.StatusOK, web.Props{ + Page: "PortalDirectory/PortalDirectory.page", + Title: "Portals", + Description: "Browse the feedback portals hosted here.", + Data: web.Map{"portals": portals}, + }) + } +} +``` + +`web.Props.Data` is a `web.Map` reaching the page as `fider.session.props` — the same +mechanism `LegalPage` uses (`app/handlers/common.go:44`). The renderer already tolerates a nil +tenant (`app/pkg/web/renderer.go:150,180`), which is why `/signup` works on the root domain. + +**Absolute logo URLs.** `/static/images/*bkey` is registered inside the `tenantAssets` group, +after `RequireTenant` (`routes.go:96`), so it only resolves on a host that maps to a tenant. +The client-side `uploadedImageURL` helper (`public/services/utils.ts:104`) builds URLs from +`Fider.settings.assetsURL`, which on the root domain has no tenant and would 404. So the +handler emits absolute per-portal logo URLs instead, via a new helper beside the existing +`LogoURL` (`app/pkg/web/context.go:599`): + +```go +// TenantLogoURL returns an absolute URL to the given tenant's logo, or "" when it has none. +// Unlike LogoURL it takes the tenant explicitly, so it works on the root domain where no +// tenant is in context. +func TenantLogoURL(ctx context.Context, tenant *entity.Tenant) string { + if tenant.LogoBlobKey == "" { + return "" + } + if env.Config.CDN.Host != "" { + request := ctx.Value(app.RequestCtxKey).(Request) + return request.URL.Scheme + "://" + tenant.Subdomain + "." + env.Config.CDN.Host + + "/static/images/" + tenant.LogoBlobKey + "?size=200" + } + return TenantBaseURL(ctx, tenant) + "/static/images/" + tenant.LogoBlobKey + "?size=200" +} +``` + +The CDN branch mirrors `AssetsURL` (`context.go:591`). It returns `""` rather than the Fider +fallback logo that `LogoURL` uses, letting the page render its own initials placeholder. + +### 5. Page + +`public/pages/PortalDirectory/` — `PortalDirectory.page.tsx`, `PortalDirectory.scss`, +`index.ts`. Pages are resolved by name at runtime through `AsyncPage` +(`public/AsyncPages.tsx`), so no registration step is needed beyond the directory existing. + +```tsx +interface PortalSummary { + name: string + url: string + host: string + logoURL?: string +} + +interface PortalDirectoryPageProps { + portals: PortalSummary[] +} +``` + +Structure: + +- Renders bare content, not ``/`
` — both read `fider.session.tenant`, which is + nil here. `SignUp.page.tsx` is the precedent for a tenant-less page. +- A filter `` over `useState`, matching case-insensitively on `name` and `host`. Pure + client-side; no network calls. +- Each portal is a card-shaped ``: logo `` when `logoURL` is set, + otherwise a CSS circle with the name's first letter (the letter-avatar endpoint at + `routes.go:97` is also tenant-gated, so it cannot be used here); name; host as subtitle. +- Two distinct empty states: no portals at all, and no portals matching the filter. +- Utility classes from `public/assets/styles/utility/` first, per CLAUDE.md; page-specific + BEM (`#p-portal-directory`, `.c-portal-card__*`) only for the card grid. +- User-facing copy wrapped in ``, with ids added to `locale/en/client.json` (the + committed source of truth; `locale/**/*.js` is generated and gitignored). The search + placeholder uses `i18n._({id, message})`, the pattern already used in + `CompleteSignInProfile.page.tsx`. + +**SSR registration.** `public/ssr.tsx` maps page names to modules in a *static* table — +esbuild cannot do dynamic imports — and `ssrRender` throws `Page not found` for anything +missing from it. Server-side rendering only runs for crawlers +(`renderer.go:242`, gated on `Request.IsCrawler()`), and a throw there is logged and degraded +to the client-rendered shell rather than failing the response. A public directory page that +crawlers cannot read defeats the point, so `PortalDirectory/PortalDirectory.page` must be +added to that table. This is what makes the page's `export default` mandatory: `ssrRender` +reads `pages[...]?.default`. + +## Data flow + +``` +GET / on root domain (multi-tenant) + → middlewares.Tenant → MultiTenant: hostname matches no subdomain/cname, tenant stays nil + → middlewares.RootDomainFallback: tenant == nil && path == "/" → handlers.PortalDirectory() + → flag off? → 404 (today's behaviour) + → flag on → bus.Dispatch(query.GetPublicTenants) + → postgres.getPublicTenants + → []dto.PortalSummary with absolute URL + logo URL per portal + → c.Page(props.Data["portals"]) + → PortalDirectory.page renders cards; filtering is local state + → click → navigates to that portal's own host, where MultiTenant resolves it normally +``` + +Any other path on the root domain, and every path on a tenant host, falls through to +`RequireTenant` unchanged. + +## Error handling + +- Flag off → `c.NotFound()`. Identical to today, so upgrades are invisible until opted in. +- Query failure → `c.Failure(err)`, the codebase's standard 500 path. +- Zero eligible portals → 200 with an empty-state page, not an error. A fresh instance with + no portals yet is a normal state. +- A portal that becomes private or disabled between page render and click → its own host + applies `CheckTenantPrivacy` / the disabled-tenant check as usual. The directory is a + pointer, never an authorisation decision. +- `RootDomainFallback` guards on `c.Tenant() == nil`, so a tenant host serving `/` is never + intercepted even with the flag on. + +## Testing + +**Go — `app/handlers/portals_test.go`** (`mock.NewServer()` is the multi-tenant harness, +`app/pkg/mock/setup.go:34`): + +- flag off → 404 +- flag on, no tenant in context → 200, and the rendered props carry exactly the eligible + portals +- each entry's `url` points at that portal's own host; `logoURL` is absolute and + tenant-hosted; a logo-less portal yields an empty `logoURL` + +**Go — `app/services/sqlstore/postgres/tenant_test.go`** (file exists), for +`getPublicTenants`: + +- active public tenants are returned, ordered by name +- private, pending, locked, and disabled tenants are excluded +- a tenant with `scheduled_deletion_at` set is excluded + +**Go — `app/middlewares/tenant_test.go`**, for `RootDomainFallback`: + +- tenant nil and path `/` → the fallback handler runs +- tenant nil and path `/posts/1` → falls through to `next` +- tenant present and path `/` → falls through to `next` + +**Jest — `public/pages/PortalDirectory/PortalDirectory.page.spec.tsx`:** + +- renders one card per portal +- filtering narrows the list case-insensitively, by name and by host +- an empty `portals` prop shows the no-portals state, and hides the filter box +- a logo-less portal renders the initial placeholder rather than a broken `` + +The two empty states are asserted by element (`.c-portal-directory__empty`, +`.c-portal-directory__nomatch`) rather than by their copy. The lingui macro hoists `` +children into a `message` prop, and `public/jest.setup.tsx` mocks `@lingui/react`'s `Trans` to +render `children` — so translated text is absent from the rendered output under test. + +**Go — SSR path, in `portals_test.go`:** a request carrying a crawler User-Agent must come +back containing `c-portal-card` markup. That substring can only appear if the page rendered +server-side, so this fails if the page is ever dropped from the `public/ssr.tsx` table. It +needs a current `ssr.js`, which `make test-server` guarantees by depending on `build-ssr`. + +Verification: `make lint` and `make test`. + +## Files touched + +New: + +- `app/handlers/portals.go`, `app/handlers/portals_test.go` +- `app/models/dto/portal.go` +- `public/pages/PortalDirectory/{PortalDirectory.page.tsx,PortalDirectory.page.scss,index.ts,PortalDirectory.page.spec.tsx}` +- `docs/superpowers/specs/2026-08-21-portal-directory-design.md` (this file) + +Modified: + +- `public/ssr.tsx` — register the page in the static SSR table +- `locale/en/client.json` — the five new message ids +- `app/pkg/web/context_test.go` — `TenantLogoURL` tests +- `app/pkg/env/env_test.go` — flag test + +- `app/pkg/env/env.go` — flag + `IsPortalDirectoryEnabled` +- `.example.env` — document `PORTAL_DIRECTORY_ENABLED` +- `app/models/query/tenant.go` — `GetPublicTenants` +- `app/services/sqlstore/postgres/tenant.go` — `getPublicTenants` +- `app/services/sqlstore/postgres/postgres.go` — register the handler +- `app/services/sqlstore/postgres/tenant_test.go` — store tests +- `app/middlewares/tenant.go` — `RootDomainFallback` +- `app/middlewares/tenant_test.go` — middleware tests +- `app/cmd/routes.go` — wire the fallback before `RequireTenant` +- `app/pkg/web/context.go` — `TenantLogoURL` + +No database migration. No changes to existing tenant behaviour. diff --git a/locale/en/client.json b/locale/en/client.json index 3d04eff4a2..7490f6570e 100644 --- a/locale/en/client.json +++ b/locale/en/client.json @@ -184,6 +184,11 @@ "page.pendingactivation.title": "Your account is pending activation", "pagination.next": "Next", "pagination.prev": "Previous", + "portaldirectory.empty": "There are no portals yet.", + "portaldirectory.nomatch": "No portals match your search.", + "portaldirectory.search.placeholder": "Search portals", + "portaldirectory.subtitle": "Browse the feedback portals hosted here.", + "portaldirectory.title": "Portals", "post.pending": "pending", "postdetails.backtoall": "Back to all suggestions", "postdetails.backtoroadmap": "Back to roadmap", diff --git a/public/pages/PortalDirectory/PortalDirectory.page.scss b/public/pages/PortalDirectory/PortalDirectory.page.scss new file mode 100644 index 0000000000..7740cd4b3e --- /dev/null +++ b/public/pages/PortalDirectory/PortalDirectory.page.scss @@ -0,0 +1,81 @@ +@use "~@fider/assets/styles/variables.scss" as *; + +#p-portal-directory { + padding-top: spacing(8); + padding-bottom: spacing(12); + + .c-portal-list { + display: grid; + grid-template-columns: 1fr; + gap: spacing(3); + + @include media("md") { + grid-template-columns: 1fr 1fr; + } + + @include media("lg") { + grid-template-columns: 1fr 1fr 1fr; + } + } + + .c-portal-card { + display: flex; + align-items: center; + gap: spacing(3); + padding: spacing(3); + border: 1px solid var(--colors-gray-200); + border-radius: 4px; + background-color: var(--colors-white); + color: inherit; + + &:hover { + border-color: var(--colors-gray-300); + text-decoration: none; + } + + &__logo { + flex-shrink: 0; + width: 40px; + height: 40px; + + img { + width: 40px; + height: 40px; + object-fit: contain; + border-radius: 4px; + } + } + + &__initial { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 4px; + background-color: var(--colors-gray-200); + color: var(--colors-gray-700); + font-weight: 600; + } + + &__text { + display: flex; + flex-direction: column; + overflow: hidden; + } + + &__name, + &__host { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + @include dark-mode { + .c-portal-card { + border-color: var(--colors-gray-300); + background-color: var(--colors-gray-100); + } + } +} diff --git a/public/pages/PortalDirectory/PortalDirectory.page.spec.tsx b/public/pages/PortalDirectory/PortalDirectory.page.spec.tsx new file mode 100644 index 0000000000..e522fb4a0f --- /dev/null +++ b/public/pages/PortalDirectory/PortalDirectory.page.spec.tsx @@ -0,0 +1,94 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { FiderContext } from "@fider/services" +import { fiderMock } from "@fider/services/testing" +import { PortalDirectoryPage, PortalSummary } from "./PortalDirectory.page" + +const avengers: PortalSummary = { + name: "Avengers", + url: "http://avengers.test.fider.io", + host: "avengers.test.fider.io", + logoURL: "http://avengers.test.fider.io/static/images/logos/avengers.png?size=200", +} + +const demo: PortalSummary = { + name: "Demonstration", + url: "http://demo.test.fider.io", + host: "demo.test.fider.io", +} + +const renderPage = (portals: PortalSummary[]) => + render( + + + + ) + +describe("", () => { + test("renders a link to each portal", () => { + renderPage([avengers, demo]) + + const links = screen.getAllByRole("link") + expect(links).toHaveLength(2) + expect(links[0]).toHaveAttribute("href", "http://avengers.test.fider.io") + expect(links[1]).toHaveAttribute("href", "http://demo.test.fider.io") + expect(screen.getByText("Avengers")).toBeInTheDocument() + expect(screen.getByText("avengers.test.fider.io")).toBeInTheDocument() + }) + + test("renders the logo when the portal has one", () => { + const { container } = renderPage([avengers]) + + const logo = container.querySelector("img") + expect(logo).toHaveAttribute("src", avengers.logoURL) + }) + + test("renders an initial placeholder when the portal has no logo", () => { + const { container } = renderPage([demo]) + + expect(container.querySelector("img")).toBeNull() + expect(container.querySelector(".c-portal-card__initial")).toHaveTextContent("D") + }) + + test("filters portals case-insensitively by name", () => { + renderPage([avengers, demo]) + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "aVeN" } }) + + const links = screen.getAllByRole("link") + expect(links).toHaveLength(1) + expect(links[0]).toHaveAttribute("href", "http://avengers.test.fider.io") + }) + + test("filters portals by host", () => { + renderPage([avengers, demo]) + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "demo.test" } }) + + const links = screen.getAllByRole("link") + expect(links).toHaveLength(1) + expect(links[0]).toHaveAttribute("href", "http://demo.test.fider.io") + }) + + // The two empty states are asserted by element rather than by copy: the lingui macro + // hoists children into a message prop, so translated text is not rendered under + // the jest mock for @lingui/react. + test("shows the no-match state when nothing matches the filter", () => { + const { container } = renderPage([avengers, demo]) + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "nothing here" } }) + + expect(screen.queryAllByRole("link")).toHaveLength(0) + expect(container.querySelector(".c-portal-directory__nomatch")).not.toBeNull() + expect(container.querySelector(".c-portal-directory__empty")).toBeNull() + }) + + test("shows the empty state, and no filter box, when there are no portals at all", () => { + const { container } = renderPage([]) + + expect(screen.queryAllByRole("link")).toHaveLength(0) + expect(screen.queryByRole("textbox")).toBeNull() + expect(container.querySelector(".c-portal-directory__empty")).not.toBeNull() + expect(container.querySelector(".c-portal-directory__nomatch")).toBeNull() + }) +}) diff --git a/public/pages/PortalDirectory/PortalDirectory.page.tsx b/public/pages/PortalDirectory/PortalDirectory.page.tsx new file mode 100644 index 0000000000..e5abc32f82 --- /dev/null +++ b/public/pages/PortalDirectory/PortalDirectory.page.tsx @@ -0,0 +1,75 @@ +import "./PortalDirectory.page.scss" + +import React, { useState } from "react" +import { i18n } from "@lingui/core" +import { Trans } from "@lingui/react/macro" +import { Input } from "@fider/components" + +export interface PortalSummary { + name: string + url: string + host: string + logoURL?: string +} + +interface PortalDirectoryPageProps { + portals: PortalSummary[] +} + +const PortalCard = (props: { portal: PortalSummary }) => { + const { portal } = props + + return ( + +
+ {portal.logoURL ? {portal.name} :
{portal.name.charAt(0).toUpperCase()}
} +
+
+ {portal.name} + {portal.host} +
+
+ ) +} + +export const PortalDirectoryPage = (props: PortalDirectoryPageProps) => { + const [filter, setFilter] = useState("") + + const term = filter.trim().toLowerCase() + const visible = term ? props.portals.filter((portal) => portal.name.toLowerCase().includes(term) || portal.host.toLowerCase().includes(term)) : props.portals + + return ( +
+

+ Portals +

+

+ Browse the feedback portals hosted here. +

+ + {props.portals.length === 0 ? ( +

+ There are no portals yet. +

+ ) : ( + <> + + + {visible.length === 0 ? ( +

+ No portals match your search. +

+ ) : ( +
+ {visible.map((portal) => ( + + ))} +
+ )} + + )} +
+ ) +} + +export default PortalDirectoryPage diff --git a/public/pages/PortalDirectory/index.ts b/public/pages/PortalDirectory/index.ts new file mode 100644 index 0000000000..9f96d1d8b5 --- /dev/null +++ b/public/pages/PortalDirectory/index.ts @@ -0,0 +1 @@ +export * from "./PortalDirectory.page" diff --git a/public/ssr.tsx b/public/ssr.tsx index 751eeebdfa..5c1ccbccc3 100644 --- a/public/ssr.tsx +++ b/public/ssr.tsx @@ -40,6 +40,7 @@ const pages: { [key: string]: any } = { "SignUp/SignUp.page": require(`./pages/SignUp/SignUp.page`), "SignUp/PendingActivation.page": require(`./pages/SignUp/PendingActivation.page`), "Legal/Legal.page": require(`./pages/Legal/Legal.page`), + "PortalDirectory/PortalDirectory.page": require(`./pages/PortalDirectory/PortalDirectory.page`), "DesignSystem/DesignSystem.page": require(`./pages/DesignSystem/DesignSystem.page`), "Error/Maintenance.page": require(`./pages/Error/Maintenance.page`), "Error/Error401.page": require(`./pages/Error/Error401.page`),