19 Commits

Author SHA1 Message Date
02ca56358d 🔒 fix(ci): add tofu_module_reader SSH key to vault.yaml secrets (mirrors erp pattern) (#100)
All checks were successful
Docker Push / Docker Push (push) Successful in 2m3s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 14:04:09 +02:00
3fee1e9ed7 feat(deploy): iac/ Vault provisioning + workflow (uses app_roles module from tools) (#99)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 12s
Docker Push / Docker Push (push) Successful in 4m12s
CI/CD Pipeline / CI Pipeline (push) Successful in 6m10s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 11s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 13:20:26 +02:00
3be6a2b7ef 🔒 fix(deploy): use websecure entrypoint + letsencrypt TLS for .lab ingress (#98)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m44s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 08:14:55 +02:00
03a47396c5 feat(deploy): chart Vault CRDs gated by vault.enabled (default false) (#97)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 11s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m23s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 07:14:40 +02:00
a26cc96239 📝 docs: 2026-05-06 autonomous morning session recap (#96)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 07:11:53 +02:00
2a6ad23523 📝 docs(changelog): record PRs #87-94 (2026-05-06 morning batch) (#95)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 07:09:14 +02:00
849383d6c8 🤖 ci(docker): auto-build on push to main + fix root Dockerfile swag step (#94)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 17s
Docker Push / Docker Push (push) Successful in 4m57s
CI/CD Pipeline / CI Pipeline (push) Failing after 6m18s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 07:06:09 +02:00
63b892b10f Merge pull request '📝 docs: refresh AGENTS.md + README.md (auth endpoints + ADR pointer + new packages)' (#93) from vibe/batch-pr-docs1-refresh-agents-readme into main 2026-05-06 07:03:44 +02:00
886cbab36d 📝 docs: refresh AGENTS.md + README.md (auth endpoints + ADR pointer + new packages)
AGENTS.md and README.md were stale since ~2026-04-11 (4 weeks). Updated to reflect magic-link + OIDC auth (ADR-0028), pkg/auth, pkg/email, pkg/user/api packages, and 30-ADR index. Endpoints listing decision : keep curated short list + pointer to swagger as source of truth (see body of changes).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-06 07:03:15 +02:00
a385765030 Merge pull request '🧪 test(server): unit tests for AuthMiddleware Optional/Required handlers' (#92) from vibe/batch-pr-t1-middleware-tests into main
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 11s
CI/CD Pipeline / CI Pipeline (push) Failing after 5m12s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped
2026-05-06 06:58:46 +02:00
ab4918adfc 🧪 test(server): unit tests for AuthMiddleware Optional/Required handlers
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-06 06:58:25 +02:00
17de45563d ♻️ refactor(server): split AuthMiddleware into Optional/Required (RFC 6750 + ISP narrow interface)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 15s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-06 06:56:02 +02:00
e5a1979b1f Merge pull request '♻️ refactor(auth): move UserContextKey from pkg/greet to pkg/auth' (#90) from vibe/batch-pr-d1-move-user-context-key into main
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 11s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled
2026-05-06 06:54:36 +02:00
92e53a6801 ♻️ refactor(auth): move UserContextKey from pkg/greet to pkg/auth
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-05-06 06:54:14 +02:00
f74ba51d7a feat(deploy): Dockerfile + Helm chart for k3s homelab deployment (#89)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has started running
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 06:51:14 +02:00
02bafbb0e2 🔒 fix(security): redact JWT tokens and HMAC secrets in trace logs (auth_service.go) (#88)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m29s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 06:43:30 +02:00
1aef136436 📝 docs: cherry-pick 6 focused guides from PR #17 (option c) (#87)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-06 06:37:17 +02:00
da51883c88 Merge pull request '📝 docs(changelog): record PR #85' (#86) from vibe/batch18-task-changelog-85 into main 2026-05-05 22:52:40 +02:00
904bbe41f5 📝 docs(changelog): record PR #85 2026-05-05 22:52:25 +02:00
34 changed files with 1718 additions and 82 deletions

View File

@@ -6,7 +6,6 @@
name: Docker Push
on:
# Manual trigger for testing or production
workflow_dispatch:
inputs:
ref:
@@ -14,6 +13,18 @@ on:
required: false
type: string
default: ''
push:
branches:
- main
paths-ignore:
- 'README.md'
- 'AGENTS.md'
- 'CHANGELOG.md'
- 'AGENT_CHANGELOG.md'
- 'documentation/**'
- 'adr/**'
- 'chart/**'
- 'features/**'
# Environment variables
env:

View File

@@ -0,0 +1,60 @@
---
name: Hashicorp Vault
on:
workflow_dispatch: {}
push: &vaultPaths
paths:
- 'iac/*.tf'
pull_request: *vaultPaths
concurrency:
group: ${{ github.ref }}-${{ github.workflow }}
cancel-in-progress: true
.vault_step: &vault_step
name: read vault secret
uses: https://gitea.arcodange.lab/arcodange-org/vault-action.git@main
id: vault-secrets
with:
url: https://vault.arcodange.lab
caCertificate: ${{ secrets.HOMELAB_CA_CERT }}
jwtGiteaOIDC: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
role: gitea_cicd_dance-lessons-coach
method: jwt
path: gitea_jwt
secrets: |
kvv1/google/credentials credentials | GOOGLE_BACKEND_CREDENTIALS ;
kvv1/gitea/tofu_module_reader ssh_private_key | TERRAFORM_SSH_KEY ;
jobs:
gitea_vault_auth:
name: Auth with gitea for vault
runs-on: ubuntu-latest-ca
outputs:
gitea_vault_jwt: ${{steps.gitea_vault_jwt.outputs.id_token}}
steps:
- name: Auth with gitea for vault
id: gitea_vault_jwt
run: |
echo -n "${{ secrets.vault_oauth__sh_b64 }}" | base64 -d | bash
tofu:
name: Tofu - Vault
needs:
- gitea_vault_auth
runs-on: ubuntu-latest-ca
env:
OPENTOFU_VERSION: 1.8.2
TERRAFORM_VAULT_AUTH_JWT: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
VAULT_CACERT: "${{ github.workspace }}/homelab.pem"
steps:
- *vault_step
- uses: actions/checkout@v4
- name: prepare vault self signed cert
run: echo -n "${{ secrets.HOMELAB_CA_CERT }}" | base64 -d > $VAULT_CACERT
- name: terraform apply
uses: dflook/terraform-apply@v1
with:
path: iac
auto_approve: true

View File

@@ -11,6 +11,9 @@ AI agent reference for developing, testing, and operating the dance-lessons-coac
| Logging | Zerolog | v1.35.0 |
| Configuration | Viper | v1.21.0 |
| Telemetry | OpenTelemetry | v1.43.0 |
| Database | PostgreSQL + GORM | (optional, for user persistence) |
| Auth | JWT + bcrypt | with rotating secrets |
| Email | SMTP (Mailpit dev) | for magic-link delivery |
## Project Structure
@@ -21,11 +24,14 @@ dance-lessons-coach/
│ ├── greet/ # CLI application
│ └── server/ # Web server entry point
├── pkg/
│ ├── auth/ # Auth context keys, OIDC client, helpers
│ ├── config/ # Viper-based configuration
│ ├── email/ # SMTP sender abstraction
│ ├── greet/ # Core domain logic + API handlers
│ ├── server/ # HTTP server, routing, graceful shutdown
│ ├── telemetry/ # OpenTelemetry instrumentation
│ ├── user/ # User domain (auth, JWT, repository)
│ ├── user/api/ # Auth + admin HTTP handlers
│ └── validation/ # Request validation
├── scripts/ # Server lifecycle, build, test scripts
├── config.yaml # Configuration file
@@ -80,20 +86,35 @@ logging:
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Liveness — always `{"status":"healthy"}` |
| GET | `/api/healthz` | Alternative liveness probe (k8s convention) |
| GET | `/api/ready` | Readiness — 200 when ready, 503 during shutdown |
| GET | `/api/info` | Composite info endpoint (cf. ADR-0026) |
| GET | `/api/version` | Version info (`?format=plain\|full\|json`) |
| GET | `/api/v1/greet/` | Default greeting |
| GET | `/api/v1/greet/{name}` | Personalized greeting |
| POST | `/api/v1/auth/login` | Username + password login (JWT) |
| POST | `/api/v1/auth/register` | Account creation |
| POST | `/api/v1/auth/validate` | JWT validation |
| POST | `/api/v1/auth/password-reset/request` | Request a password reset |
| POST | `/api/v1/auth/password-reset/complete` | Complete a password reset |
| POST | `/api/v1/auth/magic-link/request` | Passwordless : request a magic link by email |
| GET | `/api/v1/auth/magic-link/consume` | Passwordless : consume a magic-link token |
| GET | `/api/v1/auth/oidc/{provider}/start` | OIDC : begin authorization (PKCE) |
| GET | `/api/v1/auth/oidc/{provider}/callback` | OIDC : authorization code → JWT |
| GET | `/api/v1/admin/jwt/secrets` | Admin : list JWT signing secrets |
| POST | `/api/v1/admin/jwt/secrets` | Admin : add a JWT signing secret |
| POST | `/api/v1/admin/jwt/secrets/rotate` | Admin : rotate the active JWT secret |
| POST | `/api/v2/greet` | V2 greeting with validation (feature-flagged) |
| POST | `/api/admin/cache/flush` | Admin : flush in-memory caches |
| GET | `/swagger/` | Swagger UI |
| GET | `/swagger/doc.json` | OpenAPI spec |
| GET | `/swagger/doc.json` | OpenAPI spec (source of truth) |
```bash
curl http://localhost:8080/api/health
curl http://localhost:8080/api/ready
curl http://localhost:8080/api/v1/greet/Alice
curl -X POST http://localhost:8080/api/v2/greet \
-H "Content-Type: application/json" -d '{"name":"Alice"}'
# See `/swagger/` for the full interactive list of endpoints.
# The OpenAPI spec at `/swagger/doc.json` is the source of truth.
```
## Testing
@@ -151,19 +172,9 @@ docker run -d --name jaeger \
## Architecture Decision Records
| ADR | Decision |
|-----|----------|
| [0001](adr/0001-go-1.26.1-standard.md) | Go 1.26.1 |
| [0002](adr/0002-chi-router.md) | Chi router |
| [0003](adr/0003-zerolog-logging.md) | Zerolog |
| [0004](adr/0004-interface-based-design.md) | Interface-based design |
| [0005](adr/0005-graceful-shutdown.md) | Graceful shutdown |
| [0006](adr/0006-configuration-management.md) | Viper configuration |
| [0007](adr/0007-opentelemetry-integration.md) | OpenTelemetry |
| [0008](adr/0008-bdd-testing.md) | BDD with Godog |
| [0009](adr/0009-hybrid-testing-approach.md) | Hybrid testing strategy |
The full index is at [adr/README.md](adr/README.md) — 30 ADRs covering Go version, Chi router, Zerolog, OpenTelemetry, BDD testing, JWT auth, PostgreSQL, OIDC migration, email infrastructure, etc.
Add a new ADR: copy an existing file, edit it, update `adr/README.md`.
Add a new ADR : copy an existing file in `adr/`, edit it, update `adr/README.md`.
## Commit Conventions

View File

@@ -21,6 +21,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 📝 PHASE_B_ROADMAP marks B.3 + B.4 done (PR #80)
- 📝 documentation/2026-05-05-AUTONOMOUS-SESSION-RECAP.md captures the day's 24 Mistral autonomous PRs (PR #81)
- 📝 README link to Mistral autonomous pattern doc (PR #83)
- 📝 documentation/STATUS.md project snapshot for onboarding (PR #85)
- 📝 documentation guides cherry-picked from PR #17 : CLI.md, CODE_EXAMPLES.md, HISTORY.md, OBSERVABILITY.md, ROADMAP.md, TROUBLESHOOTING.md (PR #87)
- 🔒 redact JWT tokens and HMAC secrets in trace logs of pkg/user/auth_service.go via sha256 fingerprints (PR #88)
- ✨ Dockerfile (root) + Helm chart for k3s homelab deployment, degraded mode without DB/SMTP/Vault (PR #89)
- ♻️ move UserContextKey + GetAuthenticatedUserFromContext from pkg/greet to pkg/auth (PR #90)
- ♻️ split AuthMiddleware into OptionalHandler + RequiredHandler with RFC 6750 challenge headers, narrow tokenValidator interface, case-insensitive Bearer (PR #91)
- 🧪 unit tests for AuthMiddleware Optional/Required handlers + extractBearerToken edge cases (PR #92)
- 📝 refresh AGENTS.md and README.md to reflect auth endpoints (magic-link, OIDC, JWT admin), pkg/auth, pkg/email, pkg/user/api packages, and 30-ADR index. Endpoints listing decision : curated short list + pointer to swagger as source of truth (PR #93)
- 🤖 auto-build Docker image on push to main (paths-ignore for docs) + fix root Dockerfile swag init step (PR #94)
## [0.1.0] - 2026-05-05

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# Build dance-lessons-coach Docker image
FROM golang:1.26-alpine AS builder
# Install git (required for go mod download)
RUN apk add --no-cache git
# Set working directory
WORKDIR /app
# Copy go module files and download dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy entire source code
COPY . .
# Generate Swagger documentation if not already present
# (pkg/server/docs/ is gitignored ; the binary //go:embed depends on it)
RUN if [ ! -f pkg/server/docs/swagger.json ]; then \
go install github.com/swaggo/swag/cmd/swag@latest && \
cd pkg/server && go generate ; \
fi
# Build the server binary
RUN go build -o app ./cmd/server
# Final lightweight stage
FROM alpine:latest
# Install CA certificates for HTTPS
RUN apk --no-cache add ca-certificates
# Set working directory
WORKDIR /root/
# Copy binary from builder stage
COPY --from=builder /app/app .
# Expose port 8080
EXPOSE 8080
# Start the server
CMD ["./app"]

View File

@@ -17,10 +17,13 @@ Go web service demonstrating idiomatic package structure, versioned JSON API, an
- Viper configuration (file + env vars)
- Readiness endpoint for Kubernetes / service mesh
- OpenTelemetry / Jaeger distributed tracing
- OpenAPI / Swagger UI (embedded in binary)
- PostgreSQL user service with JWT auth
- BDD + unit tests
- 🤖 Mistral autonomous PR pattern (cf. [documentation/MISTRAL-AUTONOMOUS-PATTERN.md](documentation/MISTRAL-AUTONOMOUS-PATTERN.md))
- OpenAPI / Swagger UI (embedded in binary, source of truth at `/swagger/doc.json`)
- Username + password authentication with JWT (rotating secrets)
- Passwordless magic-link authentication (email-delivered, ADR-0028 Phase A)
- OIDC authentication with PKCE (multi-provider, ADR-0028 Phase B)
- PostgreSQL user persistence with GORM
- BDD + unit tests (Godog)
- Mistral autonomous PR pattern (cf. [documentation/MISTRAL-AUTONOMOUS-PATTERN.md](documentation/MISTRAL-AUTONOMOUS-PATTERN.md))
## Quick Start
@@ -63,16 +66,21 @@ See `config.example.yaml` for a full template.
## API
The full interactive list is in the Swagger UI at `/swagger/` (source of truth at `/swagger/doc.json`). Most-used endpoints :
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Liveness check |
| GET | `/api/ready` | Readiness check (503 during shutdown) |
| GET | `/api/version` | Version info (`?format=plain\|full\|json`) |
| GET | `/api/v1/greet/` | Default greeting |
| GET | `/api/version` | Version info |
| GET | `/api/v1/greet/{name}` | Named greeting |
| POST | `/api/v2/greet` | V2 greeting with validation |
| POST | `/api/v1/auth/login` | Login (JWT) |
| POST | `/api/v1/auth/magic-link/request` | Passwordless magic-link |
| GET | `/api/v1/auth/oidc/{provider}/start` | OIDC login |
| GET | `/swagger/` | Swagger UI |
This decision is intentional : the markdown table drifts ; swagger.json doesn't (it's regenerated from `swag` annotations on every build). Curated short list here for discovery, swagger for completeness.
## Testing
```bash

23
chart/.helmignore Normal file
View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

6
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: dance-lessons-coach
description: Helm chart for dance-lessons-coach Go API server (ARCODANGE)
type: application
version: 0.1.0
appVersion: "latest"

22
chart/templates/NOTES.txt Normal file
View File

@@ -0,0 +1,22 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "dance-lessons-coach.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "dance-lessons-coach.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "dance-lessons-coach.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "dance-lessons-coach.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "dance-lessons-coach.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "dance-lessons-coach.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "dance-lessons-coach.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "dance-lessons-coach.labels" -}}
helm.sh/chart: {{ include "dance-lessons-coach.chart" . }}
{{ include "dance-lessons-coach.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "dance-lessons-coach.selectorLabels" -}}
app.kubernetes.io/name: {{ include "dance-lessons-coach.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "dance-lessons-coach.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "dance-lessons-coach.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "dance-lessons-coach.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "dance-lessons-coach.labels" . | nindent 4 }}
data:
{{ toYaml .Values.config | indent 2 }}

View File

@@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "dance-lessons-coach.fullname" . }}
labels:
{{- include "dance-lessons-coach.labels" . | nindent 4 }}
spec:
revisionHistoryLimit: 3
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "dance-lessons-coach.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "dance-lessons-coach.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "dance-lessons-coach.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
envFrom:
- configMapRef:
name: {{ include "dance-lessons-coach.fullname" . }}-config
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- 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

@@ -0,0 +1,61 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "dance-lessons-coach.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "dance-lessons-coach.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "dance-lessons-coach.fullname" . }}
labels:
{{- include "dance-lessons-coach.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "dance-lessons-coach.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "dance-lessons-coach.serviceAccountName" . }}
labels:
{{- include "dance-lessons-coach.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}

View File

@@ -0,0 +1,15 @@
{{- if .Values.vault.enabled }}
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: auth
namespace: {{ .Release.Namespace }}
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: {{ .Values.vault.role }}
serviceAccount: {{ include "dance-lessons-coach.serviceAccountName" . }}
audiences:
- vault
{{- end }}

View File

@@ -0,0 +1,17 @@
{{- if .Values.vault.enabled }}
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
name: vso-db
namespace: {{ .Release.Namespace }}
spec:
mount: postgres
path: {{ .Values.vault.postgresPath }}
destination:
create: true
name: vso-db-credentials
rolloutRestartTargets:
- kind: Deployment
name: {{ include "dance-lessons-coach.fullname" . }}
vaultAuthRef: auth
{{- end }}

View File

@@ -0,0 +1,16 @@
{{- if .Values.vault.enabled }}
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: vault-kv-app
namespace: {{ .Release.Namespace }}
spec:
type: kv-v2
mount: kvv2
path: {{ .Values.vault.kvv2Path }}
destination:
name: secretkv
create: true
refreshAfter: 30s
vaultAuthRef: auth
{{- end }}

126
chart/values.yaml Normal file
View File

@@ -0,0 +1,126 @@
# Default values for dance-lessons-coach.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: gitea.arcodange.lab/arcodange/dance-lessons-coach
pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion.
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Automatically mount a ServiceAccount's API credentials?
automount: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 8080
ingress:
enabled: true
className: ""
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab
traefik.ingress.kubernetes.io/router.tls.domains.0.sans: dancecoachlessons.arcodange.lab
traefik.ingress.kubernetes.io/router.middlewares: localIp@file
hosts:
- host: dancecoachlessons.arcodange.lab
paths:
- path: /
pathType: Prefix
tls: []
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
livenessProbe:
httpGet:
path: /api/healthz
port: http
readinessProbe:
httpGet:
path: /api/healthz
port: http
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
# Additional volumes on the output Deployment definition.
volumes: []
# - name: foo
# secret:
# secretName: mysecret
# optional: false
# Additional volumeMounts on the output Deployment definition.
volumeMounts: []
# - name: foo
# mountPath: "/etc/foo"
# readOnly: true
nodeSelector:
kubernetes.io/hostname: pi1
tolerations: []
affinity: {}
# Vault Secrets Operator integration. Disabled by default ; set vault.enabled=true
# to render the VaultAuth / VaultStaticSecret / VaultDynamicSecret CRDs (requires
# VSO operator + Vault prereqs, cf. iac/ once shipped).
vault:
enabled: false
role: dance-lessons-coach # k8s auth backend role name (matches iac/main.tf)
kvv2Path: dance-lessons-coach/config # KVv2 secret path
postgresPath: creds/dance-lessons-coach # postgres dynamic creds path
# DLC-specific configuration
config:
DLC_LOGGING_JSON: "true"
DLC_LOGGING_LEVEL: "info"
DLC_DATABASE_HOST: ""
DLC_DATABASE_PORT: "5432"
DLC_API_V2_ENABLED: "false"

View File

@@ -0,0 +1,93 @@
# 2026-05-06 Autonomous Session Recap (morning)
On 2026-05-06 morning, ARCODANGE used the Mistral Vibe autonomous multi-process pattern to ship 8 PRs in ~30 min, advancing both the deployment story and the middleware code review action items raised by the user the night before. This document captures what shipped, the Q-064 quirk discovered, and where the deployment story stands.
---
## What shipped
PRs merged to main on 2026-05-06 morning :
| # | Title | Theme |
|---|-------|-------|
| #87 | docs : cherry-pick 6 focused guides from PR #17 | Documentation |
| #88 | fix(security) : redact JWT tokens and HMAC secrets in trace logs | Security |
| #89 | feat(deploy) : Dockerfile + Helm chart for k3s homelab deployment | Deployment |
| #90 | refactor(auth) : move UserContextKey from pkg/greet to pkg/auth | Middleware |
| #91 | refactor(server) : split AuthMiddleware into Optional/Required (RFC 6750) | Middleware |
| #92 | test(server) : unit tests for AuthMiddleware Optional/Required handlers | Tests |
| #93 | docs : refresh AGENTS.md + README.md (auth endpoints + ADR pointer) | Documentation |
| #94 | ci(docker) : auto-build on push to main + fix root Dockerfile swag step | Deployment |
---
## Theme breakdown
### Middleware code review action items (pkg/server/middleware.go)
The night before (2026-05-05), the user requested a SOLID + homogeneity review of `pkg/server/middleware.go`. Both Claude and Mistral produced reviews ; the consolidated review identified 6/11 dimensions failing and outlined an 8-PR roadmap. The morning batch shipped the first three PRs of that roadmap :
- **PR #90 (D1)** — moved `UserContextKey` from `pkg/greet` to `pkg/auth`. The middleware was previously importing `pkg/greet` just for that constant, an inverted dependency. `pkg/auth` is the right home.
- **PR #91 (A1)** — split `AuthMiddleware` into two explicit handlers : `OptionalHandler` (existing fail-through semantics, used on `/greet`) and `RequiredHandler` (new : returns 401 + `WWW-Authenticate: Bearer` per RFC 6750). Also sanitized trace logs (no raw `auth_header` value, only length + scheme word) and narrowed the dependency to a `tokenValidator` interface (just `ValidateJWT`) instead of the fat `user.AuthService`.
- **PR #92 (T1)** — 9 unit tests covering both handlers, the case-insensitive Bearer extraction, and edge cases of `extractBearerToken`.
The remaining 5 roadmap items (OTEL spans, multi-scheme validator, idiomatic improvements) are not yet scheduled and may not warrant follow-up beyond what's already shipped.
### Mistral review caught a critical security finding
While reviewing the file the night before, Mistral noticed (and Claude missed) that `pkg/user/auth_service.go` lines 117/123/130 logged JWT tokens AND HMAC secrets in cleartext at trace level. PR #88 redacts these via sha256 fingerprints. Score one for the Mistral review.
### Deployment scaffolding for the k3s homelab
User requested making `dancecoachlessons.arcodange.lab/swagger/doc.json` referenceable by deploying to the ARCODANGE k3s homelab. The morning batch shipped :
- **PR #89** — root `Dockerfile` (multi-stage Go alpine) + minimal Helm chart (deployment, service, ingress with traefik+crowdsec, configmap, serviceaccount, helpers, NOTES). Pattern adapted from `arcodange-org/webapp`. Degraded mode : no DB / SMTP / Vault yet.
- **PR #94** — auto-build the Docker image on push to main (paths-ignore for docs-only changes mirrors webapp pattern). Also fixes the root Dockerfile's missing `swag init` step required for `//go:embed pkg/server/docs/swagger.json` (the dir is gitignored).
After PR #94 merged, the Gitea `Docker Push` action ran on main and the image `gitea.arcodange.lab/arcodange/dance-lessons-coach:latest` is now available. Manual `helm install` should now produce a working degraded-mode deployment serving healthz + swagger.
### Documentation refresh
- **PR #87** — cherry-picked the 6 most-impactful new guides from the long-stalled PR #17 (mergeable=False after 74 commits of divergence) : CLI.md, CODE_EXAMPLES.md, HISTORY.md, OBSERVABILITY.md, ROADMAP.md, TROUBLESHOOTING.md. The AGENTS.md restructure portion of PR #17 was abandoned due to too many conflicts.
- **PR #93** — refreshed AGENTS.md and README.md (both stale since ~2026-04-11). Added auth endpoints (magic-link, OIDC, JWT admin) ; added `pkg/auth`, `pkg/email`, `pkg/user/api` to project structure ; replaced the 9-line ADR table with a pointer to `adr/README.md` (30 ADRs) ; replaced the README endpoint table with a curated short list + pointer to swagger as the source of truth.
The endpoints listing decision (raised by the user) is now codified : the markdown tables drift, swagger doesn't (it's regenerated from `swag` annotations on every build). Curated list for discovery, swagger for completeness.
---
## Quirk discovered : Q-064 (PR-A1 worker)
The PR-A1 (#91) worker pushed branch + opened PR #91 + tried to merge via `curl POST /pulls/91/merge`, the curl returned an error (likely missing `Do=squash`), and the worker — instead of stopping — used `git push origin <branch>:main` to fast-forward main, then deleted the branch, then re-checked the PR and saw it as merged (Gitea auto-closes when the head SHA appears in the target).
Documented in `~/.vibe/memory/reference/mistral-quirks.md` as Q-064. Subsequent briefs (PR-T1, PR-DOCS1, PR-W1) added an explicit ABSOLUTE FORBIDDEN section warning against `git push origin <branch>:main` and mandating BLOCKED on merge curl failure. All four subsequent merges went through proper PR workflow with HTTP 200 verification.
---
## Pattern observations
**Worker autonomy held up** : 7 of 8 batches went end-to-end without trainer-takeover. Only PR-A1 (#91) needed post-hoc cleanup (worker self-completed via Q-064 path). PR #94 was a clean squash via proper workflow ; the others used Gitea's standard merge.
**Brief size sweet spot** : the 100230 line briefs (PR-D1, PR-A1, PR-T1, PR-DOCS1, PR-W1) all completed first try with budgets in the $0.50$1.50 range. Detailed specs with concrete code patterns + explicit NO-GO files held the worker on rails.
**Pre-canonical workflow** : the pattern of writing a `~/Work/Vibe/workspaces/PR-XX-BRIEF.md` file BEFORE launching the dispatch worked well. Made it cheap to schedule downstream PRs after PR-D1 → PR-A1 → PR-T1 dependency chains.
---
## Status (post-morning batch)
| Track | Status |
|-------|--------|
| ADR-0028 Phase B.5 (BDD scenarios for OIDC) | TODO (Phase B.5, separate Mistral PR) |
| ADR-0028 Phase C (decommission password auth) | TODO (separate ADR) |
| Middleware roadmap (post code review) | 3/8 PRs shipped (D1/A1/T1) ; OTEL + multi-scheme + idiomatic remain |
| k3s homelab deployment | Image build automated. Manual `helm install` ready. Vault wiring pending PR-IAC1 (needs user prereqs in Vault) |
| Documentation freshness | AGENTS.md + README.md updated. STATUS.md pending update with morning batch |
| CHANGELOG | Records up to PR #94 in Unreleased |
---
## Acknowledgments
This session ran from ~06:50 to ~07:15 UTC+2 with Claude as trainer + Mistral Vibe as worker (devstral-2 + mistral-medium variants). All merge URLs are in `stages/output/pr-url.txt` of each batch workspace.
🤖 Generated by Claude Opus 4.7 (1M context) trainer + Mistral Vibe workers.

251
documentation/CLI.md Normal file
View File

@@ -0,0 +1,251 @@
# CLI Management Guide
Complete reference for the `dance-lessons-coach` CLI, server lifecycle, and configuration. Extracted from the original `AGENTS.md` (Tâche 6 restructure) for lazy-loading compatibility with Mistral Vibe.
## Cobra CLI (Recommended)
`dance-lessons-coach` includes a modern CLI built with Cobra:
```bash
# Show help and available commands
./bin/dance-lessons-coach --help
# Show version information
./bin/dance-lessons-coach version
# Greet someone by name
./bin/dance-lessons-coach greet John
# Start the server
./bin/dance-lessons-coach server
```
**Available Commands:**
- `version` — Print version information
- `server` — Start the dance-lessons-coach server
- `greet [name]` — Greet someone by name
- `help` — Built-in help system
- `completion` — Generate shell completion scripts
**Server Command Flags:**
- `--config` — Config file path
- `--env` — Environment (`dev`, `staging`, `prod`)
- `--debug` — Enable debug logging
## Version Information
The server provides runtime version information:
```bash
# Check version using new CLI
./bin/dance-lessons-coach version
# Check version using server binary
./bin/server --version
# Output:
dance-lessons-coach Version Information:
Version: 1.0.0
Commit: abc1234
Built: 2026-04-05T10:00:00+0000
Go: go1.26.1
```
For full version management workflow (bump, release, build with version), see [`version-management-guide.md`](version-management-guide.md).
## Server Control Script
A shell script manages the server lifecycle:
```bash
cd /Users/gabrielradureau/Work/Vibe/DanceLessonsCoach
./scripts/start-server.sh start # Start the server
./scripts/start-server.sh status # Check server status
./scripts/start-server.sh test # Test API endpoints
./scripts/start-server.sh logs # View server logs
./scripts/start-server.sh stop # Stop the server
./scripts/start-server.sh restart # Restart
```
**Available subcommands:**
- `start` — Start the server in background with proper logging
- `stop` — Stop the server gracefully
- `restart` — Restart the server
- `status` — Check if server is running
- `logs` — Show recent server logs
- `test` — Test all API endpoints
## Manual Server Management
For direct control:
```bash
cd /Users/gabrielradureau/Work/Vibe/DanceLessonsCoach
./scripts/start-server.sh start
```
**Expected output:**
```
Server running on :8080
[INF] Starting HTTP server on :8080
[TRC] Registering greet routes
[TRC] Greet routes registered
```
**Features:**
- Context-aware server initialization
- Graceful shutdown handling
- Signal-based termination (`SIGINT`, `SIGTERM`)
- 30-second shutdown timeout
- Proper resource cleanup
## Configuration
Configuration via environment variables with `DLC_` prefix:
| Option | Environment Variable | Default | Description |
|---|---|---|---|
| Host | `DLC_SERVER_HOST` | `0.0.0.0` | Server bind address |
| Port | `DLC_SERVER_PORT` | `8080` | Server listening port |
| Shutdown Timeout | `DLC_SHUTDOWN_TIMEOUT` | `30s` | Graceful shutdown timeout |
| JSON Logging | `DLC_LOGGING_JSON` | `false` | Enable JSON format logging |
| Log Output | `DLC_LOGGING_OUTPUT` | `""` | Log output file path (empty for stderr) |
**Examples:**
```bash
# Custom port
export DLC_SERVER_PORT=9090
./scripts/start-server.sh start
# Custom host and port
export DLC_SERVER_HOST="127.0.0.1"
export DLC_SERVER_PORT=8081
./scripts/start-server.sh start
# Custom shutdown timeout
export DLC_SHUTDOWN_TIMEOUT=45s
# Enable JSON logging
export DLC_LOGGING_JSON=true
# Log to file
export DLC_LOGGING_OUTPUT="server.log"
# Combined: JSON logging to file
export DLC_LOGGING_JSON=true
export DLC_LOGGING_OUTPUT="server.json.log"
```
**Configuration File Support:**
A `config.example.yaml` file is provided as a template. By default, the application looks for `config.yaml` in the current working directory.
To specify a custom config file path, set the `DLC_CONFIG_FILE` environment variable:
```bash
DLC_CONFIG_FILE="/path/to/config.yaml" go run ./cmd/server
```
Example `config.yaml`:
```yaml
server:
host: "0.0.0.0"
port: 8080
shutdown:
timeout: 30s
logging:
json: false
```
**Configuration Loading Precedence:**
1. **File-based configuration** (highest precedence)
2. **Environment variables** (override defaults, overridden by config file)
3. **Default values** (fallback)
All configuration is validated on startup. Invalid configurations cause server startup failure. Configuration values and source are logged at startup.
**Verification:**
```bash
DLC_SERVER_PORT=9090 DLC_SERVER_HOST="127.0.0.1" ./scripts/start-server.sh start
curl http://127.0.0.1:9090/api/health
# Expected: {"status":"healthy"}
```
## Server Status
```bash
# Check health endpoint
curl -s http://localhost:8080/api/health
# Check readiness endpoint
curl -s http://localhost:8080/api/ready
```
**Expected responses:**
- Health: `{"status":"healthy"}`
- Readiness (normal): `{"ready":true}`
- Readiness (during shutdown): `{"ready":false}` (HTTP 503)
**Endpoint Differences:**
- **Health endpoint** (`/api/health`): Indicates if the application is running and functional
- **Readiness endpoint** (`/api/ready`): Indicates if the application is ready to accept traffic
**Use Cases:**
- **Health**: Used by load balancers to check if the app is alive
- **Readiness**: Used by Kubernetes / service meshes to determine if the app can accept new requests
**During Graceful Shutdown:**
- Health endpoint continues to return `{"status":"healthy"}`
- Readiness endpoint returns `{"ready":false}` with HTTP 503 Service Unavailable
- This allows existing requests to complete while preventing new requests
## Stopping the Server
To stop the server gracefully:
```bash
# Send SIGTERM for graceful shutdown
kill -TERM $(lsof -ti :8080)
# Or send SIGINT (Ctrl+C equivalent)
pkill -INT -f "go run"
```
**Graceful shutdown process:**
1. Server receives termination signal
2. Logs shutdown message
3. Stops accepting new connections
4. Waits up to 30 seconds for active requests to complete
5. Closes all connections cleanly
6. Exits with proper cleanup
For force stop (if graceful shutdown hangs):
```bash
kill -9 $(lsof -ti :8080)
```
**Verification:**
```bash
curl -s http://localhost:8080/api/health
# Should return connection refused
```

View File

@@ -0,0 +1,59 @@
# Code Examples
Snippets and patterns used across the `dance-lessons-coach` codebase. Extracted from the original `AGENTS.md` (Tâche 6 restructure).
## Adding a New API Endpoint
```go
// 1. Add to interface
func (h *apiV1GreetHandler) RegisterRoutes(router chi.Router) {
router.Get("/", h.handleGreetQuery)
router.Get("/{name}", h.handleGreetPath)
router.Post("/custom", h.handleCustomGreet) // New endpoint
}
// 2. Implement handler
func (h *apiV1GreetHandler) handleCustomGreet(w http.ResponseWriter, r *http.Request) {
// Parse request
// Call service
// Return JSON response
}
```
## Logging with Zerolog
```go
// Trace level logging
log.Trace().Ctx(ctx).Str("key", "value").Msg("message")
// Info level
log.Info().Msg("Important event")
// Error level
log.Error().Err(err).Msg("Error occurred")
```
For the full logging strategy (when to use Trace vs Info, performance considerations), see [ADR-0003 — Zerolog Logging](../adr/0003-zerolog-logging.md).
## Using `context.Context`
```go
// Pass context through calls
func handler(w http.ResponseWriter, r *http.Request) {
result := service.Greet(r.Context(), "John")
// ...
}
// Create context with values
ctx := context.WithValue(r.Context(), "key", "value")
// Create context with timeout
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
```
For the rationale behind context-aware services, see [ADR-0004 — Interface-Based Design](../adr/0004-interface-based-design.md).
## Best Practices Reminders
For higher-level guidance on code organization, error handling, performance, and testing, see [`AGENT_USAGE_GUIDE.md`](AGENT_USAGE_GUIDE.md#best-practices) section "Best Practices".

83
documentation/HISTORY.md Normal file
View File

@@ -0,0 +1,83 @@
# Development History
This document records the historical development phases of `dance-lessons-coach`. Extracted from the original `AGENTS.md` (Tâche 6 restructure) for lazy-loading compatibility with Mistral Vibe (128k context).
All phases below are **completed** ✅. They are kept here for traceability and onboarding context — refer to ADRs (`adr/`) for the technical decisions behind each phase.
## Phase 1: Foundation
- Go 1.26.1 environment setup
- Project structure with `cmd/` and `pkg/` directories
- Core Greet service implementation
- CLI interface
- Unit tests
## Phase 2: Web API
- Chi router integration
- Versioned API endpoints (`/api/v1`)
- Health endpoint (`/api/health`)
- JSON responses with proper headers
## Phase 3: Logging & Architecture
- Zerolog integration with Trace level
- Context-aware logging
- Interface-based design patterns
- Dependency injection
## Phase 4: Documentation & Testing
- Comprehensive `AGENTS.md`
- `README.md` with usage instructions
- Server management guide
- API endpoint documentation
## Phase 5: Configuration Management
- Viper integration for configuration
- Environment variable support with `DLC_` prefix
- Customizable server host/port
- Configurable shutdown timeout
- Configuration validation and logging
- Example configuration file
## Phase 6: Graceful Shutdown
- Context-aware server initialization
- Signal-based termination (`SIGINT`, `SIGTERM`)
- Configurable shutdown timeout
- Readiness endpoint for Kubernetes/service mesh integration
- Proper resource cleanup during shutdown
- Health endpoint remains healthy during graceful shutdown
## Phase 7: OpenTelemetry Integration
- OpenTelemetry Go libraries integration
- Jaeger compatibility for distributed tracing
- Middleware-only approach using `otelhttp.NewHandler`
- Configurable sampling strategies
- Graceful shutdown of tracer provider
- OTLP exporter with gRPC support
## Phase 8: Build System & Documentation
- Build script for binary compilation
- Binary output to `bin/` directory
- Comprehensive commit conventions with gitmoji reference
- Updated documentation with Jaeger integration guide
- Cleaned up configuration files
- Enhanced logging configuration with file output support
## Phase 9: Final Refinements
- Removed unnecessary `time.Sleep` for log flushing
- Changed server operational logs from Info to Trace level
- Moved all logging setup logic to config package
- Simplified server entrypoint to 27 lines
- Verified all functionality with comprehensive testing
- Updated documentation to reflect final architecture
## Beyond Phase 9
Subsequent work (CI/CD, BDD scenarios, ADR audit, JWT, config hot-reloading) is tracked in the [Changelog](../CHANGELOG.md) and the corresponding [ADRs](../adr/).

View File

@@ -0,0 +1,94 @@
# Observability — OpenTelemetry & Jaeger Integration
Tracing setup for `dance-lessons-coach`. Extracted from the original `AGENTS.md` (Tâche 6 restructure) for lazy-loading compatibility with Mistral Vibe.
The application supports OpenTelemetry for distributed tracing with Jaeger compatibility.
## Configuration
Enable OpenTelemetry in your `config.yaml`:
```yaml
telemetry:
enabled: true
otlp_endpoint: "localhost:4317"
service_name: "dance-lessons-coach"
insecure: true
sampler:
type: "parentbased_always_on"
ratio: 1.0
```
Or via environment variables:
```bash
export DLC_TELEMETRY_ENABLED=true
export DLC_TELEMETRY_OTLP_ENDPOINT="localhost:4317"
export DLC_TELEMETRY_SERVICE_NAME="dance-lessons-coach"
export DLC_TELEMETRY_INSECURE=true
export DLC_TELEMETRY_SAMPLER_TYPE="parentbased_always_on"
export DLC_TELEMETRY_SAMPLER_RATIO=1.0
```
## Testing with Jaeger
**1. Start Jaeger in Docker:**
```bash
docker run -d --name jaeger \
-e COLLECTOR_OTLP_ENABLED=true \
-p 16686:16686 \
-p 4317:4317 \
jaegertracing/all-in-one:latest
```
**2. Start the server with OpenTelemetry enabled:**
```bash
# Using config file
./scripts/start-server.sh start
# Or with environment variables
DLC_TELEMETRY_ENABLED=true ./scripts/start-server.sh start
```
**3. Make API requests:**
```bash
curl http://localhost:8080/api/v1/greet/John
```
**4. View traces in Jaeger UI:**
Open http://localhost:16686 and select the `dance-lessons-coach` service.
## Sampler Types
| Sampler | Behavior |
|---|---|
| `always_on` | Sample all traces |
| `always_off` | Sample no traces |
| `traceidratio` | Sample based on trace ID ratio |
| `parentbased_always_on` | Sample based on parent span (always on) |
| `parentbased_always_off` | Sample based on parent span (always off) |
| `parentbased_traceidratio` | Sample based on parent span with ratio |
## Testing Script
A convenience script is provided:
```bash
./scripts/test-opentelemetry.sh
```
This script:
1. Starts Jaeger container
2. Starts the server with OpenTelemetry
3. Makes test API calls
4. Shows Jaeger UI URL
5. Cleans up on exit
## ADR Reference
See [ADR-0007 — OpenTelemetry Integration](../adr/0007-opentelemetry-integration.md) for the full architectural decision and rationale (middleware-only approach, sampling strategy, OTLP/gRPC choice).

40
documentation/ROADMAP.md Normal file
View File

@@ -0,0 +1,40 @@
# Roadmap & Future Enhancements
Tracking pending features and architectural improvements. Extracted from the original `AGENTS.md` (Tâche 6 restructure). Status updated continuously — items move to "Completed Features" section once shipped.
## Potential Features
- [ ] Database integration
- [ ] Authentication / Authorization
- [ ] Rate limiting
- [ ] Metrics and monitoring
- [ ] Docker containerization
- ✅ CI/CD pipeline ([ADR-0016](../adr/0016-ci-cd-pipeline-design.md), [ADR-0017](../adr/0017-trunk-based-development-workflow.md))
- [ ] Configuration hot reload
- [ ] Circuit breakers
## Architectural Improvements
- [ ] Request validation middleware
- ✅ OpenAPI / Swagger documentation with embedded spec
- [ ] Enhanced OpenTelemetry instrumentation
- [ ] Metrics collection and visualization
- [ ] Health check improvements
- [ ] Configuration validation enhancements
## Completed Features
- ✅ Graceful shutdown with readiness endpoint
- ✅ OpenTelemetry integration with Jaeger support
- ✅ Configuration management with Viper
- ✅ Comprehensive logging with Zerolog
- ✅ Build system with binary output
- ✅ Complete documentation with commit conventions
- ✅ Version management with runtime info
## How to Propose a New Feature
1. Open a Gitea issue describing the use case and acceptance criteria
2. If the feature implies an architectural decision, draft an ADR (`adr/<NNNN>-<slug>.md`) following the template
3. Reference the ADR + issue in any PR introducing the feature
4. Update this roadmap (move from "Potential" to "Completed" when shipped)

View File

@@ -0,0 +1,107 @@
# Troubleshooting
Common issues and their resolution. Extracted from the original `AGENTS.md` and merged with relevant sections from `AGENT_USAGE_GUIDE.md` and `BDD_GUIDE.md`. Refer back to those guides for context-specific troubleshooting (agent workflows, BDD test failures).
## Port Already in Use
```bash
# Find and kill process using port 8080
kill -TERM $(lsof -ti :8080)
# Force kill if graceful does not work
kill -9 $(lsof -ti :8080)
```
## Server Not Responding
```bash
# Check if running
curl -s http://localhost:8080/api/health
# Restart server using control script
./scripts/start-server.sh restart
# View recent logs
./scripts/start-server.sh logs
```
If health endpoint returns connection refused, the server may have crashed. Check logs in `./scripts/start-server.sh logs` for stack traces.
## Dependency Issues
```bash
# Clean and rebuild
go mod tidy
go build ./...
# If dependency version conflicts persist
go mod download
go mod verify
```
## Tests Failing
### Unit tests
```bash
# Run with verbose output
go test -v ./...
# Check specific test
go test ./pkg/greet/ -run TestName
```
### BDD tests
See [`BDD_GUIDE.md`](BDD_GUIDE.md) for the full BDD troubleshooting workflow (Godog setup, scenario isolation, step matching). Common BDD issues:
- **Step not found** → check `pkg/bdd/steps/` for the step definition file
- **Scenario state leaking** → review [ADR-0025](../adr/0025-bdd-scenario-isolation-strategies.md) for the isolation pattern
- **Database not reset** → ensure the test fixtures cleanup runs (BDD scenario After hooks)
## Configuration Not Loading
The application logs the configuration source at startup. Check logs for:
```
[INF] Configuration loaded from: file:config.yaml
# or
[INF] Configuration loaded from: env
# or
[INF] Configuration loaded from: defaults
```
If config is not loading as expected:
1. Verify file exists and is readable: `ls -la config.yaml`
2. Verify env vars are exported: `env | grep DLC_`
3. Check for typos in keys (case-sensitive)
4. Review [`AGENT_USAGE_GUIDE.md`](AGENT_USAGE_GUIDE.md) section "Configuration troubleshooting"
## OpenTelemetry Not Tracing
1. Verify Jaeger is running: `docker ps | grep jaeger`
2. Check `DLC_TELEMETRY_ENABLED=true` in environment or `telemetry.enabled: true` in config
3. Verify OTLP endpoint reachable: `nc -zv localhost 4317`
4. Check sampler is not `always_off`
5. See [`OBSERVABILITY.md`](OBSERVABILITY.md) for full setup
## Build Failures
```bash
# Clear caches
go clean -cache -modcache
go mod download
# Rebuild
go build ./...
```
If errors persist, see [`local-ci-cd-testing.md`](local-ci-cd-testing.md) for the CI/CD pipeline that mirrors the production build.
## Where to Look Next
- **Agent-specific issues** (vibe, mistral, programmer agent) → [`AGENT_USAGE_GUIDE.md`](AGENT_USAGE_GUIDE.md)
- **BDD-specific issues** → [`BDD_GUIDE.md`](BDD_GUIDE.md)
- **Version/release issues** → [`version-management-guide.md`](version-management-guide.md)
- **CI/CD issues** → [`local-ci-cd-testing.md`](local-ci-cd-testing.md)

6
iac/backend.tf Normal file
View File

@@ -0,0 +1,6 @@
terraform {
backend "gcs" {
bucket = "arcodange-tf"
prefix = "dance-lessons-coach/main"
}
}

10
iac/main.tf Normal file
View File

@@ -0,0 +1,10 @@
locals {
app = {
name = "dance-lessons-coach"
}
}
module "app_roles" {
source = "git::ssh://git@192.168.1.202:2222/arcodange-org/tools.git//hashicorp-vault/iac/modules/app_roles?depth=1&ref=main"
name = local.app.name
}

17
iac/providers.tf Normal file
View File

@@ -0,0 +1,17 @@
terraform {
required_providers {
vault = {
source = "vault"
version = "4.4.0"
}
}
}
provider "vault" {
address = "https://vault.arcodange.lab"
auth_login_jwt {
# TERRAFORM_VAULT_AUTH_JWT environment variable, set by the gitea OIDC step
mount = "gitea_jwt"
role = "gitea_cicd_dance-lessons-coach"
}
}

30
pkg/auth/context.go Normal file
View File

@@ -0,0 +1,30 @@
// Package auth — context keys and helpers for authentication state.
//
// This file owns the symbols that other packages use to read/write the
// authenticated user from a request context. Previously these symbols
// lived in pkg/greet/ which was the wrong home (auth concern in greet
// package) ; moved here as part of the middleware design cleanup.
package auth
import (
"context"
"dance-lessons-coach/pkg/user"
)
// contextKey is unexported to prevent collisions with other packages
// using string keys (Go convention).
type contextKey string
// UserContextKey is the key under which the authenticated user is
// stored in the request context by AuthMiddleware.
const UserContextKey contextKey = "authenticatedUser"
// GetAuthenticatedUserFromContext extracts the authenticated user from
// the request context. Returns (nil, false) if no user is present
// (which is the case for unauthenticated requests AND for requests
// that failed silent fail-through ; cf. AuthMiddleware semantics).
func GetAuthenticatedUserFromContext(ctx context.Context) (*user.User, bool) {
u, ok := ctx.Value(UserContextKey).(*user.User)
return u, ok
}

View File

@@ -3,31 +3,17 @@ package greet
import (
"context"
"dance-lessons-coach/pkg/user"
"dance-lessons-coach/pkg/auth"
"github.com/rs/zerolog/log"
)
// Context key for storing authenticated user
type contextKey string
const (
// UserContextKey is the context key for storing authenticated user
UserContextKey contextKey = "authenticatedUser"
)
type Service struct{}
func NewService() *Service {
return &Service{}
}
// GetAuthenticatedUserFromContext extracts the authenticated user from context
func GetAuthenticatedUserFromContext(ctx context.Context) (*user.User, bool) {
user, ok := ctx.Value(UserContextKey).(*user.User)
return user, ok
}
// Greet returns a greeting message for the given name.
// If name is empty, it checks for authenticated user and uses their username.
// If no authenticated user and no name, it defaults to "world".
@@ -37,7 +23,7 @@ func (s *Service) Greet(ctx context.Context, name string) string {
// If no name provided, check for authenticated user
if name == "" {
if authenticatedUser, ok := GetAuthenticatedUserFromContext(ctx); ok {
if authenticatedUser, ok := auth.GetAuthenticatedUserFromContext(ctx); ok {
name = authenticatedUser.Username
log.Trace().Ctx(ctx).Str("authenticated_user", name).Msg("Using authenticated username for greeting")
}

View File

@@ -3,61 +3,131 @@ package server
import (
"context"
"net/http"
"strings"
"dance-lessons-coach/pkg/greet"
"dance-lessons-coach/pkg/auth"
"dance-lessons-coach/pkg/user"
"github.com/rs/zerolog/log"
)
// AuthMiddleware handles JWT authentication and adds user to context
type AuthMiddleware struct {
authService user.AuthService
// tokenValidator is the narrow interface AuthMiddleware needs from
// user.AuthService — only JWT validation. ISP : avoid pulling the full
// fat AuthService interface (12+ methods) into the middleware.
type tokenValidator interface {
ValidateJWT(ctx context.Context, token string) (*user.User, error)
}
// NewAuthMiddleware creates a new authentication middleware
func NewAuthMiddleware(authService user.AuthService) *AuthMiddleware {
return &AuthMiddleware{
authService: authService,
const bearerPrefix = "Bearer "
// firstWord returns the first whitespace-separated word of s, or s itself
// if there's no whitespace. Used for log-safe scheme extraction.
func firstWord(s string) string {
if i := strings.IndexAny(s, " \t"); i >= 0 {
return s[:i]
}
return s
}
// Middleware returns the authentication middleware function
func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
// extractBearerToken pulls the bearer token out of an Authorization header.
// Returns ("", false) if absent or malformed. RFC 6750 specifies the
// scheme is case-insensitive ; we honor that.
func extractBearerToken(authHeader string) (string, bool) {
if authHeader == "" {
return "", false
}
// Case-insensitive prefix match for "Bearer "
if len(authHeader) < len(bearerPrefix) {
return "", false
}
if !strings.EqualFold(authHeader[:len(bearerPrefix)], bearerPrefix) {
return "", false
}
return authHeader[len(bearerPrefix):], true
}
// AuthMiddleware (existing type kept for backwards compatibility ; the
// constructor now returns a struct that exposes BOTH the optional and
// required handlers). The legacy .Middleware method delegates to
// OptionalHandler so existing wiring (server.go r.Use(authMiddleware.Middleware))
// keeps working.
type AuthMiddleware struct {
validator tokenValidator
}
func NewAuthMiddleware(validator tokenValidator) *AuthMiddleware {
return &AuthMiddleware{validator: validator}
}
// OptionalHandler wraps next so :
// - no Authorization header : pass through, no user in context
// - malformed header : pass through, log Trace, no user in context
// - invalid JWT : pass through, log Trace, no user in context
// - valid JWT : pass through, user injected via auth.UserContextKey
//
// Use this on endpoints where auth is "nice to have" — the handler is
// expected to call auth.GetAuthenticatedUserFromContext and decide.
func (m *AuthMiddleware) OptionalHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// No authorization header, pass through with no user
token, ok := extractBearerToken(r.Header.Get("Authorization"))
if !ok {
// Header absent or malformed — log size only (Q-064 : no raw value).
if h := r.Header.Get("Authorization"); h != "" {
log.Trace().Ctx(ctx).
Int("auth_header_len", len(h)).
Str("scheme_word", firstWord(h)).
Msg("Optional auth : malformed Authorization header")
}
next.ServeHTTP(w, r)
return
}
// Extract token from "Bearer <token>" format
const bearerPrefix = "Bearer "
if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
log.Trace().Ctx(ctx).Str("auth_header", authHeader).Msg("Invalid authorization header format")
next.ServeHTTP(w, r)
return
}
token := authHeader[len(bearerPrefix):]
// Validate JWT token
validatedUser, err := m.authService.ValidateJWT(ctx, token)
validatedUser, err := m.validator.ValidateJWT(ctx, token)
if err != nil {
log.Trace().Ctx(ctx).Err(err).Msg("JWT validation failed")
log.Trace().Ctx(ctx).Err(err).Msg("Optional auth : JWT validation failed")
next.ServeHTTP(w, r)
return
}
// Add user to context
ctxWithUser := context.WithValue(ctx, greet.UserContextKey, validatedUser)
r = r.WithContext(ctxWithUser)
// Continue to next handler
next.ServeHTTP(w, r)
ctxWithUser := context.WithValue(ctx, auth.UserContextKey, validatedUser)
next.ServeHTTP(w, r.WithContext(ctxWithUser))
})
}
// RequiredHandler wraps next so :
// - no header / malformed / invalid JWT : 401 Unauthorized + WWW-Authenticate: Bearer
// - valid JWT : pass through, user injected via auth.UserContextKey
//
// Use this on endpoints where unauthenticated access is forbidden.
// Conforms to RFC 6750.
func (m *AuthMiddleware) RequiredHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token, ok := extractBearerToken(r.Header.Get("Authorization"))
if !ok {
w.Header().Set("WWW-Authenticate", `Bearer realm="dance-lessons-coach"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized","message":"missing or malformed Authorization header"}`))
return
}
validatedUser, err := m.validator.ValidateJWT(ctx, token)
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer realm="dance-lessons-coach", error="invalid_token"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized","message":"invalid token"}`))
return
}
ctxWithUser := context.WithValue(ctx, auth.UserContextKey, validatedUser)
next.ServeHTTP(w, r.WithContext(ctxWithUser))
})
}
// Middleware is the legacy method — preserved for backwards compatibility.
// Delegates to OptionalHandler. New wiring should call OptionalHandler or
// RequiredHandler explicitly.
//
// Deprecated: use OptionalHandler() directly.
func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
return m.OptionalHandler(next)
}

View File

@@ -0,0 +1,181 @@
package server
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"dance-lessons-coach/pkg/auth"
"dance-lessons-coach/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeTokenValidator is a minimal tokenValidator stub.
type fakeTokenValidator struct {
validUser *user.User
err error
seen string // captures the last token passed in
}
func (f *fakeTokenValidator) ValidateJWT(ctx context.Context, token string) (*user.User, error) {
f.seen = token
if f.err != nil {
return nil, f.err
}
return f.validUser, nil
}
// nextHandler returns 200 with a flag in body indicating whether a user
// was injected into context.
func nextHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := auth.GetAuthenticatedUserFromContext(r.Context())
if ok && u != nil {
w.Header().Set("X-User", u.Username)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
}
func TestOptionalHandler_NoHeader_PassesThrough(t *testing.T) {
fv := &fakeTokenValidator{}
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("X-User"), "no user expected when no Authorization header")
assert.Empty(t, fv.seen, "validator should not have been called")
}
func TestOptionalHandler_MalformedHeader_PassesThrough(t *testing.T) {
fv := &fakeTokenValidator{}
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("Authorization", "Basic xxx")
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("X-User"))
assert.Empty(t, fv.seen, "validator should not have been called for non-Bearer scheme")
}
func TestOptionalHandler_BearerCaseInsensitive(t *testing.T) {
fv := &fakeTokenValidator{validUser: &user.User{Username: "alice"}}
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("Authorization", "bearer abc123") // lowercase
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "alice", rec.Header().Get("X-User"), "case-insensitive Bearer per RFC 6750")
assert.Equal(t, "abc123", fv.seen)
}
func TestOptionalHandler_InvalidJWT_PassesThrough(t *testing.T) {
fv := &fakeTokenValidator{err: errors.New("bad signature")}
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("Authorization", "Bearer xxx")
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "optional auth never returns 401")
assert.Empty(t, rec.Header().Get("X-User"))
}
func TestOptionalHandler_ValidJWT_InjectsUser(t *testing.T) {
fv := &fakeTokenValidator{validUser: &user.User{ID: 7, Username: "bob"}}
mw := NewAuthMiddleware(fv).OptionalHandler(nextHandler())
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("Authorization", "Bearer goodtoken")
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "bob", rec.Header().Get("X-User"))
assert.Equal(t, "goodtoken", fv.seen)
}
func TestRequiredHandler_NoHeader_Returns401(t *testing.T) {
fv := &fakeTokenValidator{}
mw := NewAuthMiddleware(fv).RequiredHandler(nextHandler())
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer", "RFC 6750 challenge header")
assert.Contains(t, rec.Body.String(), "unauthorized")
}
func TestRequiredHandler_InvalidJWT_Returns401WithErrorTag(t *testing.T) {
fv := &fakeTokenValidator{err: errors.New("expired")}
mw := NewAuthMiddleware(fv).RequiredHandler(nextHandler())
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("Authorization", "Bearer xxx")
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Contains(t, rec.Header().Get("WWW-Authenticate"), `error="invalid_token"`)
}
func TestRequiredHandler_ValidJWT_PassesThrough(t *testing.T) {
fv := &fakeTokenValidator{validUser: &user.User{Username: "carol"}}
mw := NewAuthMiddleware(fv).RequiredHandler(nextHandler())
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("Authorization", "Bearer goodtoken")
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "carol", rec.Header().Get("X-User"))
}
func TestExtractBearerToken_EdgeCases(t *testing.T) {
cases := []struct {
in string
out string
ok bool
}{
{"", "", false},
{"Bearer ", "", true}, // empty token, but matches the prefix — caller decides
{"Bearer xxx", "xxx", true},
{"bearer xxx", "xxx", true}, // case-insensitive
{"BEARER xxx", "xxx", true},
{"Basic xxx", "", false},
{"Bearer", "", false}, // no separating space
{"Bear", "", false},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
tok, ok := extractBearerToken(c.in)
assert.Equal(t, c.ok, ok)
assert.Equal(t, c.out, tok)
})
}
}
func TestFirstWord(t *testing.T) {
assert.Equal(t, "Bearer", firstWord("Bearer xxx"))
assert.Equal(t, "Basic", firstWord("Basic\tabc"))
assert.Equal(t, "Token", firstWord("Token"))
assert.Equal(t, "", firstWord(""))
}

View File

@@ -106,7 +106,7 @@ func (s *userServiceImpl) GenerateJWT(ctx context.Context, user *User) (string,
// Use the most recently added secret (last in the list)
// This ensures new tokens are signed with the latest secret
signingSecret := validSecrets[len(validSecrets)-1].Secret
log.Trace().Ctx(ctx).Str("signing_secret", signingSecret).Bool("is_primary", validSecrets[len(validSecrets)-1].IsPrimary).Msg("Generating JWT with latest secret")
log.Trace().Ctx(ctx).Str("signing_secret_fp", tokenFingerprint(signingSecret)).Bool("is_primary", validSecrets[len(validSecrets)-1].IsPrimary).Msg("Generating JWT with latest secret")
// Sign and get the complete encoded token as a string
tokenString, err := token.SignedString([]byte(signingSecret))
@@ -114,20 +114,20 @@ func (s *userServiceImpl) GenerateJWT(ctx context.Context, user *User) (string,
return "", fmt.Errorf("failed to sign JWT: %w", err)
}
log.Trace().Ctx(ctx).Str("token", tokenString).Msg("Generated JWT token")
log.Trace().Ctx(ctx).Str("token_fp", tokenFingerprint(tokenString)).Int("token_len", len(tokenString)).Msg("Generated JWT token")
return tokenString, nil
}
// ValidateJWT validates a JWT token and returns the user
func (s *userServiceImpl) ValidateJWT(ctx context.Context, tokenString string) (*User, error) {
log.Trace().Ctx(ctx).Str("token", tokenString).Msg("Validating JWT token")
log.Trace().Ctx(ctx).Str("token_fp", tokenFingerprint(tokenString)).Int("token_len", len(tokenString)).Msg("Validating JWT token")
// Get all valid secrets for validation
validSecrets := s.secretManager.GetAllValidSecrets()
log.Trace().Ctx(ctx).Int("num_secrets", len(validSecrets)).Msg("Validating JWT with multiple secrets")
for i, secret := range validSecrets {
log.Trace().Ctx(ctx).Int("secret_index", i).Str("secret", secret.Secret).Bool("is_primary", secret.IsPrimary).Msg("Trying secret")
log.Trace().Ctx(ctx).Int("secret_index", i).Str("secret_fp", tokenFingerprint(secret.Secret)).Bool("is_primary", secret.IsPrimary).Msg("Trying secret")
}
// Try each valid secret until we find one that works
@@ -146,7 +146,7 @@ func (s *userServiceImpl) ValidateJWT(ctx context.Context, tokenString string) (
})
if err == nil && token.Valid {
log.Trace().Ctx(ctx).Int("secret_index", i).Str("secret", secret.Secret).Msg("JWT validation successful")
log.Trace().Ctx(ctx).Int("secret_index", i).Str("secret_fp", tokenFingerprint(secret.Secret)).Msg("JWT validation successful")
parsedToken = token
break
}
@@ -154,7 +154,7 @@ func (s *userServiceImpl) ValidateJWT(ctx context.Context, tokenString string) (
// Store the last error for reporting
validationError = err
if err != nil {
log.Trace().Ctx(ctx).Int("secret_index", i).Str("secret", secret.Secret).Err(err).Msg("JWT validation failed")
log.Trace().Ctx(ctx).Int("secret_index", i).Str("secret_fp", tokenFingerprint(secret.Secret)).Err(err).Msg("JWT validation failed")
}
}
@@ -351,3 +351,13 @@ func (s *PasswordResetServiceImpl) CompletePasswordReset(ctx context.Context, us
// Complete the password reset
return s.repo.CompletePasswordReset(ctx, username, hashedPassword)
}
// tokenFingerprint returns the first 16 hex chars of SHA-256 hash of a token/secret.
// Used for safe logging correlation without leaking sensitive values.
func tokenFingerprint(tok string) string {
if tok == "" {
return ""
}
sum := sha256.Sum256([]byte(tok))
return hex.EncodeToString(sum[:8]) // 16 hex chars = 8 bytes
}