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
6
.github/copilot-instructions.md
vendored
6
.github/copilot-instructions.md
vendored
|
|
@ -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:`.
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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