diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index de4a115..0b72007 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 `.guess.local`. The frontend derives the + tenant from the hostname, calls `http://.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:`. diff --git a/README.md b/README.md index bfbaf48..81a2d84 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,21 @@ The complete stack runs with: make dev ``` -Open 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 or . The +hostname's first label is the tenant ID: the frontend calls +`http://.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 . 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 diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index ac2de26..4450227 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -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") +} diff --git a/frontend/README.md b/frontend/README.md index 3706d17..1e71bd0 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 `. 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` diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 0247f7b..8ad51e4 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -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', diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index dda3da7..32c809c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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): 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 { + const tenant = tenantFromHostname(window.location.hostname) + return tenant ? { 'X-Tenant-Id': tenant } : {} +} + async function request( path: string, options: RequestInit = {}, protectedRequest = true, ): Promise { - 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( async function requestBlob(path: string): Promise { 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( '/auth/login', - { method: 'POST', body: JSON.stringify({ email, password }), headers: {'X-Tenant-Id': getLiveTenant()!!} }, + { method: 'POST', body: JSON.stringify({ email, password }) }, false, ), jobs: { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 00c23b9..6895cc8 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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',