Skip to content

Add public portal directory at the root domain (multi-tenant) - #1652

Open
diagonraleigh wants to merge 2 commits into
getfider:mainfrom
diagonraleigh:portal-dashboard
Open

Add public portal directory at the root domain (multi-tenant)#1652
diagonraleigh wants to merge 2 commits into
getfider:mainfrom
diagonraleigh:portal-dashboard

Conversation

@diagonraleigh

Copy link
Copy Markdown

Issue: #1651

Adds a public portal directory at the root domain of a multi-tenant instance, so the portals an instance hosts can be discovered without already knowing their subdomains. Today the root domain resolves no tenant, so RequireTenant 404s every path except /signup.

Important

This has not been through the feedback.fider.io → roadmap-acceptance flow. Per CONTRIBUTING.md and discussion #1529 that means you may well close it, and that's a fair outcome — I'm not asking anyone to absorb review burden for an unaccepted feature. It's here so the proposal in #1651 has something concrete attached. Happy to move the idea to feedback.fider.io, cut scope, or close it.

Behaviour

Off by default behind a new PORTAL_DIRECTORY_ENABLED env var. With the flag off the root domain 404s exactly as before, so upgrading an instance changes nothing until an operator opts in.

Only active, non-private portals are listed. Private, pending, locked and disabled tenants are excluded, as is any site inside its deletion grace window.

Host Result
root domain, flag on 200, the directory
root domain, flag off 404
a tenant's subdomain unchanged, its own site
private tenant unchanged, 307 to /signin
unknown / disabled subdomain 404 (not the directory)
crawler on root domain server-rendered markup

Notable decisions

  • prevent_indexing is deliberately not part of the eligibility filter. CreateTenant inserts it as true for every new tenant, so honouring it would leave the directory permanently empty. It governs search-engine indexing, not membership of an on-instance list.
  • The root-domain check is an exact hostname == HOST_DOMAIN comparison. "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 (MultiTenant intentionally leaves the tenant unset there), and a custom domain matching no tenant. Guarding only on tenant == nil && path == "/" turns all of those 404s into a 200 serving the portal list. env.Subdomain(host) == "" is not a valid substitute, since it also returns "" for hosts outside the multi-tenant domain.
  • Routing uses a fallthrough middleware rather than a second "/" route. r.Get("/", handlers.Index()) is registered after RequireTenant, and the router (httprouter) panics on a duplicate method+path. RootDomainFallback sits immediately before RequireTenant — exactly where the root-domain 404 originates — and takes the handler as a parameter so middlewares doesn't import handlers. Routes registered earlier (/signup, /terms, OAuth) are unaffected, since Engine.handle composes each chain eagerly at registration time.
  • Logo URLs are absolute and per-portal. /static/images/*bkey lives in the tenantAssets group behind RequireTenant, so it only resolves on a host where a tenant resolves; a root-domain-relative URL (what the client-side uploadedImageURL builds) would 404. New web.TenantLogoURL takes the tenant explicitly and mirrors AssetsURL's per-subdomain CDN branch. Portals without a logo render an initial-letter placeholder, since the letter-avatar endpoint is also tenant-gated.
  • The page is registered in public/ssr.tsx. That table is static (esbuild has no dynamic imports) and ssrRender throws for anything missing from it. SSR is crawler-gated and degrades to the client shell on error, so an omission fails silently — a public directory page crawlers can't read would defeat the point. There's a test asserting a crawler receives real card markup.

No database migration. No changes to existing tenant behaviour.

Verification

  • 24 new tests: getPublicTenants eligibility/ordering (3), RootDomainFallback host and path guards (5), PortalDirectory handler incl. the crawler SSR path (4), web.TenantLogoURL incl. CNAME and CDN (4), the config flag (1), and the page's rendering, filtering and both empty states (7, jest).
  • Full jest suite green (134 tests, 11 suites).
  • go test ./... -race -short green other than app/services/email/smtp, which already fails at unmodified HEAD (TestSend_Success, TestBatch_Success — verified against a clean git archive HEAD export; unrelated to this change).
  • Driven against a real server and Postgres with seeded tenants to confirm each row of the table above.

claude added 2 commits August 21, 2026 13:17
Design for a public portal directory served at the root domain of a
multi-tenant instance, so portals on the same instance are discoverable
without already knowing their subdomain. Off by default behind
PORTAL_DIRECTORY_ENABLED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In multi-tenant mode a portal is only reachable if you already know its
subdomain. The root domain resolves no tenant, so RequireTenant returns
404 for every path except /signup, and nothing lists the portals an
instance hosts.

Serve a browsable directory there instead: every active, non-private
portal, each linking to its own URL, with a client-side filter.

Off by default behind PORTAL_DIRECTORY_ENABLED, so upgrading an existing
instance never starts enumerating its tenants without the operator
opting in.

- query.GetPublicTenants excludes private, pending, locked and disabled
  tenants, plus any site inside its deletion grace window.
  prevent_indexing is deliberately not part of the filter: CreateTenant
  defaults it to true, so honouring it would leave the directory
  permanently empty.
- RootDomainFallback serves the page where RequireTenant would otherwise
  404. It requires the host to be exactly HOST_DOMAIN, so an unknown or
  disabled subdomain, or a custom domain matching no tenant, keeps its
  404 rather than being handed the portal list.
- web.TenantLogoURL emits absolute per-portal logo URLs, because
  /static/images only resolves on a host where a tenant resolves.
- The page is registered in public/ssr.tsx, so crawlers receive
  server-rendered markup instead of the client shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants