From 9a8881c4cc0563676c983ebd55d1a59849f4925c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:46:00 +0200 Subject: [PATCH] docs: single-domain routing design spec Path-based routing on one hostname. SPA assets move to /_app/, Logto gets /assets/ + /oidc/ + /interaction/. Server-ui at /server/. Includes requirements for server team (split JWK/issuer, BASE_PATH). Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-04-05-single-domain-routing-design.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-05-single-domain-routing-design.md diff --git a/docs/superpowers/specs/2026-04-05-single-domain-routing-design.md b/docs/superpowers/specs/2026-04-05-single-domain-routing-design.md new file mode 100644 index 0000000..a015404 --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-single-domain-routing-design.md @@ -0,0 +1,119 @@ +# Single-Domain Routing Design + +## Problem + +Customers cannot always provision subdomains (`auth.domain.com`, `server.domain.com`). The current architecture requires 3 DNS records. Some enterprise environments only allow a single hostname pointing at the server. + +## Root Cause + +Logto's sign-in UI serves assets at `/assets/*`. The SPA (Vite-built) also outputs assets to `/assets/*`. With path-based Traefik routing on a single domain, both conflict on the same prefix. + +## Solution + +Move the SPA's asset output directory from `/assets/` to `/_app/`. Then Traefik can route `/assets/*` to Logto while the SPA's assets are served from `/_app/*` via the catch-all. Single domain, single DNS record, no subdomains. + +## Architecture + +### Routing (all on `${PUBLIC_HOST}`) + +| Path | Target | Priority | +|------|--------|----------| +| `/oidc/*` | logto:3001 | default | +| `/interaction/*` | logto:3001 | default | +| `/assets/*` | logto:3001 | default | +| `/server/*` | cameleer3-server-ui:80 (prefix stripped) | default | +| `/api/*` | cameleer-saas:8080 | default | +| `/*` (catch-all) | cameleer-saas:8080 | 1 (lowest) | + +SPA assets at `/_app/*` are served by cameleer-saas through the catch-all route. + +### Logto Configuration + +- `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain as SPA) +- OIDC issuer = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` +- Same origin for browser — no CORS, cookies work +- Logto Management API (`/api/*` on port 3001) is only accessed via Docker-internal networking, never through Traefik — no conflict with cameleer-saas `/api/*` + +### TLS + +- Self-signed cert init container generates wildcard cert on first boot (dev) +- Traefik ACME (Let's Encrypt) for production +- HTTP→HTTPS redirect + +### Customer Bootstrap + +``` +# .env +PUBLIC_HOST=cameleer.mycompany.com +PUBLIC_PROTOCOL=https + +# DNS: 1 record +cameleer.mycompany.com → server IP + +# Start +docker compose up -d +``` + +## Changes by Repository + +### cameleer-saas (this repo) + +1. **`ui/vite.config.ts`** — Set `build.assetsDir: '_app'` +2. **`docker-compose.yml`** — Revert to path-based routing: + - Logto: `PathPrefix(/oidc) || PathPrefix(/interaction) || PathPrefix(/assets)` + - Server-UI: `PathPrefix(/server)` with strip prefix middleware + - SPA: `PathPrefix(/)` priority 1 (catch-all) — same as original + - Remove Host-based routing labels + - Logto `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (no `auth.` prefix) + - All `LOGTO_PUBLIC_ENDPOINT`, `LOGTO_ISSUER_URI` use `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (no `auth.`) +3. **`docker/logto-bootstrap.sh`** — Host header = `${HOST}` (matches ENDPOINT, no `auth.` prefix). Redirect URIs use `${PROTO}://${HOST}/callback`. +4. **`ui/src/config.ts`** — Fallback `logtoEndpoint` = `${window.location.origin}` (same origin) +5. **`ui/src/components/Layout.tsx`** — Dashboard link = `/server/` + +### cameleer3-server-ui (server team) + +Add configurable `BASE_PATH` env var so the SPA can be served from a subpath. + +**Build changes:** +- Accept `BASE_PATH` env var (default: `/`) +- At build time or container start, set Vite's `base` to `BASE_PATH` value +- If using runtime config: nginx rewrites `` to `` at container startup + +**nginx changes:** +- Serve SPA at the configured base path +- API proxy remains internal (`CAMELEER_API_URL` → `http://cameleer3-server:8081`) + +**In cameleer-saas docker-compose.yml:** +```yaml +cameleer3-server-ui: + environment: + CAMELEER_API_URL: http://cameleer3-server:8081 + BASE_PATH: /server/ +``` + +### cameleer3-server (server team) + +Add split JWK/issuer OIDC configuration so token validation works in Docker environments where the public issuer URL is not directly reachable from containers. + +**What to add:** +- New config property: `cameleer.oidc.jwk-set-uri` (or equivalent) +- When set, fetch JWKs from this URL instead of deriving from the OIDC discovery document +- Validate token `iss` claim against `cameleer.oidc.issuer-uri` (string comparison) +- Accept `at+jwt` token type header (Logto uses this instead of plain `jwt`) +- Algorithm: ES384 (Logto's signing algorithm) + +**Reference implementation:** `cameleer-saas/src/main/java/.../config/SecurityConfig.java` — custom `JwtDecoder` bean that does exactly this. + +**In cameleer-saas docker-compose.yml:** +```yaml +cameleer3-server: + environment: + CAMELEER_OIDC_ISSUER_URI: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc + CAMELEER_OIDC_JWK_SET_URI: http://logto:3001/oidc/jwks +``` + +## Future: Custom Sign-In UI (Roadmap) + +For full auth UX control (branding, custom flows), build a custom sign-in experience using Logto's Experience API. This eliminates Logto's interaction pages entirely — Logto becomes a pure OIDC/API backend. The SPA renders all auth screens (login, register, forgot password, MFA, social login). + +This is the right long-term play for a SaaS product but is a separate project.