Files
cameleer-saas/docs/superpowers/specs/2026-04-05-configurable-base-path-design.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer,
update all references in workflows, Docker configs, docs, and bootstrap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:44 +02:00

5.4 KiB

Configurable Base Path for SaaS App

Problem

Logto uses many root-level paths (/sign-in, /register, /consent, /social, /api/interaction, /api/experience, /assets, etc.) that conflict with the SaaS app's catch-all routing. Enumerating Logto's paths in Traefik is fragile and keeps growing.

Solution

Move the SaaS app to a configurable base path (default: /platform). Logto becomes the Traefik catch-all. Zero path enumeration — any path Logto adds in the future just works.

Routing

Path Target Priority
/platform/* cameleer-saas:8080 default
/server/* cameleer-server-ui:80 default
/* logto:3001 (catch-all) 1 (lowest)

Configuration

# .env
CONTEXT_PATH=/platform    # Change to /saas, /app, etc. No rebuild needed.

Implementation

1. Spring Boot — application.yml

server:
  servlet:
    context-path: ${CONTEXT_PATH:/platform}

Spring automatically prefixes all endpoints. Controllers, SecurityConfig matchers, interceptor patterns — all relative to context-path. No changes needed in Java code.

2. Vite — ui/vite.config.ts

Build with relative base so assets work from any prefix:

build: {
  outDir: 'dist',
  emptyOutDir: true,
  assetsDir: '_app',
  // removed: base (default '/' for dev, entrypoint injects <base> for production)
},

Change base to './' so index.html references become relative:

<!-- Before: <script src="/_app/index.js"> (absolute, breaks with prefix) -->
<!-- After:  <script src="_app/index.js"> (relative, works from any base) -->

3. Container entrypoint — inject <base href>

Create docker/entrypoint.sh:

#!/bin/sh
# Inject <base href> into index.html for runtime base path support
CONTEXT_PATH="${CONTEXT_PATH:-/platform}"
sed -i "s|<head>|<head><base href=\"${CONTEXT_PATH}/\">|" /app/static/index.html
exec java -jar /app/app.jar

In Dockerfile (or docker-compose override for dev):

entrypoint: ["sh", "/app/entrypoint.sh"]

For dev mode (mounted dist), the docker-compose.dev.yml entrypoint runs the sed on the mounted file.

4. Frontend — derive base path at runtime

ui/src/config.ts — use document.baseURI:

const basePath = new URL(document.baseURI).pathname.replace(/\/$/, '');
// basePath = "/platform"

fetch(basePath + '/api/config')

ui/src/api/client.ts — dynamic API base:

const basePath = new URL(document.baseURI).pathname.replace(/\/$/, '');
const API_BASE = basePath + '/api';

ui/src/main.tsx — router basename:

const basePath = new URL(document.baseURI).pathname.replace(/\/$/, '') || '/';
<BrowserRouter basename={basePath}>

5. Docker Compose — Traefik labels

cameleer-saas:

labels:
  - traefik.http.routers.saas.rule=PathPrefix(`${CONTEXT_PATH:-/platform}`)
  - traefik.http.routers.saas.entrypoints=websecure
  - traefik.http.routers.saas.tls=true
  - traefik.http.services.saas.loadbalancer.server.port=8080

logto (catch-all):

labels:
  - traefik.http.routers.logto.rule=PathPrefix(`/`)
  - traefik.http.routers.logto.priority=1
  - traefik.http.routers.logto.entrypoints=websecure
  - traefik.http.routers.logto.tls=true
  - traefik.http.services.logto.loadbalancer.server.port=3001

Remove all the enumerated Logto paths — Logto is now the catch-all.

6. Logto ENDPOINT

Logto's ENDPOINT stays at root (no prefix):

ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}

OIDC issuer = https://domain.com/oidc. Same domain as the SPA.

7. Bootstrap — redirect URIs

Update redirect URIs to include the context path:

SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}${CONTEXT_PATH}/callback\"]"
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}${CONTEXT_PATH}/login\"]"

Pass CONTEXT_PATH to the bootstrap container.

8. Tests — application-test.yml

server:
  servlet:
    context-path: /platform

Test MockMvc paths are relative to context-path, so existing test paths (/api/tenants, etc.) continue to work without changes.

Files to modify

  • src/main/resources/application.yml — add context-path property
  • src/main/resources/application-test.yml — add context-path for tests
  • ui/vite.config.tsbase: './' for relative assets
  • ui/src/config.ts — derive base path from document.baseURI
  • ui/src/api/client.ts — dynamic API_BASE
  • ui/src/main.tsxBrowserRouter basename from document.baseURI
  • docker/entrypoint.sh — NEW, injects <base href> into index.html
  • docker-compose.yml — Traefik labels (SaaS at /platform, Logto catch-all), pass CONTEXT_PATH to bootstrap
  • docker/logto-bootstrap.sh — context path in redirect URIs

Files that do NOT change

  • All 11 Java controllers — Spring context-path handles prefix transparently
  • SecurityConfig.java — matchers are relative to context-path
  • WebConfig.java — interceptor pattern relative to context-path
  • ui/src/api/hooks.ts — uses centralized API_BASE
  • All test files — MockMvc is context-path aware

Customer experience

# .env
PUBLIC_HOST=cameleer.mycompany.com
PUBLIC_PROTOCOL=https
CONTEXT_PATH=/platform

# DNS: 1 record
# cameleer.mycompany.com → server IP

# docker compose up -d
# SaaS at https://cameleer.mycompany.com/platform/
# Logto at https://cameleer.mycompany.com/ (login, OIDC)
# Server UI at https://cameleer.mycompany.com/server/