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

@ -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',