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:
parent
5dbda53759
commit
98f47df8c1
7 changed files with 103 additions and 34 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue