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.
|
domain types, and `src/styles/global.css` contains application-wide styles.
|
||||||
Keep feature-specific components inside their feature directory. Vite proxies
|
Keep feature-specific components inside their feature directory. Vite proxies
|
||||||
`/api` to the Go service in development.
|
`/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.
|
Goose files in `backend/migrations` are schema-only and run in every environment.
|
||||||
`internal/database/seed/common.sql` contains shared reference data;
|
`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`.
|
`email`, `contactInfo`, `photo`, and `cv`.
|
||||||
- Return explicit errors; map expected failures to HTTP status codes and log
|
- Return explicit errors; map expected failures to HTTP status codes and log
|
||||||
unexpected failures instead of silently falling back.
|
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
|
- Commit every repository change with a meaningful conventional prefix such as
|
||||||
`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`.
|
`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`.
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -20,9 +20,21 @@ The complete stack runs with:
|
||||||
make dev
|
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`
|
- Password: `admin123`
|
||||||
|
|
||||||
To run the applications outside containers:
|
To run the applications outside containers:
|
||||||
|
|
@ -35,8 +47,10 @@ make backend
|
||||||
make frontend
|
make frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
The API listens on <http://localhost:8080>. Set a different secure `JWT_SECRET`
|
The API listens on port `8080`. Vite permits the documented local tenant
|
||||||
outside local development.
|
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
|
## Quality commands
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package router
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -95,8 +96,11 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler
|
||||||
func cors(origin string) func(http.Handler) http.Handler {
|
func cors(origin string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
requestOrigin := r.Header.Get("Origin")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
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().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
w.Header().Add("Vary", "Origin")
|
w.Header().Add("Vary", "Origin")
|
||||||
if r.Method == http.MethodOptions {
|
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
|
## 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
|
`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
|
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
|
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
|
- `PATCH /api/questions/:id` to deprecate or restore a question
|
||||||
- `GET|POST /api/participants`
|
- `GET|POST /api/participants`
|
||||||
- `GET /api/participants/:id`
|
- `GET /api/participants/:id`
|
||||||
|
- `PUT /api/participants/:id/cv`
|
||||||
- `GET /api/participants/:id/photo`
|
- `GET /api/participants/:id/photo`
|
||||||
- `GET /api/participants/:id/cv`
|
- `GET /api/participants/:id/cv`
|
||||||
- `GET /api/interviews/:token`
|
- `GET /api/interviews/:token`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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'
|
import { subscribeToDomainEvent } from './domainEvents'
|
||||||
|
|
||||||
describe('API client', () => {
|
describe('API client', () => {
|
||||||
|
|
@ -7,6 +7,17 @@ describe('API client', () => {
|
||||||
sessionStorage.clear()
|
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 () => {
|
it('sends the persisted bearer token on protected requests', async () => {
|
||||||
authStorage.save({
|
authStorage.save({
|
||||||
token: 'signed-jwt',
|
token: 'signed-jwt',
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,6 @@ import type {
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { publishDomainEvent } from './domainEvents'
|
import { publishDomainEvent } from './domainEvents'
|
||||||
|
|
||||||
let activeTenant: string | null = null;
|
|
||||||
|
|
||||||
|
|
||||||
const TOKEN_KEY = 'go-guess-token'
|
const TOKEN_KEY = 'go-guess-token'
|
||||||
const USER_KEY = 'go-guess-user'
|
const USER_KEY = 'go-guess-user'
|
||||||
export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired'
|
export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired'
|
||||||
|
|
@ -69,25 +66,49 @@ export function invalidateSession() {
|
||||||
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT))
|
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>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
protectedRequest = true,
|
protectedRequest = true,
|
||||||
): Promise<T> {
|
): 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 token = authStorage.token()
|
||||||
const response = await fetch(`/api${path}`, {
|
const response = await fetch(apiURL(path), {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}),
|
...(protectedRequest && token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
...(activeTenant ? { 'X-Tenant-Id': activeTenant } : {}),
|
...tenantHeader(),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -116,16 +137,10 @@ async function request<T>(
|
||||||
|
|
||||||
async function requestBlob(path: string): Promise<Blob> {
|
async function requestBlob(path: string): Promise<Blob> {
|
||||||
const token = authStorage.token()
|
const token = authStorage.token()
|
||||||
let activeTenant: string | undefined
|
const response = await fetch(apiURL(path), {
|
||||||
const hostname = window.location.hostname;
|
|
||||||
const parts = hostname.split('.');
|
|
||||||
if (parts.length > 1) {
|
|
||||||
activeTenant = parts[0];
|
|
||||||
}
|
|
||||||
const response = await fetch(`/api${path}`, {
|
|
||||||
headers: {
|
headers: {
|
||||||
Accept: '*/*',
|
Accept: '*/*',
|
||||||
...(activeTenant ? { 'X-Tenant-Id': activeTenant } : {}),
|
...tenantHeader(),
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -157,10 +172,11 @@ async function subscribeToInterviewEvents(
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
try {
|
try {
|
||||||
const token = authStorage.token()
|
const token = authStorage.token()
|
||||||
const response = await fetch(`/api${path}`, {
|
const response = await fetch(apiURL(path), {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
...(protectedRequest && token ? { Authorization: 'Bearer ' + token } : {}),
|
...(protectedRequest && token ? { Authorization: 'Bearer ' + token } : {}),
|
||||||
|
...tenantHeader(),
|
||||||
},
|
},
|
||||||
signal,
|
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 = {
|
export const api = {
|
||||||
login: (email: string, password: string) =>
|
login: (email: string, password: string) =>
|
||||||
request<AuthResponse>(
|
request<AuthResponse>(
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
{ method: 'POST', body: JSON.stringify({ email, password }), headers: {'X-Tenant-Id': getLiveTenant()!!} },
|
{ method: 'POST', body: JSON.stringify({ email, password }) },
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
jobs: {
|
jobs: {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { defineConfig } from 'vitest/config'
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ['nmbs.guess.local', 'ypto.guess.local'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue