feat: route API requests by tenant hostname

Derive local API origins and tenant headers from the browser hostname, allow tenant CORS preflights, and document the multi-tenant contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-10-01 17:49:42 +02:00
parent 5dbda53759
commit 98f47df8c1
7 changed files with 103 additions and 34 deletions

View file

@ -44,6 +44,10 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
domain types, and `src/styles/global.css` contains application-wide styles.
Keep feature-specific components inside their feature directory. Vite proxies
`/api` to the Go service in development.
- Local multi-tenant URLs use `<tenant>.guess.local`. The frontend derives the
tenant from the hostname, calls `http://<tenant>.guess.local:8080/api`, and
sends `X-Tenant-Id` on every request type. Other deployments use same-origin
`/api`. Keep this contract documented in `README.md` and `frontend/README.md`.
Goose files in `backend/migrations` are schema-only and run in every environment.
`internal/database/seed/common.sql` contains shared reference data;
@ -88,5 +92,7 @@ exposes only candidate-safe schedule and shared-document data.
`email`, `contactInfo`, `photo`, and `cv`.
- Return explicit errors; map expected failures to HTTP status codes and log
unexpected failures instead of silently falling back.
- Update the directly related local documentation whenever behavior,
configuration, architecture, commands, or public API surfaces change.
- Commit every repository change with a meaningful conventional prefix such as
`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`.

View file

@ -20,9 +20,21 @@ The complete stack runs with:
make dev
```
Open <http://localhost:5173> and sign in with:
Add the local tenant hostnames once:
- Email: `interviewer@go-guess.local`
```text
127.0.0.1 nmbs.guess.local ypto.guess.local
```
Open <http://nmbs.guess.local:5173> or <http://ypto.guess.local:5173>. The
hostname's first label is the tenant ID: the frontend calls
`http://<tenant>.guess.local:8080/api` and sends the same value in the
`X-Tenant-Id` header on JSON, multipart, file-download, and event-stream
requests. The backend uses that tenant ID as the isolated PostgreSQL schema.
Sign in with:
- Email: `interviewer@ypto.local`
- Password: `admin123`
To run the applications outside containers:
@ -35,8 +47,10 @@ make backend
make frontend
```
The API listens on <http://localhost:8080>. Set a different secure `JWT_SECRET`
outside local development.
The API listens on port `8080`. Vite permits the documented local tenant
hostnames; the API CORS policy permits `*.guess.local` development origins and
the `X-Tenant-Id` request header. Non-local deployments keep using same-origin
`/api`. Set a different secure `JWT_SECRET` outside local development.
## Quality commands

View file

@ -3,6 +3,7 @@ package router
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
@ -95,8 +96,11 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler
func cors(origin string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
requestOrigin := r.Header.Get("Origin")
if allowedCORSOrigin(requestOrigin, origin) {
w.Header().Set("Access-Control-Allow-Origin", requestOrigin)
}
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Tenant-Id")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Add("Vary", "Origin")
if r.Method == http.MethodOptions {
@ -107,3 +111,17 @@ func cors(origin string) func(http.Handler) http.Handler {
})
}
}
func allowedCORSOrigin(requestOrigin, configuredOrigin string) bool {
if requestOrigin == "" {
return false
}
if requestOrigin == configuredOrigin {
return true
}
parsed, err := url.Parse(requestOrigin)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return false
}
return strings.HasSuffix(strings.ToLower(parsed.Hostname()), ".guess.local")
}

View file

@ -36,7 +36,13 @@ application shell.
## API integration
Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in
For local tenant hosts such as `nmbs.guess.local`, `src/api/client.ts` derives
tenant `nmbs`, calls `http://nmbs.guess.local:8080/api`, and sends
`X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream
request. `ypto.guess.local` behaves identically for tenant `ypto`. Other
hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
Login stores the returned JWT in
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
shown in the UI and are never replaced with demo data. A `401` from a protected
JSON, file, or event-stream request clears authentication and returns the user to
@ -60,6 +66,7 @@ Supported routes:
- `PATCH /api/questions/:id` to deprecate or restore a question
- `GET|POST /api/participants`
- `GET /api/participants/:id`
- `PUT /api/participants/:id/cv`
- `GET /api/participants/:id/photo`
- `GET /api/participants/:id/cv`
- `GET /api/interviews/:token`

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api, authStorage, SESSION_EXPIRED_EVENT } from './client'
import { api, apiBaseURL, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client'
import { subscribeToDomainEvent } from './domainEvents'
describe('API client', () => {
@ -7,6 +7,17 @@ describe('API client', () => {
sessionStorage.clear()
})
it('derives the local tenant API origin from the browser hostname', () => {
expect(tenantFromHostname('nmbs.guess.local')).toBe('nmbs')
expect(tenantFromHostname('ypto.guess.local')).toBe('ypto')
expect(tenantFromHostname('localhost')).toBeNull()
expect(tenantFromHostname('127.0.0.1')).toBeNull()
expect(apiBaseURL({ hostname: 'nmbs.guess.local', protocol: 'http:' })).toBe(
'http://nmbs.guess.local:8080/api',
)
expect(apiBaseURL({ hostname: 'app.example.com', protocol: 'https:' })).toBe('/api')
})
it('sends the persisted bearer token on protected requests', async () => {
authStorage.save({
token: 'signed-jwt',

View file

@ -19,9 +19,6 @@ import type {
} from '../types'
import { publishDomainEvent } from './domainEvents'
let activeTenant: string | null = null;
const TOKEN_KEY = 'go-guess-token'
const USER_KEY = 'go-guess-user'
export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired'
@ -69,25 +66,49 @@ export function invalidateSession() {
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT))
}
export function tenantFromHostname(hostname: string): string | null {
const normalized = hostname.toLowerCase().replace(/\.$/, '')
if (
normalized === 'localhost' ||
normalized.includes(':') ||
/^\d+(?:\.\d+){3}$/.test(normalized)
) {
return null
}
const labels = normalized.split('.')
return labels.length >= 3 ? labels[0] : null
}
export function apiBaseURL(location: Pick<Location, 'hostname' | 'protocol'>): string {
const hostname = location.hostname.toLowerCase().replace(/\.$/, '')
if (hostname.endsWith('.guess.local')) {
return `${location.protocol}//${hostname}:8080/api`
}
return '/api'
}
function apiURL(path: string): string {
return `${apiBaseURL(window.location)}${path}`
}
function tenantHeader(): Record<string, string> {
const tenant = tenantFromHostname(window.location.hostname)
return tenant ? { 'X-Tenant-Id': tenant } : {}
}
async function request<T>(
path: string,
options: RequestInit = {},
protectedRequest = true,
): Promise<T> {
let activeTenant: string | undefined
const hostname = window.location.hostname;
const parts = hostname.split('.');
if (parts.length > 1) {
activeTenant = parts[0];
}
const token = authStorage.token()
const response = await fetch(`/api${path}`, {
const response = await fetch(apiURL(path), {
...options,
headers: {
Accept: 'application/json',
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}),
...(activeTenant ? { 'X-Tenant-Id': activeTenant } : {}),
...tenantHeader(),
...options.headers,
},
})
@ -116,16 +137,10 @@ async function request<T>(
async function requestBlob(path: string): Promise<Blob> {
const token = authStorage.token()
let activeTenant: string | undefined
const hostname = window.location.hostname;
const parts = hostname.split('.');
if (parts.length > 1) {
activeTenant = parts[0];
}
const response = await fetch(`/api${path}`, {
const response = await fetch(apiURL(path), {
headers: {
Accept: '*/*',
...(activeTenant ? { 'X-Tenant-Id': activeTenant } : {}),
...tenantHeader(),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
@ -157,10 +172,11 @@ async function subscribeToInterviewEvents(
while (!signal.aborted) {
try {
const token = authStorage.token()
const response = await fetch(`/api${path}`, {
const response = await fetch(apiURL(path), {
headers: {
Accept: 'text/event-stream',
...(protectedRequest && token ? { Authorization: 'Bearer ' + token } : {}),
...tenantHeader(),
},
signal,
})
@ -198,16 +214,12 @@ async function subscribeToInterviewEvents(
}
}
}
function getLiveTenant(): string | null {
const hostname = window.location.hostname;
const parts = hostname.split('.');
return parts.length > 1 ? parts[0] : null;
}
export const api = {
login: (email: string, password: string) =>
request<AuthResponse>(
'/auth/login',
{ method: 'POST', body: JSON.stringify({ email, password }), headers: {'X-Tenant-Id': getLiveTenant()!!} },
{ method: 'POST', body: JSON.stringify({ email, password }) },
false,
),
jobs: {

View file

@ -4,6 +4,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: ['nmbs.guess.local', 'ypto.guess.local'],
proxy: {
'/api': {
target: 'http://localhost:8080',