Compare commits

..

No commits in common. "5765c6be8b8daac30ba4d86c8832ec7265103604" and "5344f249c7e453fdb7a3cf3664979305a286764b" have entirely different histories.

80 changed files with 720 additions and 3930 deletions

View file

@ -44,15 +44,19 @@ 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.
- Multi-tenant URLs use `<tenant>.<app domain>`, where the app domain is
`guess-dev.urpi.be` in local development and the Kubernetes development
profile, and `guess.urpi.be` in production. The frontend derives the tenant from
the hostname, calls same-origin `/api` through the HTTPS proxy, and sends
`X-Tenant-Id` on every request type. Generated participant links must use the
validated matching tenant origin. Keep this contract documented in `README.md`
and `frontend/README.md`.
- Protected interviewer routes require a bearer JWT only. Go Loose is used for
browser login, not for API-key authorization.
- Local multi-tenant URLs use `<tenant>.guess.local`. The frontend derives the
tenant from the hostname, calls same-origin `/api` through the HTTPS proxy, and
sends `X-Tenant-Id` on every request type. Other deployments use same-origin
`/api`. Generated participant links must use the validated matching tenant
origin. Keep this contract documented in `README.md` and `frontend/README.md`.
- Protected interviewer routes also require Go Loose `X-API-Key` authorization
for application `guess`. The tenant passed to Go Loose comes from
`X-Tenant-Id`. `GO_LOOSE_BASE_URL` defaults to `https://%s.auth.dev`;
`%s` is that tenant and must be one DNS label. Do not require the API key on
`/api/health`, `/api/openapi.json`, `/api/auth/login`, or opaque-token
participant routes. The frontend sends the key only when
`VITE_GO_LOOSE_API_KEY` is present at build time. Document both variables when
their contract changes.
Goose files in `backend/migrations` are schema-only and run in every environment.
`internal/database/seed/common.sql` contains shared reference data;
@ -63,19 +67,13 @@ test updates. Never put demo users or test records in schema migrations.
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`,
`/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token
participant interview routes. Any tenant host starts browser login with
`github.com/danyel/go-loose/client`: `/api/auth/login` redirects to
`https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize` and Go Loose returns to
`/api/auth/callback`. `GO_LOOSE_AUTH_DOMAIN` is `auth-dev.urpi.be` locally and in
the development profile, and `auth.urpi.be` in production; `GO_LOOSE_APP_DOMAIN`
is the matching application domain. Both default to the development domains.
`GET /api/auth/session` answers `404` when no Go Loose login is configured for the
request host, and the frontend uses that to fall back to the password form instead
of redirecting. Go Loose client credentials come from uncommitted environment
variables. The tenant comes from `X-Tenant-Id`, or from the request host and
`X-Forwarded-Host` when the header is absent; missing tenant context uses the
public schema. Never expose password hashes, CV/photo bytes, or interviewer
reference answers in public JSON.
participant interview routes. Tenant hosts `nmbs.guess.dev` and `ypto.guess.dev`
start login with `github.com/danyel/go-loose/client`: `/api/auth/login` redirects
to `https://<tenant>.auth.dev/connect/authorize` and Go Loose returns to
`/api/auth/callback`. Go Loose client credentials come from uncommitted
environment variables. Missing tenant context uses the public schema; a
`*.guess.dev` host supplies the schema when `X-Tenant-Id` is absent. Never expose
password hashes, CV/photo bytes, or interviewer reference answers in public JSON.
Questions are permanent reusable records linked to jobs through
`job_posting_questions`; detach links rather than deleting questions. Deprecated

View file

@ -52,7 +52,6 @@ jobs:
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ forgejo.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max
${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -36,11 +36,6 @@ COPY --chown=app:app backend/migrations/ ./migrations/
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/entrypoint.sh /usr/local/bin/go-guess-entrypoint
# One user, and it owns everything the runtime writes to. A second USER line here
# silently replaced this one with a uid that owns neither /app nor /tmp/nginx, so
# nginx could not create its pid file or its temp paths and the container failed to
# start. nginx also ignores its own user directive when the master is not root, so
# its workers run as whoever this names.
USER app
EXPOSE 8080
ENV ADDRESS=:8081 \

View file

@ -1,5 +1,5 @@
SHELL := /bin/sh
REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell
DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable
JWT_SECRET ?= local-development-secret-change-me-12345
GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0
@ -7,15 +7,10 @@ BDD_COMPOSE := docker compose -f docker-compose.bdd.yml
HELM ?= helm
HELM_CHART := deploy/helm/go-guess
HELM_RELEASE ?= go-guess
HELM_NAMESPACE ?= go-insane
KUBECONFIG ?= $(HOME)/.kube/config
HELM_KUBECTL := KUBECONFIG="$(KUBECONFIG)" $(HELM)
GO_LOOSE_SET ?= --set-string api.goLoose.tenants.nmbs.clientID="$${GO_LOOSE_NMBS_CLIENT_ID:-}" \
--set-string api.goLoose.tenants.nmbs.clientSecret="$${GO_LOOSE_NMBS_CLIENT_SECRET:-}" \
--set-string api.goLoose.tenants.ypto.clientID="$${GO_LOOSE_YPTO_CLIENT_ID:-}" \
--set-string api.goLoose.tenants.ypto.clientSecret="$${GO_LOOSE_YPTO_CLIENT_SECRET:-}"
HELM_NAMESPACE ?= go-guess
KUBECONFIG ?= $(HOME)/.config/kubectl/rancher.urpi.local.yaml
.PHONY: help dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-bdd test-bdd-down test-env test-env-down lint build rancher-storage ingress-nginx-install cert-manager-install cluster-prepare helm-lint helm-deploy-development helm-deploy-production helm-down-production helm-down-development
.PHONY: help dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-bdd test-bdd-down test-env test-env-down lint build rancher-storage helm-lint helm-deploy-development helm-deploy-production helm-down-production helm-down-development
help:
@echo "make dev builds the demo docker image"
@ -37,13 +32,7 @@ help:
@echo "make test-env-down stops the test image"
@echo "make lint runs lint on the frontend"
@echo "make build builds the entire project"
@echo "make docker build the all-in-one image"
@echo "make docker-push build and push the image to the private registry"
@echo "make docker-run run the image on :8080"
@echo "make rancher-storage installs the persistent local-path StorageClass"
@echo "make ingress-nginx-install installs the ingress-nginx controller serving 80/443"
@echo "make cert-manager-install installs cert-manager for chart-issued TLS certificates"
@echo "make cluster-prepare runs every cluster prerequisite in order"
@echo "make helm-lint lints development and production Helm configurations"
@echo "make helm-deploy-development deploys the development Helm release"
@echo "make helm-deploy-production deploys the production Helm release"
@ -112,41 +101,9 @@ build:
cd backend && go build ./...
cd frontend && npm run build
docker:
docker build -t $(IMAGE):$(TAG) .
docker-push: docker
docker tag $(IMAGE):$(TAG) $(REGISTRY_IMAGE):$(TAG)
docker push $(REGISTRY_IMAGE):$(TAG)
docker-run:
docker run --rm -p 8080:8080 --env-file .env $(IMAGE):$(TAG)
rancher-storage:
KUBECONFIG="$(KUBECONFIG)" kubectl apply -f deploy/rancher/local-path.yaml
ingress-nginx-install:
$(HELM_KUBECTL) repo add ingress-nginx https://kubernetes.github.io/ingress-nginx --force-update
$(HELM_KUBECTL) repo update
$(HELM_KUBECTL) upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.type=NodePort \
--set controller.hostPort.enabled=true \
--set controller.kind=DaemonSet \
--set controller.admissionWebhooks.enabled=false \
--wait --timeout 10m
cert-manager-install:
$(HELM_KUBECTL) repo add jetstack https://charts.jetstack.io --force-update
$(HELM_KUBECTL) repo update
$(HELM_KUBECTL) upgrade --install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=true \
--wait --timeout 10m
cluster-prepare: rancher-storage ingress-nginx-install cert-manager-install
helm-lint:
KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml
KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-production.yaml
@ -156,25 +113,17 @@ helm-lint:
-f $(HELM_CHART)/values-production.yaml >/dev/null
helm-deploy-development:
$(HELM_KUBECTL) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
--namespace $(HELM_NAMESPACE)-development --create-namespace \
-f $(HELM_CHART)/values-development.yaml \
--set secrets.postgresqlPassword="go_guess" \
--set secrets.rabbitmqPassword="guest" \
$(GO_LOOSE_SET) \
--rollback-on-failure --wait --timeout 10m
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) --namespace $(HELM_NAMESPACE)-development --create-namespace -f $(HELM_CHART)/values-development.yaml --rollback-on-failure --wait --timeout 10m
helm-deploy-production:
$(HELM_KUBECTL) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
--namespace $(HELM_NAMESPACE)-production --create-namespace \
-f $(HELM_CHART)/values-production.yaml \
-f $(HELM_CHART)/values-production.yaml --wait --timeout 10m\
--set secrets.postgresqlPassword="go_guess" \
--set secrets.rabbitmqPassword="guest" \
$(GO_LOOSE_SET) \
--wait --timeout 10m
--set secrets.rabbitmqPassword="guest"
helm-down-production:
$(HELM_KUBECTL) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production
helm-down-development:
$(HELM_KUBECTL) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development

182
README.md
View file

@ -21,46 +21,33 @@ make dev
```
`docker compose up -d --build` serves the UI through the local Traefik edge at
<https://nmbs.guess-dev.urpi.be> and <https://ypto.guess-dev.urpi.be>. Browser
sign-in goes through the development Go Loose installation at
<https://nmbs.auth-dev.urpi.be>. Both domains must resolve to the local edge:
<https://nmbs.guess.dev> and <https://ypto.guess.dev>. Add the hostnames once:
```text
127.0.0.1 nmbs.guess-dev.urpi.be ypto.guess-dev.urpi.be
127.0.0.1 nmbs.auth-dev.urpi.be ypto.auth-dev.urpi.be
127.0.0.1 nmbs.guess.dev ypto.guess.dev
127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev
```
Production uses `guess.urpi.be` and `auth.urpi.be` instead. `GO_LOOSE_AUTH_DOMAIN`
and `GO_LOOSE_APP_DOMAIN` select the pair; both default to the development
domains so a local run never touches the production identity provider.
Start the shared TLS proxy described in
`/home/dnoulet/go/infrastructure/docs/APPLICATIONS.md`, then open
<https://nmbs.guess-dev.urpi.be> or <https://ypto.guess-dev.urpi.be>. The
hostname's first label is the tenant ID: the frontend calls same-origin `/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 and builds participant invitation and meeting links
from the matching tenant browser origin instead of the static localhost fallback.
`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, then open
<https://nmbs.guess.dev> or <https://ypto.guess.dev>. The hostname's first
label is the tenant ID: the frontend calls same-origin `/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 and
builds participant invitation and meeting links from the matching tenant browser
origin instead of the static localhost fallback.
Compose mounts the infrastructure CA from
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API at
`/certs/local-ca.crt`, which `GO_LOOSE_CA_FILE` points at so the API trusts the
local Traefik certificate. The browser is redirected through the matching Go Loose
tenant and returns to `/api/auth/callback`. Set the per-tenant
`GO_LOOSE_*_CLIENT_ID` and `GO_LOOSE_*_CLIENT_SECRET` variables in the
environment; never commit them.
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API. The
browser is redirected through the matching Go Loose tenant and returns to
`/api/auth/callback`. Set the per-tenant `GO_LOOSE_*_CLIENT_ID` and
`GO_LOOSE_*_CLIENT_SECRET` variables in the environment; never commit them.
Sign in with:
- Email: `interviewer@ypto.local`
- Password: `admin123`
Those credentials are only for hosts without Go Loose login. When Go Loose
client credentials are configured, the frontend exchanges the Go Loose session
for a JWT silently and redirects to the identity provider instead of showing the
password form.
To run the applications outside containers:
```bash
@ -73,8 +60,7 @@ make frontend
The API listens on port `8080` inside Docker and is exposed to browsers only
through Traefik and the frontend's same-origin `/api` proxy. Vite permits the
documented local tenant hostnames; the API CORS policy permits the configured
tenant domain and `*.guess.local` development origins and
documented local tenant hostnames; the API CORS policy permits `*.guess.local` development origins and
the `Authorization` and `X-Tenant-Id` request headers. Non-local
deployments keep using same-origin `/api`. Set a different secure `JWT_SECRET`
outside local development.
@ -82,7 +68,7 @@ outside local development.
## API contract
The backend exposes its current OpenAPI 3.0 contract at
<https://nmbs.guess-dev.urpi.be/api/openapi.json>. The document is assembled in memory
<https://nmbs.guess.dev/api/openapi.json>. The document is assembled in memory
from the same typed registrations that add routes to Chi, including request and
response schemas, status codes, authentication, tenant headers, path/query
parameters, multipart uploads, downloads, and event streams. It therefore
@ -92,24 +78,16 @@ requires no generation command and cannot miss a newly registered route.
tenant header. Every other route declares required `X-Tenant-Id`. Protected
operations also declare bearer authentication.
## Go Loose browser login
## Go Loose authorization
Tenant hosts start browser authentication at `GET /api/auth/login`. The API
redirects to `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize`; Go Loose
Tenant hosts start browser authentication at `GET /api/auth/login`. Go Loose
returns to `/api/auth/callback`, and `GET /api/auth/session` exchanges the
resulting secure browser session for the application's bearer JWT. The tenant is
derived from the request host, or from `X-Forwarded-Host` when an ingress proxy
replaces it, and from `X-Tenant-Id` for API calls.
resulting secure browser session for the application's bearer JWT. The tenant
is derived from the request host, or from `X-Tenant-Id` for API calls.
| Environment | `GO_LOOSE_AUTH_DOMAIN` | `GO_LOOSE_APP_DOMAIN` |
| --- | --- | --- |
| Local development | `auth-dev.urpi.be` | `guess-dev.urpi.be` |
| Kubernetes development | `auth-dev.urpi.be` | `guess-dev.urpi.be` |
| Production | `auth.urpi.be` | `guess.urpi.be` |
Those two variables, `GO_LOOSE_CA_FILE`, and per-tenant client ID and secret
variables are configured by Compose and by the chart. These routes do not
require a JWT:
Compose configures `GO_LOOSE_AUTH_DOMAIN`, `GO_LOOSE_APP_DOMAIN`,
`GO_LOOSE_CA_FILE`, and per-tenant client ID and secret variables. These routes
do not require a JWT:
- `GET /api/health` and `GET /api/openapi.json`
- browser login, callback, logout, and session routes under `/api/auth`
@ -120,13 +98,6 @@ require a JWT:
Login and participant routes still require `X-Tenant-Id`. Health and OpenAPI do
not. Browser authentication routes derive the tenant from the configured host.
The frontend decides whether to attempt browser login from the host it is served
on, then asks the API whether Go Loose is configured there: `GET /api/auth/session`
answers `404` for any host without a Go Loose login, which is the signal to show
the password form instead of redirecting. A `401` means Go Loose is configured and
the browser has no valid session yet, so the frontend redirects to
`GET /api/auth/login`.
## Switch the Git remote
Run `scripts/toggle-origin.sh` to inspect `origin` and switch it between
@ -185,72 +156,36 @@ make test-env-down
## Production image
Two images are published to the private registry: `go-guess-api:0.0.1` for the Go
service and `go-guess-web:0.0.1` for the React front end behind nginx.
Build the complete application as a single image:
```bash
docker build -t batty1039.startdedicated.net:5000/go-guess-api:0.0.1 ./backend
docker build -t batty1039.startdedicated.net:5000/go-guess-web:0.0.1 ./frontend
docker build -t go-guess .
```
The API image listens on `8080` and applies database migrations and shared
fixtures before serving. The web image listens on `80`, serves the built assets,
and proxies `/api/` to `http://api:8080`, so both containers must share a
network with the API reachable under the DNS name `api`. PostgreSQL, RabbitMQ,
and these environment variables must be provided to the API:
The image serves the frontend and API on port `8080`. It applies database
migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ,
and these environment variables must be provided:
```bash
docker run --rm -p 8080:8080 \
-e DATABASE_URL='postgres://user:password@postgres:5432/go_guess?sslmode=disable' \
-e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \
-e JWT_SECRET='replace-with-at-least-32-characters' \
-e FRONTEND_URL='https://nmbs.guess-dev.urpi.be' \
batty1039.startdedicated.net:5000/go-guess-api:0.0.1
-e FRONTEND_URL='https://go-guess.example.com' \
go-guess
```
The `Dockerfile` at the repository root still builds one combined image that
serves both halves on port `8080`. It is convenient for local runs and browser
BDD tests, while Kubernetes deploys the two images separately.
That combined image runs as the `app` user, which owns everything the runtime
writes to. There is deliberately only one `USER` line: a second one silently
replaced it with a uid owning neither `/app` nor `/tmp/nginx`, and nginx could
then not create its pid file or its temp paths, so the container failed to start.
nginx ignores its own `user` directive when the master is not root, so its
workers run as whoever this names.
`THEME_BASE_URL` points the page at the [go-bananas](https://github.com/urpi/go-bananas)
design-token service, which supplies the palette and the light/dark switch at run
time. It is optional and empty by default, so an image without a theme service
renders exactly as before. The entrypoint writes it into `runtime-config.js` on
every start, so one image serves every environment. See
[the front-end README](frontend/README.md#theming) for the deployment
requirements.
Pushes to `master` publish `go-guess:latest` and `go-guess:<commit-sha>` to the
private registry configured in `.github/workflows/publish-image.yml`. The
repository must provide `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets.
## Rancher and Helm
The chart under `deploy/helm/go-guess` deploys the API image, the web image, and
their PostgreSQL and RabbitMQ. Every tenant gets its own HTTPS host at
`https://<tenant>.guess.urpi.be` in production and
`https://<tenant>.guess-dev.urpi.be` in the development profile. The chart values are documented in
[deploy/helm/go-guess/README.md](deploy/helm/go-guess/README.md).
The chart under `deploy/helm/go-guess` deploys the application, PostgreSQL, and
RabbitMQ. It works with any Kubernetes cluster managed by Rancher; Rancher's free
edition does not restrict Helm or GitHub Actions deployments.
One web deployment serves every tenant host because the API resolves the tenant
from the request `Host` header, so a new tenant is one entry in `tenants`.
Install the cluster prerequisites once per cluster. Storage for persistent
volumes, an ingress-nginx controller for the `nginx` ingress class, and
cert-manager for the chart-issued certificate:
```bash
make cluster-prepare
```
Then validate or deploy one of the environment profiles:
Validate or deploy one of the environment profiles:
```bash
make helm-lint
@ -258,41 +193,25 @@ make helm-deploy-development
make helm-deploy-production
```
Development uses `latest` images, development fixtures, and ephemeral database
and broker storage in namespace `go-insane-development`. Production uses the
released `0.0.1` tags, production fixtures, and persistent volumes in namespace
`go-insane-production`.
For the repository's single-node Docker Rancher setup, run
`make rancher-storage` once before the first production deployment. This
installs the default persistent StorageClass documented in
`rancher-install.txt`.
Certificates are self-signed and issued by the chart, so every browser or client
that opens a tenant host needs the CA from the release:
```bash
kubectl -n go-insane-production get secret go-guess-web-ca \
-o jsonpath='{.data.ca\.crt}' | base64 -d > go-guess-ca.crt
```
Point each tenant host at the ingress controller address, then open
`https://nmbs.guess.urpi.be` and `https://ypto.guess.urpi.be`. Browser sign-in
through Go Loose needs the per-tenant client credentials; pass them from the
environment to `make helm-deploy-production`:
```bash
export GO_LOOSE_NMBS_CLIENT_ID=... GO_LOOSE_NMBS_CLIENT_SECRET=...
export GO_LOOSE_YPTO_CLIENT_ID=... GO_LOOSE_YPTO_CLIENT_SECRET=...
```
Without credentials for a tenant, that host keeps password login. The cluster
served by `~/.kube/config` is managed by Rancher at `rancher.urpi.be`, which is
the management interface and not the deployment target. One.com DNS, Nginx,
Certbot, and firewall instructions for the single-node dedicated server are in
`deploy/nginx/README.md`.
Development uses demo fixtures, ephemeral database and message-broker storage,
and NodePort `30080`. Production uses production fixtures, persistent volumes,
generated secrets that are preserved across upgrades, and larger resource
limits. It exposes the application at NodePort `31374` for the dedicated-server
Nginx proxy while PostgreSQL and RabbitMQ remain cluster-internal. The public
URL is `https://goguess.urpi.be`; one.com DNS, Nginx, Certbot, firewall, and
verification instructions are in `deploy/nginx/README.md`.
To use external managed services, disable the bundled StatefulSets and provide
full connection URLs:
```bash
helm upgrade --install go-guess deploy/helm/go-guess \
--namespace go-insane-production --create-namespace \
--namespace go-guess-production --create-namespace \
-f deploy/helm/go-guess/values-production.yaml \
--set postgresql.enabled=false \
--set rabbitmq.enabled=false \
@ -310,8 +229,8 @@ workflow succeeds. Create GitHub environments named `development` and
- Secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and
`GO_GUESS_JWT_SECRET`.
- Variable `RANCHER_NAMESPACE` for the target namespace.
- Optional secrets `GO_LOOSE_NMBS_CLIENT_ID`, `GO_LOOSE_NMBS_CLIENT_SECRET`,
`GO_LOOSE_YPTO_CLIENT_ID`, and `GO_LOOSE_YPTO_CLIENT_SECRET`.
- Optional variable `GO_GUESS_FRONTEND_URL` when overriding an environment's
configured public URL.
Because the configured image registry uses HTTP, every Rancher cluster node must
trust `batty1039.startdedicated.net:5000` as an insecure registry. Use HTTPS for
@ -320,9 +239,8 @@ the registry in production when possible.
## Architecture
Requests enter the Chi router and web handlers under `backend/internal/web`.
Tenant isolation runs first. The protected interviewer group then requires a
bearer JWT, issued either by `POST /api/auth/login` or by exchanging a Go Loose
browser session.
Tenant isolation runs first. The protected interviewer group then applies Go
Loose API-key authorization and JWT authentication.
Web request/response models are translated to service models before business logic
runs. Services own validation, CV trait extraction, and candidate scoring. The
repository maps service models to GORM entities and persists them in PostgreSQL.

View file

@ -3,7 +3,7 @@ module github.com/danyel/go-guess/backend
go 1.27.1
require (
github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14
github.com/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0

View file

@ -22,8 +22,8 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd h1:YCvHmqMNr0cfEqdAzRyVe4MOn+yYyMh8/FLSMydvYQM=
github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14 h1:kX0zybG4E3VLB4QpOsyJEBu4HoXx33CY2x8rtACHRRM=
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -4,7 +4,6 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
@ -31,27 +30,6 @@ type GoLooseTenant struct {
ClientSecret string
}
const (
// DefaultAuthDomain is the Go Loose identity domain used for local
// development. Production signs in through DefaultProdAuthDomain.
DefaultAuthDomain = "auth-dev.urpi.be"
// DefaultProdAuthDomain is the Go Loose identity domain used in production.
DefaultProdAuthDomain = "auth.urpi.be"
// DefaultAppDomain is the public application domain used for local
// development, matching deploy/helm/go-guess/values-dev.yaml.
DefaultAppDomain = "guess-dev.urpi.be"
// DefaultProdAppDomain is the public application domain used in production,
// matching deploy/helm/go-guess/values.yaml.
DefaultProdAppDomain = "guess.urpi.be"
)
// GoLooseCredentialEnv returns the environment variable names holding the Go
// Loose client credentials of a tenant.
func GoLooseCredentialEnv(slug string) (idEnv, secretEnv string) {
prefix := "GO_LOOSE_" + strings.ToUpper(slug)
return prefix + "_CLIENT_ID", prefix + "_CLIENT_SECRET"
}
func Load() (Config, error) {
ttl, err := time.ParseDuration(env("TOKEN_TTL", "8h"))
if err != nil {
@ -77,30 +55,22 @@ func Load() (Config, error) {
return cfg, nil
}
// loadGoLoose reads the Go Loose browser login configuration. Both domains are
// single host suffixes: a tenant named "nmbs" signs in through
// https://nmbs.<AuthDomain> and is served from https://nmbs.<AppDomain>.
//
// The defaults are the development domains, matching the rest of the local
// defaults in Load. Production deployments set GO_LOOSE_AUTH_DOMAIN and
// GO_LOOSE_APP_DOMAIN explicitly (see deploy/helm/go-guess/values.yaml).
func loadGoLoose() GoLooseConfig {
cfg := GoLooseConfig{
AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", DefaultAuthDomain),
AppDomain: env("GO_LOOSE_APP_DOMAIN", DefaultAppDomain),
AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),
AppDomain: env("GO_LOOSE_APP_DOMAIN", "guess.dev"),
CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
Tenants: map[string]GoLooseTenant{},
}
add := func(slug string) {
idEnv, secretEnv := GoLooseCredentialEnv(slug)
add := func(slug, idEnv, secretEnv string) {
id, secret := os.Getenv(idEnv), os.Getenv(secretEnv)
if id == "" || secret == "" {
return
}
cfg.Tenants[slug] = GoLooseTenant{ClientID: id, ClientSecret: secret}
}
add("nmbs")
add("ypto")
add("nmbs", "GO_LOOSE_NMBS_CLIENT_ID", "GO_LOOSE_NMBS_CLIENT_SECRET")
add("ypto", "GO_LOOSE_YPTO_CLIENT_ID", "GO_LOOSE_YPTO_CLIENT_SECRET")
return cfg
}

View file

@ -24,13 +24,16 @@ type Registry struct {
func New(cfg config.GoLooseConfig) (*Registry, error) {
registry := &Registry{
appDomain: domain(cfg.AppDomain),
appDomain: strings.TrimPrefix(strings.ToLower(cfg.AppDomain), "."),
bySlug: map[string]*goloose.BrowserAuth{},
}
if registry.appDomain == "" {
registry.appDomain = "guess.dev"
}
if len(cfg.Tenants) == 0 {
return registry, nil
}
authDomain := domain(cfg.AuthDomain)
authDomain := strings.TrimPrefix(strings.ToLower(cfg.AuthDomain), ".")
if authDomain == "" {
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
}
@ -39,7 +42,7 @@ func New(cfg config.GoLooseConfig) (*Registry, error) {
return nil, err
}
for slug, tenant := range cfg.Tenants {
slug = domain(slug)
slug = strings.ToLower(strings.TrimSpace(slug))
if slug == "" || strings.Contains(slug, ".") {
return nil, fmt.Errorf("invalid Go Loose tenant slug %q", slug)
}
@ -65,13 +68,11 @@ func New(cfg config.GoLooseConfig) (*Registry, error) {
func (r *Registry) AppDomain() string {
if r == nil || r.appDomain == "" {
return config.DefaultAppDomain
return "guess.dev"
}
return r.appDomain
}
// ForHost resolves the Go Loose client of the tenant that owns host. Ingress
// proxies may replace the request Host, so X-Forwarded-Host wins when present.
func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
if r == nil {
return nil, "", false
@ -81,21 +82,16 @@ func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
return auth, slug, ok
}
// Host returns the host that identifies the tenant of a request, preferring the
// proxy-supplied X-Forwarded-Host over the request Host.
func Host(r *http.Request) string {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwarded != "" {
return forwarded
}
return r.Host
}
func TenantFromHost(host, appDomain string) string {
hostname := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
hostname := strings.ToLower(host)
if parsed, _, err := net.SplitHostPort(host); err == nil {
hostname = strings.ToLower(strings.TrimSuffix(parsed, "."))
hostname = strings.ToLower(parsed)
}
suffix := "." + domain(appDomain)
appDomain = strings.TrimPrefix(strings.ToLower(appDomain), ".")
if appDomain == "" {
appDomain = "guess.dev"
}
suffix := "." + appDomain
if !strings.HasSuffix(hostname, suffix) {
return ""
}
@ -106,11 +102,6 @@ func TenantFromHost(host, appDomain string) string {
return slug
}
// domain normalizes a configured host suffix or tenant slug.
func domain(value string) string {
return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".")
}
func client(caFile string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
if caFile != "" {

View file

@ -1,181 +0,0 @@
package golooseauth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/danyel/go-guess/backend/internal/config"
)
func tenants() map[string]config.GoLooseTenant {
return map[string]config.GoLooseTenant{
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
"ypto": {ClientID: "glc_ypto", ClientSecret: "secret"},
}
}
func TestNewUsesDevelopmentDomainsByDefault(t *testing.T) {
registry, err := New(config.GoLooseConfig{AuthDomain: config.DefaultAuthDomain, AppDomain: config.DefaultAppDomain, Tenants: tenants()})
if err != nil {
t.Fatal(err)
}
if registry.AppDomain() != "guess-dev.urpi.be" {
t.Fatalf("app domain = %q", registry.AppDomain())
}
location := loginRedirect(t, registry, "nmbs.guess-dev.urpi.be")
if !strings.HasPrefix(location, "https://nmbs.auth-dev.urpi.be/connect/authorize?") {
t.Fatalf("development login redirect = %q", location)
}
if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess-dev.urpi.be%2Fapi%2Fauth%2Fcallback") {
t.Fatalf("development callback missing from %q", location)
}
}
func TestNewUsesProductionDomains(t *testing.T) {
registry, err := New(config.GoLooseConfig{
AuthDomain: config.DefaultProdAuthDomain,
AppDomain: config.DefaultProdAppDomain,
Tenants: tenants(),
})
if err != nil {
t.Fatal(err)
}
location := loginRedirect(t, registry, "nmbs.guess.urpi.be")
if !strings.HasPrefix(location, "https://nmbs.auth.urpi.be/connect/authorize?") {
t.Fatalf("production login redirect = %q", location)
}
if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess.urpi.be%2Fapi%2Fauth%2Fcallback") {
t.Fatalf("production callback missing from %q", location)
}
}
func TestNewRequiresAnAuthDomainWithCredentials(t *testing.T) {
if _, err := New(config.GoLooseConfig{Tenants: tenants()}); err == nil {
t.Fatal("expected an error when GO_LOOSE_AUTH_DOMAIN is empty")
}
}
func TestNewNormalizesConfiguredDomains(t *testing.T) {
registry, err := New(config.GoLooseConfig{
AuthDomain: " .Auth-Dev.Urpi.Be. ",
AppDomain: ".Guess-Dev.Urpi.Be.",
Tenants: map[string]config.GoLooseTenant{"NMBS": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
})
if err != nil {
t.Fatal(err)
}
if registry.AppDomain() != "guess-dev.urpi.be" {
t.Fatalf("app domain = %q", registry.AppDomain())
}
if location := loginRedirect(t, registry, "nmbs.guess-dev.urpi.be"); !strings.HasPrefix(location, "https://nmbs.auth-dev.urpi.be/connect/authorize?") {
t.Fatalf("normalized login redirect = %q", location)
}
}
func TestNewRejectsMultiLabelTenantSlug(t *testing.T) {
_, err := New(config.GoLooseConfig{
AuthDomain: config.DefaultAuthDomain,
AppDomain: config.DefaultAppDomain,
Tenants: map[string]config.GoLooseTenant{"nmbs.eu": {ClientID: "id", ClientSecret: "secret"}},
})
if err == nil {
t.Fatal("expected an error for a tenant slug containing a dot")
}
}
func TestAppDomainFallsBackToTheDevelopmentDomain(t *testing.T) {
var missing *Registry
if missing.AppDomain() != config.DefaultAppDomain {
t.Fatalf("nil registry app domain = %q", missing.AppDomain())
}
empty, err := New(config.GoLooseConfig{})
if err != nil {
t.Fatal(err)
}
if empty.AppDomain() != config.DefaultAppDomain {
t.Fatalf("empty registry app domain = %q", empty.AppDomain())
}
}
func TestForHostResolvesTheTenantOfTheHost(t *testing.T) {
registry, err := New(config.GoLooseConfig{
AuthDomain: config.DefaultAuthDomain,
AppDomain: config.DefaultAppDomain,
Tenants: tenants(),
})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
host string
slug string
ok bool
}{
{host: "nmbs.guess-dev.urpi.be", slug: "nmbs", ok: true},
{host: "ypto.guess-dev.urpi.be", slug: "ypto", ok: true},
{host: "NMBS.Guess-Dev.Urpi.Be:8443", slug: "nmbs", ok: true},
// The apex host identifies no tenant.
{host: "guess-dev.urpi.be", slug: "", ok: false},
// Another environment's domain has no configured Go Loose client here.
{host: "nmbs.guess.urpi.be", slug: "", ok: false},
{host: "guess.urpi.be", slug: "", ok: false},
{host: "localhost:5173", slug: "", ok: false},
{host: "", slug: "", ok: false},
} {
auth, slug, ok := registry.ForHost(tc.host)
if slug != tc.slug || ok != tc.ok {
t.Fatalf("ForHost(%q) = %q, %v, want %q, %v", tc.host, slug, ok, tc.slug, tc.ok)
}
if ok && auth == nil {
t.Fatalf("ForHost(%q) returned no client", tc.host)
}
}
}
func TestHostPrefersTheForwardedHost(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
request.Host = "api:8080"
if Host(request) != "api:8080" {
t.Fatalf("host = %q", Host(request))
}
request.Header.Set("X-Forwarded-Host", "nmbs.guess-dev.urpi.be")
if Host(request) != "nmbs.guess-dev.urpi.be" {
t.Fatalf("forwarded host = %q", Host(request))
}
}
func TestTenantFromHost(t *testing.T) {
for _, tc := range []struct {
host string
want string
}{
{host: "nmbs.guess-dev.urpi.be", want: "nmbs"},
{host: "ypto.guess-dev.urpi.be:443", want: "ypto"},
{host: "nmbs.guess-dev.urpi.be.", want: "nmbs"},
{host: "deep.nmbs.guess-dev.urpi.be", want: ""},
{host: "guess-dev.urpi.be", want: ""},
{host: "nmbs.evil.test", want: ""},
} {
if got := TenantFromHost(tc.host, config.DefaultAppDomain); got != tc.want {
t.Fatalf("TenantFromHost(%q) = %q, want %q", tc.host, got, tc.want)
}
}
}
func loginRedirect(t *testing.T, registry *Registry, host string) string {
t.Helper()
auth, _, ok := registry.ForHost(host)
if !ok {
t.Fatalf("no Go Loose client for %q", host)
}
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = host
response := httptest.NewRecorder()
auth.LoginHandler(response, request)
if response.Code != http.StatusFound {
t.Fatalf("login for %q = %d, want 302", host, response.Code)
}
return response.Header().Get("Location")
}

View file

@ -8,10 +8,6 @@ type User struct {
PasswordHash string
DisplayName string
Role string
// AvatarURL is the picture Go Loose serves for this person. It is resolved
// from the identity provider on every sign-in rather than stored, because a
// stored key stops resolving once the picture is replaced or removed.
AvatarURL string
}
type Trait struct {

View file

@ -28,7 +28,7 @@ const CandidateThreshold = 60.0
type IAuthService interface {
Login(context.Context, string, string) (string, model.User, error)
EstablishExternalSession(context.Context, ExternalIdentity) (string, model.User, error)
EstablishExternalSession(context.Context, string, string) (string, model.User, error)
}
type IJobService interface {
@ -89,19 +89,9 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
return token, user, nil
}
// ExternalIdentity is what Go Loose reports about the person signing in. The
// avatar URL is deliberately not persisted: Go Loose retires a picture key when
// the picture changes, so a stored copy would eventually point at nothing. It is
// read from the live identity on every session instead.
type ExternalIdentity struct {
Email string
DisplayName string
AvatarURL string
}
func (s *AuthService) EstablishExternalSession(ctx context.Context, identity ExternalIdentity) (string, model.User, error) {
email := strings.ToLower(strings.TrimSpace(identity.Email))
displayName := strings.TrimSpace(identity.DisplayName)
func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displayName string) (string, model.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
displayName = strings.TrimSpace(displayName)
if email == "" {
return "", model.User{}, ErrValidation
}
@ -126,7 +116,6 @@ func (s *AuthService) EstablishExternalSession(ctx context.Context, identity Ext
return "", model.User{}, err
}
user.PasswordHash = ""
user.AvatarURL = strings.TrimSpace(identity.AvatarURL)
return token, user, nil
}

View file

@ -653,10 +653,8 @@ func (h *Handler) frontendURLForRequest(r *http.Request) string {
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return h.frontendURL
}
// Only trust the Origin when it is the browser origin of the requesting
// tenant, so an invitation link cannot be pointed at another host.
if strings.EqualFold(parsed.Hostname(), tenant+"."+h.AppDomain()) ||
strings.EqualFold(parsed.Hostname(), tenant+".guess.local") {
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") ||
strings.EqualFold(parsed.Hostname(), tenant+".guess.dev") {
return origin
}
return h.frontendURL

View file

@ -1,22 +1,17 @@
package handler
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/danyel/go-guess/backend/internal/config"
"github.com/danyel/go-guess/backend/internal/golooseauth"
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
goloose "github.com/danyel/go-loose/client"
"github.com/danyel/go-loose/client"
)
func TestFrontendURLForTenantRequest(t *testing.T) {
handler := &Handler{frontendURL: "http://localhost:5173"}
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "http://ypto.guess.local:5173")
request.Header.Set(goloose.XTenantId, "ypto")
request.Header.Set(client.XTenantId, "ypto")
if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" {
t.Fatalf("unexpected frontend URL %q", value)
@ -27,7 +22,7 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
handler := &Handler{frontendURL: "http://localhost:5173"}
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "http://nmbs.guess.local:5173")
request.Header.Set(goloose.XTenantId, "ypto")
request.Header.Set(client.XTenantId, "ypto")
if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
t.Fatalf("expected configured frontend URL, got %q", value)
@ -37,87 +32,10 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
func TestFrontendURLAcceptsDevTenantOrigin(t *testing.T) {
handler := &Handler{frontendURL: "http://localhost:5173"}
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "https://ypto."+config.DefaultAppDomain)
request.Header.Set(goloose.XTenantId, "ypto")
request.Header.Set("Origin", "https://ypto.guess.dev")
request.Header.Set(client.XTenantId, "ypto")
if value := handler.frontendURLForRequest(request); value != "https://ypto."+config.DefaultAppDomain {
if value := handler.frontendURLForRequest(request); value != "https://ypto.guess.dev" {
t.Fatalf("expected tenant dev origin, got %q", value)
}
}
func TestFrontendURLAcceptsConfiguredProductionDomain(t *testing.T) {
browser, err := golooseauth.New(config.GoLooseConfig{
AuthDomain: config.DefaultProdAuthDomain,
AppDomain: config.DefaultProdAppDomain,
Tenants: map[string]config.GoLooseTenant{
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
},
})
if err != nil {
t.Fatal(err)
}
handler := &Handler{frontendURL: "https://nmbs.guess.urpi.be"}
handler.SetBrowserAuth(browser)
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "https://nmbs."+config.DefaultProdAppDomain)
request.Header.Set(goloose.XTenantId, "nmbs")
if value := handler.frontendURLForRequest(request); value != "https://nmbs."+config.DefaultProdAppDomain {
t.Fatalf("expected tenant production origin, got %q", value)
}
// The development domain is not the configured app domain in production.
request = httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "https://nmbs."+config.DefaultAppDomain)
request.Header.Set(goloose.XTenantId, "nmbs")
if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
t.Fatalf("expected configured frontend URL, got %q", value)
}
}
// TestExternalIdentityCarriesThePicture pins that the picture Go Loose serves is
// handed to the service and ends up in the session payload. The URL is resolved on
// every sign-in rather than stored, so it must survive the whole way through
// rather than being dropped at the first boundary.
func TestExternalIdentityCarriesThePicture(t *testing.T) {
identity := externalIdentity(goloose.User{
Email: "ada@example.test",
DisplayName: "Ada Lovelace",
AvatarURL: "https://auth.example.test/api/v1/avatars/kZ3abc",
Role: "developer",
})
if identity.Email != "ada@example.test" {
t.Errorf("email = %q", identity.Email)
}
if identity.DisplayName != "Ada Lovelace" {
t.Errorf("display name = %q", identity.DisplayName)
}
if identity.AvatarURL != "https://auth.example.test/api/v1/avatars/kZ3abc" {
t.Errorf("avatar URL = %q", identity.AvatarURL)
}
// A person without a picture yields an empty URL rather than a broken one, so
// the frontend can fall back to initials.
without := externalIdentity(goloose.User{Email: "bob@example.test"})
if without.AvatarURL != "" {
t.Errorf("avatar URL = %q, want empty", without.AvatarURL)
}
}
// TestUserToWebIncludesTheAvatar pins the JSON field name the frontend reads.
func TestUserToWebIncludesTheAvatar(t *testing.T) {
encoded, err := json.Marshal(webmapper.UserToWeb(servicemodel.User{
ID: 7, Email: "ada@example.test", DisplayName: "Ada", Role: "interviewer",
AvatarURL: "https://auth.example.test/api/v1/avatars/kZ3abc",
}))
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatal(err)
}
if payload["avatarUrl"] != "https://auth.example.test/api/v1/avatars/kZ3abc" {
t.Errorf("avatarUrl = %v, payload %s", payload["avatarUrl"], encoded)
}
}

View file

@ -2,14 +2,11 @@ package handler
import (
"errors"
"fmt"
"net/http"
goloose "github.com/danyel/go-loose/client"
"github.com/danyel/go-guess/backend/internal/config"
"github.com/danyel/go-guess/backend/internal/golooseauth"
"github.com/danyel/go-guess/backend/internal/service"
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
)
@ -17,8 +14,6 @@ import (
var (
errMissingSession = errors.New("missing Go Loose session")
errTenantMismatch = errors.New("Go Loose session does not match this tenant")
errLoginDisabled = errors.New("Go Loose browser login is not enabled")
errNoTenantHost = errors.New("this host does not identify a configured tenant")
)
func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) {
@ -27,33 +22,33 @@ func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) {
func (h *Handler) AppDomain() string {
if h.browser == nil {
return config.DefaultAppDomain
return "guess.dev"
}
return h.browser.AppDomain()
}
func (h *Handler) BeginExternalLogin(w http.ResponseWriter, r *http.Request) {
auth, slug, ok := h.externalAuthWithSlug(r)
auth, ok := h.externalAuth(r)
if !ok {
writeExternalAuthError(w, h, slug)
http.NotFound(w, r)
return
}
auth.LoginHandler(w, r)
}
func (h *Handler) CompleteExternalLogin(w http.ResponseWriter, r *http.Request) {
auth, slug, ok := h.externalAuthWithSlug(r)
auth, ok := h.externalAuth(r)
if !ok {
writeExternalAuthError(w, h, slug)
http.NotFound(w, r)
return
}
auth.CallbackHandler(w, r)
}
func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) {
auth, slug, ok := h.externalAuthWithSlug(r)
auth, ok := h.externalAuth(r)
if !ok {
writeExternalAuthError(w, h, slug)
http.NotFound(w, r)
return
}
auth.LogoutHandler(w, r)
@ -62,7 +57,7 @@ func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
auth, slug, ok := h.externalAuthWithSlug(r)
if !ok {
writeExternalAuthError(w, h, slug)
http.NotFound(w, r)
return
}
if _, err := r.Cookie(golooseauth.SessionCookie); err != nil {
@ -75,7 +70,7 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, errTenantMismatch)
return
}
token, local, err := h.auth.EstablishExternalSession(r.Context(), externalIdentity(user))
token, local, err := h.auth.EstablishExternalSession(r.Context(), user.Email, user.DisplayName)
if err != nil {
internalError(w, r, err)
return
@ -84,17 +79,6 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
})).ServeHTTP(w, r)
}
// externalIdentity is what Go Loose reports about the person signing in. The
// picture URL is carried through rather than looked up: Go Loose serves it
// publicly, so the browser can render it without another round trip.
func externalIdentity(user goloose.User) service.ExternalIdentity {
return service.ExternalIdentity{
Email: user.Email,
DisplayName: user.DisplayName,
AvatarURL: user.AvatarURL,
}
}
func (h *Handler) externalAuth(r *http.Request) (*goloose.BrowserAuth, bool) {
auth, _, ok := h.externalAuthWithSlug(r)
return auth, ok
@ -104,19 +88,5 @@ func (h *Handler) externalAuthWithSlug(r *http.Request) (*goloose.BrowserAuth, s
if h.browser == nil {
return nil, "", false
}
return h.browser.ForHost(golooseauth.Host(r))
}
func writeExternalAuthError(w http.ResponseWriter, h *Handler, slug string) {
if h.browser == nil {
writeError(w, http.StatusNotFound, errLoginDisabled)
return
}
if slug == "" {
writeError(w, http.StatusNotFound, errNoTenantHost)
return
}
idEnv, secretEnv := config.GoLooseCredentialEnv(slug)
writeError(w, http.StatusServiceUnavailable,
fmt.Errorf("Go Loose login is not configured for tenant %q: set %s and %s, then restart the API", slug, idEnv, secretEnv))
return h.browser.ForHost(r.Host)
}

View file

@ -94,7 +94,6 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel
func UserToWeb(value servicemodel.User) webmodel.User {
return webmodel.User{
ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role,
AvatarURL: value.AvatarURL,
}
}

View file

@ -11,18 +11,11 @@ import (
"sync"
"time"
"github.com/danyel/go-guess/backend/internal/config"
"github.com/danyel/go-loose/client"
)
const guessApplication = "guess"
// defaultBaseURLPattern is the Go Loose identity domain used when
// GO_LOOSE_BASE_URL is unset.
func defaultBaseURLPattern() string {
return "https://%s." + config.DefaultAuthDomain
}
// A tenant used in the Go Loose host must be one DNS label.
var safeTenantLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62})?$`)
@ -30,12 +23,11 @@ var safeTenantLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62})?$`
// from the X-Tenant-Id header, not from process configuration.
//
// GO_LOOSE_BASE_URL may contain one %s placeholder for that tenant. The
// default is the development identity domain, https://%s.auth-dev.urpi.be.
// A URL without %s is used as-is.
// default is https://%s.auth.dev. A URL without %s is used as-is.
func GoLooseMiddleware() func(http.Handler) http.Handler {
pattern := os.Getenv("GO_LOOSE_BASE_URL")
if pattern == "" {
pattern = defaultBaseURLPattern()
pattern = "https://%s.auth.dev"
}
httpClient := &http.Client{Timeout: 2 * time.Second}
var (

View file

@ -7,7 +7,6 @@ import (
"net/http/httptest"
"testing"
"github.com/danyel/go-guess/backend/internal/config"
"github.com/danyel/go-loose/client"
)
@ -80,22 +79,8 @@ func TestGoLooseMiddlewareRejectsMissingTenantAndKey(t *testing.T) {
}
}
func TestGoLooseMiddlewareDefaultsToTheDevelopmentIdentityDomain(t *testing.T) {
t.Setenv("GO_LOOSE_BASE_URL", "")
baseURL, err := goLooseBaseURL(defaultBaseURLPattern(), "nmbs")
if err != nil {
t.Fatal(err)
}
if baseURL != "https://nmbs."+config.DefaultAuthDomain {
t.Fatalf("default base URL = %q", baseURL)
}
if defaultBaseURLPattern() != "https://%s.auth-dev.urpi.be" {
t.Fatalf("default pattern = %q", defaultBaseURLPattern())
}
}
func TestGoLooseMiddlewareRejectsUnsafeTenantInHost(t *testing.T) {
t.Setenv("GO_LOOSE_BASE_URL", "https://%s.auth-dev.urpi.be")
t.Setenv("GO_LOOSE_BASE_URL", "https://%s.auth.dev")
handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler reached for unsafe tenant")
}))

View file

@ -28,7 +28,7 @@ func TenantSchemaMiddleware(db *gorm.DB, appDomain string) func(http.Handler) ht
}
tenantID := r.Header.Get(client.XTenantId)
if tenantID == "" {
tenantID = golooseauth.TenantFromHost(golooseauth.Host(r), appDomain)
tenantID = golooseauth.TenantFromHost(r.Host, appDomain)
}
if tenantID == "" || db == nil {
next.ServeHTTP(w, r)

View file

@ -21,9 +21,6 @@ type User struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
// AvatarURL is an absolute, publicly fetchable picture URL served by Go Loose,
// or an empty string when the person has not uploaded one.
AvatarURL string `json:"avatarUrl"`
}
type CreateUserRequest struct {

View file

@ -21,7 +21,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
router := chi.NewRouter()
spec := openapi.New()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
router.Use(cors(frontendURL, h.AppDomain()))
router.Use(cors(frontendURL))
router.Use(mw.TenantSchemaMiddleware(h.Db(), h.AppDomain()))
router.Use(middleware.Timeout(2 * time.Minute))
register(router, spec, false, http.MethodGet, "/api/openapi.json", spec.ServeHTTP,
@ -164,11 +164,11 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler
}
}
func cors(origin, appDomain string) 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) {
requestOrigin := r.Header.Get("Origin")
if allowedCORSOrigin(requestOrigin, origin, appDomain) {
if allowedCORSOrigin(requestOrigin, origin) {
w.Header().Set("Access-Control-Allow-Origin", requestOrigin)
}
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Tenant-Id")
@ -183,11 +183,7 @@ func cors(origin, appDomain string) func(http.Handler) http.Handler {
}
}
// allowedCORSOrigin accepts the configured frontend origin plus any tenant host
// of the configured application domain, which is what the Vite dev server on
// http://<tenant>.<appDomain>:5173 sends. *.guess.local stays allowed for the
// offline BDD and local password-only setups.
func allowedCORSOrigin(requestOrigin, configuredOrigin, appDomain string) bool {
func allowedCORSOrigin(requestOrigin, configuredOrigin string) bool {
if requestOrigin == "" {
return false
}
@ -198,11 +194,5 @@ func allowedCORSOrigin(requestOrigin, configuredOrigin, appDomain string) bool {
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return false
}
hostname := strings.ToLower(parsed.Hostname())
for _, suffix := range []string{"." + appDomain, ".guess.local"} {
if suffix != "." && strings.HasSuffix(hostname, suffix) {
return true
}
}
return false
return strings.HasSuffix(strings.ToLower(parsed.Hostname()), ".guess.local")
}

View file

@ -69,7 +69,7 @@ func (authStub) Login(context.Context, string, string) (string, model.User, erro
return "", model.User{}, service.ErrInvalidCredentials
}
func (authStub) EstablishExternalSession(context.Context, service.ExternalIdentity) (string, model.User, error) {
func (authStub) EstablishExternalSession(context.Context, string, string) (string, model.User, error) {
return "", model.User{}, service.ErrInvalidCredentials
}
@ -313,8 +313,8 @@ func TestOpenAPIDoesNotRequireTenant(t *testing.T) {
func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
h := newTestHandler(t, nil)
browser, err := golooseauth.New(config.GoLooseConfig{
AuthDomain: config.DefaultAuthDomain,
AppDomain: config.DefaultAppDomain,
AuthDomain: "auth.dev",
AppDomain: "guess.dev",
Tenants: map[string]config.GoLooseTenant{
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
"ypto": {ClientID: "glc_ypto", ClientSecret: "secret"},
@ -328,184 +328,19 @@ func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
for _, slug := range []string{"nmbs", "ypto"} {
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = slug + ".guess-dev.urpi.be"
request.Host = slug + ".guess.dev"
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
location := response.Header().Get("Location")
if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://"+slug+".auth-dev.urpi.be/connect/authorize?") {
if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://"+slug+".auth.dev/connect/authorize?") {
t.Fatalf("%s login redirect = %d %s", slug, response.Code, location)
}
if !strings.Contains(location, "redirect_uri=https%3A%2F%2F"+slug+".guess-dev.urpi.be%2Fapi%2Fauth%2Fcallback") {
if !strings.Contains(location, "redirect_uri=https%3A%2F%2F"+slug+".guess.dev%2Fapi%2Fauth%2Fcallback") {
t.Fatalf("%s callback missing from %s", slug, location)
}
}
}
func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) {
h := newTestHandler(t, nil)
browser, err := golooseauth.New(config.GoLooseConfig{
AuthDomain: config.DefaultAuthDomain,
AppDomain: config.DefaultAppDomain,
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
})
if err != nil {
t.Fatal(err)
}
h.SetBrowserAuth(browser)
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = "ypto.guess-dev.urpi.be"
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable ||
!strings.Contains(response.Body.String(), "GO_LOOSE_YPTO_CLIENT_ID") {
t.Fatalf("ypto login = %d %s", response.Code, response.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = "guess-dev.urpi.be"
response = httptest.NewRecorder()
api.ServeHTTP(response, request)
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "tenant") {
t.Fatalf("apex login = %d %s", response.Code, response.Body.String())
}
}
func TestLoginWithoutGoLooseRegistryIsNotFound(t *testing.T) {
api := New(
newTestHandler(t, nil),
security.NewTokenManager("01234567890123456789012345678901", time.Hour),
"http://localhost:5173",
)
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = "nmbs.guess-dev.urpi.be"
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "not enabled") {
t.Fatalf("login without registry = %d %s", response.Code, response.Body.String())
}
}
// The frontend falls back to password sign-in only when it can tell that Go
// Loose login is unavailable, so a host without a registry must answer 404
// rather than 401 even when the request carries no session cookie.
func TestSessionWithoutGoLooseRegistryIsNotFound(t *testing.T) {
api := New(
newTestHandler(t, nil),
security.NewTokenManager("01234567890123456789012345678901", time.Hour),
"http://localhost:5173",
)
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
request.Host = "nmbs.guess-dev.urpi.be"
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "not enabled") {
t.Fatalf("session without registry = %d %s", response.Code, response.Body.String())
}
}
func TestSessionOnAnUnconfiguredTenantHostIsNotFound(t *testing.T) {
h := newTestHandler(t, nil)
browser, err := golooseauth.New(config.GoLooseConfig{
AuthDomain: config.DefaultAuthDomain,
AppDomain: config.DefaultAppDomain,
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
})
if err != nil {
t.Fatal(err)
}
h.SetBrowserAuth(browser)
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
request.Host = "ypto.guess-dev.urpi.be"
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable ||
!strings.Contains(response.Body.String(), "GO_LOOSE_YPTO_CLIENT_ID") {
t.Fatalf("unconfigured tenant session = %d %s", response.Code, response.Body.String())
}
}
func TestSessionWithoutCookieIsUnauthorizedOnATenantHost(t *testing.T) {
h := newTestHandler(t, nil)
browser, err := golooseauth.New(config.GoLooseConfig{
AuthDomain: config.DefaultAuthDomain,
AppDomain: config.DefaultAppDomain,
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
})
if err != nil {
t.Fatal(err)
}
h.SetBrowserAuth(browser)
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
request.Host = "nmbs.guess-dev.urpi.be"
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || !strings.Contains(response.Body.String(), "missing Go Loose session") {
t.Fatalf("session without cookie = %d %s", response.Code, response.Body.String())
}
}
// An ingress proxy may replace the request Host, so the tenant has to be read
// from X-Forwarded-Host when it is present.
func TestTenantHostLoginHonorsTheForwardedHost(t *testing.T) {
h := newTestHandler(t, nil)
browser, err := golooseauth.New(config.GoLooseConfig{
AuthDomain: config.DefaultProdAuthDomain,
AppDomain: config.DefaultProdAppDomain,
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
})
if err != nil {
t.Fatal(err)
}
h.SetBrowserAuth(browser)
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "https://nmbs.guess.urpi.be")
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = "go-guess-api.go-insane-production.svc.cluster.local:8080"
request.Header.Set("X-Forwarded-Host", "nmbs.guess.urpi.be")
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
location := response.Header().Get("Location")
if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://nmbs.auth.urpi.be/connect/authorize?") {
t.Fatalf("forwarded host login redirect = %d %s", response.Code, location)
}
if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess.urpi.be%2Fapi%2Fauth%2Fcallback") {
t.Fatalf("forwarded host callback missing from %s", location)
}
}
func TestCORSAllowsTheConfiguredTenantOrigin(t *testing.T) {
api := New(
newTestHandler(t, nil),
security.NewTokenManager("01234567890123456789012345678901", time.Hour),
"https://nmbs.guess.urpi.be",
)
for _, tc := range []struct {
origin string
allow string
}{
{origin: "https://nmbs.guess.urpi.be", allow: "https://nmbs.guess.urpi.be"},
{origin: "http://nmbs.guess-dev.urpi.be:5173", allow: "http://nmbs.guess-dev.urpi.be:5173"},
{origin: "http://ypto.guess.local:5173", allow: "http://ypto.guess.local:5173"},
{origin: "https://evil.test", allow: ""},
{origin: "https://nmbs.guess.urpi.be.evil.test", allow: ""},
} {
request := httptest.NewRequest(http.MethodOptions, "/api/jobs", nil)
request.Header.Set("Origin", tc.origin)
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
if got := response.Header().Get("Access-Control-Allow-Origin"); got != tc.allow {
t.Fatalf("CORS allow origin for %q = %q, want %q", tc.origin, got, tc.allow)
}
}
}
type stubDriver struct{}
func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil }

View file

@ -1,6 +1,6 @@
apiVersion: v2
name: go-guess
description: Go Guess recruitment workspace with per-tenant web hosts
description: Go Guess recruitment workspace
type: application
version: 0.3.0
appVersion: "0.3.0"
version: 0.2.0
appVersion: "0.2.0"

View file

@ -1,272 +0,0 @@
# Go Guess Helm chart
This chart deploys Go Guess as two images, an API and a web front end, plus its
own PostgreSQL and RabbitMQ, and publishes one HTTPS host per tenant under
`https://<tenant>.guess.urpi.be`.
## 1. What the chart creates
| Resource | Name | Purpose |
|---|---|---|
| Deployment | `go-guess-api` | Go API, runs migrations and seeds before serving |
| Deployment | `go-guess-web` | nginx serving the built React application |
| Service | `api` | Cluster-internal API, proxied by the web image |
| Service | `go-guess-web` | Cluster-internal web front end, ingress backend |
| Ingress | `go-guess` | One rule and one TLS SAN per tenant host |
| Issuer | `go-guess-selfsigned` | cert-manager self-signed issuer |
| Certificate | `go-guess-web-ca` | Chart CA that signs the leaf certificate |
| Certificate | `go-guess-web` | Leaf certificate covering every tenant host |
| StatefulSet | `go-guess-postgresql` | PostgreSQL, `emptyDir` or persistent volume |
| StatefulSet | `go-guess-rabbitmq` | RabbitMQ, `emptyDir` or persistent volume |
| Secret | `go-guess-secrets` | JWT secret, connection URLs, broker credentials |
A single web deployment serves every tenant host. The API resolves the tenant
from the request `Host` header, so adding a tenant means adding one entry to
`tenants`, not deploying another stack.
## 2. Prerequisites
- A Kubernetes cluster reachable through `KUBECONFIG`.
- A `StorageClass` when `postgresql.persistence.enabled` or
`rabbitmq.persistence.enabled` is `true`.
- ingress-nginx with the `nginx` ingress class: `make ingress-nginx-install`.
- cert-manager when `ingress.tls.enabled` is `true`: `make cert-manager-install`.
- Cluster nodes trusting `batty1039.startdedicated.net:5000` as an insecure
registry, or an HTTPS registry.
Run everything the chart depends on in one step:
```bash
make cluster-prepare
```
## 3. Deploy
```bash
make helm-lint
make helm-deploy-development
```
The development profile uses `latest` images, development fixtures, ephemeral
storage, and namespace `go-insane-development`. Production uses released tags,
production fixtures, persistent volumes, and namespace `go-insane-production`:
```bash
make helm-deploy-production
```
Both targets accept per-tenant Go Loose credentials from the environment:
```bash
export GO_LOOSE_NMBS_CLIENT_ID=...
export GO_LOOSE_NMBS_CLIENT_SECRET=...
export GO_LOOSE_YPTO_CLIENT_ID=...
export GO_LOOSE_YPTO_CLIENT_SECRET=...
make helm-deploy-production
```
After a successful install the release notes list every tenant URL and the
command that exports the CA certificate. With the local platform, prefer
`docs/KUBERNETES-DEPLOYMENTS.md` in the infrastructure repository, which covers
`/etc/hosts` entries and CA trust for the whole platform.
## 4. Tenants and hosts
`tenants` is the list of slugs and `domain` is the shared parent domain:
```yaml
domain: guess.urpi.be
tenants:
- nmbs
- ypto
```
The chart derives, without any extra configuration:
- `GO_LOOSE_APP_DOMAIN=<domain>`, so the API accepts `nmbs.<domain>` and
`ypto.<domain>` as tenant hosts.
- One ingress rule per tenant, all pointing at `go-guess-web`.
- One certificate SAN per tenant, so a single secret covers every host.
- `FRONTEND_URL`, when `api.frontendURL` is empty, as
`https://<first tenant>.<domain>`. This is the fallback used to build
invitation and meeting links, so the first entry is the tenant that receives
shareable links.
A slug must be a single DNS label. The API rejects slugs containing a dot, and
the certificate would not match a two-level host.
Adding a tenant therefore requires three things: the slug in `tenants`, a DNS
record pointing at the ingress controller, and a Go Loose client when browser
sign-in is used.
## 5. Values
### Top level
| Key | Default | Description |
|---|---|---|
| `nameOverride` | `""` | Overrides the chart name in resource names and labels |
| `fullnameOverride` | `""` | Replaces the generated resource name prefix |
| `domain` | `guess.urpi.be` | Parent domain of every tenant host. `guess-dev.urpi.be` in the development profile |
| `tenants` | `[nmbs, ypto]` | Tenant slugs served by the release |
| `imagePullSecrets` | `[]` | Pull secrets added to both pods |
### api
| Key | Default | Description |
|---|---|---|
| `api.replicaCount` | `1` | API replicas, keep at `1` while migrations run on startup |
| `api.image.repository` | `batty1039.startdedicated.net:5000/go-guess-api` | API image |
| `api.image.tag` | `0.0.1` | API image tag |
| `api.image.pullPolicy` | `IfNotPresent` | API pull policy |
| `api.environment` | `development` | `APP_ENV`, `production` seeds production fixtures only |
| `api.frontendURL` | `""` | Fallback public URL for invitation and meeting links |
| `api.tokenTTL` | `8h` | `TOKEN_TTL` for issued API tokens |
| `api.maxUploadMB` | `10` | `MAX_UPLOAD_MB` upload limit, match `proxy-body-size` |
| `api.service.name` | `api` | Service DNS name, fixed by the web image |
| `api.service.type` | `ClusterIP` | API service type |
| `api.service.port` | `8080` | API service port |
| `api.goLoose.enabled` | `true` | Enables Go Loose browser sign-in |
| `api.goLoose.authDomain` | `auth.urpi.be` | Tenant login host `https://<tenant>.<authDomain>`. `auth-dev.urpi.be` in the development profile |
| `api.goLoose.ca.existingSecret` | `""` | Secret holding the Go Loose CA bundle |
| `api.goLoose.ca.key` | `ca.crt` | Key projected from that secret |
| `api.goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target |
| `api.goLoose.tenants.<slug>.clientID` | `""` | Go Loose client ID for that tenant |
| `api.goLoose.tenants.<slug>.clientSecret` | `""` | Go Loose client secret for that tenant |
| `api.resources` | `50m`/`128Mi` to `500m`/`512Mi` | API requests and limits |
| `api.podAnnotations`, `api.podLabels` | `{}` | Extra pod metadata |
| `api.podSecurityContext` | `{}` | Pod security context |
| `api.securityContext` | non-root, read-only root filesystem | Container security context |
| `api.strategy.type` | `Recreate` | Old pod exits before migrations run again |
### web
| Key | Default | Description |
|---|---|---|
| `web.replicaCount` | `2` | nginx replicas, safe to scale because nginx is stateless |
| `web.image.repository` | `batty1039.startdedicated.net:5000/go-guess-web` | Web image |
| `web.image.tag` | `0.0.1` | Web image tag |
| `web.image.pullPolicy` | `IfNotPresent` | Web pull policy |
| `web.service.type` | `ClusterIP` | Web service type |
| `web.service.port` | `80` | Web service port |
| `web.service.nodePort` | `null` | Node port when the type is `NodePort` |
| `web.resources` | `25m`/`32Mi` to `300m`/`128Mi` | Web requests and limits |
| `web.podAnnotations`, `web.podLabels` | `{}` | Extra pod metadata |
| `web.podSecurityContext` | `{}` | Pod security context |
| `web.securityContext` | root, `NET_BIND_SERVICE` only | Container security context |
| `web.strategy` | rolling, `maxUnavailable: 0` | Keeps the ingress backed up during rollout |
Neither container has a forced uid. Both images declare their own user, the API
as `app` and nginx as `nginx`, and `runAsNonRoot` validates it.
The web security context differs from the API one on purpose. nginx binds the
privileged port `80` and forks workers as the `nginx` user, so the pod runs as
root with `CHOWN`, `SETGID`, `SETUID`, and `NET_BIND_SERVICE`, and mounts
writable `emptyDir` volumes at `/var/cache/nginx`, `/var/run`, and `/tmp`.
### ingress
| Key | Default | Description |
|---|---|---|
| `ingress.enabled` | `true` | Creates the multi-host ingress |
| `ingress.className` | `nginx` | Ingress class of the controller |
| `ingress.annotations` | body size `10m`, timeouts `3600`, buffering off, TLS redirect | Controller annotations |
| `ingress.path` | `/` | Path routed to the web service |
| `ingress.pathType` | `Prefix` | Path type of that route |
| `ingress.tls.enabled` | `true` | Terminates TLS on the controller |
| `ingress.tls.secretName` | `""` | Defaults to `<fullname>-web-tls` |
| `ingress.tls.existingSecret` | `""` | Uses a certificate managed outside the chart |
The proxy annotations matter for the interview feature: the API streams events
over server-sent events and accepts video uploads, so buffering off, a one-hour
read timeout, and a body size at least as large as `api.maxUploadMB` are
required, otherwise streams stall and uploads fail with `413`.
### certManager
| Key | Default | Description |
|---|---|---|
| `certManager.enabled` | `true` | Chart creates the certificates |
| `certManager.issuerRef` | `{}` | Uses an existing issuer instead of the chart CA |
| `certManager.duration` | `8760h` | Certificate lifetime |
| `certManager.renewBefore` | `720h` | Renewal window |
| `certManager.caSecretName` | `""` | Defaults to `<fullname>-web-ca` |
Three certificate objects are created in order: a self-signed `Issuer`, a CA
`Certificate` stored in `caSecretName`, and the leaf `Certificate` stored in the
ingress TLS secret with one `dnsNames` entry per tenant. Set `issuerRef` to sign
with a cluster issuer instead, for example an internal ACME issuer:
```yaml
certManager:
issuerRef:
name: internal-acme
kind: ClusterIssuer
group: cert-manager.io
```
Set `ingress.tls.existingSecret` to use a certificate the chart does not own,
and `certManager.enabled: false` to stop emitting cert-manager objects.
### Secrets, storage, and scheduling
| Key | Default | Description |
|---|---|---|
| `secrets.jwtSecret` | `""` | Generated and preserved across upgrades when empty |
| `secrets.databaseURL` | derived | Full URL, required when PostgreSQL is disabled |
| `secrets.rabbitmqURL` | derived | Full URL, required when RabbitMQ is disabled |
| `secrets.postgresqlPassword` | generated | PostgreSQL user password |
| `secrets.rabbitmqPassword` | generated | RabbitMQ user password |
| `postgresql.enabled` | `true` | Bundled PostgreSQL |
| `postgresql.persistence.enabled` | `true` | Persistent volume instead of `emptyDir` |
| `postgresql.persistence.size` | `8Gi` | Volume size |
| `postgresql.auth.username` | `go_guess` | Database user |
| `rabbitmq.enabled` | `true` | Bundled RabbitMQ |
| `rabbitmq.persistence.enabled` | `true` | Persistent volume instead of `emptyDir` |
| `rabbitmq.persistence.size` | `4Gi` | Volume size |
| `nodeSelector`, `tolerations`, `affinity` | empty | Applied to both deployments |
Generated secrets are read back with `lookup` on upgrade, so leaving them empty
rotates nothing and keeps sessions valid across releases.
## 6. External services
Disable the bundled StatefulSets and pass full connection URLs to use managed
PostgreSQL and RabbitMQ:
```bash
helm upgrade --install go-guess deploy/helm/go-guess \
--namespace go-insane-production --create-namespace \
-f deploy/helm/go-guess/values-production.yaml \
--set postgresql.enabled=false \
--set rabbitmq.enabled=false \
--set-string secrets.databaseURL="$DATABASE_URL" \
--set-string secrets.rabbitmqURL="$RABBITMQ_URL"
```
## 7. Upgrade and remove
```bash
make helm-deploy-production
helm -n go-insane-production history go-guess
helm -n go-insane-production rollback go-guess 1
make helm-down-production
```
Uninstalling removes the release secrets, so store `secrets.jwtSecret` and the
two connection URLs outside the cluster before deleting a release whose database
must survive.
## 8. Verify
```bash
kubectl -n go-insane-production get ingress go-guess
kubectl -n go-insane-production get certificate
kubectl -n go-insane-production rollout status deployment/go-guess-api
kubectl -n go-insane-production rollout status deployment/go-guess-web
curl --resolve nmbs.guess.urpi.be:443:127.0.0.1 --cacert go-guess-ca.crt \
https://nmbs.guess.urpi.be/api/health
```
The test pod installed by `helm test` checks the API health endpoint and the web
root from inside the cluster.

View file

@ -1,48 +1,17 @@
Go Guess is installed in namespace {{ .Release.Namespace }}.
Components:
API service/{{ include "go-guess.apiServiceName" . }}:{{ .Values.api.service.port }} (cluster-internal)
Web service/{{ include "go-guess.webDeploymentName" . }}:{{ .Values.web.service.port }}
Ingress {{ if .Values.ingress.enabled }}class {{ if .Values.ingress.className }}{{ .Values.ingress.className }}{{ else }}default{{ end }}{{ else }}disabled{{ end }}
{{ if .Values.ingress.enabled }}
Tenant URLs:
{{- range .Values.tenants }}
{{ if $.Values.ingress.tls.enabled }}https{{ else }}http{{ end }}://{{ printf "%s.%s" . $.Values.domain }}
{{- end }}
Point every tenant host at the ingress controller address, for example in
/etc/hosts:
{{- range .Values.tenants }}
<ingress-controller-ip> {{ printf "%s.%s" . $.Values.domain }}
{{- end }}
{{- if and .Values.ingress.tls.enabled .Values.certManager.enabled (not .Values.ingress.tls.existingSecret) }}
The certificate is self-signed, so trust the chart CA in every browser or client
that opens these hosts:
kubectl -n {{ .Release.Namespace }} get secret {{ include "go-guess.certManagerCASecretName" . }} \
-o jsonpath='{.data.ca\.crt}' | base64 -d > go-guess-ca.crt
{{- end }}
{{- if .Values.api.goLoose.enabled }}
Browser sign-in redirects to https://<tenant>.{{ .Values.api.goLoose.authDomain }}:
{{- range $tenant, $credentials := .Values.api.goLoose.tenants }}
{{- if and $credentials.clientID $credentials.clientSecret }}
{{ $tenant }}: Go Loose browser sign-in
Go Guess has been installed in namespace {{ .Release.Namespace }}.
{{- if .Values.goLoose.enabled }}
Browser sign-in uses Go Loose at https://<tenant>.{{ .Values.goLoose.authDomain }}.
{{- else }}
{{ $tenant }}: no client credentials configured, password login only
{{- end }}
Browser sign-in uses the application password login. Enable goLoose to redirect tenant hosts.
{{- end }}
{{- if .Values.ingress.enabled }}
Open {{ .Values.app.frontendURL }}
{{- else if eq .Values.service.type "NodePort" }}
The application is exposed on cluster node port {{ .Values.service.nodePort }}.
Open {{ .Values.app.frontendURL }} through the external reverse proxy.
{{- else }}
Browser sign-in uses the application password login.
{{- end }}
{{- else if and (eq .Values.web.service.type "NodePort") .Values.web.service.nodePort }}
The web Service is exposed on cluster node port {{ .Values.web.service.nodePort }}.
{{- else }}
Run:
kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-guess.webDeploymentName" . }} 8080:{{ .Values.web.service.port }}
kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-guess.fullname" . }} 8080:{{ .Values.service.port }}
Then open http://127.0.0.1:8080.
{{- end }}

View file

@ -39,72 +39,3 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- define "go-guess.rabbitmqName" -}}
{{- printf "%s-rabbitmq" (include "go-guess.fullname" .) }}
{{- end }}
{{- define "go-guess.apiDeploymentName" -}}
{{- printf "%s-api" (include "go-guess.fullname" .) | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "go-guess.webDeploymentName" -}}
{{- printf "%s-web" (include "go-guess.fullname" .) | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/* DNS name the API is reachable under. The web image hardcodes "api:8080". */}}
{{- define "go-guess.apiServiceName" -}}
{{- default "api" .Values.api.service.name }}
{{- end }}
{{/* Host serving one tenant, for example nmbs.guess.urpi.be. */}}
{{- define "go-guess.tenantHost" -}}
{{- printf "%s.%s" (index . 0) (index . 1) }}
{{- end }}
{{/* Fallback public URL for invitation and meeting links. */}}
{{- define "go-guess.frontendURL" -}}
{{- if .Values.api.frontendURL }}
{{- .Values.api.frontendURL }}
{{- else if .Values.tenants }}
{{- printf "https://%s.%s" (first .Values.tenants) .Values.domain }}
{{- end }}
{{- end }}
{{/* Secret holding the wildcard-per-tenant web certificate. */}}
{{- define "go-guess.tlsSecretName" -}}
{{- if .Values.ingress.tls.existingSecret }}
{{- .Values.ingress.tls.existingSecret }}
{{- else if .Values.ingress.tls.secretName }}
{{- .Values.ingress.tls.secretName }}
{{- else }}
{{- printf "%s-web-tls" (include "go-guess.fullname" .) }}
{{- end }}
{{- end }}
{{/* Cluster-scoped by default so one issuer signs every tenant release. */}}
{{- define "go-guess.certManagerIssuerRef" -}}
{{- if .Values.certManager.issuerRef }}
{{- toYaml .Values.certManager.issuerRef | nindent 4 }}
{{- else }}
name: selfsigned
kind: Issuer
{{- end }}
{{- end }}
{{- define "go-guess.validate" -}}
{{- if not .Values.tenants }}
{{- fail "tenants must list at least one tenant slug" }}
{{- end }}
{{- if not .Values.domain }}
{{- fail "domain is required" }}
{{- end }}
{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled (not .Values.certManager.enabled) (not .Values.ingress.tls.existingSecret) }}
{{- fail "ingress.tls.enabled requires certManager.enabled or ingress.tls.existingSecret" }}
{{- end }}
{{- end }}
{{/* Secret holding the CA that signs the leaf web certificate. */}}
{{- define "go-guess.certManagerCASecretName" -}}
{{- if .Values.certManager.caSecretName }}
{{- .Values.certManager.caSecretName }}
{{- else }}
{{- printf "%s-web-ca" (include "go-guess.fullname" .) }}
{{- end }}
{{- end }}

View file

@ -1,155 +0,0 @@
{{- include "go-guess.validate" . -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "go-guess.apiDeploymentName" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: api
spec:
replicas: {{ .Values.api.replicaCount }}
strategy:
type: {{ .Values.api.strategy.type }}
selector:
matchLabels:
{{- include "go-guess.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: api
template:
metadata:
annotations:
checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.api.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "go-guess.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: api
{{- with .Values.api.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.api.podSecurityContext | nindent 8 }}
containers:
- name: api
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
securityContext:
{{- toYaml .Values.api.securityContext | nindent 12 }}
env:
- name: APP_ENV
value: {{ .Values.api.environment | quote }}
- name: FRONTEND_URL
value: {{ include "go-guess.frontendURL" . | quote }}
{{- with .Values.api.themeBaseUrl }}
# The theme service is read by the browser, not the API, so it is passed
# through to the entrypoint, which writes the runtime config the page
# reads. Empty means this deployment keeps its own styling.
- name: THEME_BASE_URL
value: {{ . | quote }}
{{- end }}
- name: TOKEN_TTL
value: {{ .Values.api.tokenTTL | quote }}
- name: MAX_UPLOAD_MB
value: {{ .Values.api.maxUploadMB | quote }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: database-url
- name: RABBITMQ_URL
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: rabbitmq-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: jwt-secret
{{- if .Values.api.goLoose.enabled }}
- name: GO_LOOSE_AUTH_DOMAIN
value: {{ .Values.api.goLoose.authDomain | quote }}
- name: GO_LOOSE_APP_DOMAIN
value: {{ .Values.domain | quote }}
{{- if .Values.api.goLoose.ca.existingSecret }}
- name: GO_LOOSE_CA_FILE
value: {{ .Values.api.goLoose.ca.mountPath | quote }}
{{- end }}
{{- range $tenant, $credentials := .Values.api.goLoose.tenants }}
{{- $envPrefix := printf "GO_LOOSE_%s" (upper $tenant) }}
{{- $keyPrefix := printf "go-loose-%s" (lower $tenant) }}
{{- if $credentials.clientID }}
- name: {{ $envPrefix }}_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" $ }}
key: {{ $keyPrefix }}-client-id
{{- end }}
{{- if $credentials.clientSecret }}
- name: {{ $envPrefix }}_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" $ }}
key: {{ $keyPrefix }}-client-secret
{{- end }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 12
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
{{- toYaml .Values.api.resources | nindent 12 }}
volumeMounts:
- name: temporary-files
mountPath: /tmp
{{- if and .Values.api.goLoose.enabled .Values.api.goLoose.ca.existingSecret }}
- name: go-loose-ca
mountPath: {{ dir .Values.api.goLoose.ca.mountPath }}
readOnly: true
{{- end }}
volumes:
- name: temporary-files
emptyDir: {}
{{- if and .Values.api.goLoose.enabled .Values.api.goLoose.ca.existingSecret }}
- name: go-loose-ca
secret:
secretName: {{ .Values.api.goLoose.ca.existingSecret }}
items:
- key: {{ .Values.api.goLoose.ca.key }}
path: {{ base .Values.api.goLoose.ca.mountPath }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -1,18 +0,0 @@
apiVersion: v1
kind: Service
metadata:
# Kept as the literal name "api": the web image proxies /api/ to api:8080.
name: {{ include "go-guess.apiServiceName" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: api
spec:
type: {{ .Values.api.service.type }}
selector:
{{- include "go-guess.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: api
ports:
- name: http
port: {{ .Values.api.service.port }}
targetPort: http
protocol: TCP

View file

@ -0,0 +1,105 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "go-guess.fullname" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: {{ .Values.strategy.type }}
selector:
matchLabels:
{{- include "go-guess.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: application
template:
metadata:
annotations:
checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "go-guess.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: application
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: go-guess
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
env:
- name: APP_ENV
value: {{ .Values.app.environment | quote }}
- name: FRONTEND_URL
value: {{ .Values.app.frontendURL | quote }}
- name: TOKEN_TTL
value: {{ .Values.app.tokenTTL | quote }}
- name: MAX_UPLOAD_MB
value: {{ .Values.app.maxUploadMB | quote }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: database-url
- name: RABBITMQ_URL
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: rabbitmq-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "go-guess.secretName" . }}
key: jwt-secret
ports:
- name: http
containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 12
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: temporary-files
mountPath: /tmp
volumes:
- name: temporary-files
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "go-guess.fullname" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "go-guess.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: application
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}

View file

@ -1,76 +0,0 @@
{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled .Values.certManager.enabled }}
{{- $fullname := include "go-guess.fullname" . }}
{{- $caSecret := include "go-guess.certManagerCASecretName" . }}
{{- $tlsSecret := include "go-guess.tlsSecretName" . }}
{{- if not .Values.certManager.issuerRef }}
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ $fullname }}-selfsigned
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ $fullname }}-web-ca
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
secretName: {{ $caSecret }}
commonName: {{ printf "%s-ca" $fullname }}
duration: {{ .Values.certManager.duration }}
renewBefore: {{ .Values.certManager.renewBefore }}
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: {{ $fullname }}-selfsigned
kind: Issuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ $fullname }}-web-ca
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
ca:
secretName: {{ $caSecret }}
---
{{- end }}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ $fullname }}-web
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
secretName: {{ $tlsSecret }}
duration: {{ .Values.certManager.duration }}
renewBefore: {{ .Values.certManager.renewBefore }}
privateKey:
algorithm: ECDSA
size: 256
usages:
- server auth
dnsNames:
{{- range .Values.tenants }}
- {{ printf "%s.%s" . $.Values.domain | quote }}
{{- end }}
issuerRef:
{{- if .Values.certManager.issuerRef }}
{{- include "go-guess.certManagerIssuerRef" . | nindent 4 }}
{{- else }}
name: {{ $fullname }}-web-ca
kind: Issuer
group: cert-manager.io
{{- end }}
{{- end }}

View file

@ -1,5 +1,4 @@
{{- if .Values.ingress.enabled }}
{{- $webService := include "go-guess.webDeploymentName" . }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@ -14,25 +13,23 @@ spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
{{- with .Values.ingress.tls }}
tls:
- hosts:
{{- range .Values.tenants }}
- {{ printf "%s.%s" . $.Values.domain | quote }}
{{- end }}
secretName: {{ include "go-guess.tlsSecretName" . }}
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.tenants }}
- host: {{ printf "%s.%s" . $.Values.domain | quote }}
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
- path: {{ $.Values.ingress.path }}
pathType: {{ $.Values.ingress.pathType }}
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ $webService }}
name: {{ include "go-guess.fullname" $ }}
port:
number: {{ $.Values.web.service.port }}
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -32,14 +32,3 @@ data:
rabbitmq-url: {{ $rabbitmqURL | b64enc | quote }}
postgresql-password: {{ $postgresPassword | b64enc | quote }}
rabbitmq-password: {{ $rabbitmqPassword | b64enc | quote }}
{{- if .Values.api.goLoose.enabled }}
{{- range $tenant, $credentials := .Values.api.goLoose.tenants }}
{{- $prefix := printf "go-loose-%s" (lower $tenant) }}
{{- if $credentials.clientID }}
{{ printf "%s-client-id" $prefix }}: {{ $credentials.clientID | b64enc | quote }}
{{- end }}
{{- if $credentials.clientSecret }}
{{ printf "%s-client-secret" $prefix }}: {{ $credentials.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -17,11 +17,4 @@ spec:
- wget
args:
- -qO-
- "http://{{ include "go-guess.apiServiceName" . }}:{{ .Values.api.service.port }}/api/health"
- name: wget-web
image: busybox:1.37
command:
- wget
args:
- -qO-
- "http://{{ include "go-guess.webDeploymentName" . }}:{{ .Values.web.service.port }}/"
- "http://{{ include "go-guess.fullname" . }}:{{ .Values.service.port }}/api/health"

View file

@ -1,97 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "go-guess.webDeploymentName" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
replicas: {{ .Values.web.replicaCount }}
strategy:
{{- toYaml .Values.web.strategy | nindent 4 }}
selector:
matchLabels:
{{- include "go-guess.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: web
template:
metadata:
{{- with .Values.web.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "go-guess.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: web
{{- with .Values.web.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.web.podSecurityContext | nindent 8 }}
containers:
- name: web
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
imagePullPolicy: {{ .Values.web.image.pullPolicy }}
securityContext:
{{- toYaml .Values.web.securityContext | nindent 12 }}
env:
{{- with .Values.web.themeBaseUrl }}
# Read by the browser, not the API, so it goes to the web container:
# the entrypoint writes it into runtime-config.js on start. Empty keeps
# the application on its own styling.
- name: THEME_BASE_URL
value: {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: 80
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
{{- toYaml .Values.web.resources | nindent 12 }}
volumeMounts:
- name: nginx-cache
mountPath: /var/cache/nginx
- name: nginx-run
mountPath: /var/run
- name: temporary-files
mountPath: /tmp
volumes:
- name: nginx-cache
emptyDir: {}
- name: nginx-run
emptyDir: {}
- name: temporary-files
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -1,17 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "go-guess.webDeploymentName" . }}
labels:
{{- include "go-guess.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
type: {{ .Values.web.service.type }}
selector:
{{- include "go-guess.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: web
ports:
- name: http
port: {{ .Values.web.service.port }}
targetPort: http
protocol: TCP

View file

@ -1,182 +0,0 @@
# Development values for go-guess
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess-dev.urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants:
- nmbs
- ypto
imagePullSecrets:
- name: nexus-registry
api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-guess
tag: "0.0.1"
pullPolicy: Always
environment: development
frontendURL: ""
tokenTTL: 8h
maxUploadMB: 10
service:
name: api
type: ClusterIP
port: 8080
goLoose:
enabled: true
authDomain: auth-dev.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
tenants:
nmbs:
clientID: ""
clientSecret: ""
ypto:
clientID: ""
clientSecret: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
strategy:
type: Recreate
web:
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-guess
tag: "0.0.1"
pullPolicy: Always
service:
type: ClusterIP
port: 80
nodePort: null
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 200m
memory: 96Mi
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- CHOWN
- SETGID
- SETUID
- NET_BIND_SERVICE
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-staging
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
secrets:
jwtSecret: ""
databaseURL: ""
rabbitmqURL: ""
postgresqlPassword: ""
rabbitmqPassword: ""
postgresql:
enabled: true
image:
repository: postgres
tag: 18-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
database: go_guess
persistence:
enabled: true
size: 2Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
rabbitmq:
enabled: true
image:
repository: rabbitmq
tag: 4-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
persistence:
enabled: true
size: 2Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -1,20 +1,18 @@
# Development profile: demo fixtures, ephemeral database and broker storage, and
# `latest` image tags. Browser sign-in goes through the development identity
# domain auth-dev.urpi.be, never the production auth.urpi.be.
domain: guess-dev.urpi.be
tenants:
- nmbs
- ypto
api:
environment: development
image:
tag: latest
pullPolicy: Always
goLoose:
enabled: true
authDomain: auth-dev.urpi.be
app:
environment: development
frontendURL: http://localhost:30080
service:
type: NodePort
nodePort: 30080
ingress:
enabled: false
resources:
requests:
cpu: 50m
@ -23,22 +21,6 @@ api:
cpu: 300m
memory: 384Mi
web:
replicaCount: 1
image:
tag: latest
pullPolicy: Always
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 200m
memory: 96Mi
ingress:
enabled: true
postgresql:
persistence:
enabled: false

View file

@ -1,181 +0,0 @@
# Local (LCL) values for go-guess.
# Tenants are served at https://<tenant>.guess-lcl.intern-urpi.be
# (nmbs, ypto), routed by the docker TLS edge into k3s Traefik.
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess-lcl.intern-urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants:
- nmbs
- ypto
# Images live in the local registry (localhost:5000), no pull secret needed.
imagePullSecrets: []
api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 1
image:
repository: localhost:5000/go-guess
tag: "0.0.1"
pullPolicy: IfNotPresent
environment: development
frontendURL: ""
tokenTTL: 8h
maxUploadMB: 10
service:
name: api
type: ClusterIP
port: 8080
goLoose:
enabled: true
authDomain: auth-lcl.intern-urpi.be
# local-ca is the certificate authority used by the docker TLS edge
# (dev-infrastructure/certs/local-ca.crt).
ca:
existingSecret: "local-ca"
key: ca.crt
mountPath: /certs/local-ca.crt
tenants:
nmbs:
clientID: ""
clientSecret: ""
ypto:
clientID: ""
clientSecret: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
strategy:
type: Recreate
web:
replicaCount: 1
image:
repository: localhost:5000/go-guess
tag: "0.0.1"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
nodePort: null
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 200m
memory: 96Mi
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- CHOWN
- SETGID
- SETUID
- NET_BIND_SERVICE
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
ingress:
enabled: true
className: traefik
annotations: {}
path: /
pathType: Prefix
tls:
enabled: true
# local-dev-cert is signed by the local CA (dev-infrastructure/certs).
existingSecret: "local-dev-cert"
secretName: ""
# No cert-manager in the local cluster.
certManager:
enabled: false
issuerRef: null
duration: 8760h
renewBefore: 720h
caSecretName: ""
# Empty values are generated once and preserved in the release secret.
secrets:
jwtSecret: ""
databaseURL: ""
rabbitmqURL: ""
postgresqlPassword: ""
rabbitmqPassword: ""
postgresql:
enabled: true
image:
repository: postgres
tag: 18-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
database: go_guess
persistence:
enabled: true
size: 2Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
rabbitmq:
enabled: true
image:
repository: rabbitmq
tag: 4-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
persistence:
enabled: true
size: 2Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -1,185 +0,0 @@
# Production values for go-guess
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess.urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants:
- nmbs
- ypto
imagePullSecrets:
- name: nexus-registry
api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 2
image:
repository: nexus.urpi.be/docker-hosted/go-guess
tag: "0.0.1"
pullPolicy: Always
environment: production
frontendURL: ""
tokenTTL: 8h
maxUploadMB: 10
service:
name: api
type: ClusterIP
port: 8080
goLoose:
enabled: true
authDomain: auth.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
tenants:
nmbs:
clientID: ""
clientSecret: ""
ypto:
clientID: ""
clientSecret: ""
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
podAnnotations: {}
podLabels: {}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
web:
replicaCount: 2
image:
repository: nexus.urpi.be/docker-hosted/go-guess
tag: "0.0.1"
pullPolicy: Always
service:
type: ClusterIP
port: 80
nodePort: null
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- CHOWN
- SETGID
- SETUID
- NET_BIND_SERVICE
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
secrets:
jwtSecret: ""
databaseURL: ""
rabbitmqURL: ""
postgresqlPassword: ""
rabbitmqPassword: ""
postgresql:
enabled: true
image:
repository: postgres
tag: 18-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
database: go_guess
persistence:
enabled: true
size: 20Gi
storageClass: ""
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
rabbitmq:
enabled: true
image:
repository: rabbitmq
tag: 4-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
persistence:
enabled: true
size: 10Gi
storageClass: ""
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -1,18 +1,18 @@
# Production profile: production fixtures, persistent volumes, released image
# tags, and self-signed certificates served for https://<tenant>.guess.urpi.be.
domain: guess.urpi.be
tenants:
- nmbs
- ypto
api:
environment: production
image:
tag: "0.0.1"
tag: latest
pullPolicy: IfNotPresent
goLoose:
enabled: true
app:
environment: production
frontendURL: https://goguess.urpi.be
service:
type: NodePort
nodePort: 31374
ingress:
enabled: false
resources:
requests:
cpu: 200m
@ -21,26 +21,6 @@ api:
cpu: "1"
memory: 768Mi
web:
replicaCount: 2
image:
tag: "0.0.1"
pullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 300m
memory: 128Mi
ingress:
enabled: true
className: nginx
certManager:
enabled: true
postgresql:
persistence:
enabled: true

View file

@ -1,177 +0,0 @@
# TST values for go-guess
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess-tst.intern-urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants:
- nmbs
- ypto
imagePullSecrets:
- name: nexus-registry
api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-guess
tag: "0.0.1"
pullPolicy: Always
environment: development
frontendURL: ""
tokenTTL: 8h
maxUploadMB: 10
service:
name: api
type: ClusterIP
port: 8080
goLoose:
enabled: true
authDomain: auth-tst.intern-urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
tenants:
nmbs:
clientID: ""
clientSecret: ""
ypto:
clientID: ""
clientSecret: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
strategy:
type: Recreate
web:
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-guess
tag: "0.0.1"
pullPolicy: Always
service:
type: ClusterIP
port: 80
nodePort: null
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 200m
memory: 96Mi
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-staging
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
secrets:
jwtSecret: ""
databaseURL: ""
rabbitmqURL: ""
postgresqlPassword: ""
rabbitmqPassword: ""
postgresql:
enabled: true
image:
repository: postgres
tag: 18-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
database: go_guess
persistence:
enabled: true
size: 5Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
rabbitmq:
enabled: true
image:
repository: rabbitmq
tag: 4-alpine
pullPolicy: IfNotPresent
auth:
username: go_guess
persistence:
enabled: true
size: 5Gi
storageClass: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -1,153 +1,30 @@
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess.urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants:
- nmbs
- ypto
imagePullSecrets:
- name: nexus-registry
api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-guess
tag: "0.0.1"
repository: batty1039.startdedicated.net:5000/go-guess
tag: latest
pullPolicy: IfNotPresent
environment: development
# Public base URL used when building invitation and meeting links. Defaults to
# https://<first tenant>.<domain> when empty.
frontendURL: ""
# Origin of the go-bananas runtime supplying the shared styling contract, for
# example https://themes.urpi.be. Empty keeps this application's own styles, so
# a deployment without a theme service is unaffected. The service must also be
# configured to allow this application's origin, or the browser will refuse to
# load the controls from it.
themeBaseUrl: ""
imagePullSecrets: []
app:
environment: production
frontendURL: http://localhost:8080
tokenTTL: 8h
maxUploadMB: 10
service:
# The web image proxies /api/ to the literal upstream name "api:8080", so this
# Service name has to stay "api" unless the web image is rebuilt.
name: api
type: ClusterIP
port: 8080
goLoose:
enabled: true
authDomain: auth.urpi.be
enabled: false
authDomain: auth.dev
appDomain: guess.dev
ca:
# Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it.
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
# Per-tenant browser authentication credentials, written to the release secret.
tenants:
nmbs:
clientID: ""
clientSecret: ""
ypto:
clientID: ""
clientSecret: ""
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
podAnnotations: {}
podLabels: {}
strategy:
type: Recreate
web:
replicaCount: 2
image:
repository: nexus.urpi.be/docker-hosted/go-guess
tag: "0.0.1"
pullPolicy: IfNotPresent
# Origin of the go-bananas runtime supplying the shared styling contract, for
# example https://bananas-dev.urpi.be. Empty keeps this application's own
# styles. The theme service must also allow this application's origin, or the
# browser refuses to load the controls from it.
themeBaseUrl: ""
service:
type: ClusterIP
port: 80
nodePort: null
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 300m
memory: 128Mi
podAnnotations: {}
podLabels: {}
# nginx workers run as uid 101, which the image already declares.
podSecurityContext: {}
# nginx binds the privileged port 80 and forks workers as the nginx user, so it
# needs the root user plus a small capability set and writable scratch paths.
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- CHOWN
- SETGID
- SETUID
- NET_BIND_SERVICE
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
path: /
pathType: Prefix
tls:
enabled: true
# Use a certificate you already manage instead of the chart-issued one.
existingSecret: ""
# Defaults to <fullname>-web-tls when empty and certManager.enabled is true.
secretName: ""
certManager:
# Requires cert-manager in the cluster: make cert-manager-install.
enabled: true
# Reuse an existing cert-manager issuer instead of creating a self-signed one.
issuerRef: {}
duration: 8760h
renewBefore: 720h
caSecretName: ""
tenants: []
secrets:
jwtSecret: ""
@ -156,6 +33,49 @@ secrets:
postgresqlPassword: ""
rabbitmqPassword: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: false
className: nginx
annotations: {}
hosts:
- host: go-guess.local
paths:
- path: /
pathType: Prefix
tls: []
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: Recreate
postgresql:
enabled: true
image:

View file

@ -55,9 +55,8 @@ curl --fail https://goguess.urpi.be/api/health
The `go-guess` service should show `80:31374/TCP`. The PostgreSQL and RabbitMQ
services should remain `ClusterIP` services without NodePorts.
Browser sign-in also calls Go Loose from the application pod, using
`GO_LOOSE_AUTH_DOMAIN` (`auth-dev.urpi.be` outside production, `auth.urpi.be` in
production) as `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>`. That address must be
reachable from the pod. The browser forwards browser authentication and API
requests without adding application credentials. Nginx does not add or strip
authorization headers.
Protected interviewer routes also call Go Loose from the application pod, using
`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the
request tenant). That address must be reachable from the cluster. The browser
forwards browser authentication and API requests without adding application
credentials. Nginx does not add or strip authorization headers.

View file

@ -25,6 +25,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
VITE_GO_LOOSE_API_KEY: gl_bdd_key_that_is_long_enough_for_client
environment:
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable&search_path=ypto
JWT_SECRET: bdd-test-secret-that-is-at-least-32-characters

View file

@ -36,12 +36,10 @@ services:
environment:
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
JWT_SECRET: local-development-secret-change-me-12345
FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess-dev.urpi.be}
# Where the go-bananas styling contract is served from. Empty disables it.
THEME_BASE_URL: ${THEME_BASE_URL:-}
FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess.local}
APP_ENV: ${APP_ENV:-development}
GO_LOOSE_AUTH_DOMAIN: ${GO_LOOSE_AUTH_DOMAIN:-auth-dev.urpi.be}
GO_LOOSE_APP_DOMAIN: ${GO_LOOSE_APP_DOMAIN:-guess-dev.urpi.be}
GO_LOOSE_AUTH_DOMAIN: auth.dev
GO_LOOSE_APP_DOMAIN: guess.dev
GO_LOOSE_CA_FILE: /certs/local-ca.crt
GO_LOOSE_NMBS_CLIENT_ID: ${GO_LOOSE_NMBS_CLIENT_ID:-}
GO_LOOSE_NMBS_CLIENT_SECRET: ${GO_LOOSE_NMBS_CLIENT_SECRET:-}
@ -54,7 +52,7 @@ services:
- go_guess_network
- local_dev_edge
volumes:
- ${LOCAL_CA_FILE:-/home/dnoulet/go/infrastructure/certs/local-ca.crt}:/certs/local-ca.crt:ro
- ${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}:/certs/local-ca.crt:ro
depends_on:
db:
condition: service_healthy

View file

@ -13,13 +13,6 @@ seed
mkdir -p /tmp/nginx
# The theme service location is environment, not build time, so one image serves
# every environment. The file is regenerated on every start and is read by the
# page before the bundle runs.
cat > /app/frontend/runtime-config.js <<CONFIG
window.__GO_GUESS_CONFIG__ = { themeBaseUrl: "${THEME_BASE_URL:-}" }
CONFIG
api &
api_pid=$!
nginx -e /dev/stderr -g 'daemon off;' &

View file

@ -8,11 +8,5 @@ RUN npm run build
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
# Writes runtime-config.js from the environment on start, so one image serves
# every environment instead of baking a hostname into the bundle at build time.
# It lives beside this Dockerfile because the build context is ./frontend.
COPY entrypoint.sh /usr/local/bin/go-guess-frontend-entrypoint
RUN chmod +x /usr/local/bin/go-guess-frontend-entrypoint
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/go-guess-frontend-entrypoint"]
CMD ["nginx", "-g", "daemon off;"]

View file

@ -36,64 +36,15 @@ application shell.
## API integration
For tenant hosts such as `nmbs.guess-dev.urpi.be` locally and
`nmbs.guess.urpi.be` in production, `src/api/client.ts` derives tenant `nmbs`,
calls same-origin `/api` through the shared HTTPS proxy, and sends
For local tenant hosts such as `nmbs.guess.dev`, `src/api/client.ts` derives
tenant `nmbs`, calls same-origin `/api` through the shared HTTPS proxy, and sends
`X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream
request. `ypto.guess-dev.urpi.be` behaves identically for tenant `ypto`. Other
request. `ypto.guess.dev` behaves identically for tenant `ypto`. Other
hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
`src/components/actions/Account.tsx` treats any host whose first DNS label is a
tenant as a possible Go Loose host and asks `/api/auth/session` whether browser
login is available there. A `404` means the API has no Go Loose login for the
host, so the password form is shown. Otherwise the sign-in screen offers a single
button that hands the browser to Go Loose, which returns to the welcome page.
Nothing redirects to the identity provider on its own: that fired on every
anonymous visit and read as a redirect loop rather than as a choice to sign in.
A person who already signed in at another application passes straight through,
because Go Loose recognises the session it holds and issues a code without
asking anything. The callback exchanges that session for the bearer token used by
the typed API client. Participant routes are exempt: a participant opens the
application through a token link and is never signing in.
## Theming
The palette and the light/dark switch come from
[go-bananas](https://github.com/urpi/go-bananas), the design-token service, as
runtime CSS custom properties. Three pieces, and the application installs all
three itself:
- `src/theme/installThemeRuntime.ts` injects
`<link rel="stylesheet" href="$THEME_BASE_URL/rt/v1/contract.css" data-theme-contract>`
and imports `$THEME_BASE_URL/rt/v1/components.js`. Both are best effort: a
theme service that is absent, slow, or unreachable must not stop the
application working.
- `src/theme/ThemeControls.tsx` renders the service's own custom elements,
`<bananas-theme-selector>` and `<bananas-appearance-toggle>`. They are custom
elements rather than components written here because the applications this
service feeds are a mix of React, vanilla JavaScript, and server-rendered HTML,
and a custom element is the one shape all three can use with no build step, no
import map, and no second copy of React. React treats an unknown element as an
inert host node, so rendering them unconditionally is safe.
- Every token in `src/styles/global.css` is written as
`var(--bn-*, fallback)`. That ordering is the whole safety property: with no
theme service configured the page renders exactly as it did before, and with
one configured the same rules follow the palette.
`THEME_BASE_URL` is read from `runtime-config.js`, which the container entrypoint
rewrites from the environment on start, so one image serves every environment.
`VITE_THEME_BASE_URL` is the equivalent for `npm run dev`.
Two deployment requirements are easy to miss. The service must be configured to
allow this application's origin (`CORS_ALLOWED_ORIGINS` on go-bananas), or the
browser refuses to load the controls from it, because a module script is always
fetched with CORS. And `api.themeBaseUrl` in the Helm values must be set; it is
empty by default, which disables theming.
`src/theme/contract.test.ts` fetches the live contract and asserts it defines
every `--bn-*` variable this application consumes. A token renamed on the service
would otherwise leave every `var()` resolving to its fallback, and the page would
look correct while never actually following the palette.
On the configured tenant hosts, the app checks `/api/auth/session` and redirects
to `/api/auth/login` when no Go Loose browser session exists. The callback
exchanges that session for the bearer token used by the typed API client.
Invitation and participant-meeting URLs use the validated request origin so
links stay on the tenant hostname. Copy actions fall back to a temporary selected
textarea when the Clipboard API is unavailable on local HTTP domains.
@ -102,7 +53,7 @@ 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
`/login`. On a tenant host with Go Loose configured, the app restores
`/login`. On `nmbs.guess.dev` and `ypto.guess.dev`, the app restores
`/api/auth/session` or redirects to `/api/auth/login`; participant routes stay
token-based. The app also schedules logout from the JWT expiration claim so an idle
expired session cannot remain on a protected page.

View file

@ -1,21 +0,0 @@
#!/bin/sh
# Writes the runtime configuration the page reads before the bundle runs, then
# hands over to nginx.
#
# The front end is built once and served by many environments, so anything that
# differs between them has to be resolved here rather than baked into the bundle.
# Right now that is the origin of the theme service; an empty value leaves the
# application on its own styling.
set -eu
CONFIG_DIR=/usr/share/nginx/html
CONFIG_FILE="$CONFIG_DIR/runtime-config.js"
# nginx serves its own error pages from this directory, so a read-only filesystem
# would otherwise turn a missing file into a 500. The default is already in the
# image, so this only replaces it.
cat > "$CONFIG_FILE" <<CONFIG
window.__GO_GUESS_CONFIG__ = { themeBaseUrl: "${THEME_BASE_URL:-}" }
CONFIG
exec "$@"

View file

@ -6,9 +6,6 @@
<meta name="theme-color" content="#081c2c" />
<meta name="description" content="Go Guess recruitment assessment workspace" />
<title>Go Guess · Recruitment workspace</title>
<!-- Read before the bundle runs: it says where the theme service is, if there
is one. Overwritten by the entrypoint in a container. -->
<script src="/runtime-config.js"></script>
</head>
<body>
<div id="root"></div>

View file

@ -1,4 +0,0 @@
/* Rewritten by the container entrypoint from THEME_BASE_URL on start, so one
* image serves every environment. The committed value disables theming, which is
* what a checkout with no theme service should do. */
window.__GO_GUESS_CONFIG__ = { themeBaseUrl: '' }

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client'
import { api, apiBaseURL, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client'
import { subscribeToDomainEvent } from './domainEvents'
describe('API client', () => {
@ -8,11 +8,12 @@ describe('API client', () => {
})
it('uses the same-origin API behind the local HTTPS proxy', () => {
expect(tenantFromHostname('nmbs.guess-dev.urpi.be')).toBe('nmbs')
expect(tenantFromHostname('ypto.guess-dev.urpi.be')).toBe('ypto')
expect(tenantFromHostname('nmbs.guess.urpi.be')).toBe('nmbs')
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: 'https:' })).toBe('/api')
expect(apiBaseURL({ hostname: 'app.example.com', protocol: 'https:' })).toBe('/api')
})
it('sends the persisted bearer token on protected requests', async () => {

View file

@ -79,10 +79,16 @@ export function tenantFromHostname(hostname: string): string | null {
return labels.length >= 3 ? labels[0] : null
}
// Every tenant host serves the API from its own origin: nginx and Traefik proxy
// /api to the API container, so the base is always the same-origin path.
export function apiBaseURL(location: Pick<Location, 'hostname' | 'protocol'>): string {
const hostname = location.hostname.toLowerCase().replace(/\.$/, '')
if (hostname.endsWith('.guess.local')) {
return '/api'
}
return '/api'
}
function apiURL(path: string): string {
return `/api${path}`
return `${apiBaseURL(window.location)}${path}`
}
function tenantHeader(): Record<string, string> {

View file

@ -258,17 +258,10 @@ describe('Go Guess frontend', () => {
'href',
'/invitations',
)
// The person is reached through the account menu rather than a bare profile
// link, so the menu trigger names them and holds the actions.
const menu = within(accountNavigation).getByRole('button', { name: /Admin/ })
expect(menu).toHaveAttribute('aria-expanded', 'false')
await userEvent.click(menu)
expect(within(accountNavigation).getByRole('link', { name: 'Profile' })).toHaveAttribute(
expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute(
'href',
'/profile',
)
expect(within(accountNavigation).getByRole('button', { name: 'Sign out' })).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
expect(
@ -336,10 +329,7 @@ describe('Go Guess frontend', () => {
renderApp('/participants/3')
const input = await screen.findByLabelText(/replace cv/i)
await user.upload(
input,
new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }),
)
await user.upload(input, new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }))
expect(await screen.findByText('React')).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith(

View file

@ -8,8 +8,8 @@ import { AuthenticatedApp } from './AuthenticatedApp'
import {DataProvider} from './DataContext'
export default function App() {
const location = useLocation()
const { authenticated, sessionExpired, ssoAvailable, completeLogin, logout } = useAuthSession()
const location = useLocation();
const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession();
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
@ -19,18 +19,11 @@ export default function App() {
</Routes>
)
}
// The sign-in screen is the entry point, so nothing redirects to the identity
// provider on its own. While the API is still answering there is nothing
// useful to show, but not a spinner either: the button is correct either way,
// because it is what the screen offers whether or not Go Loose is configured.
if (!authenticated) {
return (
<LoginPage
onLogin={completeLogin}
sessionExpired={sessionExpired}
ssoAvailable={ssoAvailable}
/>
)
if (ssoStatus !== 'idle') {
return <main className="login-shell"><p>Redirecting to sign in…</p></main>
}
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
}
return (
<DataProvider>

View file

@ -13,7 +13,7 @@ export function AuthenticatedApp({onLogout}: { onLogout: () => void }) {
<div className="app-shell">
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
<main className="main-content">
<WorkspaceUtilities user={user} onSignOut={onLogout}/>
<WorkspaceUtilities user={user}/>
<ApplicationRoutes/>
<AppFooter/>
</main>

View file

@ -1,185 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isSSOHost, useAuthSession } from './Account'
interface FakeLocation {
hostname: string
pathname: string
assign: ReturnType<typeof vi.fn>
replace: ReturnType<typeof vi.fn>
}
function setHost(hostname: string, pathname = '/'): FakeLocation {
const location: FakeLocation = {
hostname,
pathname,
assign: vi.fn(),
replace: vi.fn(),
}
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: location,
})
return location
}
function json(body: unknown, status: number) {
return Promise.resolve(
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
}),
)
}
function Probe() {
const { authenticated, ssoAvailable } = useAuthSession()
return <p>{`${String(ssoAvailable)}|${String(authenticated)}`}</p>
}
function renderProbe(path = '/') {
return render(
<MemoryRouter initialEntries={[path]}>
<Probe />
</MemoryRouter>,
)
}
const authResponse = {
token: 'sso-token',
user: { id: 1, email: 'ssuser@example.com', displayName: 'SSO', role: 'interviewer' },
}
describe('isSSOHost', () => {
it('recognises tenant hosts on the development and production domains', () => {
for (const hostname of [
'nmbs.guess-dev.urpi.be',
'ypto.guess-dev.urpi.be',
'nmbs.guess.urpi.be',
'ypto.guess.urpi.be',
]) {
setHost(hostname)
expect(isSSOHost(), hostname).toBe(true)
}
})
it('ignores localhost and raw addresses', () => {
for (const hostname of ['localhost', '127.0.0.1', '']) {
setHost(hostname)
expect(isSSOHost(), hostname).toBe(false)
}
})
})
describe('useAuthSession Go Loose bootstrap', () => {
beforeEach(() => {
sessionStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('signs in from an existing Go Loose session without leaving the page', async () => {
const location = setHost('nmbs.guess-dev.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => json(authResponse, 200)),
)
renderProbe()
await waitFor(() => expect(screen.getByText('true|true')).toBeInTheDocument())
expect(sessionStorage.getItem('go-guess-token')).toBe('sso-token')
expect(location.replace).not.toHaveBeenCalled()
})
it('leaves the sign-in screen in place when there is no session', async () => {
// Signing in starts from the screen and one button. Redirecting on sight would
// skip past the screen and fire on every anonymous visit, which reads as a
// redirect loop rather than as a choice to sign in.
const location = setHost('nmbs.guess.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => json({ error: 'missing Go Loose session' }, 401)),
)
renderProbe()
await waitFor(() => expect(screen.getByText('true|false')).toBeInTheDocument())
expect(location.replace).not.toHaveBeenCalled()
expect(location.assign).not.toHaveBeenCalled()
})
it('keeps the single sign-on button when the API cannot be reached', async () => {
// Nothing answers, so there is no way to know whether Go Loose is configured.
// The button is the only way in on such a host, so it stays.
const location = setHost('nmbs.guess.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('offline'))),
)
renderProbe()
await waitFor(() => expect(screen.getByText('null|false')).toBeInTheDocument())
expect(location.assign).not.toHaveBeenCalled()
})
it('falls back to password sign-in when the tenant has no client credentials', async () => {
// A 503 is what a configured tenant without Go Loose credentials returns. It is
// just as unusable as a 404, and treating it as "available" left the sign-in
// button leading to an error page with no way in.
const location = setHost('nmbs.guess-dev.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => json({ error: 'Go Loose login is not configured for tenant "nmbs"' }, 503)),
)
renderProbe()
await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument())
expect(location.assign).not.toHaveBeenCalled()
})
it('falls back to password sign-in when the API reports no Go Loose login', async () => {
const location = setHost('nmbs.guess-dev.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => json({ error: 'Go Loose browser login is not enabled' }, 404)),
)
renderProbe()
// false is what switches the screen from the button to the password form.
await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument())
expect(location.replace).not.toHaveBeenCalled()
})
it('never probes the session outside a tenant host', async () => {
setHost('localhost')
const fetchMock = vi.fn(() => json(authResponse, 200))
vi.stubGlobal('fetch', fetchMock)
renderProbe()
// A host with no tenant label cannot have Go Loose login, so it is false
// without asking the API anything.
await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument())
expect(fetchMock).not.toHaveBeenCalled()
})
it('skips the bootstrap on participant routes', async () => {
const location = setHost('nmbs.guess-dev.urpi.be', '/participant/meeting/token')
const fetchMock = vi.fn(() => json(authResponse, 200))
vi.stubGlobal('fetch', fetchMock)
renderProbe('/participant/meeting/token')
await waitFor(() => expect(screen.getByText('null|false')).toBeInTheDocument())
expect(fetchMock).not.toHaveBeenCalled()
expect(location.replace).not.toHaveBeenCalled()
})
})

View file

@ -1,101 +1,67 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import {
authStorage,
invalidateSession,
tenantFromHostname,
SESSION_EXPIRED_EVENT,
} from '../../api/client'
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
const SESSION_PATH = '/api/auth/session'
const LOGOUT_PATH = '/api/auth/logout'
const ssoHosts = new Set(['nmbs.guess.dev', 'ypto.guess.dev'])
/**
* Every tenant is served from https://<tenant>.<app domain>, on guess-dev.urpi.be
* locally and guess.urpi.be in production, so the first DNS label of a host
* with an app domain below it names the tenant. Those hosts may authenticate
* through Go Loose. Whether they actually do is answered by the API, which
* reports 404 for /api/auth/session on any host it has no Go Loose login for,
* and that host then falls back to password sign-in.
*/
export function isSSOHost() {
return tenantFromHostname(window.location.hostname) !== null
return ssoHosts.has(window.location.hostname)
}
/**
* Tracks who is signed in.
*
* There is deliberately no automatic redirect to the identity provider: signing in
* starts from the sign-in screen and one button. Redirecting on sight would skip
* past the screen, and would also fire on every anonymous visit to the
* application, which reads as a redirect loop rather than as a choice to sign in.
*/
export function useAuthSession() {
const location = useLocation()
// A participant opens the application through a token link and is never a
// member of the tenant, so nothing here may probe or redirect them.
const participant = location.pathname.startsWith('/participant/')
const navigate = useNavigate()
const sso = isSSOHost() && !location.pathname.startsWith('/participant/')
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
const [sessionExpired, setSessionExpired] = useState(false)
// Whether Go Loose login is configured for this host. A host with no tenant
// label cannot have it, so that is known without asking: false there, and null
// on a tenant host until /api/auth/session has answered.
const [ssoAvailable, setSsoAvailable] = useState<boolean | null>(() =>
isSSOHost() ? null : false,
)
const storedToken = authStorage.token()
const [redirecting, setRedirecting] = useState(false)
const needsSSO = sso && !authenticated && !authStorage.token()
const ssoStatus = !needsSSO ? 'idle' : redirecting ? 'redirecting' : 'checking'
const navigate = useNavigate()
useEffect(() => {
function handleSessionExpired() {
setAuthenticated(false)
setSessionExpired(true)
if (isSSOHost() && !window.location.pathname.startsWith('/participant/')) {
window.location.replace('/api/auth/login')
return
}
navigate('/login', { replace: true })
}
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
}, [])
}, [navigate])
useEffect(() => {
// A host without a tenant label cannot have Go Loose login configured, and a
// participant is not signing in at all.
if (participant || !isSSOHost() || storedToken) return
if (!needsSSO) return
let cancelled = false
void (async () => {
try {
const response = await fetch(SESSION_PATH, {
const response = await fetch('/api/auth/session', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
redirect: 'manual',
})
if (cancelled) return
if (response.ok) {
// An existing Go Loose session. This is also the single sign-on case:
// having signed in anywhere in the family, the person lands here
// already authenticated.
setSsoAvailable(true)
authStorage.save(await response.json())
setAuthenticated(true)
setSessionExpired(false)
return
}
if (response.status === 404 || response.status === 503) {
// Either Go Loose login is not set up for this host at all (404), or the
// host is a known tenant whose client credentials have not been supplied
// (503). Neither can produce a sign-in, so offering the button would
// dead-end on an error page instead of letting the person in.
setSsoAvailable(false)
return
}
setSsoAvailable(true)
} catch {
// An unreachable API leaves ssoAvailable null, which keeps the single
// sign-on button on screen: it is the only way in on such a host anyway.
// The browser redirect below starts a fresh Go Loose login.
}
if (!cancelled) {
setRedirecting(true)
window.location.replace('/api/auth/login')
}
})()
return () => {
cancelled = true
}
}, [storedToken, participant])
}, [needsSSO])
useEffect(() => {
if (!authenticated) return
@ -113,8 +79,6 @@ export function useAuthSession() {
function completeLogin() {
setAuthenticated(true)
setSessionExpired(false)
// The password path can be entered from any URL, so the welcome page is the
// destination. The Go Loose path is already sent here by its redirect.
navigate('/', { replace: true })
}
@ -122,10 +86,8 @@ export function useAuthSession() {
authStorage.clear()
setAuthenticated(false)
setSessionExpired(false)
// Signing out also ends the Go Loose session, so the next application in the
// family asks for credentials again rather than letting this one back in.
if (isSSOHost()) window.location.assign(LOGOUT_PATH)
if (isSSOHost()) window.location.assign('/api/auth/logout')
}
return { authenticated, sessionExpired, ssoAvailable, completeLogin, logout }
return { authenticated, sessionExpired, ssoStatus, completeLogin, logout }
}

View file

@ -1,97 +0,0 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import type { User } from '../../types'
import { UserMenu } from './UserMenu'
const user: User = {
id: 1,
email: 'ada@example.test',
displayName: 'Ada Lovelace',
role: 'interviewer',
avatarUrl: 'https://auth.example.test/api/v1/avatars/kZ3abc',
}
function renderMenu(onSignOut = vi.fn()) {
render(
<MemoryRouter>
<UserMenu user={user} onSignOut={onSignOut} />
</MemoryRouter>,
)
return onSignOut
}
function trigger() {
return screen.getByRole('button', { name: /Ada Lovelace/ })
}
describe('UserMenu', () => {
it('starts closed and says so', () => {
renderMenu()
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('link', { name: 'Profile' })).not.toBeInTheDocument()
})
it('opens and closes on the trigger', async () => {
const user_ = userEvent.setup()
renderMenu()
await user_.click(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('link', { name: 'Profile' })).toHaveAttribute('href', '/profile')
await user_.click(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('closes on Escape and returns focus to the trigger', async () => {
const user_ = userEvent.setup()
renderMenu()
await user_.click(trigger())
await user_.keyboard('{Escape}')
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
expect(trigger()).toHaveFocus()
})
it('closes when the click lands outside it', async () => {
const user_ = userEvent.setup()
render(
<MemoryRouter>
<div>
<UserMenu user={user} onSignOut={vi.fn()} />
<button type="button">elsewhere</button>
</div>
</MemoryRouter>,
)
await user_.click(trigger())
await user_.click(screen.getByRole('button', { name: 'elsewhere' }))
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('signs out and closes', async () => {
const user_ = userEvent.setup()
const onSignOut = renderMenu()
await user_.click(trigger())
await user_.click(screen.getByRole('button', { name: 'Sign out' }))
expect(onSignOut).toHaveBeenCalledTimes(1)
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('shows the picture and the address once open', async () => {
const user_ = userEvent.setup()
renderMenu()
await user_.click(trigger())
const panel = screen.getByRole('link', { name: 'Profile' }).closest('.user-menu-panel')!
expect(within(panel as HTMLElement).getByText('ada@example.test')).toBeInTheDocument()
expect(within(panel as HTMLElement).getByTestId('avatar-image')).toBeInTheDocument()
})
it('falls back to the address when there is no name', () => {
render(
<MemoryRouter>
<UserMenu user={{ ...user, displayName: '' }} onSignOut={vi.fn()} />
</MemoryRouter>,
)
expect(screen.getByRole('button', { name: /ada@example.test/ })).toBeInTheDocument()
})
})

View file

@ -1,97 +0,0 @@
import { LogOut, UserRound } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import type { User } from '../../types'
import { Avatar } from '../ui/Avatar'
/**
* The account menu in the workspace utilities bar.
*
* It is a disclosure rather than a hover menu: the trigger is a real button with
* aria-expanded, the panel is labelled, Escape closes it, a click anywhere else
* closes it, and focus returns to the trigger. A menu that only opens on hover
* cannot be used from the keyboard or on a touch screen.
*
* The panel is not an ARIA menu. Its two actions are a link and a button, and
* giving them menuitem roles would strip the link semantics from one of them for
* no gain: everything here is reachable by tabbing, which is the behaviour that
* matters.
*/
export function UserMenu({ user, onSignOut }: { user: User | null; onSignOut: () => void }) {
const [open, setOpen] = useState(false)
const container = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const panelId = useId()
useEffect(() => {
if (!open) return
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false)
trigger.current?.focus()
}
}
function onPointerDown(event: MouseEvent) {
if (!container.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('mousedown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('mousedown', onPointerDown)
}
}, [open])
const name = (user?.displayName || user?.email || 'Profile').trim()
const email = user?.email ?? ''
return (
<div className="user-menu" ref={container}>
<button
type="button"
ref={trigger}
className="user-menu-trigger"
aria-expanded={open}
aria-haspopup="menu"
aria-controls={panelId}
onClick={() => setOpen((value) => !value)}
>
<Avatar user={user} size="tiny" />
{/* The trigger stays to the name alone: the address is one click away in
the panel, and repeating it on every screen is noise. */}
<span className="user-menu-identity">
<span className="user-menu-name">{name}</span>
</span>
</button>
{open && (
<div className="user-menu-panel" id={panelId} aria-label="Account">
<div className="user-menu-header">
<Avatar user={user} size="large" />
<div>
<p className="user-menu-panel-name">{name}</p>
{email && <p className="user-menu-panel-email">{email}</p>}
</div>
</div>
<Link className="user-menu-item" to="/profile" onClick={() => setOpen(false)}>
<UserRound size={18} aria-hidden="true" />
<span>Profile</span>
</Link>
<button
type="button"
className="user-menu-item"
onClick={() => {
setOpen(false)
onSignOut()
}}
>
<LogOut size={18} aria-hidden="true" />
<span>Sign out</span>
</button>
</div>
)}
</div>
)
}

View file

@ -1,16 +1,9 @@
import { CalendarDays, Mail } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import type { User } from '../../types'
import { ThemeControls } from '../../theme'
import { UserMenu } from './UserMenu'
export function WorkspaceUtilities({
user,
onSignOut,
}: {
user: User | null
onSignOut: () => void
}) {
export function WorkspaceUtilities({ user }: { user: User | null }) {
const profileLabel = user?.displayName || user?.email || 'Profile'
return (
<nav className="workspace-utilities" aria-label="Account navigation">
<NavLink className="utility-link" to="/invitations">
@ -21,8 +14,12 @@ export function WorkspaceUtilities({
<CalendarDays size={18} aria-hidden="true" />
<span>Schedule</span>
</NavLink>
<ThemeControls />
<UserMenu user={user} onSignOut={onSignOut} />
<NavLink className="utility-link profile-utility" to="/profile">
<span className="avatar tiny" aria-hidden="true">
{profileLabel.slice(0, 2).toUpperCase()}
</span>
<span>{profileLabel}</span>
</NavLink>
</nav>
)
}

View file

@ -1,76 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { User } from '../../types'
import { Avatar } from './Avatar'
import { initialsFor } from './initials'
const withPicture = {
displayName: 'Ada Lovelace',
email: 'ada@example.test',
avatarUrl: 'https://auth.example.test/api/v1/avatars/kZ3abc',
}
const withoutPicture = { displayName: 'Ada Lovelace', email: 'ada@example.test' }
function renderAvatar(user: Partial<Pick<User, 'displayName' | 'email' | 'avatarUrl'>> | null) {
return render(<Avatar user={user} />)
}
describe('initialsFor', () => {
it('uses the first letter of the first two words', () => {
expect(initialsFor('Ada Lovelace')).toBe('AL')
expect(initialsFor('Ada Byron King Lovelace')).toBe('AB')
})
it('falls back to the address, and to a single name', () => {
expect(initialsFor('', 'ada@example.test')).toBe('AD')
expect(initialsFor('Prince')).toBe('PR')
})
it('has something to show for nothing at all', () => {
expect(initialsFor('', '')).toBe('?')
expect(initialsFor(null, null)).toBe('?')
})
})
describe('Avatar', () => {
it('renders the picture Go Loose serves', () => {
renderAvatar(withPicture)
const image = screen.getByTestId('avatar-image').querySelector('img')
expect(image).toHaveAttribute('src', withPicture.avatarUrl)
// The surrounding element already names the person, so the image itself is
// decorative and must not be announced twice.
expect(image).toHaveAttribute('alt', '')
})
it('falls back to initials when there is no picture', () => {
renderAvatar(withoutPicture)
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('AL')
})
it('falls back to initials when the stored link no longer resolves', () => {
// Go Loose retires a picture key when the picture changes, so a cached URL in
// this browser can be dead. A broken image icon would be worse than initials.
renderAvatar(withPicture)
const image = screen.getByTestId('avatar-image').querySelector('img')!
// An error event on an image does not bubble, so React only sees it when the
// event is dispatched through the testing library.
fireEvent.error(image)
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('AL')
})
it('has something to show for no user at all', () => {
// With no person there is no name either, so the neutral placeholder the rest
// of the interface uses stands in.
renderAvatar(null)
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('PR')
})
})
describe('Avatar sizing', () => {
it('carries the requested size', () => {
const { container } = render(<Avatar user={withoutPicture} size="tiny" className="extra" />)
const avatar = container.querySelector('.avatar')!
expect(avatar.className).toContain('tiny')
expect(avatar.className).toContain('extra')
})
})

View file

@ -1,45 +0,0 @@
import { useState } from 'react'
import type { User } from '../../types'
import { initialsFor } from './initials'
/**
* A person's picture, falling back to their initials.
*
* The URL comes from Go Loose and stops resolving the moment the picture is
* replaced or removed there, because retiring the key is what stops a stale link
* from being a privacy problem. A cached copy in this browser can therefore be
* dead, so a failed load falls back rather than leaving a broken image. An empty
* or absent URL is the common case for anyone who has not uploaded one.
*/
export function Avatar({
user,
size = 'default',
className = '',
}: {
user: Partial<Pick<User, 'displayName' | 'email' | 'avatarUrl'>> | null
size?: 'tiny' | 'default' | 'large'
className?: string
}) {
const url = user?.avatarUrl ?? ''
// Remembering which URL failed, rather than a bare flag, means a new picture
// for the same person is retried without an effect to reset anything.
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const failed = failedUrl === url && url !== ''
const classes = ['avatar', size === 'default' ? '' : size, className].filter(Boolean).join(' ')
const label = (user?.displayName || user?.email || 'Profile').trim()
if (!url || failed) {
return (
<span className={classes} aria-hidden="true" data-testid="avatar-fallback">
{initialsFor(label)}
</span>
)
}
return (
<span className={classes} data-testid="avatar-image">
<img src={url} alt="" onError={() => setFailedUrl(url)} />
<span className="sr-only">{label}</span>
</span>
)
}

View file

@ -1,16 +0,0 @@
/**
* The person's initials, used until a picture loads and whenever there is none.
* The first letter of the first two words reads better than one letter, so
* "Ada Lovelace" gives AL rather than A.
*/
export function initialsFor(name: string | null | undefined, email?: string | null): string {
const source = (name ?? '').trim() || (email ?? '').trim()
if (!source) return '?'
// An address is not a name: "ada@example.test" must not become AE, so only the
// part before the @ counts.
const local = source.includes('@') ? source.slice(0, source.indexOf('@')) : source
const words = local.split(/[\s._-]+/).filter(Boolean)
if (words.length === 0) return '?'
if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase()
return (words[0]!.slice(0, 1) + words[1]!.slice(0, 1)).toUpperCase()
}

View file

@ -1,64 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LoginPage } from './LoginPage'
function stubLocation() {
const location = { assign: vi.fn(), replace: vi.fn(), hostname: 'nmbs.guess.urpi.be' }
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: location,
})
return location
}
function renderPage(ssoAvailable: boolean | null) {
return render(
<MemoryRouter>
<LoginPage onLogin={vi.fn()} sessionExpired={false} ssoAvailable={ssoAvailable} />
</MemoryRouter>,
)
}
describe('LoginPage', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('offers only the Go Loose button while the answer is unknown', () => {
renderPage(null)
expect(screen.getByRole('button', { name: /sign in with go loose/i })).toBeInTheDocument()
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/^password$/i)).not.toBeInTheDocument()
})
it('offers only the Go Loose button where Go Loose is configured', () => {
renderPage(true)
expect(screen.getByRole('button', { name: /sign in with go loose/i })).toBeInTheDocument()
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
})
it('sends the browser to Go Loose rather than routing there', async () => {
// A router push cannot follow a redirect chain across two origins, so this has
// to be a real navigation.
const location = stubLocation()
const user = userEvent.setup()
renderPage(true)
await user.click(screen.getByRole('button', { name: /sign in with go loose/i }))
expect(location.assign).toHaveBeenCalledWith('/api/auth/login')
})
it('falls back to the password form on a host without Go Loose login', () => {
renderPage(false)
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /sign in with go loose/i })).not.toBeInTheDocument()
})
it('says why the password form is being shown', () => {
renderPage(false)
expect(screen.getByText(/single sign-on is not configured/i)).toBeInTheDocument()
})
})

View file

@ -1,47 +1,20 @@
import { ArrowRight, CircleHelp, KeyRound, Sparkles } from 'lucide-react'
import { ArrowRight, CircleHelp, Sparkles } from 'lucide-react'
import { SyntheticEvent, useState } from 'react'
import Logo from '../../components/layout/Logo'
import { FormField } from '../../components/form'
import { ErrorAlert, errorMessage } from '../../components/actions'
import { ThemeControls } from '../../theme'
import { api, authStorage } from '../../api/client'
const LOGIN_PATH = '/api/auth/login'
/**
* The sign-in screen.
*
* Signing in happens at Go Loose, so the usual case is one button that hands the
* browser over to it and comes back to the welcome page. That also means a person
* who already signed in at another application passes straight through, because
* Go Loose recognises the session it already holds and issues a code without
* asking anything.
*
* The password form is only for a host where Go Loose login is not configured:
* the API answers /api/auth/session with 404 there, and without a fallback the
* application would be unreachable on a bare localhost. It is not offered where
* the identity provider is in charge.
*/
export function LoginPage({
onLogin,
sessionExpired,
ssoAvailable,
}: {
onLogin: () => void
sessionExpired: boolean
/** null until the API has said whether Go Loose login is configured here. */
ssoAvailable: boolean | null
}) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
function beginExternalLogin() {
setLoading(true)
// A full navigation, not a router push: Go Loose answers with a redirect
// chain across two origins that the router has no part in.
window.location.assign(LOGIN_PATH)
}
async function submit(event: SyntheticEvent<HTMLFormElement>) {
event.preventDefault()
setLoading(true)
@ -74,10 +47,7 @@ export function LoginPage({
</span>
</div>
</div>
<div className="login-foot">
<ThemeControls />
<p>Designed for fair, collaborative recruitment.</p>
</div>
<p className="login-foot">Designed for fair, collaborative recruitment.</p>
</section>
<section className="login-panel" aria-labelledby="login-title">
<div className="login-card">
@ -88,16 +58,8 @@ export function LoginPage({
Your session is no longer active. Sign in again to continue.
</div>
)}
<p className="muted">
Use your work account to continue. If you are already signed in elsewhere, this takes
you straight through.
</p>
{ssoAvailable === false ? (
<p className="muted">Use your work account to continue.</p>
<form onSubmit={submit} className="stack-form">
<p className="muted">
Single sign-on is not configured for this host. Sign in with a local account.
</p>
<FormField label="Email address" htmlFor="email">
<input id="email" name="email" type="email" autoComplete="email" required />
</FormField>
@ -115,19 +77,6 @@ export function LoginPage({
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
</button>
</form>
) : (
<div className="stack-form">
<button
className="button primary wide"
type="button"
onClick={beginExternalLogin}
disabled={loading}
>
{loading ? 'Redirecting…' : 'Sign in with Go Loose'} <KeyRound size={18} />
</button>
</div>
)}
<p className="demo-note">
<CircleHelp size={16} /> Contact your administrator for access.
</p>

View file

@ -1,6 +1,5 @@
import { Mail, ShieldCheck, UserRound } from 'lucide-react'
import { authStorage } from '../../api/client'
import { Avatar } from '../../components/ui/Avatar'
export function ProfilePage() {
const user = authStorage.user()
@ -15,8 +14,9 @@ export function ProfilePage() {
</header>
<section className="card profile-card">
<div className="profile-identity">
{/* The picture Go Loose serves, with the initials as the fallback. */}
<Avatar user={user} size="large" className="profile-avatar" />
<span className="avatar profile-avatar" aria-hidden="true">
{user.displayName.slice(0, 2).toUpperCase()}
</span>
<div>
<h1>{user.displayName}</h1>
<span className={`status status-${user.role}`}>{user.role}</span>

View file

@ -2,14 +2,8 @@ import { StrictMode } from 'react'
import {createRoot} from 'react-dom/client'
import {BrowserRouter} from 'react-router-dom'
import App from './app/App'
import { installThemeRuntime } from './theme/installThemeRuntime'
import './styles/global.css'
// Started before the first render so the contract stylesheet is in the document
// while the tree mounts. Nothing awaits it: a slow or absent theme service must
// not delay the application, and the page is correct either way.
void installThemeRuntime()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>

View file

@ -1,71 +1,24 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@600;700;800&display=swap');
/* Design tokens.
*
* Every value here is `var(--bn-*, fallback)`. The contract stylesheet published
* by go-bananas defines the --bn-* half at run time from whichever palette and
* appearance are selected; the second argument is this application's own value,
* used whenever that stylesheet is absent.
*
* That ordering is what makes the feature safe to adopt: with no theme service
* configured the page renders exactly as it did before, and with one configured
* the same rules follow the palette. Nothing below this block needs to know which
* of the two it is getting. */
:root {
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--ink);
background: var(--page);
color: #142b3a;
background: #f3f7f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
/* Surfaces, from the page up to the most raised. */
--page: var(--bn-bg, #f3f7f8);
--surface: var(--bn-surface, #ffffff);
--raised: var(--bn-surface-raised, #ffffff);
--sunken: var(--bn-surface-sunken, #eef3f4);
/* The navigation rail is a distinct plane rather than another surface, so it
* takes the sunken step: readable against the page in either appearance. */
--navy: var(--bn-surface-sunken, #081c2c);
--navy-soft: var(--bn-surface, #123449);
/* Text on the rail. It comes from the contract's own foreground pairing rather
* than being fixed to white, because a light appearance wants dark text there. */
--on-rail: var(--bn-on-secondary, #ffffff);
/* Text. */
--ink: var(--bn-text, #142b3a);
--muted: var(--bn-muted, #667986);
--subtle: var(--bn-subtle, #8a9aa4);
/* Lines. */
--line: var(--bn-border, #dce5e8);
--line-strong: var(--bn-border-strong, #c2d2d6);
/* Accents. */
--cyan: var(--bn-accent, #00a9c8);
--cyan-dark: var(--bn-accent-hover, #007f98);
--lime: var(--bn-secondary, #d9ef4d);
--orange: var(--bn-warning, #ff7c52);
--good: var(--bn-good, #13704d);
--danger: var(--bn-danger, #b3261e);
--focus: var(--bn-focus, #007f98);
/* Shape and depth. */
--radius: var(--bn-radius-lg, 16px);
--radius-sm: var(--bn-radius-md, 10px);
--shadow: var(--bn-shadow-md, 0 12px 35px rgba(14, 42, 56, 0.08));
--navy: #081c2c;
--navy-soft: #123449;
--ink: #142b3a;
--muted: #667986;
--line: #dce5e8;
--surface: #ffffff;
--cyan: #00a9c8;
--cyan-dark: #007f98;
--lime: #d9ef4d;
--orange: #ff7c52;
--radius: 16px;
--shadow: 0 12px 35px rgba(14, 42, 56, 0.08);
}
/* Native widgets follow the selected appearance, so a scrollbar, a text
* selection, or a date picker in a form is not light-on-light in dark mode. The
* light default keeps a browser without a theme service exactly as it was. */
:root {
color-scheme: light;
}
:root[data-mode='dark'] {
color-scheme: dark;
}
.eligible-row {
grid-template-columns: auto 1fr;
}
@ -78,7 +31,7 @@
}
html {
min-width: 320px;
background: var(--page);
background: #f3f7f8;
}
body {
margin: 0;
@ -294,7 +247,7 @@ button {
}
.login-story {
background: var(--navy);
color: var(--on-rail);
color: white;
padding: 2.2rem clamp(2rem, 6vw, 6rem);
display: flex;
flex-direction: column;
@ -372,12 +325,12 @@ button {
display: grid;
place-items: center;
padding: 2rem;
background: var(--page);
background: #f3f7f8;
}
.login-card {
width: min(430px, 100%);
padding: clamp(1.8rem, 5vw, 3rem);
background: var(--raised);
background: white;
border-radius: 20px;
box-shadow: var(--shadow);
}
@ -411,7 +364,7 @@ button {
justify-content: center;
}
.form-error {
color: var(--danger);
color: #a92e2e;
}
.error-alert {
display: flex;
@ -458,7 +411,7 @@ button {
inset: 0 auto 0 0;
width: 255px;
background: var(--navy);
color: var(--on-rail);
color: white;
display: flex;
flex-direction: column;
padding: 1.6rem 1.1rem;
@ -721,8 +674,8 @@ button {
color: var(--cyan-dark);
}
.card {
background: var(--raised);
border: 1px solid var(--line);
background: white;
border: 1px solid #e1e9eb;
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
}
@ -2849,135 +2802,3 @@ fieldset {
animation-iteration-count: 1 !important;
}
}
/* Account menu.
The trigger sits in the utilities bar and the panel is anchored to it, so the
panel is positioned against a relatively positioned wrapper rather than the
viewport: it must stay with its trigger when the utilities bar wraps. */
.user-menu {
position: relative;
}
.user-menu-trigger {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-height: 40px;
padding: 0.3rem 0.7rem 0.3rem 0.35rem;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
font: inherit;
color: var(--ink);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s,
background 0.2s;
}
.user-menu-trigger:hover,
.user-menu-trigger:focus-visible {
border-color: var(--line);
background: var(--surface);
}
.user-menu-trigger:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 2px;
}
.user-menu-identity {
display: grid;
line-height: 1.2;
}
.user-menu-name {
font-size: 0.84rem;
font-weight: 700;
}
.user-menu-email {
font-size: 0.72rem;
color: var(--muted);
}
.user-menu-panel {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 30;
min-width: 16rem;
padding: 0.5rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
}
.user-menu-header {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.6rem 0.7rem 0.85rem;
border-bottom: 1px solid var(--line);
margin-bottom: 0.4rem;
}
.user-menu-header p {
margin: 0;
}
.user-menu-panel-name {
font-weight: 700;
}
.user-menu-panel-email {
font-size: 0.78rem;
color: var(--muted);
overflow-wrap: anywhere;
}
.user-menu-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.6rem 0.7rem;
background: transparent;
border: 0;
border-radius: 10px;
font: inherit;
font-size: 0.86rem;
font-weight: 600;
color: var(--ink);
text-decoration: none;
cursor: pointer;
}
.user-menu-item:hover,
.user-menu-item:focus-visible {
background: #eef6f7;
}
.user-menu-item:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -2px;
}
.user-menu-panel + .user-menu-item {
margin-top: 0.15rem;
}
@media (max-width: 720px) {
/* The sidebar carries the same actions on a narrow screen, so the menu keeps
only the picture and its accessible name. */
.user-menu-identity {
display: none;
}
.user-menu-trigger {
padding: 0.3rem;
}
}
/* Theme controls.
The controls render into their own shadow root, so nothing here can reach
inside them and nothing inside can leak out. They inherit the custom
properties that cross the boundary, which is why they follow the palette
without a single rule of their own. */
.theme-controls {
display: flex;
align-items: center;
gap: 0.4rem;
}
.login-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}

View file

@ -1,31 +0,0 @@
import { themeBaseUrl } from './installThemeRuntime'
/**
* The palette picker and the appearance toggle.
*
* Both are the custom elements go-bananas ships rather than components written
* here. That is the whole point of the service: the applications it feeds are a
* mix of React, vanilla JavaScript and server-rendered HTML, and a custom element
* is the one shape all three can use without a build step, an import map, or a
* second copy of React. React treats an unknown element as an inert host node, so
* rendering them unconditionally is safe: with no theme service configured they
* simply stay empty.
*
* Neither takes a `base` prop. They read the service origin from the contract
* stylesheet already on the page, which is the one place it is configured, so
* there is no second place to get wrong.
*
* Whether to render at all is a plain read rather than state: the runtime config
* is written before the first render and does not change afterwards, so there is
* nothing to track and no effect to wait on.
*/
export function ThemeControls() {
if (!themeBaseUrl()) return null
return (
<div className="theme-controls">
<bananas-appearance-toggle label="Appearance" />
<bananas-theme-selector label="Palette" />
</div>
)
}

View file

@ -1,27 +0,0 @@
import type { DetailedHTMLProps, HTMLAttributes } from 'react'
/**
* The custom elements go-bananas ships.
*
* React needs to be told about an unknown element before it will render one, and
* declaring them here is the whole integration: the tags, the attributes they
* accept, and nothing else. The behaviour lives in the service, so there is
* nothing to keep in step.
*
* `label` is read by both controls for their accessible name.
*/
type BananasControl = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
/** Accessible name for the control. */
label?: string
}
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'bananas-theme-selector': BananasControl
'bananas-appearance-toggle': BananasControl
}
}
}
export {}

View file

@ -1,62 +0,0 @@
import { describe, expect, it } from 'vitest'
// The stylesheet is imported as text rather than read from disk: this keeps Node
// APIs out of a project whose tsconfig deliberately does not include them, so
// application code cannot reach for one by accident.
import stylesheet from '../styles/global.css?raw'
/**
* The contract is a remote document, so the way this application consumes it can
* rot quietly: a token renamed on the service leaves every `var(--bn-*, fallback)`
* resolving to its fallback, and the page looks fine while never actually
* following the palette. Nothing else would catch that.
*
* This reads the stylesheet, takes every contract variable the application asks
* for, and checks the real contract defines each one. It is skipped when no theme
* service is configured, so an ordinary checkout does not need one.
*/
const baseUrl = (
import.meta.env.THEME_BASE_URL ??
import.meta.env.VITE_THEME_BASE_URL ??
''
).replace(/\/+$/, '')
/** Every `--bn-*` name the stylesheet consumes. */
function contractVariablesUsed(): string[] {
const found = new Set<string>()
const pattern = /var\(\s*(--bn-[a-z0-9-]+)/g
let match: RegExpExecArray | null
while ((match = pattern.exec(stylesheet)) !== null) {
found.add(match[1]!)
}
return [...found].sort()
}
describe('the styling contract', () => {
const used = contractVariablesUsed()
it('consumes the contract rather than hardcoding a palette', () => {
expect(used.length).toBeGreaterThan(0)
})
it('gives every contract variable a fallback, so a missing service changes nothing', () => {
// Each use must carry a second argument. A bare var(--bn-x) with no fallback
// resolves to nothing at all when the service is absent, which is how a
// half-configured deployment ends up with invisible text.
const bare = [...stylesheet.matchAll(/var\(\s*(--bn-[a-z0-9-]+)\s*\)/g)].map((m) => m[1]!)
expect(bare).toEqual([])
})
it.runIf(baseUrl)('defines every variable this application asks for', async () => {
const response = await fetch(`${baseUrl}/rt/v1/contract.css`)
expect(response.status, `fetching the contract from ${baseUrl}`).toBe(200)
const contract = await response.text()
const defined = new Set<string>()
for (const match of contract.matchAll(/(--bn-[a-z0-9-]+)\s*:/g)) {
defined.add(match[1]!)
}
const missing = used.filter((name) => !defined.has(name))
expect(missing, `the contract at ${baseUrl} does not define these`).toEqual([])
})
})

View file

@ -1,2 +0,0 @@
export { ThemeControls } from './ThemeControls'
export { installThemeRuntime, themeBaseUrl } from './installThemeRuntime'

View file

@ -1,66 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { installThemeRuntime, themeBaseUrl } from './installThemeRuntime'
const CONTRACT_LINK_MARKER = 'data-theme-contract'
function config(themeBaseUrl: string) {
window.__GO_GUESS_CONFIG__ = { themeBaseUrl }
}
afterEach(() => {
delete window.__GO_GUESS_CONFIG__
document.head.innerHTML = ''
vi.restoreAllMocks()
})
describe('themeBaseUrl', () => {
it('is empty when nothing is configured', () => {
expect(themeBaseUrl()).toBe('')
})
it('reads the runtime config the entrypoint writes', () => {
config('https://themes.example.com')
expect(themeBaseUrl()).toBe('https://themes.example.com')
})
it('tolerates a trailing slash, which would otherwise double up in the path', () => {
config('https://themes.example.com/')
expect(themeBaseUrl()).toBe('https://themes.example.com')
})
})
describe('installThemeRuntime', () => {
it('does nothing when no theme service is configured', async () => {
config('')
await installThemeRuntime()
expect(document.head.querySelector(`link[${CONTRACT_LINK_MARKER}]`)).toBeNull()
})
it('adds the contract stylesheet, marked so the controls can find the origin', async () => {
config('https://themes.example.com')
await installThemeRuntime()
const link = document.head.querySelector<HTMLLinkElement>(`link[${CONTRACT_LINK_MARKER}]`)
expect(link).not.toBeNull()
expect(link!.rel).toBe('stylesheet')
expect(link!.href).toBe('https://themes.example.com/rt/v1/contract.css')
})
it('adds the stylesheet only once, however often it is called', async () => {
config('https://themes.example.com')
await installThemeRuntime()
await installThemeRuntime()
await installThemeRuntime()
expect(document.head.querySelectorAll(`link[${CONTRACT_LINK_MARKER}]`)).toHaveLength(1)
})
it('resolves rather than throwing when the module cannot be fetched', async () => {
// A theme service that is down must not stop the application. The controls are
// custom elements, so failing to load leaves inert elements and the page keeps
// the values baked into its own stylesheet.
config('https://themes.example.com')
await expect(installThemeRuntime()).resolves.toBeUndefined()
// The stylesheet is still requested, so the palette follows whatever
// selection is already stored even with no picker to change it.
expect(document.head.querySelector(`link[${CONTRACT_LINK_MARKER}]`)).not.toBeNull()
})
})

View file

@ -1,63 +0,0 @@
/**
* Where the theme service lives, and whether it answered.
*
* The URL is read from the runtime config rather than baked into the bundle so
* one image serves every environment: the entrypoint rewrites runtime-config.js
* from the environment on start. A build-time variable is only the convenience
* for `npm run dev`, where there is no entrypoint to do it.
*/
export interface RuntimeConfig {
/** Origin of the go-bananas runtime, or empty when theming is not configured. */
themeBaseUrl?: string
}
declare global {
interface Window {
__GO_GUESS_CONFIG__?: RuntimeConfig
}
}
/** The marker the shipped controls read the base URL from. */
const CONTRACT_LINK_MARKER = 'data-theme-contract'
export function themeBaseUrl(): string {
const configured = window.__GO_GUESS_CONFIG__?.themeBaseUrl
if (configured) return configured.replace(/\/+$/, '')
const built = import.meta.env.VITE_THEME_BASE_URL
return built ? built.replace(/\/+$/, '') : ''
}
/** Whether the contract stylesheet is already on the page. */
function contractLinked(): boolean {
return document.querySelector(`link[${CONTRACT_LINK_MARKER}]`) !== null
}
/**
* Loads the shared styling contract and the two controls, once.
*
* The stylesheet goes in first and synchronously-ish, before the controls, so the
* palette is applied before anything paints rather than flashing the application's
* own colours and then swapping them.
*
* Everything here is best effort. A theme service that is unreachable, slow, or
* not configured must not stop the application from working, so every failure
* resolves quietly and the page keeps the values baked into the stylesheet.
*/
export async function installThemeRuntime(): Promise<void> {
const base = themeBaseUrl()
if (!base || contractLinked()) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `${base}/rt/v1/contract.css`
link.setAttribute(CONTRACT_LINK_MARKER, '')
document.head.append(link)
try {
await import(/* @vite-ignore */ `${base}/rt/v1/components.js`)
} catch {
// The controls are custom elements, so a failure here leaves inert elements
// in the page rather than an error. The contract stylesheet still applies, so
// the palette follows the stored selection even without the picker.
}
}

View file

@ -5,12 +5,6 @@ export interface User {
email: string
displayName: string
role: string
/**
* Absolute URL of the picture Go Loose serves for this person, or empty when
* they have not uploaded one. Resolved on every sign-in rather than stored, so
* it always reflects the current picture.
*/
avatarUrl?: string
}
export interface AuthResponse {

View file

@ -4,14 +4,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: [
'nmbs.guess-dev.urpi.be',
'ypto.guess-dev.urpi.be',
'nmbs.guess.urpi.be',
'ypto.guess.urpi.be',
'nmbs.guess.local',
'ypto.guess.local',
],
allowedHosts: ['nmbs.guess.local', 'ypto.guess.local'],
proxy: {
'/api': {
target: 'http://localhost:8080',

179
rancher-install.md Normal file
View file

@ -0,0 +1,179 @@
# Rancher single-server installation
This runbook reproduces the local Rancher management instance installed on the
dedicated server. It binds Rancher only to loopback; Nginx and Certbot provide
public access separately.
This Docker-based Rancher installation is appropriate for a single-server,
non-HA environment. A production environment that requires control-plane high
availability should run Rancher with its Helm chart on a dedicated multi-node
Kubernetes cluster instead of copying this single-container topology.
## Installed versions and paths
- Rancher: `v2.15.1`
- Image:
`rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5`
- Persistent data: `/opt/rancher`
- Registry configuration: `~/.config/kubectl/registries.yaml`
- Local HTTP: `http://127.0.0.1:8000`
- Local HTTPS: `https://127.0.0.1:8443`
- Development application NodePort: `http://127.0.0.1:30080`
- Production application NodePort: `http://127.0.0.1:31374`
Pinning the digest makes another installation reproducible. Review and update
the Rancher version deliberately rather than running an unpinned `latest` image
in production.
## 1. Configure the HTTP image registry
The source file is committed at `deploy/rancher/registries.yaml`. Install it:
```bash
install -Dm0644 deploy/rancher/registries.yaml \
"$HOME/.config/kubectl/registries.yaml"
```
Its content is:
```yaml
mirrors:
"batty1039.startdedicated.net:5000":
endpoint:
- "http://batty1039.startdedicated.net:5000"
configs:
"batty1039.startdedicated.net:5000":
tls:
insecure_skip_verify: true
```
The original draft contained duplicate YAML keys. Duplicate keys are invalid
for this purpose because parsers may silently discard one occurrence.
## 2. Prepare persistent storage
```bash
sudo install -d -m 0755 /opt/rancher
```
Back up this directory before Rancher upgrades. It contains the embedded K3s
management cluster and all Rancher state.
## 3. Pull and run Rancher
```bash
docker pull \
rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5
docker run -d \
--restart=unless-stopped \
-p 127.0.0.1:8000:80 \
-p 127.0.0.1:8443:443 \
-p 127.0.0.1:10000:10000 \
-p 127.0.0.1:10100:10100 \
-v /opt/rancher:/var/lib/rancher \
-v "$HOME/.config/kubectl/registries.yaml:/etc/rancher/k3s/registries.yaml:ro" \
--privileged \
--name rancher \
rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5
```
The explicit `127.0.0.1` bindings prevent Rancher and application NodePorts
from being published directly to the internet. Nginx on the same server can
still reach them. Docker cannot add port mappings to an existing container, so
new NodePorts must be included when recreating the Rancher container.
## 4. Verify the installation
Wait until the readiness endpoint returns `pong`:
```bash
until curl -ksSf https://127.0.0.1:8443/ping | grep -q pong; do
sleep 5
done
docker ps --filter name='^/rancher$'
docker exec rancher kubectl get nodes -o wide
```
Expected results:
- The container is running with restart policy `unless-stopped`.
- Only loopback ports `8000`, `8443`, `30080`, and `31374` are published.
- `https://127.0.0.1:8443/ping` returns `pong`.
- The embedded `local-node` reports `Ready`.
After deploying Go Guess, verify the application NodePort:
```bash
curl --fail http://127.0.0.1:30080/api/health
```
Production PostgreSQL and RabbitMQ require persistent volumes. Install the
single-node local-path provisioner before the production Helm release:
```bash
make rancher-storage
make helm-deploy-production
```
The provisioner stores data under `/var/lib/rancher/k3s/storage`, which is
inside the persistent `/opt/rancher` host mount. Its StorageClass uses a
`Retain` reclaim policy so deleting a claim does not automatically delete the
underlying production data.
Retrieve the one-time bootstrap password locally, open
`https://127.0.0.1:8443`, and immediately choose a permanent administrator
password:
```bash
docker logs rancher 2>&1 |
sed -n 's/.*Bootstrap Password: //p' |
tail -n 1
```
Do not save that password in this repository.
## 5. Add Nginx and Certbot separately
For a public Rancher hostname, configure one.com DNS to point at the dedicated
server. Proxy Nginx to `https://127.0.0.1:8443`, enable WebSocket upgrade
headers, and disable upstream certificate verification because Rancher's local
certificate is self-signed. Then request the public certificate:
```bash
sudo certbot --nginx -d rancher.urpi.be
sudo certbot renew --dry-run
```
Expose only public TCP ports `80` and `443`. Keep `8000` and `8443` bound to
loopback as configured above.
## Operations
Inspect status and logs:
```bash
docker ps --filter name='^/rancher$'
docker logs --tail 200 rancher
```
Restart Rancher:
```bash
docker restart rancher
```
Create a consistent backup:
```bash
docker stop rancher
sudo tar -C /opt -czf "rancher-backup-$(date +%Y%m%d-%H%M%S).tar.gz" rancher
docker start rancher
```
Before an upgrade, read the Rancher release notes and compatibility matrix,
back up `/opt/rancher`, pull a specifically pinned replacement image, remove
only the stopped `rancher` container, and recreate it with the same mounts and
port bindings.

View file

@ -3,7 +3,7 @@
set -eu
github_url='https://github.com/danyel/go-guess.git'
forgejo_url='https://forgejo.urpi.be/exr462/go-guess.git'
forgejo_url='https://forgejo.dev/exr462/go-guess.git'
remote='origin'
current_url=$(git remote get-url "$remote")