diff --git a/.github/workflows/users.yml b/.github/workflows/users.yml
new file mode 100644
index 0000000..e9620b8
--- /dev/null
+++ b/.github/workflows/users.yml
@@ -0,0 +1,66 @@
+name: users
+
+on:
+ push:
+ paths:
+ - "users/**"
+ - ".github/workflows/users.yml"
+ pull_request:
+ paths:
+ - "users/**"
+ - ".github/workflows/users.yml"
+
+defaults:
+ run:
+ working-directory: users
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: scimesh
+ POSTGRES_PASSWORD: scimesh
+ POSTGRES_DB: scimesh_users
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U scimesh"
+ --health-interval 5s
+ --health-timeout 3s
+ --health-retries 10
+
+ env:
+ TEST_DATABASE_URL: postgres://scimesh:scimesh@localhost:5432/scimesh_users?sslmode=disable
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: users/go.mod
+ cache-dependency-path: users/go.sum
+
+ - name: go vet
+ run: go vet ./...
+
+ - name: gofmt
+ run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
+
+ - name: unit tests (race)
+ run: go test -race ./...
+
+ - name: lint
+ run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --build-tags=integration ./...
+
+ - name: install migrate CLI
+ run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.17.1
+
+ - name: apply migrations
+ run: migrate -path migrations -database "$TEST_DATABASE_URL" up
+
+ - name: integration tests
+ run: go test -tags=integration ./internal/storage/postgres/ -v
diff --git a/PLAN.md b/PLAN.md
index 91f5922..2e43ebc 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -872,7 +872,9 @@ workload logic into the service.
**Acceptance criteria:**
-- the service has a versioned, documented API and owns user identity data;
+- the service has a versioned, documented API in
+ [`docs/user-service-api-contract.md`](docs/user-service-api-contract.md) and
+ owns user identity data;
- credentials and authentication tokens are stored and handled securely; they
are never logged or exposed to workers;
- authenticated identity is propagated to coordinator requests through an
diff --git a/coordinator/Dockerfile b/coordinator/Dockerfile
index fc28a60..1eafa17 100644
--- a/coordinator/Dockerfile
+++ b/coordinator/Dockerfile
@@ -5,7 +5,7 @@
# build fails with "the --mount option requires BuildKit".
# --- build stage ----------------------------------------------------------
-FROM golang:1.24-alpine AS build
+FROM golang:1.25-alpine AS build
WORKDIR /src
diff --git a/coordinator/Makefile b/coordinator/Makefile
index 6f17ed6..54aa7ae 100644
--- a/coordinator/Makefile
+++ b/coordinator/Makefile
@@ -35,7 +35,7 @@ help:
' make demo-down Stop the demo services and workers.' \
' make test / make vet Run Go verification.' \
'' \
- 'Demo UI: http://localhost:18080/ui (operator / demo-ui-secret).'
+ 'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
demo-ui:
@DEMO_PROJECT="$(DEMO_PROJECT)" \
diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go
index 46b077c..61de190 100644
--- a/coordinator/cmd/coordinator/main.go
+++ b/coordinator/cmd/coordinator/main.go
@@ -9,6 +9,7 @@ import (
"syscall"
"github.com/emil28092005/SciMesh/coordinator/internal/infra"
+ "github.com/emil28092005/SciMesh/coordinator/internal/metrics"
"github.com/emil28092005/SciMesh/coordinator/internal/storage/blob"
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
httptransport "github.com/emil28092005/SciMesh/coordinator/internal/transport/http"
@@ -59,13 +60,14 @@ func run() error {
}
var (
- clk = infra.NewClock()
- tx = postgres.NewTxManager(pool)
- taskRepo = postgres.NewTaskRepo(pool)
- jobRepo = postgres.NewJobRepo(pool)
- workerRepo = postgres.NewWorkerRepo(pool)
- artifactRepo = postgres.NewArtifactRepo(pool)
- uiReadRepo = postgres.NewUIReadRepo(pool)
+ clk = infra.NewClock()
+ tx = postgres.NewTxManager(pool)
+ taskRepo = postgres.NewTaskRepo(pool)
+ jobRepo = postgres.NewJobRepo(pool)
+ workerRepo = postgres.NewWorkerRepo(pool)
+ artifactRepo = postgres.NewArtifactRepo(pool)
+ uiReadRepo = postgres.NewUIReadRepo(pool)
+ taskResultRepo = postgres.NewTaskResultRepo(pool)
)
useCases := httptransport.UseCases{
@@ -74,12 +76,12 @@ func run() error {
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts),
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration),
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
- CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, tx, clk),
+ CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize),
ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk),
- FailTask: usecase.NewFailTask(taskRepo, jobRepo, tx, clk),
+ FailTask: usecase.NewFailTask(taskRepo, jobRepo, workerRepo, tx, clk),
GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo),
CancelJob: usecase.NewCancelJob(jobRepo, taskRepo, tx, clk),
- UploadArtifact: usecase.NewUploadArtifact(taskRepo, artifactRepo, blobStore, tx, clk),
+ UploadArtifact: usecase.NewUploadArtifact(taskRepo, workerRepo, artifactRepo, blobStore, tx, clk),
DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore),
GetJobResult: usecase.NewGetJobResult(jobRepo, usecase.NewDownloadArtifact(artifactRepo, blobStore)),
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
@@ -108,9 +110,18 @@ func run() error {
}(r.name, r.fn)
}
+ // Business metrics: gauges of tasks/jobs/workers by status, sampled from the
+ // database on every Prometheus scrape.
+ statsRepo := postgres.NewStatsRepo(pool)
+ m := metrics.New()
+ m.RegisterBusiness(func(ctx context.Context) (metrics.Stats, error) {
+ tasks, jobs, workers, err := statsRepo.Counts(ctx)
+ return metrics.Stats{Tasks: tasks, Jobs: jobs, Workers: workers}, err
+ })
+
// pool.Ping backs /health: readiness means the database answers, not just
// that the process is alive.
- api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, pool.Ping)
+ api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping)
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
// Shutdown order matters, and defers alone cannot express it (they run
diff --git a/coordinator/docker-compose.monitoring.yml b/coordinator/docker-compose.monitoring.yml
new file mode 100644
index 0000000..1833a98
--- /dev/null
+++ b/coordinator/docker-compose.monitoring.yml
@@ -0,0 +1,30 @@
+# Demo overlay: Prometheus scrapes the coordinator's /metrics, Grafana shows the
+# provisioned SciMesh dashboard. Merged by scripts/demo-ui.sh with a third -f.
+# Both share the coordinator's compose network, so Prometheus reaches it by name.
+
+services:
+ prometheus:
+ image: prom/prometheus:v2.54.1
+ volumes:
+ - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ ports:
+ - "${PROMETHEUS_PORT:-19090}:9090"
+ restart: unless-stopped
+
+ grafana:
+ image: grafana/grafana:11.2.0
+ depends_on:
+ - prometheus
+ environment:
+ GF_SECURITY_ADMIN_USER: admin
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
+ # Anonymous viewing so the demo dashboard opens without a login.
+ GF_AUTH_ANONYMOUS_ENABLED: "true"
+ GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
+ GF_USERS_DEFAULT_THEME: dark
+ volumes:
+ - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
+ ports:
+ - "${GRAFANA_PORT:-13000}:3000"
+ restart: unless-stopped
diff --git a/coordinator/docker-compose.users.yml b/coordinator/docker-compose.users.yml
new file mode 100644
index 0000000..30e5483
--- /dev/null
+++ b/coordinator/docker-compose.users.yml
@@ -0,0 +1,66 @@
+# Demo overlay: adds the userservice (its own Postgres + migrations) alongside
+# the coordinator and wires the two together with a shared JWT secret, so the
+# operator UI authenticates through userservice login/registration.
+#
+# Used only by scripts/demo-ui.sh, merged onto docker-compose.yml with a second
+# -f. Not part of the plain `make up` stack.
+
+services:
+ postgres-users:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-scimesh}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh}
+ POSTGRES_DB: scimesh_users
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d scimesh_users"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 5s
+
+ migrate-users:
+ image: migrate/migrate:v4.17.1
+ depends_on:
+ postgres-users:
+ condition: service_healthy
+ volumes:
+ - ../users/migrations:/migrations:ro
+ command:
+ - -path=/migrations
+ - -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable
+ - up
+ restart: on-failure
+
+ userservice:
+ build:
+ context: ../users
+ depends_on:
+ postgres-users:
+ condition: service_healthy
+ migrate-users:
+ condition: service_completed_successfully
+ environment:
+ USERSERVICE_ADDR: ":8081"
+ DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable
+ JWT_SECRET: ${JWT_SECRET}
+ # Seeds the first admin the very first time it boots (idempotent after).
+ BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-root@scimesh.local}
+ BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD}
+ LOG_LEVEL: ${LOG_LEVEL:-info}
+ ports:
+ - "${USERSERVICE_PORT:-18081}:8081"
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+ restart: unless-stopped
+
+ # Turn the coordinator UI into session mode: the same shared secret verifies
+ # userservice tokens locally, and USERSERVICE_URL is where login/register proxy.
+ coordinator:
+ environment:
+ JWT_SECRET: ${JWT_SECRET}
+ USERSERVICE_URL: http://userservice:8081
diff --git a/coordinator/go.mod b/coordinator/go.mod
index 4fc8bcc..7d9cb06 100644
--- a/coordinator/go.mod
+++ b/coordinator/go.mod
@@ -1,23 +1,33 @@
module github.com/emil28092005/SciMesh/coordinator
-go 1.22
+go 1.25.0
require (
github.com/Masterminds/squirrel v1.5.4
github.com/cenkalti/backoff/v4 v4.3.0
+ github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.6.0
github.com/joho/godotenv v1.5.1
+ github.com/prometheus/client_golang v1.19.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.55.0 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
- golang.org/x/sync v0.1.0 // indirect
- golang.org/x/text v0.14.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/coordinator/go.sum b/coordinator/go.sum
index d9c880a..df0b061 100644
--- a/coordinator/go.sum
+++ b/coordinator/go.sum
@@ -1,10 +1,18 @@
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -21,20 +29,34 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
+github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
+github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
-golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
diff --git a/coordinator/internal/authctx/authctx.go b/coordinator/internal/authctx/authctx.go
new file mode 100644
index 0000000..398cc38
--- /dev/null
+++ b/coordinator/internal/authctx/authctx.go
@@ -0,0 +1,43 @@
+// Package authctx carries the authenticated requester across the transport and
+// use-case layers without either one importing the other. The HTTP middleware
+// stamps a Requester after verifying a user's JWT; the job use cases read it to
+// record ownership and to enforce that a non-admin only touches their own jobs.
+package authctx
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+)
+
+// Requester is the identity behind a request, derived from a verified JWT.
+// A request authenticated only by the shared worker/service token carries no
+// Requester at all (From returns ok=false), which is how worker traffic and
+// legacy unauthenticated-user traffic stay owner-less.
+type Requester struct {
+ UserID uuid.UUID
+ Role string
+ Verified bool
+}
+
+// IsAdmin reports whether the requester may act on any user's jobs.
+func (r Requester) IsAdmin() bool { return r.Role == "admin" }
+
+// IsTrusted reports whether workers this requester registers produce results
+// the coordinator accepts without quorum. Admins and verified contributors are
+// trusted; a plain unverified user is not.
+func (r Requester) IsTrusted() bool { return r.IsAdmin() || r.Verified }
+
+type ctxKey struct{}
+
+// With returns a copy of ctx carrying r.
+func With(ctx context.Context, r Requester) context.Context {
+ return context.WithValue(ctx, ctxKey{}, r)
+}
+
+// From returns the requester stamped by the middleware, or ok=false when the
+// request was not authenticated as a user.
+func From(ctx context.Context) (Requester, bool) {
+ r, ok := ctx.Value(ctxKey{}).(Requester)
+ return r, ok
+}
diff --git a/coordinator/internal/domain/job.go b/coordinator/internal/domain/job.go
index 16ec023..67ad075 100644
--- a/coordinator/internal/domain/job.go
+++ b/coordinator/internal/domain/job.go
@@ -19,7 +19,11 @@ const (
// Job is one user submission that fans out into one or more tasks.
type Job struct {
- ID uuid.UUID
+ ID uuid.UUID
+ // OwnerID is the userservice user who submitted the job (JWT `sub`). nil
+ // when the job was created without user authentication. Not a foreign key:
+ // users live in a separate service/database.
+ OwnerID *uuid.UUID
Workload string
InputURI string // external input URI; empty for uploaded datasets
InputArtifactID *uuid.UUID // uploaded input artifact; nil for URI submissions
diff --git a/coordinator/internal/domain/task.go b/coordinator/internal/domain/task.go
index 7864660..1148f18 100644
--- a/coordinator/internal/domain/task.go
+++ b/coordinator/internal/domain/task.go
@@ -24,6 +24,10 @@ const (
// ErrCodeLeaseExpired marks tasks failed by the reaper rather than by a worker.
const ErrCodeLeaseExpired = "lease_expired"
+// ErrCodeQuorumFailed marks a task whose untrusted results never reached a
+// verifying quorum before its attempts ran out.
+const ErrCodeQuorumFailed = "quorum_failed"
+
// Task is one independently executable chunk of a job.
//
// Nullable columns are pointers so "no lease" stays distinguishable from
@@ -216,6 +220,33 @@ func (t *Task) CompleteWith(resultArtifactID uuid.UUID, metrics map[string]any,
return nil
}
+// ReleaseAfterVote returns an untrusted worker's task to the queue after its
+// result was recorded as a quorum vote but quorum was not yet reached, so a
+// different owner can compute it independently. When no attempts remain the task
+// fails: its untrusted results could not be verified.
+func (t *Task) ReleaseAfterVote(worker string, attempt int, now time.Time) error {
+ if t.Status == TaskCompleted {
+ return nil // settled by a concurrent quorum
+ }
+ if err := t.verifyLease(worker, attempt, now); err != nil {
+ return err
+ }
+ t.LeaseOwner = nil
+ t.LeaseExpiresAt = nil
+ t.Version++
+
+ if t.CanRetry() {
+ t.Status = TaskPending
+ return nil
+ }
+ code, msg := ErrCodeQuorumFailed, "untrusted results did not reach quorum"
+ t.ErrorCode = &code
+ t.ErrorMessage = &msg
+ t.Status = TaskFailed
+ t.CompletedAt = &now
+ return nil
+}
+
// Fail records a worker-reported failure. A retryable failure with attempts
// left returns the task to the queue; otherwise it terminates as failed.
func (t *Task) Fail(worker string, attempt int, code, message string, retryable bool, now time.Time) error {
diff --git a/coordinator/internal/domain/worker.go b/coordinator/internal/domain/worker.go
index e7e0bf9..75ee27b 100644
--- a/coordinator/internal/domain/worker.go
+++ b/coordinator/internal/domain/worker.go
@@ -14,14 +14,30 @@ const (
WorkerOffline WorkerStatus = "offline"
)
+// WorkerTrust says whether a worker's results are accepted directly or must
+// clear quorum cross-checking.
+type WorkerTrust string
+
+const (
+ // WorkerTrusted — lab machine (shared token) or a verified/admin contributor.
+ WorkerTrusted WorkerTrust = "trusted"
+ // WorkerUntrusted — a plain enthusiast; results are quarantined until quorum.
+ WorkerUntrusted WorkerTrust = "untrusted"
+)
+
// Worker is a registered process/machine allowed to claim tasks. Its
// capabilities are the allowlisted workload names it can run; the coordinator
// never hands it a task outside that set.
type Worker struct {
- ID uuid.UUID
- Name string
- Capabilities []string
- Status WorkerStatus
+ ID uuid.UUID
+ Name string
+ Capabilities []string
+ Status WorkerStatus
+ // OwnerID is the userservice user who registered this worker; nil for a
+ // worker registered with the shared service token.
+ OwnerID *uuid.UUID
+ // TrustLevel decides whether this worker's results need quorum.
+ TrustLevel WorkerTrust
LastHeartbeatAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
@@ -29,6 +45,9 @@ type Worker struct {
// NewWorker registers a worker. A worker with no capabilities could never be
// handed a task, so an empty set is rejected rather than silently stored.
+//
+// Trust defaults to WorkerTrusted (the shared-token lab worker); the caller
+// overrides it for a volunteer registered through the userservice.
func NewWorker(name string, capabilities []string, now time.Time) (*Worker, error) {
if len(capabilities) == 0 {
return nil, ErrInvalidInput
@@ -38,6 +57,7 @@ func NewWorker(name string, capabilities []string, now time.Time) (*Worker, erro
Name: name,
Capabilities: capabilities,
Status: WorkerOnline,
+ TrustLevel: WorkerTrusted,
LastHeartbeatAt: now,
CreatedAt: now,
UpdatedAt: now,
diff --git a/coordinator/internal/infra/config.go b/coordinator/internal/infra/config.go
index 7d6db0e..5a414bd 100644
--- a/coordinator/internal/infra/config.go
+++ b/coordinator/internal/infra/config.go
@@ -26,6 +26,17 @@ type Config struct {
Token string
// Local operator UI credential. Empty disables the embedded UI entirely.
UIToken string
+ // Shared HS256 secret used to verify userservice-issued JWTs. When set, a
+ // submitter may authenticate with a JWT (in addition to workers using the
+ // shared token) and their jobs are stamped with owner_id. Empty disables
+ // user-JWT auth entirely — the pre-userservice behaviour. Must match the
+ // userservice's JWT_SECRET.
+ JWTSecret string
+ // Base URL of the userservice, e.g. http://userservice:8081. When set
+ // together with JWTSecret, the operator UI authenticates via userservice
+ // login/registration (cookie session) instead of the static UI_AUTH_TOKEN
+ // basic auth. Empty keeps the basic-auth UI.
+ UserserviceURL string
// Minimum log level: debug, info, warn, error.
LogLevel string
@@ -50,6 +61,9 @@ type Config struct {
LeaseDuration time.Duration
// Default attempt ceiling for newly created tasks.
DefaultMaxAttempts int
+ // How many distinct owners must agree on an untrusted result before it is
+ // accepted (trusted workers are accepted directly).
+ QuorumSize int
// How often the background lease-reaper runs.
ReaperInterval time.Duration
// A worker silent for longer than this is marked offline by the reaper.
@@ -80,6 +94,8 @@ func LoadConfig() (Config, error) {
// former name, still honoured so existing .env files keep working.
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
UIToken: os.Getenv("UI_AUTH_TOKEN"),
+ JWTSecret: os.Getenv("JWT_SECRET"),
+ UserserviceURL: os.Getenv("USERSERVICE_URL"),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
@@ -90,6 +106,7 @@ func LoadConfig() (Config, error) {
HeartbeatInterval: 15 * time.Second,
LeaseDuration: 2 * time.Minute,
DefaultMaxAttempts: 3,
+ QuorumSize: 2,
ReaperInterval: 30 * time.Second,
WorkerOfflineAfter: 1 * time.Minute,
}
@@ -100,6 +117,11 @@ func LoadConfig() (Config, error) {
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
}
+ // A short secret makes the HMAC brute-forceable; refuse a weak one rather
+ // than verify tokens against it.
+ if cfg.JWTSecret != "" && len(cfg.JWTSecret) < 32 {
+ return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
+ }
var err error
if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
@@ -129,6 +151,12 @@ func LoadConfig() (Config, error) {
if cfg.DefaultMaxAttempts, err = getEnvInt("DEFAULT_MAX_ATTEMPTS", cfg.DefaultMaxAttempts); err != nil {
return Config{}, err
}
+ if cfg.QuorumSize, err = getEnvInt("QUORUM_SIZE", cfg.QuorumSize); err != nil {
+ return Config{}, err
+ }
+ if cfg.QuorumSize < 1 {
+ return Config{}, fmt.Errorf("QUORUM_SIZE must be positive")
+ }
if cfg.DefaultMaxAttempts < 1 {
return Config{}, fmt.Errorf("DEFAULT_MAX_ATTEMPTS must be positive")
}
diff --git a/coordinator/internal/memstore/memstore.go b/coordinator/internal/memstore/memstore.go
index 28f4318..9c911e4 100644
--- a/coordinator/internal/memstore/memstore.go
+++ b/coordinator/internal/memstore/memstore.go
@@ -417,3 +417,36 @@ func contains(ss []string, s string) bool {
}
return false
}
+
+// TaskResultRepo is an in-memory usecase.TaskResultRepository: one vote per
+// (task, owner).
+type TaskResultRepo struct {
+ mu sync.Mutex
+ votes map[uuid.UUID]map[uuid.UUID]string // taskID -> ownerID -> sha256
+}
+
+func NewTaskResultRepo() *TaskResultRepo {
+ return &TaskResultRepo{votes: make(map[uuid.UUID]map[uuid.UUID]string)}
+}
+
+func (r *TaskResultRepo) RecordVote(_ context.Context, taskID, ownerID uuid.UUID, sha256 string, _ uuid.UUID) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.votes[taskID] == nil {
+ r.votes[taskID] = make(map[uuid.UUID]string)
+ }
+ r.votes[taskID][ownerID] = sha256
+ return nil
+}
+
+func (r *TaskResultRepo) CountAgreeing(_ context.Context, taskID uuid.UUID, sha256 string) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ n := 0
+ for _, s := range r.votes[taskID] {
+ if s == sha256 {
+ n++
+ }
+ }
+ return n, nil
+}
diff --git a/coordinator/internal/memstore/ui_read.go b/coordinator/internal/memstore/ui_read.go
index 23bff9d..cc2c681 100644
--- a/coordinator/internal/memstore/ui_read.go
+++ b/coordinator/internal/memstore/ui_read.go
@@ -27,7 +27,7 @@ var _ usecase.UIReadRepository = (*UIReadRepo)(nil)
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
return r.jobs.Get(ctx, id)
}
-func (r *UIReadRepo) ListJobs(_ context.Context, limit int) ([]domain.Job, error) {
+func (r *UIReadRepo) ListJobs(_ context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
@@ -35,6 +35,9 @@ func (r *UIReadRepo) ListJobs(_ context.Context, limit int) ([]domain.Job, error
defer r.jobs.mu.Unlock()
out := make([]domain.Job, 0, len(r.jobs.jobs))
for _, job := range r.jobs.jobs {
+ if owner != nil && (job.OwnerID == nil || *job.OwnerID != *owner) {
+ continue
+ }
out = append(out, *job)
}
sort.Slice(out, func(i, j int) bool {
diff --git a/coordinator/internal/metrics/business.go b/coordinator/internal/metrics/business.go
new file mode 100644
index 0000000..1a87348
--- /dev/null
+++ b/coordinator/internal/metrics/business.go
@@ -0,0 +1,66 @@
+package metrics
+
+import (
+ "context"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+)
+
+// Stats is a point-in-time snapshot of the coordinator's domain state: counts of
+// tasks, jobs, and workers keyed by their status. Maps are expected to be
+// zero-filled by the provider so every known status is always present, giving
+// the dashboard flat zero lines instead of gaps.
+type Stats struct {
+ Tasks map[string]int
+ Jobs map[string]int
+ Workers map[string]int
+}
+
+// StatsFunc returns the current snapshot. It is called on every scrape, so it
+// must be a cheap aggregate query.
+type StatsFunc func(context.Context) (Stats, error)
+
+// RegisterBusiness registers a collector that reports domain-state gauges
+// (scimesh_tasks/jobs/workers by status) sourced from collect on each scrape.
+// Deriving the gauges at scrape time keeps them fresh without a background
+// goroutine, and a failed query simply yields no samples for that scrape.
+func (m *Metrics) RegisterBusiness(collect StatsFunc) {
+ m.reg.MustRegister(&businessCollector{
+ collect: collect,
+ tasks: prometheus.NewDesc("scimesh_tasks", "Tasks by status.", []string{"status"}, nil),
+ jobs: prometheus.NewDesc("scimesh_jobs", "Jobs by status.", []string{"status"}, nil),
+ workers: prometheus.NewDesc("scimesh_workers", "Workers by status.", []string{"status"}, nil),
+ })
+}
+
+type businessCollector struct {
+ collect StatsFunc
+ tasks, jobs, workers *prometheus.Desc
+}
+
+func (c *businessCollector) Describe(ch chan<- *prometheus.Desc) {
+ ch <- c.tasks
+ ch <- c.jobs
+ ch <- c.workers
+}
+
+func (c *businessCollector) Collect(ch chan<- prometheus.Metric) {
+ // A bounded query so one slow scrape cannot stall Prometheus.
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ s, err := c.collect(ctx)
+ if err != nil {
+ return // no samples this scrape; Prometheus keeps the last value
+ }
+ emit(ch, c.tasks, s.Tasks)
+ emit(ch, c.jobs, s.Jobs)
+ emit(ch, c.workers, s.Workers)
+}
+
+func emit(ch chan<- prometheus.Metric, desc *prometheus.Desc, counts map[string]int) {
+ for status, n := range counts {
+ ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, float64(n), status)
+ }
+}
diff --git a/coordinator/internal/metrics/business_test.go b/coordinator/internal/metrics/business_test.go
new file mode 100644
index 0000000..d127ce8
--- /dev/null
+++ b/coordinator/internal/metrics/business_test.go
@@ -0,0 +1,51 @@
+package metrics
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func scrape(t *testing.T, m *Metrics) string {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
+ m.Handler().ServeHTTP(rec, req)
+ return rec.Body.String()
+}
+
+func TestBusinessCollectorEmitsGauges(t *testing.T) {
+ m := New()
+ m.RegisterBusiness(func(context.Context) (Stats, error) {
+ return Stats{
+ Tasks: map[string]int{"pending": 3, "running": 1, "completed": 0},
+ Jobs: map[string]int{"running": 2},
+ Workers: map[string]int{"online": 4},
+ }, nil
+ })
+
+ body := scrape(t, m)
+ for _, want := range []string{
+ `scimesh_tasks{status="pending"} 3`,
+ `scimesh_tasks{status="completed"} 0`,
+ `scimesh_jobs{status="running"} 2`,
+ `scimesh_workers{status="online"} 4`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("metrics missing %q\n%s", want, body)
+ }
+ }
+}
+
+func TestBusinessCollectorSkipsOnError(t *testing.T) {
+ m := New()
+ m.RegisterBusiness(func(context.Context) (Stats, error) {
+ return Stats{}, errors.New("db down")
+ })
+ if strings.Contains(scrape(t, m), "scimesh_tasks") {
+ t.Error("a failed snapshot must emit no business samples")
+ }
+}
diff --git a/coordinator/internal/metrics/metrics.go b/coordinator/internal/metrics/metrics.go
new file mode 100644
index 0000000..1070e72
--- /dev/null
+++ b/coordinator/internal/metrics/metrics.go
@@ -0,0 +1,112 @@
+// Package metrics exposes Prometheus instrumentation for the coordinator: an
+// HTTP RED middleware (rate, errors, duration) plus the standard Go runtime and
+// process collectors, all on a private registry so nothing leaks in from global
+// state.
+package metrics
+
+import (
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/collectors"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+type Metrics struct {
+ reg *prometheus.Registry
+ requests *prometheus.CounterVec
+ duration *prometheus.HistogramVec
+}
+
+// New builds the registry and registers the runtime, process, and HTTP metrics.
+func New() *Metrics {
+ reg := prometheus.NewRegistry()
+ reg.MustRegister(
+ collectors.NewGoCollector(),
+ collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
+ )
+
+ requests := prometheus.NewCounterVec(prometheus.CounterOpts{
+ Namespace: "scimesh",
+ Subsystem: "http",
+ Name: "requests_total",
+ Help: "HTTP requests, labelled by method, normalized route, and status.",
+ }, []string{"method", "route", "status"})
+
+ duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "scimesh",
+ Subsystem: "http",
+ Name: "request_duration_seconds",
+ Help: "HTTP request duration in seconds.",
+ Buckets: prometheus.DefBuckets,
+ }, []string{"method", "route"})
+
+ reg.MustRegister(requests, duration)
+ return &Metrics{reg: reg, requests: requests, duration: duration}
+}
+
+// Handler serves the metrics in Prometheus text format.
+func (m *Metrics) Handler() http.Handler {
+ return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{})
+}
+
+// Registry exposes the registry so callers can register extra collectors.
+func (m *Metrics) Registry() *prometheus.Registry { return m.reg }
+
+// Middleware records one request into the RED metrics. It normalizes the path
+// so per-id routes collapse to a single low-cardinality label.
+func (m *Metrics) Middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rec, r)
+
+ route := normalizeRoute(r.URL.Path)
+ m.requests.WithLabelValues(r.Method, route, strconv.Itoa(rec.status)).Inc()
+ m.duration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds())
+ })
+}
+
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+}
+
+func (s *statusRecorder) WriteHeader(code int) {
+ s.status = code
+ s.ResponseWriter.WriteHeader(code)
+}
+
+var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+
+// normalizeRoute collapses uuid and numeric path segments to {id}, keeping the
+// route label cardinality bounded (otherwise every job/task id would be its own
+// time series).
+func normalizeRoute(path string) string {
+ if path == "" {
+ return "/"
+ }
+ segs := strings.Split(path, "/")
+ for i, s := range segs {
+ if s == "" {
+ continue
+ }
+ if uuidRe.MatchString(s) || isAllDigits(s) {
+ segs[i] = "{id}"
+ }
+ }
+ return strings.Join(segs, "/")
+}
+
+func isAllDigits(s string) bool {
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return s != ""
+}
diff --git a/coordinator/internal/metrics/metrics_test.go b/coordinator/internal/metrics/metrics_test.go
new file mode 100644
index 0000000..75729ee
--- /dev/null
+++ b/coordinator/internal/metrics/metrics_test.go
@@ -0,0 +1,47 @@
+package metrics
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestNormalizeRoute(t *testing.T) {
+ cases := map[string]string{
+ "/health": "/health",
+ "/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301": "/jobs/{id}",
+ "/tasks/3f2504e0-4f89-41d3-9a0c-0305e82c3301/result": "/tasks/{id}/result",
+ "/ui/jobs/12345": "/ui/jobs/{id}",
+ "/": "/",
+ }
+ for in, want := range cases {
+ if got := normalizeRoute(in); got != want {
+ t.Errorf("normalizeRoute(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestMiddlewareAndHandler(t *testing.T) {
+ m := New()
+ h := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ }))
+
+ req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301", nil)
+ h.ServeHTTP(httptest.NewRecorder(), req)
+
+ // Scrape and confirm the request was recorded under the normalized route.
+ rec := httptest.NewRecorder()
+ greq, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
+ m.Handler().ServeHTTP(rec, greq)
+
+ body := rec.Body.String()
+ if !strings.Contains(body, `scimesh_http_requests_total{method="POST",route="/jobs/{id}",status="201"}`) {
+ t.Errorf("requests_total not recorded as expected; body:\n%s", body)
+ }
+ if !strings.Contains(body, "go_goroutines") {
+ t.Error("Go runtime collector not registered")
+ }
+}
diff --git a/coordinator/internal/storage/postgres/integration_test.go b/coordinator/internal/storage/postgres/integration_test.go
index 3252b00..ba974c3 100644
--- a/coordinator/internal/storage/postgres/integration_test.go
+++ b/coordinator/internal/storage/postgres/integration_test.go
@@ -144,7 +144,7 @@ func TestUIReadRepoListsReducerFields(t *testing.T) {
if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed {
t.Fatalf("claim reduction = (%v, %v)", claimed, err)
}
- listed, err := NewUIReadRepo(pool).ListJobs(ctx, 20)
+ listed, err := NewUIReadRepo(pool).ListJobs(ctx, nil, 20)
if err != nil {
t.Fatalf("list UI jobs: %v", err)
}
@@ -406,8 +406,9 @@ func TestCompleteTaskReplayIsIdempotent(t *testing.T) {
job, _ := seedJob(t, pool, 1)
tasks, jobs, artifacts, tx := NewTaskRepo(pool), NewJobRepo(pool), NewArtifactRepo(pool), NewTxManager(pool)
+ workers, results := NewWorkerRepo(pool), NewTaskResultRepo(pool)
clk := fixedClock{now: time.Now().UTC()}
- uc := usecase.NewCompleteTask(tasks, jobs, artifacts, tx, clk)
+ uc := usecase.NewCompleteTask(tasks, jobs, artifacts, workers, results, tx, clk, 2)
claimed, err := tasks.ClaimNext(ctx, usecase.ClaimFilter{
Owner: "worker-1", Now: clk.now, LeaseUntil: clk.now.Add(time.Minute),
diff --git a/coordinator/internal/storage/postgres/job_repo.go b/coordinator/internal/storage/postgres/job_repo.go
index d889a23..eb859f2 100644
--- a/coordinator/internal/storage/postgres/job_repo.go
+++ b/coordinator/internal/storage/postgres/job_repo.go
@@ -28,14 +28,15 @@ var _ usecase.JobRepository = (*JobRepo)(nil)
var jobColumns = []string{
"id", "workload", "input_uri", "parameters", "status", "created_at", "completed_at",
"input_artifact_id", "result_artifact_id", "error_code", "error_message", "reducer_started_at",
+ "owner_id",
}
// Insert runs inside the caller's transaction, alongside the job's tasks — that
// is what makes "all tasks or none" hold.
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
sql, args, err := psql.Insert("jobs").
- Columns("id", "workload", "input_uri", "parameters", "status", "created_at").
- Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt).
+ Columns("id", "workload", "input_uri", "parameters", "status", "created_at", "owner_id").
+ Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt, j.OwnerID).
ToSql()
if err != nil {
return err
@@ -59,7 +60,8 @@ func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
)
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
- &j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt)
+ &j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
+ &j.OwnerID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrJobNotFound
}
diff --git a/coordinator/internal/storage/postgres/stats_repo.go b/coordinator/internal/storage/postgres/stats_repo.go
new file mode 100644
index 0000000..9be7e87
--- /dev/null
+++ b/coordinator/internal/storage/postgres/stats_repo.go
@@ -0,0 +1,65 @@
+package postgres
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/domain"
+)
+
+// Known statuses per entity, so counts are zero-filled and every status is
+// always present in the metrics (a flat 0 line beats a gap on the dashboard).
+var (
+ taskStatuses = []string{string(domain.TaskPending), string(domain.TaskLeased), string(domain.TaskRunning), string(domain.TaskCompleted), string(domain.TaskFailed), string(domain.TaskCancelled)}
+ jobStatuses = []string{string(domain.JobPending), string(domain.JobRunning), string(domain.JobReducing), string(domain.JobCompleted), string(domain.JobFailed), string(domain.JobCancelled)}
+ workerStatuses = []string{string(domain.WorkerOnline), string(domain.WorkerBusy), string(domain.WorkerOffline)}
+)
+
+// StatsRepo answers the aggregate status counts the business metrics report. It
+// runs one cheap GROUP BY per entity; the collector calls this on every scrape.
+type StatsRepo struct {
+ pool *pgxpool.Pool
+}
+
+func NewStatsRepo(pool *pgxpool.Pool) *StatsRepo {
+ return &StatsRepo{pool: pool}
+}
+
+// Counts returns status->count maps for tasks, jobs, and workers, each
+// zero-filled across its known statuses.
+func (r *StatsRepo) Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error) {
+ if tasks, err = r.countByStatus(ctx, "tasks", taskStatuses); err != nil {
+ return nil, nil, nil, err
+ }
+ if jobs, err = r.countByStatus(ctx, "jobs", jobStatuses); err != nil {
+ return nil, nil, nil, err
+ }
+ if workers, err = r.countByStatus(ctx, "workers", workerStatuses); err != nil {
+ return nil, nil, nil, err
+ }
+ return tasks, jobs, workers, nil
+}
+
+func (r *StatsRepo) countByStatus(ctx context.Context, table string, known []string) (map[string]int, error) {
+ out := make(map[string]int, len(known))
+ for _, s := range known {
+ out[s] = 0 // zero-fill
+ }
+ // table is a fixed internal constant, never user input — safe to format.
+ rows, err := r.pool.Query(ctx, fmt.Sprintf("SELECT status, count(*) FROM %s GROUP BY status", table))
+ if err != nil {
+ return nil, fmt.Errorf("count %s by status: %w", table, err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var status string
+ var n int
+ if err := rows.Scan(&status, &n); err != nil {
+ return nil, err
+ }
+ out[status] = n // an unknown status still shows up, which is a useful signal
+ }
+ return out, rows.Err()
+}
diff --git a/coordinator/internal/storage/postgres/task_repo.go b/coordinator/internal/storage/postgres/task_repo.go
index 11b0616..a373087 100644
--- a/coordinator/internal/storage/postgres/task_repo.go
+++ b/coordinator/internal/storage/postgres/task_repo.go
@@ -84,6 +84,9 @@ WITH candidate AS (
WHERE status = 'pending'
AND attempt < max_attempts
AND (cardinality($1::text[]) = 0 OR workload = ANY($1))
+ AND ($5::uuid IS NULL OR NOT EXISTS (
+ SELECT 1 FROM task_results tr
+ WHERE tr.task_id = tasks.id AND tr.owner_id = $5))
ORDER BY created_at, chunk_index
FOR UPDATE SKIP LOCKED
LIMIT 1
@@ -108,7 +111,7 @@ func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domai
var task *domain.Task
err := withRetry(ctx, func(ctx context.Context) error {
- row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now)
+ row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now, f.VoterOwner)
t, err := scanTask(row)
if errors.Is(err, pgx.ErrNoRows) {
task = nil
diff --git a/coordinator/internal/storage/postgres/task_result_repo.go b/coordinator/internal/storage/postgres/task_result_repo.go
new file mode 100644
index 0000000..aa5907a
--- /dev/null
+++ b/coordinator/internal/storage/postgres/task_result_repo.go
@@ -0,0 +1,45 @@
+package postgres
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// TaskResultRepo records and tallies quorum votes for untrusted task results.
+type TaskResultRepo struct {
+ pool *pgxpool.Pool
+}
+
+func NewTaskResultRepo(pool *pgxpool.Pool) *TaskResultRepo {
+ return &TaskResultRepo{pool: pool}
+}
+
+// RecordVote stores (or replaces) one owner's vote for a task's result.
+func (r *TaskResultRepo) RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error {
+ const sql = `
+INSERT INTO task_results (task_id, owner_id, result_sha256, result_artifact_id)
+VALUES ($1, $2, $3, $4)
+ON CONFLICT (task_id, owner_id) DO UPDATE
+SET result_sha256 = EXCLUDED.result_sha256,
+ result_artifact_id = EXCLUDED.result_artifact_id,
+ created_at = now()`
+ if _, err := conn(ctx, r.pool).Exec(ctx, sql, taskID, ownerID, sha256, artifactID); err != nil {
+ return fmt.Errorf("record vote: %w", err)
+ }
+ return nil
+}
+
+// CountAgreeing returns how many distinct owners have voted for the given result
+// hash on this task — the size of the agreeing set the quorum is measured
+// against.
+func (r *TaskResultRepo) CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error) {
+ const sql = `SELECT count(DISTINCT owner_id) FROM task_results WHERE task_id = $1 AND result_sha256 = $2`
+ var n int
+ if err := conn(ctx, r.pool).QueryRow(ctx, sql, taskID, sha256).Scan(&n); err != nil {
+ return 0, fmt.Errorf("count agreeing: %w", err)
+ }
+ return n, nil
+}
diff --git a/coordinator/internal/storage/postgres/ui_read_repo.go b/coordinator/internal/storage/postgres/ui_read_repo.go
index c759b2a..5c8a6a6 100644
--- a/coordinator/internal/storage/postgres/ui_read_repo.go
+++ b/coordinator/internal/storage/postgres/ui_read_repo.go
@@ -24,11 +24,15 @@ func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, err
return job, err
}
-func (r *UIReadRepo) ListJobs(ctx context.Context, limit int) ([]domain.Job, error) {
+func (r *UIReadRepo) ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
- sql, args, err := psql.Select(jobColumns...).From("jobs").OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
+ q := psql.Select(jobColumns...).From("jobs")
+ if owner != nil {
+ q = q.Where(sq.Eq{"owner_id": *owner})
+ }
+ sql, args, err := q.OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
if err != nil {
return nil, err
}
@@ -44,6 +48,7 @@ func (r *UIReadRepo) ListJobs(ctx context.Context, limit int) ([]domain.Job, err
if err := rows.Scan(
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
+ &j.OwnerID,
); err != nil {
return nil, err
}
diff --git a/coordinator/internal/storage/postgres/worker_repo.go b/coordinator/internal/storage/postgres/worker_repo.go
index 1df545e..5f75183 100644
--- a/coordinator/internal/storage/postgres/worker_repo.go
+++ b/coordinator/internal/storage/postgres/worker_repo.go
@@ -23,13 +23,13 @@ func NewWorkerRepo(pool *pgxpool.Pool) *WorkerRepo {
return &WorkerRepo{pool: pool}
}
-var workerColumns = []string{"id", "name", "capabilities", "status", "last_heartbeat_at", "created_at", "updated_at"}
+var workerColumns = []string{"id", "name", "capabilities", "status", "owner_id", "trust_level", "last_heartbeat_at", "created_at", "updated_at"}
func (r *WorkerRepo) Insert(ctx context.Context, w *domain.Worker) error {
sql, args, err := psql.Insert("workers").
Columns(workerColumns...).
// capabilities is a jsonb column; pgx marshals the []string to a JSON array.
- Values(w.ID, w.Name, w.Capabilities, string(w.Status),
+ Values(w.ID, w.Name, w.Capabilities, string(w.Status), w.OwnerID, string(w.TrustLevel),
w.LastHeartbeatAt, w.CreatedAt, w.UpdatedAt).
ToSql()
if err != nil {
@@ -95,11 +95,13 @@ func scanWorker(row pgx.Row) (*domain.Worker, error) {
var (
w domain.Worker
status string
+ trust string
)
- if err := row.Scan(&w.ID, &w.Name, &w.Capabilities, &status,
+ if err := row.Scan(&w.ID, &w.Name, &w.Capabilities, &status, &w.OwnerID, &trust,
&w.LastHeartbeatAt, &w.CreatedAt, &w.UpdatedAt); err != nil {
return nil, err
}
w.Status = domain.WorkerStatus(status)
+ w.TrustLevel = domain.WorkerTrust(trust)
return &w, nil
}
diff --git a/coordinator/internal/token/verifier.go b/coordinator/internal/token/verifier.go
new file mode 100644
index 0000000..93b78ee
--- /dev/null
+++ b/coordinator/internal/token/verifier.go
@@ -0,0 +1,60 @@
+// Package token verifies the HS256 JWTs minted by the userservice. The
+// coordinator only ever *verifies* — it never issues — so this is a deliberately
+// small counterpart to the userservice's issuer. Verification is local: the
+// shared secret is enough, with no runtime call back to the userservice.
+package token
+
+import (
+ "fmt"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+)
+
+// Claims is the subset of a userservice token the coordinator cares about.
+type Claims struct {
+ UserID uuid.UUID
+ Role string
+ Verified bool
+}
+
+// Verifier checks tokens against the shared HS256 secret.
+type Verifier struct {
+ secret []byte
+}
+
+// NewVerifier returns a Verifier, or nil when secret is empty — a nil Verifier
+// means user-JWT auth is disabled and only the shared service token is accepted.
+func NewVerifier(secret string) *Verifier {
+ if secret == "" {
+ return nil
+ }
+ return &Verifier{secret: []byte(secret)}
+}
+
+type claims struct {
+ Role string `json:"role"`
+ Verified bool `json:"verified"`
+ jwt.RegisteredClaims
+}
+
+// Verify checks the signature and expiry and returns the identity. It pins the
+// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
+// key — the classic algorithm-substitution attack.
+func (v *Verifier) Verify(raw string) (Claims, error) {
+ var c claims
+ _, err := jwt.ParseWithClaims(raw, &c, func(t *jwt.Token) (any, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return v.secret, nil
+ })
+ if err != nil {
+ return Claims{}, err
+ }
+ id, err := uuid.Parse(c.Subject)
+ if err != nil {
+ return Claims{}, fmt.Errorf("token subject is not a uuid: %w", err)
+ }
+ return Claims{UserID: id, Role: c.Role, Verified: c.Verified}, nil
+}
diff --git a/coordinator/internal/token/verifier_test.go b/coordinator/internal/token/verifier_test.go
new file mode 100644
index 0000000..9c3b0f4
--- /dev/null
+++ b/coordinator/internal/token/verifier_test.go
@@ -0,0 +1,98 @@
+package token
+
+import (
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+)
+
+const secret = "coordinator-verify-secret-32-bytes!!"
+
+func sign(t *testing.T, method jwt.SigningMethod, key any, sub, role string, exp time.Time) string {
+ t.Helper()
+ return signVerified(t, method, key, sub, role, false, exp)
+}
+
+func signVerified(t *testing.T, method jwt.SigningMethod, key any, sub, role string, verified bool, exp time.Time) string {
+ t.Helper()
+ tok := jwt.NewWithClaims(method, claims{
+ Role: role,
+ Verified: verified,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Subject: sub,
+ ExpiresAt: jwt.NewNumericDate(exp),
+ },
+ })
+ raw, err := tok.SignedString(key)
+ if err != nil {
+ t.Fatalf("sign: %v", err)
+ }
+ return raw
+}
+
+func TestVerifyCarriesVerifiedClaim(t *testing.T) {
+ v := NewVerifier(secret)
+ raw := signVerified(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", true, time.Now().Add(time.Hour))
+
+ claims, err := v.Verify(raw)
+ if err != nil {
+ t.Fatalf("verify: %v", err)
+ }
+ if !claims.Verified {
+ t.Error("verified claim not read from token")
+ }
+}
+
+func TestNewVerifierNilWhenNoSecret(t *testing.T) {
+ if NewVerifier("") != nil {
+ t.Error("empty secret must yield a nil verifier (auth disabled)")
+ }
+}
+
+func TestVerifyRoundTrip(t *testing.T) {
+ v := NewVerifier(secret)
+ id := uuid.New()
+ raw := sign(t, jwt.SigningMethodHS256, []byte(secret), id.String(), "admin", time.Now().Add(time.Hour))
+
+ claims, err := v.Verify(raw)
+ if err != nil {
+ t.Fatalf("verify: %v", err)
+ }
+ if claims.UserID != id {
+ t.Errorf("UserID = %v, want %v", claims.UserID, id)
+ }
+ if claims.Role != "admin" {
+ t.Errorf("Role = %q, want admin", claims.Role)
+ }
+}
+
+func TestVerifyRejectsExpired(t *testing.T) {
+ v := NewVerifier(secret)
+ raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(-time.Minute))
+ if _, err := v.Verify(raw); err == nil {
+ t.Error("expired token accepted")
+ }
+}
+
+func TestVerifyRejectsWrongSecret(t *testing.T) {
+ raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(time.Hour))
+ if _, err := NewVerifier("another-secret-also-at-least-32-byte").Verify(raw); err == nil {
+ t.Error("token verified under the wrong secret")
+ }
+}
+
+func TestVerifyRejectsNoneAlg(t *testing.T) {
+ raw := sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, uuid.New().String(), "admin", time.Now().Add(time.Hour))
+ if _, err := NewVerifier(secret).Verify(raw); err == nil {
+ t.Error("none-signed token accepted")
+ }
+}
+
+func TestVerifyRejectsNonUUIDSubject(t *testing.T) {
+ raw := sign(t, jwt.SigningMethodHS256, []byte(secret), "not-a-uuid", "user", time.Now().Add(time.Hour))
+ if _, err := NewVerifier(secret).Verify(raw); err == nil {
+ t.Error("non-uuid subject accepted")
+ }
+}
diff --git a/coordinator/internal/transport/http/handlers.go b/coordinator/internal/transport/http/handlers.go
index e8c827b..b002cd6 100644
--- a/coordinator/internal/transport/http/handlers.go
+++ b/coordinator/internal/transport/http/handlers.go
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
@@ -56,10 +57,24 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
- worker, err := s.uc.RegisterWorker.Execute(ctx, usecase.RegisterWorkerInput{
+ // Resolve the worker's trust tier from how the caller authenticated:
+ // - shared service token (no requester) -> trusted lab worker
+ // - verified/admin user JWT -> trusted volunteer
+ // - plain user JWT -> untrusted (quarantined)
+ in := usecase.RegisterWorkerInput{
Name: req.Name,
Capabilities: req.Capabilities,
- })
+ TrustLevel: domain.WorkerTrusted,
+ }
+ if requester, ok := authctx.From(ctx); ok {
+ id := requester.UserID
+ in.OwnerID = &id
+ if !requester.IsTrusted() {
+ in.TrustLevel = domain.WorkerUntrusted
+ }
+ }
+
+ worker, err := s.uc.RegisterWorker.Execute(ctx, in)
if err != nil {
s.writeError(w, r, err)
return
diff --git a/coordinator/internal/transport/http/middleware.go b/coordinator/internal/transport/http/middleware.go
index 2808313..c304827 100644
--- a/coordinator/internal/transport/http/middleware.go
+++ b/coordinator/internal/transport/http/middleware.go
@@ -9,6 +9,9 @@ import (
"net/http"
"strings"
"time"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
)
type ctxKey string
@@ -41,25 +44,46 @@ func newRequestID() string {
// withAuth enforces the shared bearer token every worker presents.
// An empty token disables the check (local development only).
-func withAuth(token string) func(http.Handler) http.Handler {
+// withAuth authenticates a request one of two ways. Workers (and legacy
+// submitters) present the shared service token. When user-JWT auth is enabled
+// (verifier != nil), a submitter may instead present a userservice JWT; on
+// success the requester is stamped into the context so the job use cases can
+// record owner_id and enforce ownership. An empty token with no verifier
+// disables auth entirely (dev only).
+func withAuth(token string, verifier *tokenpkg.Verifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if token == "" {
+ if token == "" && verifier == nil {
next.ServeHTTP(w, r)
return
}
presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
- // Constant-time compare: a byte-by-byte early exit would let an
- // attacker recover the token by timing responses.
- if subtle.ConstantTimeCompare([]byte(presented), []byte(token)) != 1 {
- w.Header().Set("WWW-Authenticate", "Bearer")
- writeJSON(w, http.StatusUnauthorized, errorResponse{
- Error: "unauthorized",
- RequestID: requestIDFrom(r.Context()),
- })
+
+ // Shared service token: constant-time compare so a byte-by-byte
+ // early exit cannot leak the token through response timing.
+ if token != "" && subtle.ConstantTimeCompare([]byte(presented), []byte(token)) == 1 {
+ next.ServeHTTP(w, r)
return
}
- next.ServeHTTP(w, r)
+
+ // Otherwise try a user JWT, if that path is configured.
+ if verifier != nil && presented != "" {
+ if claims, err := verifier.Verify(presented); err == nil {
+ ctx := authctx.With(r.Context(), authctx.Requester{
+ UserID: claims.UserID,
+ Role: claims.Role,
+ Verified: claims.Verified,
+ })
+ next.ServeHTTP(w, r.WithContext(ctx))
+ return
+ }
+ }
+
+ w.Header().Set("WWW-Authenticate", "Bearer")
+ writeJSON(w, http.StatusUnauthorized, errorResponse{
+ Error: "unauthorized",
+ RequestID: requestIDFrom(r.Context()),
+ })
})
}
}
diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go
index 9eb56e2..6439da1 100644
--- a/coordinator/internal/transport/http/server.go
+++ b/coordinator/internal/transport/http/server.go
@@ -7,8 +7,11 @@ import (
"context"
"log/slog"
"net/http"
+ "strings"
"time"
+ "github.com/emil28092005/SciMesh/coordinator/internal/metrics"
+ tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
@@ -40,23 +43,47 @@ type Server struct {
requestTimeout time.Duration
heartbeatInterval time.Duration
maxUploadBytes int64
+ // verifier validates userservice JWTs. nil disables user-JWT auth, leaving
+ // only the shared service token — the pre-userservice behaviour.
+ verifier *tokenpkg.Verifier
+ // userserviceURL is the base URL the UI proxies login/registration to. Empty
+ // keeps the static basic-auth UI.
+ userserviceURL string
+ // httpClient makes the login/register calls to the userservice.
+ httpClient *http.Client
+ // metrics holds the Prometheus registry and HTTP instrumentation.
+ metrics *metrics.Metrics
// ready probes downstream dependencies (the database) for /health. Kept as
// a func so the transport layer never imports pgx.
ready func(context.Context) error
}
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
- maxUploadBytes int64, ready func(context.Context) error) *Server {
+ maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error) *Server {
+ if m == nil {
+ m = metrics.New()
+ }
return &Server{
uc: uc,
log: log,
requestTimeout: requestTimeout,
heartbeatInterval: heartbeatInterval,
maxUploadBytes: maxUploadBytes,
+ verifier: tokenpkg.NewVerifier(jwtSecret),
+ userserviceURL: strings.TrimRight(userserviceURL, "/"),
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ metrics: m,
ready: ready,
}
}
+// uiSessionMode reports whether the operator UI authenticates via userservice
+// login (cookie session) rather than the static basic-auth token. It needs both
+// a verifier (to check the JWT locally) and a userservice URL (to issue it).
+func (s *Server) uiSessionMode() bool {
+ return s.verifier != nil && s.userserviceURL != ""
+}
+
// Handler builds the router. Go 1.22's ServeMux matches on method and path
// wildcards, so no third-party router is needed.
func (s *Server) Handler(token string, uiToken ...string) http.Handler {
@@ -77,19 +104,57 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
- if len(uiToken) > 0 && uiToken[0] != "" && s.uc.Dashboard != nil {
+ // Unauthenticated like /health, so a Prometheus scraper needs no credential.
+ mux.Handle("GET /metrics", s.metrics.Handler())
+
+ hasBasicAuth := len(uiToken) > 0 && uiToken[0] != ""
+ if s.uc.Dashboard != nil && (s.uiSessionMode() || hasBasicAuth) {
ui := http.NewServeMux()
- ui.HandleFunc("GET /ui", s.handleUIHome)
- ui.HandleFunc("GET /ui/jobs/new", s.handleUINewJob)
- ui.HandleFunc("GET /ui/jobs/{job_id}", s.handleUIJob)
- ui.HandleFunc("GET /ui/api/overview", s.handleUIOverviewJSON)
- ui.HandleFunc("GET /ui/api/jobs/{job_id}", s.handleUIJobJSON)
- ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob)
- ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset)
- ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload)
- ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview)
- mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
- mux.Handle("/ui/", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
+
+ // The operator application routes, all requiring an authenticated caller.
+ app := []struct {
+ pattern string
+ handler http.HandlerFunc
+ }{
+ {"GET /ui", s.handleUIHome},
+ {"GET /ui/jobs/new", s.handleUINewJob},
+ {"GET /ui/jobs/{job_id}", s.handleUIJob},
+ {"GET /ui/api/overview", s.handleUIOverviewJSON},
+ {"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON},
+ {"POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob},
+ {"POST /ui/api/jobs/upload", s.handleUploadDataset},
+ {"GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload},
+ {"GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview},
+ }
+
+ if s.uiSessionMode() {
+ // Public auth pages — reachable without a session so a user can log in.
+ ui.HandleFunc("GET /ui/login", s.handleUILoginForm)
+ ui.HandleFunc("POST /ui/login", s.handleUILogin)
+ ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm)
+ ui.HandleFunc("POST /ui/register", s.handleUIRegister)
+ ui.HandleFunc("POST /ui/logout", s.handleUILogout)
+ gate := withUISession(s.verifier)
+ for _, rt := range app {
+ ui.Handle(rt.pattern, gate(rt.handler))
+ }
+ ui.Handle("GET /ui/profile", gate(http.HandlerFunc(s.handleUIProfile)))
+ // Admin panel: session + admin role.
+ ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin))
+ ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin))
+ } else {
+ for _, rt := range app {
+ ui.HandleFunc(rt.pattern, rt.handler)
+ }
+ }
+
+ common := []func(http.Handler) http.Handler{withRequestID, withAccessLog(s.log)}
+ if !s.uiSessionMode() {
+ common = append(common, withBasicAuth(uiToken[0]))
+ }
+ common = append(common, withSameOrigin)
+ mux.Handle("/ui", chain(ui, common...))
+ mux.Handle("/ui/", chain(ui, common...))
} else {
// More specific than the protected catch-all: UI absence is not an auth
// failure and does not disclose that a UI feature is configured elsewhere.
@@ -99,9 +164,10 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
mux.Handle("/", chain(protected,
withRequestID, // outermost: every response gets an ID,
withAccessLog(s.log), // including the 401s below
- withAuth(token),
+ withAuth(token, s.verifier),
))
- return mux
+ // Measure every request once, outermost, with a normalized route label.
+ return s.metrics.Middleware(mux)
}
// handleHealth reports readiness. It probes the database so an orchestrator
diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go
index e43086e..3d9748f 100644
--- a/coordinator/internal/transport/http/server_test.go
+++ b/coordinator/internal/transport/http/server_test.go
@@ -50,13 +50,13 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3),
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease),
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
- CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, tx, clk),
+ CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2),
ReduceJob: usecase.NewReduceJob(jobs, tasks, arts, blobs, tx, clk),
- FailTask: usecase.NewFailTask(tasks, jobs, tx, clk),
+ FailTask: usecase.NewFailTask(tasks, jobs, work, tx, clk),
GetJobStatus: usecase.NewGetJobStatus(jobs, tasks),
GetJobResult: usecase.NewGetJobResult(jobs, downloadArtifact),
CancelJob: usecase.NewCancelJob(jobs, tasks, tx, clk),
- UploadArtifact: usecase.NewUploadArtifact(tasks, arts, blobs, tx, clk),
+ UploadArtifact: usecase.NewUploadArtifact(tasks, work, arts, blobs, tx, clk),
DownloadArtifact: downloadArtifact,
GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs),
Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts)),
@@ -68,7 +68,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
if err != nil {
t.Fatalf("register test worker: %v", err)
}
- srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, ready)
+ srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", "", nil, ready)
ts := httptest.NewServer(srv.Handler(token, configuredUIToken))
t.Cleanup(ts.Close)
return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()}
diff --git a/coordinator/internal/transport/http/templates/admin.html b/coordinator/internal/transport/http/templates/admin.html
new file mode 100644
index 0000000..37f704a
--- /dev/null
+++ b/coordinator/internal/transport/http/templates/admin.html
@@ -0,0 +1,46 @@
+{{define "admin.html"}}
+
+
+
+
+
+ Admin · SciMesh
+
+
+
+
+
+ Admin panel
User & run control
+
+
+ Signed in as {{.Role}} . Promote or verify a user by their id, and control every job from the dashboard.
+
+ {{if .Msg}}{{.Msg}}
{{end}}
+ {{if .Error}}{{.Error}}
{{end}}
+
+
+ Manage a user
+ Paste the user id (the JWT sub / the value shown at registration). Actions are applied immediately.
+
+
+
+
+ Jobs & tasks
+ As an admin you already see every user's jobs on the dashboard, with per-task status and job cancellation. A regular user sees only their own.
+
+
+
+
+
+{{end}}
diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html
index af31dd1..f4ee300 100644
--- a/coordinator/internal/transport/http/templates/dashboard.html
+++ b/coordinator/internal/transport/http/templates/dashboard.html
@@ -13,7 +13,7 @@
How a search becomes a result 01Upload TSV The coordinator validates and slices the dataset.
02Run shards Workers fingerprint molecules and return shard top-k CSVs.
03Merge exactly The coordinator ranks retained candidates deterministically.
04Download CSV A checksum-protected global result is ready.
diff --git a/coordinator/internal/transport/http/templates/job.html b/coordinator/internal/transport/http/templates/job.html
index f0091e6..c9c77b2 100644
--- a/coordinator/internal/transport/http/templates/job.html
+++ b/coordinator/internal/transport/http/templates/job.html
@@ -12,7 +12,7 @@
- ← Back to control room
+
{{workloadLabel .Workload}}
Live pipeline One job, shown from accepted input through its final coordinator-owned scientific result.
Live · refreshes every 2 seconds
{{statusLabel .Status}} {{statusHint .Status}}
Stop unfinished shards Completed shards are preserved.
{{.Completed}} of {{.Total}} shards complete
{{.Total}} total shards
{{.Completed}} completed
{{.Pending}} waiting
{{add .Leased .Running}} with workers
{{.Failed}} failed
{{.Cancelled}} stopped
diff --git a/coordinator/internal/transport/http/templates/login.html b/coordinator/internal/transport/http/templates/login.html
new file mode 100644
index 0000000..9482e91
--- /dev/null
+++ b/coordinator/internal/transport/http/templates/login.html
@@ -0,0 +1,27 @@
+{{define "login.html"}}
+
+
+
+
+
+ Sign in · SciMesh
+
+
+
+
+ SciMesh
+ Sign in
+
+ {{if .Error}}{{.Error}}
{{end}}
+ No account? Register
+
+
+
+{{end}}
diff --git a/coordinator/internal/transport/http/templates/profile.html b/coordinator/internal/transport/http/templates/profile.html
new file mode 100644
index 0000000..c84ed30
--- /dev/null
+++ b/coordinator/internal/transport/http/templates/profile.html
@@ -0,0 +1,32 @@
+{{define "profile.html"}}
+
+
+
+
+
+ Profile · SciMesh
+
+
+
+
+
+
+ {{if .Error}}{{.Error}}
{{end}}
+ {{with .Profile}}
+
+ User id {{.ID}}
+ Email {{.Email}}
+ Role {{.Role}}
+ Verified contributor {{if .Verified}}yes {{else}}no {{end}}
+ Member since {{.CreatedAt}}
+
+ Your user id is what the coordinator stores as the owner of every job you submit. Give it to an admin to be promoted or verified.
+ {{end}}
+
+
+
+{{end}}
diff --git a/coordinator/internal/transport/http/templates/register.html b/coordinator/internal/transport/http/templates/register.html
new file mode 100644
index 0000000..e05e2fe
--- /dev/null
+++ b/coordinator/internal/transport/http/templates/register.html
@@ -0,0 +1,28 @@
+{{define "register.html"}}
+
+
+
+
+
+ Register · SciMesh
+
+
+
+
+ SciMesh
+ Create account
+
+ {{if .Error}}{{.Error}}
{{end}}
+ Already have an account? Sign in
+
+
+
+{{end}}
diff --git a/coordinator/internal/transport/http/ui_admin.go b/coordinator/internal/transport/http/ui_admin.go
new file mode 100644
index 0000000..6287b91
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_admin.go
@@ -0,0 +1,114 @@
+package http
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+)
+
+// adminUserActions are the userservice endpoints the admin panel may invoke, by
+// their path suffix. A whitelist so a crafted form can never proxy an arbitrary
+// path.
+var adminUserActions = map[string]bool{
+ "promote": true,
+ "demote": true,
+ "verify": true,
+ "unverify": true,
+}
+
+// requireAdmin gates a route on the session caller being an admin. It runs
+// inside withUISession, which has already stamped the requester. A non-admin is
+// sent back to the dashboard rather than shown the panel.
+func requireAdmin(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() {
+ http.Redirect(w, r, "/ui", http.StatusSeeOther)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (s *Server) handleUIAdmin(w http.ResponseWriter, r *http.Request) {
+ role := ""
+ if req, ok := authctx.From(r.Context()); ok {
+ role = req.Role
+ }
+ s.renderUI(w, "admin.html", map[string]any{
+ "Role": role,
+ "Msg": r.URL.Query().Get("msg"),
+ "Error": r.URL.Query().Get("error"),
+ })
+}
+
+// handleUIAdminUserAction proxies a user-management action to the userservice,
+// forwarding the admin's session token so the userservice re-checks the role.
+// The user id and action come from the form, so a single static form action can
+// drive every operation.
+func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) {
+ userID := strings.TrimSpace(r.FormValue("user_id"))
+ action := r.FormValue("action")
+
+ if !adminUserActions[action] {
+ http.Redirect(w, r, "/ui/admin?error=unknown+action", http.StatusSeeOther)
+ return
+ }
+ if _, err := uuid.Parse(userID); err != nil {
+ http.Redirect(w, r, "/ui/admin?error=invalid+user+id", http.StatusSeeOther)
+ return
+ }
+ c, err := r.Cookie(sessionCookie)
+ if err != nil {
+ redirectToLogin(w, r)
+ return
+ }
+
+ status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value)
+ if err != nil {
+ s.log.Error("admin action proxy", "err", err, "action", action)
+ http.Redirect(w, r, "/ui/admin?error=service+unavailable", http.StatusSeeOther)
+ return
+ }
+ switch status {
+ case http.StatusNoContent:
+ http.Redirect(w, r, "/ui/admin?msg="+url.QueryEscape(action+" applied"), http.StatusSeeOther)
+ case http.StatusNotFound:
+ http.Redirect(w, r, "/ui/admin?error=user+not+found", http.StatusSeeOther)
+ case http.StatusForbidden, http.StatusUnauthorized:
+ http.Redirect(w, r, "/ui/admin?error=not+authorized", http.StatusSeeOther)
+ default:
+ http.Redirect(w, r, "/ui/admin?error=action+failed", http.StatusSeeOther)
+ }
+}
+
+// callUserserviceAuthed makes an authenticated call to the userservice, passing
+// the caller's JWT through as a bearer token. Used for admin actions; login and
+// registration use the unauthenticated callUserservice.
+func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, []byte, error) {
+ // path is not attacker-controlled: the caller composes it only from a
+ // uuid-validated id and an action from a fixed whitelist, and the host is
+ // the operator-configured userservice — so the SSRF taint gosec sees here
+ // cannot reach an arbitrary destination.
+ req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, nil) //nolint:gosec // G704: path is validated, host is config
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+bearer)
+
+ resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above
+ if err != nil {
+ return 0, nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return 0, nil, err
+ }
+ return resp.StatusCode, body, nil
+}
diff --git a/coordinator/internal/transport/http/ui_admin_internal_test.go b/coordinator/internal/transport/http/ui_admin_internal_test.go
new file mode 100644
index 0000000..92f3c27
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_admin_internal_test.go
@@ -0,0 +1,113 @@
+package http
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ "github.com/emil28092005/SciMesh/coordinator/internal/usecase"
+)
+
+func adminReq(t *testing.T, role string) *http.Request {
+ t.Helper()
+ req := newReq(http.MethodGet, "/ui/admin", nil)
+ return req.WithContext(authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: role}))
+}
+
+func TestRequireAdminAllowsAdminOnly(t *testing.T) {
+ reached := false
+ h := requireAdmin(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
+
+ // Admin passes through.
+ h.ServeHTTP(httptest.NewRecorder(), adminReq(t, "admin"))
+ if !reached {
+ t.Error("admin must reach the handler")
+ }
+
+ // Plain user is redirected to the dashboard.
+ reached = false
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminReq(t, "user"))
+ if reached {
+ t.Error("non-admin must not reach the handler")
+ }
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
+ t.Errorf("non-admin got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
+ }
+}
+
+func TestAdminUserActionForwardsBearer(t *testing.T) {
+ targetID := uuid.NewString()
+ var gotAuth, gotPath string
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ gotPath = r.URL.Path
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer stub.Close()
+ s := newLoginServer(stub)
+
+ req := newReq(http.MethodPost, "/ui/admin/user-action",
+ strings.NewReader(url.Values{"user_id": {targetID}, "action": {"promote"}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "admin.jwt.token"})
+ rec := httptest.NewRecorder()
+ s.handleUIAdminUserAction(rec, req)
+
+ if gotAuth != "Bearer admin.jwt.token" {
+ t.Errorf("forwarded auth = %q, want the admin bearer", gotAuth)
+ }
+ if gotPath != "/users/"+targetID+"/promote" {
+ t.Errorf("forwarded path = %q", gotPath)
+ }
+ if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "msg=") {
+ t.Errorf("got %d -> %q, want 303 with a success msg", rec.Code, rec.Header().Get("Location"))
+ }
+}
+
+func TestAdminUserActionRejectsUnknownAction(t *testing.T) {
+ s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ t.Fatal("userservice must not be called for an invalid action")
+ })))
+ req := newReq(http.MethodPost, "/ui/admin/user-action",
+ strings.NewReader(url.Values{"user_id": {uuid.NewString()}, "action": {"delete"}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"})
+ rec := httptest.NewRecorder()
+ s.handleUIAdminUserAction(rec, req)
+ if !strings.Contains(rec.Header().Get("Location"), "error=") {
+ t.Errorf("unknown action redirect = %q, want an error", rec.Header().Get("Location"))
+ }
+}
+
+func TestAdminUserActionRejectsBadID(t *testing.T) {
+ s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ t.Fatal("userservice must not be called for an invalid id")
+ })))
+ req := newReq(http.MethodPost, "/ui/admin/user-action",
+ strings.NewReader(url.Values{"user_id": {"not-a-uuid"}, "action": {"promote"}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"})
+ rec := httptest.NewRecorder()
+ s.handleUIAdminUserAction(rec, req)
+ if !strings.Contains(rec.Header().Get("Location"), "error=") {
+ t.Errorf("bad id redirect = %q, want an error", rec.Header().Get("Location"))
+ }
+}
+
+func TestDashboardAdminLinkOnlyForAdmin(t *testing.T) {
+ admin := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}})
+ if !strings.Contains(admin, "/ui/admin") {
+ t.Error("admin must see the Admin link")
+ }
+ user := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "user"}})
+ if strings.Contains(user, "/ui/admin") {
+ t.Error("a plain user must not see the Admin link")
+ }
+}
diff --git a/coordinator/internal/transport/http/ui_auth.go b/coordinator/internal/transport/http/ui_auth.go
new file mode 100644
index 0000000..4741126
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_auth.go
@@ -0,0 +1,171 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
+)
+
+// sessionCookie holds the userservice JWT for the operator UI. It is httpOnly so
+// page scripts cannot read the token, and scoped to /ui so it never rides along
+// with worker API calls.
+const sessionCookie = "scimesh_session"
+
+// withUISession gates the operator UI on a valid userservice session cookie.
+// A missing or invalid token redirects to the login page rather than returning
+// 401, because the caller here is a browser, not an API client. On success it
+// stamps the requester so downstream handlers can scope views by owner.
+func withUISession(v tokenVerifier) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(sessionCookie)
+ if err != nil || c.Value == "" {
+ redirectToLogin(w, r)
+ return
+ }
+ claims, err := v.Verify(c.Value)
+ if err != nil {
+ // Expired or tampered: drop the stale cookie and re-authenticate.
+ clearSessionCookie(w, r)
+ redirectToLogin(w, r)
+ return
+ }
+ ctx := authctx.With(r.Context(), authctx.Requester{
+ UserID: claims.UserID,
+ Role: claims.Role,
+ Verified: claims.Verified,
+ })
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// tokenVerifier is satisfied by *token.Verifier; taking an interface keeps the
+// UI auth testable with a stub.
+type tokenVerifier interface {
+ Verify(raw string) (tokenpkg.Claims, error)
+}
+
+func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) {
+ s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")})
+}
+
+func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) {
+ s.renderUI(w, "register.html", map[string]any{"Error": r.URL.Query().Get("error")})
+}
+
+// handleUILogin exchanges the submitted credentials for a userservice token and
+// stores it in the session cookie. The coordinator never sees or stores the
+// password beyond forwarding it once.
+func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
+ email, password := r.FormValue("email"), r.FormValue("password")
+
+ status, body, err := s.callUserservice(r.Context(), "/login", email, password)
+ if err != nil {
+ s.log.Error("userservice login call", "err", err)
+ http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
+ return
+ }
+ if status != http.StatusOK {
+ http.Redirect(w, r, "/ui/login?error=invalid+email+or+password", http.StatusSeeOther)
+ return
+ }
+
+ var resp struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil || resp.Token == "" {
+ http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
+ return
+ }
+ setSessionCookie(w, r, resp.Token)
+ http.Redirect(w, r, "/ui", http.StatusSeeOther)
+}
+
+// handleUIRegister creates an account through the userservice, then sends the
+// user to the login page. The new account is a plain user until an admin
+// promotes or verifies it.
+func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) {
+ email, password := r.FormValue("email"), r.FormValue("password")
+
+ status, _, err := s.callUserservice(r.Context(), "/register", email, password)
+ if err != nil {
+ s.log.Error("userservice register call", "err", err)
+ http.Redirect(w, r, "/ui/register?error=service+unavailable", http.StatusSeeOther)
+ return
+ }
+ switch status {
+ case http.StatusCreated:
+ http.Redirect(w, r, "/ui/login?error=registered,+please+log+in", http.StatusSeeOther)
+ case http.StatusConflict:
+ http.Redirect(w, r, "/ui/register?error=email+already+registered", http.StatusSeeOther)
+ default:
+ http.Redirect(w, r, "/ui/register?error=invalid+email+or+password", http.StatusSeeOther)
+ }
+}
+
+func (s *Server) handleUILogout(w http.ResponseWriter, r *http.Request) {
+ clearSessionCookie(w, r)
+ http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
+}
+
+// callUserservice POSTs credentials to the userservice and returns its status
+// and body. It is the only runtime dependency on the userservice — login and
+// registration; token verification stays local.
+func (s *Server) callUserservice(ctx context.Context, path, email, password string) (int, []byte, error) {
+ payload, _ := json.Marshal(map[string]string{"email": email, "password": password})
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.userserviceURL+path, bytes.NewReader(payload))
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := s.httpClient.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Cap the response; login/register bodies are tiny.
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return 0, nil, err
+ }
+ return resp.StatusCode, body, nil
+}
+
+func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
+ // Secure is set under TLS; a local demo runs plain HTTP, where forcing
+ // Secure would stop the browser from ever sending the cookie back.
+ http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
+ Name: sessionCookie,
+ Value: token,
+ Path: "/ui",
+ HttpOnly: true,
+ Secure: r.TLS != nil,
+ SameSite: http.SameSiteLaxMode,
+ Expires: time.Now().Add(24 * time.Hour),
+ })
+}
+
+func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
+ Name: sessionCookie,
+ Value: "",
+ Path: "/ui",
+ HttpOnly: true,
+ Secure: r.TLS != nil,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: -1,
+ })
+}
+
+func redirectToLogin(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
+}
diff --git a/coordinator/internal/transport/http/ui_auth_internal_test.go b/coordinator/internal/transport/http/ui_auth_internal_test.go
new file mode 100644
index 0000000..081ad37
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_auth_internal_test.go
@@ -0,0 +1,175 @@
+package http
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
+)
+
+// newReq builds a request carrying a context, which http.NewRequestWithContext
+// provides on go1.22 (httptest.NewRequestWithContext needs go1.23).
+func newReq(method, target string, body io.Reader) *http.Request {
+ req, err := http.NewRequestWithContext(context.Background(), method, target, body)
+ if err != nil {
+ panic(err)
+ }
+ return req
+}
+
+type stubVerifier struct {
+ claims tokenpkg.Claims
+ err error
+}
+
+func (s stubVerifier) Verify(string) (tokenpkg.Claims, error) { return s.claims, s.err }
+
+func TestWithUISessionRedirectsWithoutCookie(t *testing.T) {
+ h := withUISession(stubVerifier{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ t.Fatal("handler must not run without a session")
+ }))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, newReq(http.MethodGet, "/ui", nil))
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("got %d, want 303", rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != "/ui/login" {
+ t.Errorf("redirect = %q, want /ui/login", loc)
+ }
+}
+
+func TestWithUISessionAcceptsValidCookieAndStampsRequester(t *testing.T) {
+ id := uuid.New()
+ verifier := stubVerifier{claims: tokenpkg.Claims{UserID: id, Role: "admin", Verified: true}}
+
+ var gotReq authctx.Requester
+ var ok bool
+ h := withUISession(verifier)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ gotReq, ok = authctx.From(r.Context())
+ }))
+
+ req := newReq(http.MethodGet, "/ui", nil)
+ req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "valid.jwt"})
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if !ok || gotReq.UserID != id || gotReq.Role != "admin" || !gotReq.Verified {
+ t.Errorf("requester = %+v (ok=%v), want id=%v admin verified", gotReq, ok, id)
+ }
+}
+
+func TestWithUISessionClearsInvalidCookie(t *testing.T) {
+ h := withUISession(stubVerifier{err: errors.New("expired")})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ t.Fatal("handler must not run with an invalid token")
+ }))
+ req := newReq(http.MethodGet, "/ui", nil)
+ req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "stale.jwt"})
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("got %d, want 303", rec.Code)
+ }
+ if c := rec.Result().Cookies(); len(c) == 0 || c[0].MaxAge >= 0 {
+ t.Error("stale cookie must be cleared (MaxAge < 0)")
+ }
+}
+
+// newLoginServer builds a Server whose userservice calls hit stub.
+func newLoginServer(stub *httptest.Server) *Server {
+ return &Server{
+ log: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ userserviceURL: strings.TrimRight(stub.URL, "/"),
+ httpClient: stub.Client(),
+ }
+}
+
+func postForm(path string, form url.Values) *http.Request {
+ req := newReq(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return req
+}
+
+func TestHandleUILoginSetsCookieOnSuccess(t *testing.T) {
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/login" {
+ t.Errorf("unexpected path %q", r.URL.Path)
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"token":"issued.jwt.here"}`))
+ }))
+ defer stub.Close()
+ s := newLoginServer(stub)
+
+ rec := httptest.NewRecorder()
+ s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"password123"}}))
+
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
+ t.Fatalf("got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) == 0 || cookies[0].Name != sessionCookie || cookies[0].Value != "issued.jwt.here" {
+ t.Errorf("session cookie not set: %+v", cookies)
+ }
+ if !cookies[0].HttpOnly {
+ t.Error("session cookie must be httpOnly")
+ }
+}
+
+func TestHandleUILoginRejectsBadCredentials(t *testing.T) {
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer stub.Close()
+ s := newLoginServer(stub)
+
+ rec := httptest.NewRecorder()
+ s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"wrong"}}))
+
+ if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/ui/login?error=") {
+ t.Fatalf("got %d -> %q, want 303 -> /ui/login?error=", rec.Code, rec.Header().Get("Location"))
+ }
+ if len(rec.Result().Cookies()) != 0 {
+ t.Error("no cookie must be set on failed login")
+ }
+}
+
+func TestHandleUIRegisterConflict(t *testing.T) {
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ }))
+ defer stub.Close()
+ s := newLoginServer(stub)
+
+ rec := httptest.NewRecorder()
+ s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"dup@b.com"}, "password": {"password123"}}))
+
+ if got := rec.Header().Get("Location"); !strings.Contains(got, "already+registered") {
+ t.Errorf("register conflict redirect = %q", got)
+ }
+}
+
+func TestHandleUILogoutClearsCookie(t *testing.T) {
+ s := &Server{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
+ rec := httptest.NewRecorder()
+ s.handleUILogout(rec, newReq(http.MethodPost, "/ui/logout", nil))
+
+ if rec.Header().Get("Location") != "/ui/login" {
+ t.Errorf("logout redirect = %q", rec.Header().Get("Location"))
+ }
+ c := rec.Result().Cookies()
+ if len(c) == 0 || c[0].MaxAge >= 0 {
+ t.Error("logout must clear the session cookie")
+ }
+}
diff --git a/coordinator/internal/transport/http/ui_logout_internal_test.go b/coordinator/internal/transport/http/ui_logout_internal_test.go
new file mode 100644
index 0000000..37f15c2
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_logout_internal_test.go
@@ -0,0 +1,42 @@
+package http
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/usecase"
+)
+
+func render(t *testing.T, name string, data any) string {
+ t.Helper()
+ var buf bytes.Buffer
+ if err := uiTemplates.ExecuteTemplate(&buf, name, data); err != nil {
+ t.Fatalf("render %s: %v", name, err)
+ }
+ return buf.String()
+}
+
+func TestDashboardLogoutOnlyInSession(t *testing.T) {
+ withSession := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}})
+ if !strings.Contains(withSession, "/ui/logout") || !strings.Contains(withSession, "Log out") {
+ t.Error("dashboard must show a logout control in session mode")
+ }
+
+ noSession := render(t, "dashboard.html", usecase.DashboardView{})
+ if strings.Contains(noSession, "/ui/logout") {
+ t.Error("dashboard must not show logout under basic auth (no session)")
+ }
+}
+
+func TestJobLogoutOnlyInSession(t *testing.T) {
+ withSession := render(t, "job.html", usecase.JobDetailView{Session: &usecase.SessionView{Role: "user"}})
+ if !strings.Contains(withSession, "/ui/logout") {
+ t.Error("job page must show a logout control in session mode")
+ }
+
+ noSession := render(t, "job.html", usecase.JobDetailView{})
+ if strings.Contains(noSession, "/ui/logout") {
+ t.Error("job page must not show logout under basic auth (no session)")
+ }
+}
diff --git a/coordinator/internal/transport/http/ui_profile.go b/coordinator/internal/transport/http/ui_profile.go
new file mode 100644
index 0000000..320ed5b
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_profile.go
@@ -0,0 +1,49 @@
+package http
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// profileView is the account data shown on the profile page, mirroring the
+// userservice /me response.
+type profileView struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ Verified bool `json:"verified"`
+ CreatedAt string `json:"created_at"`
+}
+
+// handleUIProfile shows the signed-in user's own account. It proxies the
+// session token to the userservice /me endpoint, which is the authority on the
+// account (email and created_at are not in the JWT).
+func (s *Server) handleUIProfile(w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(sessionCookie)
+ if err != nil {
+ redirectToLogin(w, r)
+ return
+ }
+ status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/me", c.Value)
+ if err != nil {
+ s.log.Error("profile /me proxy", "err", err)
+ s.renderUI(w, "profile.html", map[string]any{"Error": "userservice unavailable"})
+ return
+ }
+ if status == http.StatusUnauthorized {
+ clearSessionCookie(w, r)
+ redirectToLogin(w, r)
+ return
+ }
+ if status != http.StatusOK {
+ s.renderUI(w, "profile.html", map[string]any{"Error": "could not load your account"})
+ return
+ }
+
+ var p profileView
+ if err := json.Unmarshal(body, &p); err != nil {
+ s.renderUI(w, "profile.html", map[string]any{"Error": "could not read your account"})
+ return
+ }
+ s.renderUI(w, "profile.html", map[string]any{"Profile": p})
+}
diff --git a/coordinator/internal/transport/http/ui_profile_internal_test.go b/coordinator/internal/transport/http/ui_profile_internal_test.go
new file mode 100644
index 0000000..882ab6a
--- /dev/null
+++ b/coordinator/internal/transport/http/ui_profile_internal_test.go
@@ -0,0 +1,43 @@
+package http
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestProfileProxiesMe(t *testing.T) {
+ var gotAuth, gotPath string
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth, gotPath = r.Header.Get("Authorization"), r.URL.Path
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","email":"me@example.com","role":"user","verified":false,"created_at":"2026-07-26T00:00:00Z"}`))
+ }))
+ defer stub.Close()
+ s := newLoginServer(stub)
+
+ req := newReq(http.MethodGet, "/ui/profile", nil)
+ req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
+ rec := httptest.NewRecorder()
+ s.handleUIProfile(rec, req)
+
+ if gotAuth != "Bearer my.jwt" || gotPath != "/me" {
+ t.Fatalf("proxy: auth=%q path=%q", gotAuth, gotPath)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "me@example.com") || !strings.Contains(body, "11111111-1111-1111-1111-111111111111") {
+ t.Error("profile page must show the email and id")
+ }
+}
+
+func TestProfileRedirectsWithoutCookie(t *testing.T) {
+ s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ t.Fatal("must not call userservice without a session")
+ })))
+ rec := httptest.NewRecorder()
+ s.handleUIProfile(rec, newReq(http.MethodGet, "/ui/profile", nil))
+ if rec.Code != http.StatusSeeOther {
+ t.Errorf("no cookie: got %d, want 303 redirect", rec.Code)
+ }
+}
diff --git a/coordinator/internal/usecase/artifact.go b/coordinator/internal/usecase/artifact.go
index 7a0187f..4c10657 100644
--- a/coordinator/internal/usecase/artifact.go
+++ b/coordinator/internal/usecase/artifact.go
@@ -12,18 +12,22 @@ import (
// UploadArtifact stores a worker's partial-result bytes and records the metadata.
type UploadArtifact struct {
tasks TaskRepository
+ workers WorkerRepository
artifacts ArtifactRepository
blobs BlobStore
tx TxManager
clk Clock
}
-func NewUploadArtifact(tasks TaskRepository, artifacts ArtifactRepository,
+func NewUploadArtifact(tasks TaskRepository, workers WorkerRepository, artifacts ArtifactRepository,
blobs BlobStore, tx TxManager, clk Clock) *UploadArtifact {
- return &UploadArtifact{tasks: tasks, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk}
+ return &UploadArtifact{tasks: tasks, workers: workers, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk}
}
func (uc *UploadArtifact) Execute(ctx context.Context, in UploadArtifactInput) (*domain.Artifact, error) {
+ if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
+ return nil, err
+ }
task, err := uc.tasks.Get(ctx, in.TaskID)
if err != nil {
return nil, err
diff --git a/coordinator/internal/usecase/dto.go b/coordinator/internal/usecase/dto.go
index 17e0498..2ab4fe0 100644
--- a/coordinator/internal/usecase/dto.go
+++ b/coordinator/internal/usecase/dto.go
@@ -4,6 +4,8 @@ import (
"io"
"github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// Use-case boundary types. Adapters map their wire formats onto these, so the
@@ -28,6 +30,11 @@ type ChunkInput struct {
type RegisterWorkerInput struct {
Name string
Capabilities []string
+ // OwnerID is the userservice user registering this worker; nil for a
+ // shared-token registration. TrustLevel is resolved by the transport layer
+ // from how the caller authenticated.
+ OwnerID *uuid.UUID
+ TrustLevel domain.WorkerTrust
}
type ClaimTaskInput struct {
diff --git a/coordinator/internal/usecase/job.go b/coordinator/internal/usecase/job.go
index eda4b49..2404cfe 100644
--- a/coordinator/internal/usecase/job.go
+++ b/coordinator/internal/usecase/job.go
@@ -53,6 +53,7 @@ func (uc *CreateJob) Execute(ctx context.Context, in CreateJobInput) (*domain.Jo
if err != nil {
return nil, err
}
+ job.OwnerID = ownerFromContext(ctx)
err = uc.tx.WithinTx(ctx, func(ctx context.Context) error {
if err := uc.jobs.Insert(ctx, job); err != nil {
@@ -97,6 +98,9 @@ func (uc *CancelJob) Execute(ctx context.Context, jobID uuid.UUID) (int64, error
if err != nil {
return err
}
+ if err := authorizeJobAccess(ctx, job); err != nil {
+ return err
+ }
if job.Status == domain.JobCancelled {
return nil
}
@@ -132,6 +136,9 @@ func (uc *GetJobStatus) Execute(ctx context.Context, jobID uuid.UUID) (domain.Jo
if err != nil {
return domain.JobProgress{}, err
}
+ if err := authorizeJobAccess(ctx, job); err != nil {
+ return domain.JobProgress{}, err
+ }
counts, err := uc.tasks.CountByStatus(ctx, jobID)
if err != nil {
return domain.JobProgress{}, err
diff --git a/coordinator/internal/usecase/ownership.go b/coordinator/internal/usecase/ownership.go
new file mode 100644
index 0000000..6e476ec
--- /dev/null
+++ b/coordinator/internal/usecase/ownership.go
@@ -0,0 +1,51 @@
+package usecase
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ "github.com/emil28092005/SciMesh/coordinator/internal/domain"
+)
+
+// ownerFromContext returns the authenticated user id to stamp on a new job, or
+// nil when the request was not authenticated as a user — worker or legacy
+// traffic, or user-JWT auth disabled. A nil owner is stored as NULL.
+func ownerFromContext(ctx context.Context) *uuid.UUID {
+ if r, ok := authctx.From(ctx); ok {
+ id := r.UserID
+ return &id
+ }
+ return nil
+}
+
+// uiOwnerFilter returns the owner a UI listing must be restricted to: nil for an
+// operator/admin or an unauthenticated (basic-auth) session, which see all jobs,
+// or the caller's id for a plain user, who sees only their own.
+func uiOwnerFilter(ctx context.Context) *uuid.UUID {
+ r, ok := authctx.From(ctx)
+ if !ok || r.IsAdmin() {
+ return nil
+ }
+ id := r.UserID
+ return &id
+}
+
+// authorizeJobAccess enforces that a non-admin user may only act on their own
+// job. It returns ErrJobNotFound — not a 403 — on a mismatch, so the response
+// never reveals that another user's job exists.
+//
+// Requests with no authenticated user (worker/legacy traffic, or JWT auth
+// disabled) are not restricted here: the shared service token already gated
+// them, and worker endpoints legitimately operate across all jobs.
+func authorizeJobAccess(ctx context.Context, job *domain.Job) error {
+ r, ok := authctx.From(ctx)
+ if !ok || r.IsAdmin() {
+ return nil
+ }
+ if job.OwnerID == nil || *job.OwnerID != r.UserID {
+ return domain.ErrJobNotFound
+ }
+ return nil
+}
diff --git a/coordinator/internal/usecase/ownership_test.go b/coordinator/internal/usecase/ownership_test.go
new file mode 100644
index 0000000..fd4297b
--- /dev/null
+++ b/coordinator/internal/usecase/ownership_test.go
@@ -0,0 +1,66 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ "github.com/emil28092005/SciMesh/coordinator/internal/domain"
+)
+
+func TestOwnerFromContext(t *testing.T) {
+ if ownerFromContext(context.Background()) != nil {
+ t.Error("no requester must yield a nil owner")
+ }
+ id := uuid.New()
+ ctx := authctx.With(context.Background(), authctx.Requester{UserID: id, Role: "user"})
+ got := ownerFromContext(ctx)
+ if got == nil || *got != id {
+ t.Errorf("owner = %v, want %v", got, id)
+ }
+}
+
+func TestAuthorizeJobAccess(t *testing.T) {
+ owner := uuid.New()
+ other := uuid.New()
+ job := &domain.Job{ID: uuid.New(), OwnerID: &owner}
+
+ ctxOf := func(id uuid.UUID, role string) context.Context {
+ return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role})
+ }
+
+ cases := []struct {
+ name string
+ ctx context.Context
+ wantErr bool
+ }{
+ {"no requester (worker/legacy) allowed", context.Background(), false},
+ {"owner allowed", ctxOf(owner, "user"), false},
+ {"admin allowed", ctxOf(other, "admin"), false},
+ {"non-owner denied", ctxOf(other, "user"), true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := authorizeJobAccess(tc.ctx, job)
+ if tc.wantErr {
+ if !errors.Is(err, domain.ErrJobNotFound) {
+ t.Errorf("got %v, want ErrJobNotFound", err)
+ }
+ } else if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ })
+ }
+}
+
+func TestAuthorizeJobAccessNilOwner(t *testing.T) {
+ // A legacy job with no owner must not be readable by an arbitrary user.
+ job := &domain.Job{ID: uuid.New(), OwnerID: nil}
+ ctx := authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: "user"})
+ if err := authorizeJobAccess(ctx, job); !errors.Is(err, domain.ErrJobNotFound) {
+ t.Errorf("got %v, want ErrJobNotFound", err)
+ }
+}
diff --git a/coordinator/internal/usecase/ports.go b/coordinator/internal/usecase/ports.go
index 933453c..d903816 100644
--- a/coordinator/internal/usecase/ports.go
+++ b/coordinator/internal/usecase/ports.go
@@ -23,6 +23,15 @@ type ClaimFilter struct {
Owner string // worker ID taking the lease
Now time.Time
LeaseUntil time.Time
+ // VoterOwner, when set, excludes tasks this owner has already voted on, so
+ // an untrusted worker never verifies its own chunk twice.
+ VoterOwner *uuid.UUID
+}
+
+// TaskResultRepository records and tallies quorum votes for untrusted results.
+type TaskResultRepository interface {
+ RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error
+ CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error)
}
// TaskRepository persists tasks.
diff --git a/coordinator/internal/usecase/preview.go b/coordinator/internal/usecase/preview.go
index 5401c85..295e23c 100644
--- a/coordinator/internal/usecase/preview.go
+++ b/coordinator/internal/usecase/preview.go
@@ -53,6 +53,11 @@ func (p *PreviewArtifact) Execute(ctx context.Context, jobID, artifactID uuid.UU
if err != nil {
return ArtifactPreviewView{}, err
}
+ // Another user's job (and not admin): report not-found, matching the
+ // artifact-absent response so nothing about it leaks.
+ if err := authorizeJobAccess(ctx, job); err != nil {
+ return ArtifactPreviewView{}, domain.ErrArtifactNotFound
+ }
artifacts, err := p.read.ListArtifactsByJob(ctx, jobID)
if err != nil {
return ArtifactPreviewView{}, err
diff --git a/coordinator/internal/usecase/reduce.go b/coordinator/internal/usecase/reduce.go
index e73f575..c1faf71 100644
--- a/coordinator/internal/usecase/reduce.go
+++ b/coordinator/internal/usecase/reduce.go
@@ -126,6 +126,9 @@ func (uc *GetJobResult) Execute(ctx context.Context, jobID uuid.UUID) (*domain.A
if err != nil {
return nil, nil, err
}
+ if err := authorizeJobAccess(ctx, job); err != nil {
+ return nil, nil, err
+ }
if job.Status != domain.JobCompleted || job.ResultArtifactID == nil {
return nil, nil, domain.ErrArtifactNotFound
}
diff --git a/coordinator/internal/usecase/task.go b/coordinator/internal/usecase/task.go
index 1af5bf8..9cb9c40 100644
--- a/coordinator/internal/usecase/task.go
+++ b/coordinator/internal/usecase/task.go
@@ -2,6 +2,7 @@ package usecase
import (
"context"
+ "errors"
"time"
"github.com/google/uuid"
@@ -46,11 +47,25 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl
return nil, domain.ErrInvalidInput
}
workloads := in.Workloads
+ var voterOwner *uuid.UUID
if workerID, err := uuid.Parse(in.WorkerID); err == nil {
worker, err := uc.workers.Get(ctx, workerID)
if err != nil {
return nil, err
}
+ // Bind the caller to the worker it claims as. A JWT-authenticated
+ // volunteer may operate only its own workers; without this the trust
+ // tier would be read off a caller-supplied worker_id, letting anyone who
+ // knows a trusted worker's id claim as it. A shared-token caller (no
+ // requester) is a lab operator and may act as any worker.
+ if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
+ return nil, err
+ }
+ // An untrusted volunteer may claim, but never a chunk its owner has
+ // already voted on — so quorum needs genuinely independent computations.
+ if worker.TrustLevel == domain.WorkerUntrusted {
+ voterOwner = worker.OwnerID
+ }
// Never trust caller-supplied capabilities: registration is the durable
// worker identity and its allowlist.
workloads = worker.Capabilities
@@ -71,6 +86,7 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl
Owner: in.WorkerID,
Now: now,
LeaseUntil: now.Add(uc.leaseDuration),
+ VoterOwner: voterOwner,
})
if err != nil {
return err
@@ -106,6 +122,9 @@ func NewRenewLease(tasks TaskRepository, workers WorkerRepository, tx TxManager,
// locked: two concurrent heartbeats must not interleave into a lost update.
// Whether the caller may renew at all is decided by the entity, not here.
func (uc *RenewLease) Execute(ctx context.Context, in RenewLeaseInput) (*domain.ClaimedTask, error) {
+ if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
+ return nil, err
+ }
var claimed domain.ClaimedTask
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
@@ -141,13 +160,22 @@ type CompleteTask struct {
tasks TaskRepository
jobs JobRepository
artifacts ArtifactRepository
+ workers WorkerRepository
+ results TaskResultRepository
tx TxManager
clock Clock
+ // quorum is how many distinct owners must agree on an untrusted result
+ // before it is accepted; a trusted worker's result is accepted directly.
+ quorum int
}
func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts ArtifactRepository,
- tx TxManager, clock Clock) *CompleteTask {
- return &CompleteTask{tasks: tasks, jobs: jobs, artifacts: artifacts, tx: tx, clock: clock}
+ workers WorkerRepository, results TaskResultRepository, tx TxManager, clock Clock, quorum int) *CompleteTask {
+ if quorum < 1 {
+ quorum = 2
+ }
+ return &CompleteTask{tasks: tasks, jobs: jobs, artifacts: artifacts, workers: workers,
+ results: results, tx: tx, clock: clock, quorum: quorum}
}
// Execute applies the result and, when that was the job's last outstanding
@@ -157,6 +185,9 @@ func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts Artifac
// Lease ownership, staleness, and idempotent replays are all decided by
// Task.CompleteWith; this use case only orchestrates.
func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*domain.Task, error) {
+ if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
+ return nil, err
+ }
var out *domain.Task
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
@@ -166,24 +197,35 @@ func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*dom
}
// Rule 10: never trust a worker-supplied artifact reference. The result
// must be an artifact the coordinator itself stored for *this* task.
- if err := uc.verifyResultArtifact(ctx, in.TaskID, in.Attempt, in.ResultArtifactID); err != nil {
+ art, err := uc.verifyResultArtifact(ctx, in.TaskID, in.Attempt, in.ResultArtifactID)
+ if err != nil {
+ return err
+ }
+
+ trusted, ownerID, err := uc.workerTrust(ctx, in.WorkerID)
+ if err != nil {
return err
}
now := uc.clock.Now()
+
+ // Untrusted (volunteer) worker: record a vote and only complete once a
+ // quorum of distinct owners agree; otherwise return the task to the queue.
+ if !trusted {
+ return uc.recordVote(ctx, task, in, art, ownerID, now, &out)
+ }
+
+ // Trusted worker (lab token, verified, or admin): accept directly.
before := task.Version
- if err := task.CompleteWith(in.ResultArtifactID, in.Metrics,
- in.WorkerID, in.Attempt, now); err != nil {
+ if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, in.WorkerID, in.Attempt, now); err != nil {
return err
}
out = task
-
// A replay of an already-recorded result leaves the entity untouched.
// Writing anyway would fail the optimistic-concurrency guard (the stored
// version already equals ours) and turn an idempotent call into a 409.
if task.Version == before {
return nil
}
-
if err := uc.tasks.Update(ctx, task); err != nil {
return err
}
@@ -195,36 +237,105 @@ func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*dom
return out, nil
}
-// verifyResultArtifact enforces that the referenced artifact was stored by the
-// coordinator for this exact task. It stops a worker from completing task B with
-// an artifact it uploaded for task A, and from naming an id that isn't a result.
-func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UUID, attempt int, artifactID uuid.UUID) error {
- art, err := uc.artifacts.Get(ctx, artifactID)
+// recordVote handles an untrusted result: it stores the vote, then completes the
+// task when the submitter's result hash has reached quorum, or returns the task
+// to the queue so another owner can compute it independently.
+func (uc *CompleteTask) recordVote(ctx context.Context, task *domain.Task, in CompleteTaskInput,
+ art *domain.Artifact, ownerID uuid.UUID, now time.Time, out **domain.Task) error {
+
+ *out = task
+ if task.Status == domain.TaskCompleted {
+ return nil // already settled by an earlier quorum; nothing to record
+ }
+ if err := uc.results.RecordVote(ctx, task.ID, ownerID, art.SHA256, in.ResultArtifactID); err != nil {
+ return err
+ }
+ agree, err := uc.results.CountAgreeing(ctx, task.ID, art.SHA256)
if err != nil {
return err
}
- if art.TaskID == nil || *art.TaskID != taskID || art.Attempt == nil || *art.Attempt != attempt || art.Kind != domain.ArtifactPartialResult {
- return domain.ErrResultConflict
+
+ if agree >= uc.quorum {
+ // The submitter's own (already verified) artifact carries the winning
+ // hash, so complete with it.
+ if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, in.WorkerID, in.Attempt, now); err != nil {
+ return err
+ }
+ } else if err := task.ReleaseAfterVote(in.WorkerID, in.Attempt, now); err != nil {
+ return err
}
- return nil
+
+ if err := uc.tasks.Update(ctx, task); err != nil {
+ return err
+ }
+ return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now)
+}
+
+// workerTrust reports whether the worker's results are accepted directly, and
+// the owner to attribute a vote to when they are not.
+func (uc *CompleteTask) workerTrust(ctx context.Context, workerID string) (trusted bool, ownerID uuid.UUID, err error) {
+ // When the worker can't be resolved, default to trusted — the pre-quorum
+ // behaviour. This is safe because completing a task requires holding its
+ // lease, and the lease owner is always a real registered worker whose trust
+ // is therefore known; only an untrusted worker ever takes the quorum path.
+ id, err := uuid.Parse(workerID)
+ if err != nil {
+ // An unparseable worker id means the worker can't be resolved; fall back
+ // to the trusted default rather than surfacing the parse error.
+ return true, uuid.Nil, nil //nolint:nilerr // unresolvable worker → trusted (pre-quorum default)
+ }
+ w, err := uc.workers.Get(ctx, id)
+ if err != nil {
+ if errors.Is(err, domain.ErrWorkerNotFound) {
+ return true, uuid.Nil, nil
+ }
+ return false, uuid.Nil, err
+ }
+ if w.TrustLevel != domain.WorkerUntrusted {
+ return true, uuid.Nil, nil
+ }
+ if w.OwnerID == nil {
+ // An untrusted worker always has an owner (it registered via a user JWT);
+ // a missing one is a data error, not a silent trust upgrade.
+ return false, uuid.Nil, domain.ErrInvalidInput
+ }
+ return false, *w.OwnerID, nil
+}
+
+// verifyResultArtifact enforces that the referenced artifact was stored by the
+// coordinator for this exact task. It stops a worker from completing task B with
+// an artifact it uploaded for task A, and from naming an id that isn't a result.
+func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UUID, attempt int, artifactID uuid.UUID) (*domain.Artifact, error) {
+ art, err := uc.artifacts.Get(ctx, artifactID)
+ if err != nil {
+ return nil, err
+ }
+ if art.TaskID == nil || *art.TaskID != taskID || art.Attempt == nil || *art.Attempt != attempt || art.Kind != domain.ArtifactPartialResult {
+ return nil, domain.ErrResultConflict
+ }
+ return art, nil
}
// --- FailTask ------------------------------------------------------------
type FailTask struct {
- tasks TaskRepository
- jobs JobRepository
- tx TxManager
- clock Clock
+ tasks TaskRepository
+ jobs JobRepository
+ workers WorkerRepository
+ tx TxManager
+ clock Clock
}
-func NewFailTask(tasks TaskRepository, jobs JobRepository, tx TxManager, clock Clock) *FailTask {
- return &FailTask{tasks: tasks, jobs: jobs, tx: tx, clock: clock}
+func NewFailTask(tasks TaskRepository, jobs JobRepository, workers WorkerRepository, tx TxManager, clock Clock) *FailTask {
+ return &FailTask{tasks: tasks, jobs: jobs, workers: workers, tx: tx, clock: clock}
}
// Execute delegates the requeue-or-terminate decision to Task.Fail, then keeps
// the parent job's status consistent in the same transaction.
func (uc *FailTask) Execute(ctx context.Context, in FailTaskInput) (*domain.Task, error) {
+ if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
+ return nil, err
+ }
var out *domain.Task
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
diff --git a/coordinator/internal/usecase/ui.go b/coordinator/internal/usecase/ui.go
index 96e5714..a8624ab 100644
--- a/coordinator/internal/usecase/ui.go
+++ b/coordinator/internal/usecase/ui.go
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
@@ -14,7 +15,9 @@ import (
// It intentionally exposes no storage paths or credentials.
type UIReadRepository interface {
GetJob(ctx context.Context, jobID uuid.UUID) (*domain.Job, error)
- ListJobs(ctx context.Context, limit int) ([]domain.Job, error)
+ // ListJobs returns the most recent jobs. A non-nil owner restricts the list
+ // to that user's jobs; nil returns all (operator/admin view).
+ ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error)
ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error)
ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error)
ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error)
@@ -85,13 +88,35 @@ type DashboardView struct {
ActiveJobs int `json:"active_jobs"`
FinishedJobs int `json:"finished_jobs"`
OnlineWorkers int `json:"online_workers"`
+ // Session is the signed-in user, when the UI runs in session mode. nil under
+ // basic auth. Template-only, never serialised to the polling JSON.
+ Session *SessionView `json:"-"`
}
+
+// SessionView is the minimal identity the UI header needs to show who is signed
+// in and to offer a logout control.
+type SessionView struct {
+ Role string
+ Verified bool
+}
+
+// sessionViewFrom builds the header session info from the request context, or
+// nil when the caller is not an authenticated user (basic-auth operator).
+func sessionViewFrom(ctx context.Context) *SessionView {
+ r, ok := authctx.From(ctx)
+ if !ok {
+ return nil
+ }
+ return &SessionView{Role: r.Role, Verified: r.Verified}
+}
+
type JobDetailView struct {
JobCard
Tasks []TaskCard `json:"tasks"`
Artifacts []ArtifactCard `json:"artifacts"`
Parameters []ParameterCard `json:"parameters"`
FinalResultAvailable bool `json:"final_result_available"`
+ Session *SessionView `json:"-"`
}
type Dashboard struct{ read UIReadRepository }
@@ -99,7 +124,7 @@ type Dashboard struct{ read UIReadRepository }
func NewDashboard(read UIReadRepository) *Dashboard { return &Dashboard{read: read} }
func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, error) {
- jobs, err := d.read.ListJobs(ctx, limit)
+ jobs, err := d.read.ListJobs(ctx, uiOwnerFilter(ctx), limit)
if err != nil {
return DashboardView{}, err
}
@@ -132,6 +157,7 @@ func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, err
out.OnlineWorkers++
}
}
+ out.Session = sessionViewFrom(ctx)
return out, nil
}
@@ -140,6 +166,11 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi
if err != nil {
return JobDetailView{}, err
}
+ // A plain user may only open their own job; a mismatch reads as not-found so
+ // the page never reveals another user's job exists.
+ if err := authorizeJobAccess(ctx, job); err != nil {
+ return JobDetailView{}, err
+ }
tasks, err := d.read.ListTasksByJob(ctx, jobID)
if err != nil {
return JobDetailView{}, err
@@ -161,6 +192,7 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi
Tasks: make([]TaskCard, 0, len(tasks)),
Artifacts: make([]ArtifactCard, 0, len(artifacts)),
Parameters: uiParameters(job.Parameters),
+ Session: sessionViewFrom(ctx),
}
for _, task := range tasks {
card := TaskCard{ID: task.ID.String(), ChunkIndex: task.ChunkIndex, Status: string(task.Status), Attempt: task.Attempt, MaxAttempts: task.MaxAttempts, LeaseExpiresAt: task.LeaseExpiresAt, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt}
@@ -198,6 +230,10 @@ func (d *Dashboard) DownloadableArtifactBelongsToJob(ctx context.Context, jobID,
if err != nil {
return false, err
}
+ // Not the caller's job (and not admin): treat as if the artifact is absent.
+ if err := authorizeJobAccess(ctx, job); err != nil {
+ return false, nil //nolint:nilerr // masking the authz error as "not found" is intentional
+ }
artifacts, err := d.read.ListArtifactsByJob(ctx, jobID)
if err != nil {
return false, err
diff --git a/coordinator/internal/usecase/ui_scope_test.go b/coordinator/internal/usecase/ui_scope_test.go
new file mode 100644
index 0000000..de3a618
--- /dev/null
+++ b/coordinator/internal/usecase/ui_scope_test.go
@@ -0,0 +1,82 @@
+package usecase_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ "github.com/emil28092005/SciMesh/coordinator/internal/domain"
+ "github.com/emil28092005/SciMesh/coordinator/internal/memstore"
+ "github.com/emil28092005/SciMesh/coordinator/internal/usecase"
+)
+
+func newDashboard() (*usecase.Dashboard, *memstore.JobRepo) {
+ jobs := memstore.NewJobRepo()
+ tasks := memstore.NewTaskRepo()
+ workers := memstore.NewWorkerRepo()
+ artifacts := memstore.NewArtifactRepo()
+ return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts)), jobs
+}
+
+func ownedJob(t *testing.T, jobs *memstore.JobRepo, owner uuid.UUID) uuid.UUID {
+ t.Helper()
+ o := owner
+ job := &domain.Job{ID: uuid.New(), Workload: "similarity-search", Status: domain.JobRunning, OwnerID: &o, CreatedAt: time.Now().UTC()}
+ if err := jobs.Insert(context.Background(), job); err != nil {
+ t.Fatalf("insert owned job: %v", err)
+ }
+ return job.ID
+}
+
+func userCtx(id uuid.UUID, role string) context.Context {
+ return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role})
+}
+
+func TestOverviewScopesJobsByOwner(t *testing.T) {
+ dash, jobs := newDashboard()
+ alice, bob := uuid.New(), uuid.New()
+ ownedJob(t, jobs, alice)
+ ownedJob(t, jobs, bob)
+
+ // A plain user sees only their own job.
+ v, err := dash.Overview(userCtx(alice, "user"), 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(v.Jobs) != 1 {
+ t.Errorf("alice sees %d jobs, want 1", len(v.Jobs))
+ }
+
+ // An admin sees every job.
+ if v, _ := dash.Overview(userCtx(uuid.New(), "admin"), 20); len(v.Jobs) != 2 {
+ t.Errorf("admin sees %d jobs, want 2", len(v.Jobs))
+ }
+
+ // No requester (basic-auth operator) sees every job — unchanged behaviour.
+ if v, _ := dash.Overview(context.Background(), 20); len(v.Jobs) != 2 {
+ t.Errorf("operator sees %d jobs, want 2", len(v.Jobs))
+ }
+}
+
+func TestJobDetailRejectsAnotherUsersJob(t *testing.T) {
+ dash, jobs := newDashboard()
+ alice, bob := uuid.New(), uuid.New()
+ jobID := ownedJob(t, jobs, alice)
+
+ // Bob cannot open Alice's job.
+ if _, err := dash.JobDetail(userCtx(bob, "user"), jobID); !errors.Is(err, domain.ErrJobNotFound) {
+ t.Errorf("bob: got %v, want ErrJobNotFound", err)
+ }
+ // Alice can.
+ if _, err := dash.JobDetail(userCtx(alice, "user"), jobID); err != nil {
+ t.Errorf("alice: unexpected error %v", err)
+ }
+ // Admin can.
+ if _, err := dash.JobDetail(userCtx(uuid.New(), "admin"), jobID); err != nil {
+ t.Errorf("admin: unexpected error %v", err)
+ }
+}
diff --git a/coordinator/internal/usecase/upload.go b/coordinator/internal/usecase/upload.go
index ad394db..4bd5caf 100644
--- a/coordinator/internal/usecase/upload.go
+++ b/coordinator/internal/usecase/upload.go
@@ -43,6 +43,7 @@ func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (Su
if err != nil {
return SubmitDatasetResult{}, err
}
+ job.OwnerID = ownerFromContext(ctx)
// Everything written to blob storage, so a failed transaction can undo it.
var putKeys []string
diff --git a/coordinator/internal/usecase/usecase_test.go b/coordinator/internal/usecase/usecase_test.go
index 2272c51..b64111f 100644
--- a/coordinator/internal/usecase/usecase_test.go
+++ b/coordinator/internal/usecase/usecase_test.go
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
@@ -34,12 +35,13 @@ func (s expiringBlobStore) Put(ctx context.Context, key string, body io.Reader)
// harness wires every use case to in-memory stores so orchestration can be
// tested without a database.
type harness struct {
- tasks *memstore.TaskRepo
- jobs *memstore.JobRepo
- work *memstore.WorkerRepo
- arts *memstore.ArtifactRepo
- blobs *memstore.BlobStore
- clk *memstore.Clock
+ tasks *memstore.TaskRepo
+ jobs *memstore.JobRepo
+ work *memstore.WorkerRepo
+ arts *memstore.ArtifactRepo
+ blobs *memstore.BlobStore
+ clk *memstore.Clock
+ taskResults *memstore.TaskResultRepo
createJob *usecase.CreateJob
submit *usecase.SubmitDataset
@@ -61,24 +63,25 @@ type harness struct {
func newHarness() *harness {
h := &harness{
- tasks: memstore.NewTaskRepo(),
- jobs: memstore.NewJobRepo(),
- work: memstore.NewWorkerRepo(),
- arts: memstore.NewArtifactRepo(),
- blobs: memstore.NewBlobStore(),
- clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)),
+ tasks: memstore.NewTaskRepo(),
+ jobs: memstore.NewJobRepo(),
+ work: memstore.NewWorkerRepo(),
+ arts: memstore.NewArtifactRepo(),
+ blobs: memstore.NewBlobStore(),
+ clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)),
+ taskResults: memstore.NewTaskResultRepo(),
}
tx := memstore.Tx{}
h.createJob = usecase.NewCreateJob(h.jobs, h.tasks, tx, h.clk)
h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3)
h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease)
h.renew = usecase.NewRenewLease(h.tasks, h.work, tx, h.clk, lease)
- h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, tx, h.clk)
- h.fail = usecase.NewFailTask(h.tasks, h.jobs, tx, h.clk)
+ h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2)
+ h.fail = usecase.NewFailTask(h.tasks, h.jobs, h.work, tx, h.clk)
h.status = usecase.NewGetJobStatus(h.jobs, h.tasks)
h.results = usecase.NewListResults(h.tasks)
h.register = usecase.NewRegisterWorker(h.work, h.clk)
- h.uploadArt = usecase.NewUploadArtifact(h.tasks, h.arts, h.blobs, tx, h.clk)
+ h.uploadArt = usecase.NewUploadArtifact(h.tasks, h.work, h.arts, h.blobs, tx, h.clk)
h.downloadArt = usecase.NewDownloadArtifact(h.arts, h.blobs)
h.getInput = usecase.NewGetTaskInput(h.tasks, h.arts, h.blobs)
h.expire = usecase.NewExpireLeases(h.tasks, h.jobs, tx, h.clk)
@@ -265,6 +268,184 @@ func TestClaimEmptyQueueReturnsNil(t *testing.T) {
}
}
+func TestRegisterWorkerDefaultsToTrusted(t *testing.T) {
+ h := newHarness()
+ // A shared-token registration carries no owner and no explicit trust.
+ w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
+ Name: "lab", Capabilities: []string{"w"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if w.TrustLevel != domain.WorkerTrusted {
+ t.Errorf("trust = %q, want trusted", w.TrustLevel)
+ }
+ if w.OwnerID != nil {
+ t.Errorf("owner = %v, want nil for a shared-token worker", w.OwnerID)
+ }
+}
+
+func TestRegisterWorkerRecordsOwnerAndUntrusted(t *testing.T) {
+ h := newHarness()
+ owner := uuid.New()
+ w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
+ Name: "volunteer", Capabilities: []string{"w"},
+ OwnerID: &owner, TrustLevel: domain.WorkerUntrusted,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if w.TrustLevel != domain.WorkerUntrusted {
+ t.Errorf("trust = %q, want untrusted", w.TrustLevel)
+ }
+ if w.OwnerID == nil || *w.OwnerID != owner {
+ t.Errorf("owner = %v, want %v", w.OwnerID, owner)
+ }
+}
+
+func TestJWTCallerCannotClaimAsAnotherUsersWorker(t *testing.T) {
+ h := newHarness()
+ h.seedJob(t, "w", 1)
+
+ // A trusted lab worker owned by nobody (shared-token registration).
+ victim, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}})
+
+ // An attacker authenticated as a JWT user tries to claim as the lab worker.
+ attacker := authctx.With(ctx, authctx.Requester{UserID: uuid.New(), Role: "user"})
+ claimed, err := h.claim.Execute(attacker, usecase.ClaimTaskInput{WorkerID: victim.ID.String()})
+ if !errors.Is(err, domain.ErrWorkerNotFound) {
+ t.Fatalf("claim as another's worker = (%v, %v), want ErrWorkerNotFound", claimed, err)
+ }
+}
+
+func TestJWTCallerCannotMutateAnotherUsersWorkerLease(t *testing.T) {
+ h := newHarness()
+ h.seedJob(t, "w", 1)
+ victimOwner := uuid.New()
+ victim, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
+ Name: "victim", Capabilities: []string{"w"}, OwnerID: &victimOwner, TrustLevel: domain.WorkerTrusted,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ claimed, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: victim.ID.String()})
+ if err != nil || claimed == nil {
+ t.Fatalf("claim = (%v, %v)", claimed, err)
+ }
+ attacker := authctx.With(ctx, authctx.Requester{UserID: uuid.New(), Role: "user"})
+
+ if _, err := h.renew.Execute(attacker, usecase.RenewLeaseInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt}); !errors.Is(err, domain.ErrWorkerNotFound) {
+ t.Errorf("foreign heartbeat err = %v, want ErrWorkerNotFound", err)
+ }
+ if _, err := h.fail.Execute(attacker, usecase.FailTaskInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, ErrorCode: "x"}); !errors.Is(err, domain.ErrWorkerNotFound) {
+ t.Errorf("foreign failure err = %v, want ErrWorkerNotFound", err)
+ }
+ if _, err := h.uploadArt.Execute(attacker, usecase.UploadArtifactInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, Filename: "x.csv", ContentType: "text/csv", Body: strings.NewReader("x")}); !errors.Is(err, domain.ErrWorkerNotFound) {
+ t.Errorf("foreign upload err = %v, want ErrWorkerNotFound", err)
+ }
+ if _, err := h.complete.Execute(attacker, usecase.CompleteTaskInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, ResultArtifactID: uuid.New()}); !errors.Is(err, domain.ErrWorkerNotFound) {
+ t.Errorf("foreign result err = %v, want ErrWorkerNotFound", err)
+ }
+}
+
+func TestJWTCallerClaimsAsOwnTrustedWorker(t *testing.T) {
+ h := newHarness()
+ h.seedJob(t, "w", 1)
+ owner := uuid.New()
+
+ // The user's own worker, trusted (e.g. a verified contributor).
+ mine, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{
+ Name: "mine", Capabilities: []string{"w"}, OwnerID: &owner, TrustLevel: domain.WorkerTrusted,
+ })
+
+ callerCtx := authctx.With(ctx, authctx.Requester{UserID: owner, Role: "user", Verified: true})
+ got, err := h.claim.Execute(callerCtx, usecase.ClaimTaskInput{WorkerID: mine.ID.String()})
+ if err != nil || got == nil {
+ t.Fatalf("own trusted worker claim = (%v, %v), want a task", got, err)
+ }
+}
+
+func TestUntrustedWorkerCanClaim(t *testing.T) {
+ h := newHarness()
+ h.seedJob(t, "w", 1)
+ owner := uuid.New()
+ worker, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
+ Name: "volunteer", Capabilities: []string{"w"},
+ OwnerID: &owner, TrustLevel: domain.WorkerUntrusted,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Volunteers are no longer quarantined — they may claim; their results are
+ // gated by quorum at completion, not by withholding work.
+ claimed, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: worker.ID.String()})
+ if err != nil || claimed == nil {
+ t.Fatalf("untrusted claim = (%v, %v), want a task", claimed, err)
+ }
+}
+
+// registerUntrusted registers a volunteer worker under a fresh owner.
+func (h *harness) registerUntrusted(t *testing.T, name, workload string) (*domain.Worker, uuid.UUID) {
+ t.Helper()
+ owner := uuid.New()
+ w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
+ Name: name, Capabilities: []string{workload},
+ OwnerID: &owner, TrustLevel: domain.WorkerUntrusted,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return w, owner
+}
+
+func TestUntrustedResultNeedsQuorum(t *testing.T) {
+ h := newHarness()
+ jobID := h.seedJob(t, "w", 1)
+ if err := h.jobs.UpdateStatus(ctx, jobID, domain.JobRunning, nil); err != nil {
+ t.Fatal(err)
+ }
+ w1, _ := h.registerUntrusted(t, "v1", "w")
+ w2, _ := h.registerUntrusted(t, "v2", "w")
+
+ // First volunteer computes and submits — one vote, not yet quorum (2).
+ taskID, attempt := h.leaseOne(t, w1.ID.String(), "w")
+ art1 := h.uploadResult(t, taskID, w1.ID.String(), attempt)
+ if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID, WorkerID: w1.ID.String(), Attempt: attempt, ResultArtifactID: art1}); err != nil {
+ t.Fatalf("first vote: %v", err)
+ }
+ if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskPending {
+ t.Fatalf("after one vote status = %s, want pending", tk.Status)
+ }
+
+ // Second volunteer (distinct owner) computes the same bytes -> quorum -> done.
+ taskID2, attempt2 := h.leaseOne(t, w2.ID.String(), "w")
+ art2 := h.uploadResult(t, taskID2, w2.ID.String(), attempt2)
+ if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID2, WorkerID: w2.ID.String(), Attempt: attempt2, ResultArtifactID: art2}); err != nil {
+ t.Fatalf("second vote: %v", err)
+ }
+ if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskCompleted {
+ t.Fatalf("after quorum status = %s, want completed", tk.Status)
+ }
+}
+
+func TestTrustedResultCompletesDirectly(t *testing.T) {
+ h := newHarness()
+ jobID := h.seedJob(t, "w", 1)
+ if err := h.jobs.UpdateStatus(ctx, jobID, domain.JobRunning, nil); err != nil {
+ t.Fatal(err)
+ }
+ // A trusted (default) worker's single result completes the task immediately.
+ worker, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}})
+ taskID, attempt := h.leaseOne(t, worker.ID.String(), "w")
+ art := h.uploadResult(t, taskID, worker.ID.String(), attempt)
+ if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID, WorkerID: worker.ID.String(), Attempt: attempt, ResultArtifactID: art}); err != nil {
+ t.Fatal(err)
+ }
+ if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskCompleted {
+ t.Fatalf("trusted result status = %s, want completed", tk.Status)
+ }
+}
+
func TestClaimRequiresWorkerID(t *testing.T) {
h := newHarness()
if _, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{}); !errors.Is(err, domain.ErrInvalidInput) {
@@ -400,7 +581,7 @@ func TestUploadRejectsLeaseThatExpiresDuringStreaming(t *testing.T) {
h.seedJob(t, "w", 1)
taskID, attempt := h.leaseOne(t, "w1", "w")
h.uploadArt = usecase.NewUploadArtifact(
- h.tasks, h.arts, expiringBlobStore{BlobStore: h.blobs, clock: h.clk}, memstore.Tx{}, h.clk,
+ h.tasks, h.work, h.arts, expiringBlobStore{BlobStore: h.blobs, clock: h.clk}, memstore.Tx{}, h.clk,
)
_, err := h.uploadArt.Execute(ctx, usecase.UploadArtifactInput{
diff --git a/coordinator/internal/usecase/worker.go b/coordinator/internal/usecase/worker.go
index c8ccab2..572f8a2 100644
--- a/coordinator/internal/usecase/worker.go
+++ b/coordinator/internal/usecase/worker.go
@@ -22,6 +22,13 @@ func (uc *RegisterWorker) Execute(ctx context.Context, in RegisterWorkerInput) (
if err != nil {
return nil, err
}
+ w.OwnerID = in.OwnerID
+ // The transport layer resolves trust from the caller's credentials; fall
+ // back to the domain default (trusted) only when it was left unset, so a
+ // zero-value input never silently downgrades a shared-token worker.
+ if in.TrustLevel != "" {
+ w.TrustLevel = in.TrustLevel
+ }
if err := uc.workers.Insert(ctx, w); err != nil {
return nil, err
}
diff --git a/coordinator/internal/usecase/worker_authorization.go b/coordinator/internal/usecase/worker_authorization.go
new file mode 100644
index 0000000..258e889
--- /dev/null
+++ b/coordinator/internal/usecase/worker_authorization.go
@@ -0,0 +1,33 @@
+package usecase
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/authctx"
+ "github.com/emil28092005/SciMesh/coordinator/internal/domain"
+)
+
+// authorizeWorkerOwner binds a JWT-authenticated requester to a worker. The
+// shared coordinator token intentionally has no requester and retains its
+// existing operator privileges.
+func authorizeWorkerOwner(ctx context.Context, workers WorkerRepository, workerID string) error {
+ requester, ok := authctx.From(ctx)
+ if !ok {
+ return nil
+ }
+ id, err := uuid.Parse(workerID)
+ if err != nil {
+ return domain.ErrWorkerNotFound
+ }
+ worker, err := workers.Get(ctx, id)
+ if err != nil {
+ return err
+ }
+ if worker.OwnerID == nil || *worker.OwnerID != requester.UserID {
+ // Mask ownership and existence from another user.
+ return domain.ErrWorkerNotFound
+ }
+ return nil
+}
diff --git a/coordinator/migrations/0011_job_owner.down.sql b/coordinator/migrations/0011_job_owner.down.sql
new file mode 100644
index 0000000..32ffe4f
--- /dev/null
+++ b/coordinator/migrations/0011_job_owner.down.sql
@@ -0,0 +1,6 @@
+BEGIN;
+
+DROP INDEX IF EXISTS ix_jobs_owner;
+ALTER TABLE jobs DROP COLUMN IF EXISTS owner_id;
+
+COMMIT;
diff --git a/coordinator/migrations/0011_job_owner.up.sql b/coordinator/migrations/0011_job_owner.up.sql
new file mode 100644
index 0000000..64c9785
--- /dev/null
+++ b/coordinator/migrations/0011_job_owner.up.sql
@@ -0,0 +1,14 @@
+BEGIN;
+
+-- Who submitted this job. Equals users.id from the userservice, taken from the
+-- JWT `sub` claim. NOT a foreign key: users live in a separate service/database,
+-- so integrity is guaranteed by the signed token, not by the DB.
+--
+-- Nullable because rows created before auth existed have no owner; new inserts
+-- must supply it (enforced in the app, not the schema, during the MVP).
+ALTER TABLE jobs ADD COLUMN owner_id uuid;
+
+-- "List my jobs" / "admin filters by owner" scans by owner.
+CREATE INDEX ix_jobs_owner ON jobs (owner_id);
+
+COMMIT;
diff --git a/coordinator/migrations/0012_worker_trust.down.sql b/coordinator/migrations/0012_worker_trust.down.sql
new file mode 100644
index 0000000..103c168
--- /dev/null
+++ b/coordinator/migrations/0012_worker_trust.down.sql
@@ -0,0 +1,8 @@
+BEGIN;
+
+DROP INDEX IF EXISTS ix_workers_owner;
+ALTER TABLE workers DROP COLUMN IF EXISTS trust_level;
+ALTER TABLE workers DROP COLUMN IF EXISTS owner_id;
+DROP TYPE IF EXISTS worker_trust;
+
+COMMIT;
diff --git a/coordinator/migrations/0012_worker_trust.up.sql b/coordinator/migrations/0012_worker_trust.up.sql
new file mode 100644
index 0000000..36114fb
--- /dev/null
+++ b/coordinator/migrations/0012_worker_trust.up.sql
@@ -0,0 +1,18 @@
+BEGIN;
+
+-- Whether a worker's results are accepted directly or must clear quorum.
+-- 'trusted' — lab machine (shared token) or a verified/admin contributor.
+-- 'untrusted' — a plain enthusiast; results are quarantined until quorum (C2).
+CREATE TYPE worker_trust AS ENUM ('trusted', 'untrusted');
+
+-- Who registered this worker (userservice user id, from the JWT sub). NULL for
+-- workers registered with the shared service token. Not a foreign key: users
+-- live in a separate service/database.
+ALTER TABLE workers ADD COLUMN owner_id uuid;
+
+-- Existing rows were all shared-token lab workers, hence 'trusted'.
+ALTER TABLE workers ADD COLUMN trust_level worker_trust NOT NULL DEFAULT 'trusted';
+
+CREATE INDEX ix_workers_owner ON workers (owner_id);
+
+COMMIT;
diff --git a/coordinator/migrations/0013_task_results.down.sql b/coordinator/migrations/0013_task_results.down.sql
new file mode 100644
index 0000000..dc4fc2c
--- /dev/null
+++ b/coordinator/migrations/0013_task_results.down.sql
@@ -0,0 +1,5 @@
+BEGIN;
+
+DROP TABLE IF EXISTS task_results;
+
+COMMIT;
diff --git a/coordinator/migrations/0013_task_results.up.sql b/coordinator/migrations/0013_task_results.up.sql
new file mode 100644
index 0000000..c71f33f
--- /dev/null
+++ b/coordinator/migrations/0013_task_results.up.sql
@@ -0,0 +1,23 @@
+BEGIN;
+
+-- Quorum votes for a task computed by untrusted (volunteer) workers. A trusted
+-- worker's result completes the task directly and never lands here; an untrusted
+-- result is recorded as one vote, and the task is only completed once enough
+-- distinct owners submit the same result_sha256.
+--
+-- One vote per (task, owner): a single volunteer cannot stuff the ballot by
+-- running many workers under one account. A resubmission updates their vote.
+CREATE TABLE task_results (
+ task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ owner_id uuid NOT NULL,
+ result_sha256 text NOT NULL,
+ result_artifact_id uuid NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ PRIMARY KEY (task_id, owner_id)
+);
+
+-- Quorum check groups a task's votes by result_sha256.
+CREATE INDEX ix_task_results_quorum ON task_results (task_id, result_sha256);
+
+COMMIT;
diff --git a/coordinator/monitoring/grafana/dashboards/coordinator.json b/coordinator/monitoring/grafana/dashboards/coordinator.json
new file mode 100644
index 0000000..e52c98e
--- /dev/null
+++ b/coordinator/monitoring/grafana/dashboards/coordinator.json
@@ -0,0 +1,154 @@
+{
+ "annotations": { "list": [] },
+ "editable": true,
+ "graphTooltip": 1,
+ "schemaVersion": 39,
+ "tags": ["scimesh"],
+ "time": { "from": "now-15m", "to": "now" },
+ "refresh": "5s",
+ "title": "SciMesh Coordinator",
+ "uid": "scimesh-coordinator",
+ "panels": [
+ {
+ "type": "timeseries",
+ "title": "HTTP request rate by route",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum by (route) (rate(scimesh_http_requests_total[1m]))",
+ "legendFormat": "{{route}}"
+ }
+ ]
+ },
+ {
+ "type": "timeseries",
+ "title": "p95 latency by route",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "histogram_quantile(0.95, sum by (le, route) (rate(scimesh_http_request_duration_seconds_bucket[5m])))",
+ "legendFormat": "{{route}}"
+ }
+ ]
+ },
+ {
+ "type": "timeseries",
+ "title": "Requests by status",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum by (status) (rate(scimesh_http_requests_total[1m]))",
+ "legendFormat": "{{status}}"
+ }
+ ]
+ },
+ {
+ "type": "timeseries",
+ "title": "Goroutines",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 },
+ "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "go_goroutines{job=\"coordinator\"}",
+ "legendFormat": "goroutines"
+ }
+ ]
+ },
+ {
+ "type": "timeseries",
+ "title": "Resident memory",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 },
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "process_resident_memory_bytes{job=\"coordinator\"}",
+ "legendFormat": "rss"
+ }
+ ]
+ },
+ {
+ "type": "row",
+ "title": "Domain state",
+ "collapsed": false,
+ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 16 }
+ },
+ {
+ "type": "timeseries",
+ "title": "Tasks by status",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
+ "fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "scimesh_tasks",
+ "legendFormat": "{{status}}"
+ }
+ ]
+ },
+ {
+ "type": "timeseries",
+ "title": "Jobs by status",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
+ "fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "scimesh_jobs",
+ "legendFormat": "{{status}}"
+ }
+ ]
+ },
+ {
+ "type": "stat",
+ "title": "Queue depth (pending tasks)",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 6, "x": 0, "y": 25 },
+ "fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 50 }, { "color": "red", "value": 500 } ] } }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum(scimesh_tasks{status=\"pending\"})",
+ "legendFormat": "pending"
+ }
+ ]
+ },
+ {
+ "type": "timeseries",
+ "title": "Workers by status",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 18, "x": 6, "y": 25 },
+ "fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "scimesh_workers",
+ "legendFormat": "{{status}}"
+ }
+ ]
+ }
+ ]
+}
diff --git a/coordinator/monitoring/grafana/provisioning/dashboards/provider.yml b/coordinator/monitoring/grafana/provisioning/dashboards/provider.yml
new file mode 100644
index 0000000..efb8556
--- /dev/null
+++ b/coordinator/monitoring/grafana/provisioning/dashboards/provider.yml
@@ -0,0 +1,10 @@
+apiVersion: 1
+
+providers:
+ - name: SciMesh
+ type: file
+ disableDeletion: false
+ allowUiUpdates: true
+ options:
+ path: /var/lib/grafana/dashboards
+ foldersFromFilesStructure: false
diff --git a/coordinator/monitoring/grafana/provisioning/datasources/prometheus.yml b/coordinator/monitoring/grafana/provisioning/datasources/prometheus.yml
new file mode 100644
index 0000000..00f9915
--- /dev/null
+++ b/coordinator/monitoring/grafana/provisioning/datasources/prometheus.yml
@@ -0,0 +1,10 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ uid: prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ editable: false
diff --git a/coordinator/monitoring/prometheus.yml b/coordinator/monitoring/prometheus.yml
new file mode 100644
index 0000000..2ebe7f7
--- /dev/null
+++ b/coordinator/monitoring/prometheus.yml
@@ -0,0 +1,10 @@
+# Prometheus scrape config for the SciMesh demo. Prometheus runs in the same
+# compose network as the coordinator, so it reaches it by service name.
+global:
+ scrape_interval: 5s
+ evaluation_interval: 5s
+
+scrape_configs:
+ - job_name: coordinator
+ static_configs:
+ - targets: ["coordinator:8080"]
diff --git a/coordinator/scripts/demo-ui.sh b/coordinator/scripts/demo-ui.sh
index f978ed1..3d58183 100755
--- a/coordinator/scripts/demo-ui.sh
+++ b/coordinator/scripts/demo-ui.sh
@@ -11,8 +11,17 @@ repo_dir=$(CDPATH= cd -- "$coordinator_dir/.." && pwd)
project=${DEMO_PROJECT:-scimesh-demo}
postgres_port=${DEMO_POSTGRES_PORT:-55432}
coordinator_port=${DEMO_COORDINATOR_PORT:-18080}
+userservice_port=${DEMO_USERSERVICE_PORT:-18081}
+prometheus_port=${DEMO_PROMETHEUS_PORT:-19090}
+grafana_port=${DEMO_GRAFANA_PORT:-13000}
ui_token=${DEMO_UI_TOKEN:-demo-ui-secret}
worker_token=${DEMO_WORKER_TOKEN:-demo-worker-token}
+# Shared HS256 secret; the coordinator verifies userservice tokens with it. Must
+# be at least 32 bytes (both services refuse a shorter one).
+jwt_secret=${DEMO_JWT_SECRET:-demo-jwt-secret-please-change-me-0123456789}
+# The first admin, seeded into the userservice on first boot.
+admin_email=${DEMO_ADMIN_EMAIL:-root@scimesh.local}
+admin_password=${DEMO_ADMIN_PASSWORD:-rootpassword}
workers=${DEMO_WORKERS:-2}
demo_dir=${DEMO_DIR:-.demo}
case "$demo_dir" in
@@ -26,9 +35,18 @@ logs_dir="$demo_dir/logs"
compose() {
POSTGRES_PORT="$postgres_port" \
COORDINATOR_PORT="$coordinator_port" \
+ USERSERVICE_PORT="$userservice_port" \
+ PROMETHEUS_PORT="$prometheus_port" \
+ GRAFANA_PORT="$grafana_port" \
UI_AUTH_TOKEN="$ui_token" \
WORKER_AUTH_TOKEN="$worker_token" \
- docker compose -p "$project" -f "$coordinator_dir/docker-compose.yml" "$@"
+ JWT_SECRET="$jwt_secret" \
+ BOOTSTRAP_ADMIN_EMAIL="$admin_email" \
+ BOOTSTRAP_ADMIN_PASSWORD="$admin_password" \
+ docker compose -p "$project" \
+ -f "$coordinator_dir/docker-compose.yml" \
+ -f "$coordinator_dir/docker-compose.users.yml" \
+ -f "$coordinator_dir/docker-compose.monitoring.yml" "$@"
}
stop_workers() {
@@ -57,10 +75,29 @@ wait_for_coordinator() {
done
}
+wait_for_userservice() {
+ local attempt=0
+ until curl --fail --silent --show-error "http://localhost:$userservice_port/health" >/dev/null; do
+ attempt=$((attempt + 1))
+ if (( attempt >= 45 )); then
+ echo "Userservice did not become ready. Recent logs:" >&2
+ compose logs --tail=80 userservice >&2 || true
+ exit 1
+ fi
+ sleep 1
+ done
+}
+
wait_for_workers() {
- local attempt=0 registered overview
+ local attempt=0 registered overview cookie="$demo_dir/session.cookies"
+ # The dashboard API is behind a userservice session now, not basic auth. Log in
+ # as the seeded admin (who sees every worker) to obtain a session cookie.
+ curl --fail --silent -c "$cookie" \
+ --data-urlencode "email=$admin_email" \
+ --data-urlencode "password=$admin_password" \
+ "http://localhost:$coordinator_port/ui/login" >/dev/null 2>&1 || true
until false; do
- overview=$(curl --fail --silent --show-error --user "operator:$ui_token" \
+ overview=$(curl --fail --silent --show-error -b "$cookie" \
"http://localhost:$coordinator_port/ui/api/overview" 2>/dev/null || true)
# The overview contains no jobs at demo startup, so every `id` belongs to
# a registered worker. Avoid adding jq just for this local helper.
@@ -96,6 +133,8 @@ start() {
compose up -d --build
echo "Waiting for the coordinator on http://localhost:$coordinator_port ..."
wait_for_coordinator
+ echo "Waiting for the userservice on http://localhost:$userservice_port ..."
+ wait_for_userservice
: > "$pid_file"
for index in $(seq 1 "$workers"); do
@@ -115,13 +154,17 @@ start() {
SciMesh manual demo is ready.
- UI: http://localhost:$coordinator_port/ui
- Username: operator
- Password: $ui_token
- Workers: $workers local reference workers
+ UI: http://localhost:$coordinator_port/ui (shows a login page)
+ Admin login: $admin_email / $admin_password
+ Userservice: http://localhost:$userservice_port
+ Grafana: http://localhost:$grafana_port (anonymous view; admin/${GRAFANA_PASSWORD:-admin} to edit)
+ Prometheus: http://localhost:$prometheus_port
+ Workers: $workers local reference workers
-Upload a small ChEMBL TSV through “New similarity search”, then watch the job
-page update. Worker logs are in $logs_dir. Stop everything with:
+Sign in with the admin above, or register a new account from the login page.
+The admin sees every job; a plain user sees only their own. Upload a small
+ChEMBL TSV through “New similarity search”, then watch the job page update.
+Worker logs are in $logs_dir. Stop everything with:
make demo-down
EOF
diff --git a/docs/user-service-api-contract.md b/docs/user-service-api-contract.md
new file mode 100644
index 0000000..91cd3c9
--- /dev/null
+++ b/docs/user-service-api-contract.md
@@ -0,0 +1,47 @@
+# SciMesh User Service API contract (v1)
+
+**Status:** `v1`. The User Service owns user accounts and issues access tokens.
+The coordinator never receives user passwords and never accesses the User
+Service database.
+
+## Authentication boundary
+
+- User Service signs access tokens; coordinator verifies them before accepting
+ user-scoped requests.
+- Tokens contain a UUID `sub`, `role` (`user` or `admin`), `verified`, `iat`,
+ and `exp` claims.
+- A user-authenticated caller may operate only workers whose `owner_id` equals
+ `sub`. This applies to claim, heartbeat, result, failure, and artifact upload.
+- Worker traffic authenticated with the coordinator's shared worker token has
+ no user identity and remains an operator-only compatibility path.
+- Role or verification changes take effect when the access token is renewed.
+ Deployments needing immediate revocation must use a short token lifetime or a
+ revocation mechanism before enabling volunteer-worker trust.
+
+## Endpoints
+
+All JSON request bodies reject unknown fields and are size-limited. Error
+responses are JSON with a stable `error` value and request ID.
+
+| Method | Path | Auth | Success |
+| --- | --- | --- | --- |
+| `GET` | `/health` | none | `200 {"status":"ok"}` |
+| `POST` | `/register` | none | `201` user object |
+| `POST` | `/login` | none | `200` user object and access token |
+| `GET` | `/me` | Bearer access token | `200` current user |
+| `POST` | `/users/{id}/verify` | Bearer admin token | `204` |
+| `POST` | `/users/{id}/unverify` | Bearer admin token | `204` |
+| `POST` | `/users/{id}/promote` | Bearer admin token | `204` |
+| `POST` | `/users/{id}/demote` | Bearer admin token | `204` |
+
+`POST /register` accepts `{ "email": string, "password": string }` and
+always creates role `user` with `verified: false`. `POST /login` accepts the
+same shape and returns `{ "token": string, "user": User }`. Password hashes,
+JWT signing material, and raw tokens must never be logged.
+
+## Coordinator integration tests
+
+The coordinator must test that a JWT user cannot claim or mutate another
+user's worker lease, including heartbeat, failure, result, and artifact upload.
+Job and artifact access is restricted to the job owner unless the caller has
+the admin role.
diff --git a/users/.dockerignore b/users/.dockerignore
new file mode 100644
index 0000000..545cfcf
--- /dev/null
+++ b/users/.dockerignore
@@ -0,0 +1,16 @@
+# Keep the build context small and never bake secrets or local state into an image.
+.env
+.git
+.gitignore
+*.md
+Makefile
+docker-compose.yml
+Dockerfile
+.dockerignore
+
+# Local build artifacts
+/userservice
+/bin/
+*.out
+/data/
+/logs/
diff --git a/users/.env.example b/users/.env.example
new file mode 100644
index 0000000..49803cf
--- /dev/null
+++ b/users/.env.example
@@ -0,0 +1,29 @@
+# Copy to .env and adjust. All settings are read from the environment.
+
+USERSERVICE_ADDR=:8081
+DATABASE_URL=postgres://scimesh:scimesh@localhost:5433/scimesh_users?sslmode=disable
+
+# Shared HS256 secret used to sign JWTs. The coordinator verifies tokens with
+# this SAME secret, so the two values must match exactly. Minimum 32 bytes.
+JWT_SECRET=change-me-to-a-long-random-secret-min-32-bytes
+# How long an issued token stays valid.
+JWT_TTL=24h
+# bcrypt work factor. Empty/0 uses the library default (10).
+# BCRYPT_COST=10
+
+# First-admin bootstrap. When both are set and no such account exists, the
+# service creates it with role=admin on startup (idempotent). This is the only
+# way to get the first admin. Leave empty in production once seeded.
+# BOOTSTRAP_ADMIN_EMAIL=root@scimesh.local
+# BOOTSTRAP_ADMIN_PASSWORD=change-me-strong
+
+# Logging. LOG_LEVEL: debug|info|warn|error. LOG_FILE empty = stdout only;
+# set a path to also write a size-rotated file (kept across restarts).
+LOG_LEVEL=info
+# LOG_FILE=./logs/userservice.log
+
+# Optional tuning (defaults shown).
+DB_MAX_CONNS=10
+# How long to keep retrying the initial DB connection while Postgres boots.
+DB_CONNECT_TIMEOUT=30s
+REQUEST_TIMEOUT=15s
diff --git a/users/.gitignore b/users/.gitignore
new file mode 100644
index 0000000..8ef0865
--- /dev/null
+++ b/users/.gitignore
@@ -0,0 +1,6 @@
+/userservice
+/bin/
+.env
+*.out
+/logs/
+/data/
diff --git a/users/.golangci.yml b/users/.golangci.yml
new file mode 100644
index 0000000..3492657
--- /dev/null
+++ b/users/.golangci.yml
@@ -0,0 +1,54 @@
+version: "2"
+
+run:
+ timeout: 3m
+
+linters:
+ # "standard" = errcheck, govet, ineffassign, staticcheck, unused.
+ default: standard
+ enable:
+ # Catches `err == ErrFoo` where errors.Is is required. Directly relevant
+ # here: domain exposes sentinel errors that use cases may wrap with %w.
+ - errorlint
+ # Returning nil after checking a non-nil error — a silent bug factory.
+ - nilerr
+ # http.Get/Do without a context: every outbound call must be cancellable.
+ - noctx
+ # Unclosed response bodies leak connections.
+ - bodyclose
+ # Common security mistakes (weak crypto, unhandled file perms).
+ - gosec
+ # Style and naming consistency.
+ - revive
+ - misspell
+ - unconvert
+
+ settings:
+ errcheck:
+ # Deferred Close/Rollback are intentionally ignored in a few places
+ # (rollback after commit is a documented no-op).
+ check-type-assertions: true
+ revive:
+ rules:
+ - name: exported
+ disabled: true # internal packages need no exported-symbol comments
+ gosec:
+ excludes:
+ - G404 # math/rand is fine for jitter; nothing here is security-sensitive
+
+ exclusions:
+ rules:
+ # Tests may skip error checks and use long literals freely.
+ - path: _test\.go
+ linters:
+ - errcheck
+ - gosec
+
+formatters:
+ enable:
+ - gofmt
+ - goimports
+ settings:
+ goimports:
+ local-prefixes:
+ - github.com/emil28092005/SciMesh/users
diff --git a/users/Dockerfile b/users/Dockerfile
new file mode 100644
index 0000000..ffe514e
--- /dev/null
+++ b/users/Dockerfile
@@ -0,0 +1,52 @@
+# syntax=docker/dockerfile:1
+#
+# Requires BuildKit (the RUN --mount cache lines below). Docker 23+ enables it
+# by default when the buildx plugin is present; install `docker-buildx` if a
+# build fails with "the --mount option requires BuildKit".
+
+# --- build stage ----------------------------------------------------------
+FROM golang:1.24-alpine AS build
+
+WORKDIR /src
+
+# Copy manifests first: this layer stays cached until dependencies actually
+# change, so editing Go sources does not re-download the module graph.
+COPY go.mod go.sum ./
+RUN --mount=type=cache,target=/go/pkg/mod go mod download
+
+COPY . .
+
+# The cache mounts persist the module cache and the compiler's build cache
+# *across* builds, so a rebuild after a code edit recompiles only what changed
+# instead of the whole dependency tree.
+#
+# CGO_ENABLED=0 produces a fully static binary, so the runtime image needs no
+# libc. -trimpath strips local paths; -s -w drop the symbol table and DWARF.
+RUN --mount=type=cache,target=/go/pkg/mod \
+ --mount=type=cache,target=/root/.cache/go-build \
+ CGO_ENABLED=0 GOOS=linux go build \
+ -trimpath -ldflags="-s -w" \
+ -o /out/userservice ./cmd/userservice
+
+# --- runtime stage --------------------------------------------------------
+FROM alpine:3.20
+
+# ca-certificates for outbound TLS; wget backs the container healthcheck.
+RUN apk add --no-cache ca-certificates wget \
+ && adduser -D -H -u 10001 userservice \
+ # Pre-create the log dir owned by the non-root user. A named volume mounted
+ # here inherits this ownership from the image, so the process can write to it —
+ # a host bind mount, owned by root, could not.
+ && mkdir -p /var/log/scimesh \
+ && chown -R userservice:userservice /var/log/scimesh
+
+COPY --from=build /out/userservice /usr/local/bin/userservice
+
+# Never run as root: a compromised process should not own the container.
+USER userservice
+
+EXPOSE 8081
+
+# Exec form, not shell: the binary becomes PID 1 and receives SIGTERM directly,
+# which is what its graceful shutdown depends on.
+ENTRYPOINT ["/usr/local/bin/userservice"]
diff --git a/users/Makefile b/users/Makefile
new file mode 100644
index 0000000..8f5c701
--- /dev/null
+++ b/users/Makefile
@@ -0,0 +1,96 @@
+.DEFAULT_GOAL := help
+
+.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps psql smoke
+
+# `check` uses its own Compose project and host ports so it never touches a
+# developer's local PostgreSQL or the normal `make up` stack.
+CHECK_PROJECT ?= scimesh-users-check
+CHECK_POSTGRES_PORT ?= 55433
+CHECK_USERSERVICE_PORT ?= 18081
+CHECK_HOST ?= http://localhost:$(CHECK_USERSERVICE_PORT)
+CHECK_DATABASE_URL ?= postgres://scimesh:scimesh@localhost:$(CHECK_POSTGRES_PORT)/scimesh_users?sslmode=disable
+CHECK_COMPOSE = POSTGRES_PORT=$(CHECK_POSTGRES_PORT) USERSERVICE_PORT=$(CHECK_USERSERVICE_PORT) docker compose -p $(CHECK_PROJECT)
+
+help:
+ @printf '%s\n' \
+ 'SciMesh userservice commands:' \
+ ' make up / make down Start or stop the userservice stack (Postgres + migrate + service).' \
+ ' make check One command: vet, lint, race tests, integration, smoke (needs Docker).' \
+ ' make test / make vet Run Go verification.' \
+ ' make smoke Exercise the live API against a running service.'
+
+# --- build / run ---------------------------------------------------------
+build:
+ go build ./...
+
+run:
+ go run ./cmd/userservice
+
+test:
+ go test ./...
+
+# Needs a running PostgreSQL with the migrations applied:
+# make test-integration TEST_DATABASE_URL='postgres://...'
+test-integration:
+ TEST_DATABASE_URL="$(TEST_DATABASE_URL)" go test -tags=integration ./... -v
+
+vet:
+ go vet ./...
+
+# Runs golangci-lint without installing it system-wide.
+LINT_VERSION := v2.12.2
+lint:
+ @command -v golangci-lint >/dev/null 2>&1 \
+ && golangci-lint run --build-tags=integration ./... \
+ || go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(LINT_VERSION) run --build-tags=integration ./...
+
+tidy:
+ go mod tidy
+
+# One command for a reviewer: unit tests + vet + lint, then the stack up and the
+# integration suite and the end-to-end smoke test. Needs Docker.
+check: vet lint
+ go test -race ./...
+ $(CHECK_COMPOSE) up -d --build
+ @echo "waiting for the userservice to be ready..."
+ @attempt=0; until curl -fsS "$(CHECK_HOST)/health" >/dev/null; do \
+ attempt=$$((attempt + 1)); \
+ if [ $$attempt -ge 30 ]; then $(CHECK_COMPOSE) logs userservice; exit 1; fi; \
+ sleep 1; \
+ done
+ TEST_DATABASE_URL="$(CHECK_DATABASE_URL)" \
+ go test -tags=integration ./internal/storage/postgres/ -v
+ HOST="$(CHECK_HOST)" ./scripts/smoke.sh
+ @echo "\nall checks passed ✓"
+
+# --- migrations ----------------------------------------------------------
+# Requires the golang-migrate CLI and DATABASE_URL, e.g.:
+# export DATABASE_URL='postgres://scimesh:scimesh@localhost:5433/scimesh_users?sslmode=disable'
+migrate-up:
+ migrate -path migrations -database "$(DATABASE_URL)" up
+
+migrate-down:
+ migrate -path migrations -database "$(DATABASE_URL)" down 1
+
+# --- docker --------------------------------------------------------------
+up:
+ docker compose up -d --build
+
+down:
+ docker compose down
+
+down-clean:
+ docker compose down -v
+
+logs:
+ docker compose logs -f userservice
+
+ps:
+ docker compose ps
+
+psql:
+ docker compose exec postgres psql -U scimesh -d scimesh_users
+
+# --- api ------------------------------------------------------------------
+smoke:
+ ./scripts/smoke.sh
diff --git a/users/README.md b/users/README.md
new file mode 100644
index 0000000..83f5d20
--- /dev/null
+++ b/users/README.md
@@ -0,0 +1,82 @@
+# SciMesh userservice
+
+Authentication service for SciMesh, in Go on PostgreSQL. It owns user accounts
+and issues the JWTs the coordinator trusts. It is a **separate bounded context**
+from the coordinator: its own database, its own binary. The only thing shared
+between the two services is the JWT signing secret.
+
+The versioned external contract is
+[`docs/user-service-api-contract.md`](../docs/user-service-api-contract.md).
+
+Built as a modular monolith following Clean Architecture — one binary, four
+layers, dependencies pointing strictly inward:
+
+```
+ infra config, DB pool, clock, HTTP server ← drivers
+ transport HTTP handlers + JWT middleware ← incoming
+ storage SQL repository ← outgoing
+ usecase Register / Login + PORTS (interfaces) ← application rules
+ domain User, Role, invariants ← business rules
+ auth bcrypt hasher, HS256 JWT issuer ← crypto adapters
+```
+
+## Endpoints
+
+| Method | Path | Auth | Purpose |
+|--------|---------------------------|--------------|---------------------------------------------|
+| GET | `/health` | none | Liveness probe (checks the database) |
+| POST | `/register` | none | Create an account (always role `user`) |
+| POST | `/login` | none | Verify credentials, return a signed JWT |
+| GET | `/me` | Bearer JWT | Return the caller's own account |
+| POST | `/users/{id}/verify` | Bearer admin | Grant the trusted-contributor badge |
+| POST | `/users/{id}/unverify` | Bearer admin | Revoke the badge |
+| POST | `/users/{id}/promote` | Bearer admin | Set the user's role to admin |
+| POST | `/users/{id}/demote` | Bearer admin | Set the user's role back to user |
+
+Two independent attributes live on an account:
+
+- **`role`** — `user` or `admin`. Governs what you may do with your own jobs.
+ Registration always creates a `user`; promotion to `admin` is a manual
+ database operation, never a request.
+- **`verified`** — a boolean trust badge, granted **only by an admin** (the
+ `/verify` endpoints above, 403 for anyone else). It tells the coordinator
+ whether this user's volunteer workers are trusted: a verified contributor's
+ results are accepted directly, an unverified one's must pass quorum
+ cross-checking. Defaults to false.
+
+Both attributes ride in the JWT (`role`, `verified` claims), so the coordinator
+reads them from the signed token without ever calling this service.
+
+## How it connects to the coordinator
+
+The coordinator never calls this service at runtime. A client logs in here, gets
+a JWT, and presents it to the coordinator, which verifies the signature locally
+with the same `JWT_SECRET` and reads `sub` (the user id) into `jobs.owner_id`.
+
+That link is **off by default**: until the coordinator is given a matching
+`JWT_SECRET`, it accepts only the shared worker token and stores `owner_id` as
+NULL. Set the same secret (≥ 32 bytes, byte-for-byte identical) on both services
+to turn it on.
+
+## Run
+
+```sh
+# whole stack: Postgres + migrations + the service on :8081
+make up
+
+# or locally against your own Postgres
+cp .env.example .env # then edit JWT_SECRET and DATABASE_URL
+make run
+```
+
+## Verify
+
+```sh
+make test # unit tests
+make check # vet, lint, race, integration, smoke — needs Docker
+make smoke # end-to-end against a running service
+```
+
+Password hashing uses bcrypt (`golang.org/x/crypto/bcrypt`); the salt and cost
+are embedded in the stored hash, so there is no separate salt column. Tokens are
+HS256 (`github.com/golang-jwt/jwt/v5`).
diff --git a/users/cmd/userservice/main.go b/users/cmd/userservice/main.go
new file mode 100644
index 0000000..97f55f4
--- /dev/null
+++ b/users/cmd/userservice/main.go
@@ -0,0 +1,82 @@
+// Command userservice runs the SciMesh authentication service: it registers
+// users, verifies logins, and issues the HS256 JWTs the coordinator trusts.
+package main
+
+import (
+ "context"
+ "fmt"
+ nethttp "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/emil28092005/SciMesh/users/internal/auth"
+ "github.com/emil28092005/SciMesh/users/internal/infra"
+ "github.com/emil28092005/SciMesh/users/internal/storage/postgres"
+ apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "fatal:", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ // Cancelled on SIGINT/SIGTERM so the HTTP server drains in-flight requests
+ // instead of dropping them.
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ cfg, err := infra.LoadConfig()
+ if err != nil {
+ return err
+ }
+
+ log, closer, err := infra.NewLogger(cfg)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = closer.Close() }()
+
+ pool, err := infra.NewPool(ctx, cfg, log)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+
+ // Adapters implementing the usecase ports.
+ users := postgres.NewUserRepo(pool)
+ hasher := auth.NewHasher(cfg.BcryptCost)
+ clock := infra.NewClock()
+ issuer := auth.NewIssuer(cfg.JWTSecret, cfg.TokenTTL, clock.Now)
+
+ uc := apihttp.UseCases{
+ Register: usecase.NewRegister(users, hasher, clock),
+ Login: usecase.NewLogin(users, hasher, issuer),
+ SetVerified: usecase.NewSetVerified(users),
+ SetRole: usecase.NewSetRole(users),
+ Users: users,
+ }
+
+ // Seed the first admin, if configured. Idempotent: a no-op once it exists.
+ if cfg.BootstrapAdminEmail != "" && cfg.BootstrapAdminPassword != "" {
+ created, err := usecase.NewBootstrapAdmin(users, hasher, clock).
+ Execute(ctx, cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword)
+ if err != nil {
+ return fmt.Errorf("bootstrap admin: %w", err)
+ }
+ if created {
+ log.Info("bootstrap admin created", "email", cfg.BootstrapAdminEmail)
+ }
+ }
+
+ handler := apihttp.NewServer(log, uc, issuer)
+ // A blanket per-request deadline: bcrypt is bounded, so anything slower is a
+ // stuck handler we want to shed rather than hold a connection open.
+ handler = nethttp.TimeoutHandler(handler, cfg.RequestTimeout, `{"error":"request timeout"}`)
+
+ return infra.RunServer(ctx, log, cfg.Addr, handler)
+}
diff --git a/users/docker-compose.yml b/users/docker-compose.yml
new file mode 100644
index 0000000..f7e155b
--- /dev/null
+++ b/users/docker-compose.yml
@@ -0,0 +1,81 @@
+# A self-contained stack for the userservice: its own PostgreSQL (a separate
+# database from the coordinator's — different bounded context), a one-shot
+# migration step, and the service. The project name and host ports differ from
+# the coordinator's so both stacks can run side by side on one machine.
+name: scimesh-users
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-scimesh}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh}
+ POSTGRES_DB: ${POSTGRES_DB:-scimesh_users}
+ ports:
+ # 5433 on the host, so it never clashes with the coordinator's 5432.
+ - "${POSTGRES_PORT:-5433}:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ # Everything else waits on this, so the check must prove the server
+ # accepts queries — not merely that the port is open.
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d ${POSTGRES_DB:-scimesh_users}"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 5s
+
+ # One-shot: applies migrations, then exits. Schema changes stay an explicit
+ # deployment step — the service binary never migrates on startup.
+ migrate:
+ image: migrate/migrate:v4.17.1
+ depends_on:
+ postgres:
+ condition: service_healthy
+ volumes:
+ - ./migrations:/migrations:ro
+ command:
+ - -path=/migrations
+ - -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh_users}?sslmode=disable
+ - up
+ restart: on-failure
+
+ userservice:
+ build:
+ context: .
+ depends_on:
+ postgres:
+ condition: service_healthy
+ # Start only once the schema exists, otherwise the first query fails.
+ migrate:
+ condition: service_completed_successfully
+ environment:
+ USERSERVICE_ADDR: ":8081"
+ # Host is the service name: compose resolves it on the project network.
+ DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh_users}?sslmode=disable
+ # MUST match the coordinator's JWT secret so it can verify these tokens.
+ JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-at-least-32-bytes}
+ JWT_TTL: ${JWT_TTL:-24h}
+ DB_MAX_CONNS: "10"
+ REQUEST_TIMEOUT: "15s"
+ LOG_LEVEL: ${LOG_LEVEL:-info}
+ # Logs are teed to stdout (docker logs) and this rotated file on a named
+ # volume, so they survive a rebuild.
+ LOG_FILE: /var/log/scimesh/userservice.log
+ ports:
+ - "${USERSERVICE_PORT:-8081}:8081"
+ # A named volume (not a host bind mount): it inherits the image's directory
+ # ownership, so the non-root process can write to it.
+ volumes:
+ - userservice_logs:/var/log/scimesh
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+ restart: unless-stopped
+
+volumes:
+ pgdata:
+ userservice_logs:
diff --git a/users/go.mod b/users/go.mod
new file mode 100644
index 0000000..86fce7a
--- /dev/null
+++ b/users/go.mod
@@ -0,0 +1,24 @@
+module github.com/emil28092005/SciMesh/users
+
+go 1.22
+
+require (
+ github.com/Masterminds/squirrel v1.5.4
+ github.com/cenkalti/backoff/v4 v4.3.0
+ github.com/golang-jwt/jwt/v5 v5.3.1
+ github.com/google/uuid v1.6.0
+ github.com/jackc/pgx/v5 v5.6.0
+ github.com/joho/godotenv v1.5.1
+ golang.org/x/crypto v0.17.0
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
+)
+
+require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
+ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
+ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
+ golang.org/x/sync v0.1.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+)
diff --git a/users/go.sum b/users/go.sum
new file mode 100644
index 0000000..9de4344
--- /dev/null
+++ b/users/go.sum
@@ -0,0 +1,45 @@
+github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
+github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+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=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
+github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/users/internal/auth/jwt.go b/users/internal/auth/jwt.go
new file mode 100644
index 0000000..ae02f26
--- /dev/null
+++ b/users/internal/auth/jwt.go
@@ -0,0 +1,72 @@
+package auth
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+// Claims is the payload of a signed token. Subject (from RegisteredClaims) is
+// the user id — it becomes the coordinator's jobs.owner_id; Role drives
+// authorization; Verified tells the coordinator whether this user's workers are
+// trusted (results accepted without quorum). Both services verify this token
+// locally with the shared HS256 secret, so no runtime call back to the
+// userservice is ever needed.
+type Claims struct {
+ Role domain.Role `json:"role"`
+ Verified bool `json:"verified"`
+ jwt.RegisteredClaims
+}
+
+// Issuer signs and verifies tokens with a shared HS256 secret.
+type Issuer struct {
+ secret []byte
+ ttl time.Duration
+ now func() time.Time
+}
+
+// NewIssuer builds an Issuer. now defaults to time.Now when nil; tests inject a
+// fixed clock to make expiry deterministic.
+func NewIssuer(secret string, ttl time.Duration, now func() time.Time) Issuer {
+ if now == nil {
+ now = time.Now
+ }
+ return Issuer{secret: []byte(secret), ttl: ttl, now: now}
+}
+
+// Issue returns a signed token for the user, valid for the configured TTL. It
+// takes the whole user so every trust-bearing field (role, verified) travels in
+// the token, keeping the two services from needing a runtime lookup.
+func (i Issuer) Issue(u *domain.User) (string, error) {
+ now := i.now()
+ claims := Claims{
+ Role: u.Role,
+ Verified: u.Verified,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Subject: u.ID.String(),
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
+ },
+ }
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
+}
+
+// Verify checks the signature and expiry and returns the claims. It pins the
+// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
+// key — the classic algorithm-substitution attack against naive verifiers.
+func (i Issuer) Verify(token string) (*Claims, error) {
+ var claims Claims
+ _, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return i.secret, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &claims, nil
+}
diff --git a/users/internal/auth/jwt_test.go b/users/internal/auth/jwt_test.go
new file mode 100644
index 0000000..a01fa7a
--- /dev/null
+++ b/users/internal/auth/jwt_test.go
@@ -0,0 +1,77 @@
+package auth
+
+import (
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+const testSecret = "test-secret-at-least-32-bytes-long!!"
+
+func TestIssueVerifyRoundTrip(t *testing.T) {
+ iss := NewIssuer(testSecret, time.Hour, nil)
+ id := uuid.New()
+
+ token, err := iss.Issue(&domain.User{ID: id, Role: domain.RoleAdmin, Verified: true})
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+
+ claims, err := iss.Verify(token)
+ if err != nil {
+ t.Fatalf("verify: %v", err)
+ }
+ if claims.Subject != id.String() {
+ t.Errorf("sub = %q, want %q", claims.Subject, id.String())
+ }
+ if claims.Role != domain.RoleAdmin {
+ t.Errorf("role = %q, want admin", claims.Role)
+ }
+ if !claims.Verified {
+ t.Error("verified claim not carried in token")
+ }
+}
+
+func TestVerifyRejectsExpired(t *testing.T) {
+ // Negative TTL: the token is already expired when issued.
+ iss := NewIssuer(testSecret, -time.Minute, nil)
+ token, _ := iss.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
+
+ if _, err := iss.Verify(token); err == nil {
+ t.Error("expired token accepted")
+ }
+}
+
+func TestVerifyRejectsWrongSecret(t *testing.T) {
+ token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
+
+ other := NewIssuer("another-secret-also-32-bytes-long!!!", time.Hour, nil)
+ if _, err := other.Verify(token); err == nil {
+ t.Error("token verified under the wrong secret")
+ }
+}
+
+func TestVerifyRejectsNoneAlgorithm(t *testing.T) {
+ // Forge a token signed with "none" — the classic algorithm-substitution
+ // attack. A verifier that trusts the header's alg would accept it.
+ tok := jwt.NewWithClaims(jwt.SigningMethodNone, Claims{
+ Role: domain.RoleAdmin,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Subject: uuid.New().String(),
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ },
+ })
+ raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
+ if err != nil {
+ t.Fatalf("sign none: %v", err)
+ }
+
+ iss := NewIssuer(testSecret, time.Hour, nil)
+ if _, err := iss.Verify(raw); err == nil {
+ t.Error("none-signed token accepted")
+ }
+}
diff --git a/users/internal/auth/password.go b/users/internal/auth/password.go
new file mode 100644
index 0000000..db790f4
--- /dev/null
+++ b/users/internal/auth/password.go
@@ -0,0 +1,40 @@
+// Package auth holds the cryptographic adapters — password hashing and JWT
+// signing/verification. They implement use-case ports and keep bcrypt and the
+// JWT library out of the domain and use-case layers.
+package auth
+
+import "golang.org/x/crypto/bcrypt"
+
+// Hasher turns plaintext passwords into storable hashes and checks them back.
+type Hasher struct {
+ cost int
+}
+
+// NewHasher builds a Hasher. A cost of 0 uses bcrypt's default work factor.
+func NewHasher(cost int) Hasher {
+ if cost == 0 {
+ cost = bcrypt.DefaultCost
+ }
+ return Hasher{cost: cost}
+}
+
+// Hash returns the bcrypt hash of password. The salt and the cost are embedded
+// in the returned string, so nothing else needs to be stored alongside it.
+//
+// bcrypt silently ignores input past 72 bytes; the use case rejects longer
+// passwords before reaching here so a truncated tail never becomes a security
+// surprise.
+func (h Hasher) Hash(password string) (string, error) {
+ b, err := bcrypt.GenerateFromPassword([]byte(password), h.cost)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+// Compare reports whether password matches the stored hash. It returns a
+// non-nil error (bcrypt.ErrMismatchedHashAndPassword) on any mismatch, which
+// the caller collapses into a generic authentication failure.
+func (h Hasher) Compare(hash, password string) error {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+}
diff --git a/users/internal/auth/password_test.go b/users/internal/auth/password_test.go
new file mode 100644
index 0000000..2094f3b
--- /dev/null
+++ b/users/internal/auth/password_test.go
@@ -0,0 +1,30 @@
+package auth
+
+import "testing"
+
+func TestHashAndCompare(t *testing.T) {
+ h := NewHasher(0) // default cost
+
+ hash, err := h.Hash("correct horse battery staple")
+ if err != nil {
+ t.Fatalf("hash: %v", err)
+ }
+ if hash == "correct horse battery staple" {
+ t.Fatal("hash must not equal the plaintext")
+ }
+ if err := h.Compare(hash, "correct horse battery staple"); err != nil {
+ t.Errorf("correct password rejected: %v", err)
+ }
+ if err := h.Compare(hash, "wrong password"); err == nil {
+ t.Error("wrong password accepted")
+ }
+}
+
+func TestHashSaltsEachTime(t *testing.T) {
+ h := NewHasher(0)
+ a, _ := h.Hash("same")
+ b, _ := h.Hash("same")
+ if a == b {
+ t.Error("two hashes of the same password must differ (random salt)")
+ }
+}
diff --git a/users/internal/domain/errors.go b/users/internal/domain/errors.go
new file mode 100644
index 0000000..39bd39a
--- /dev/null
+++ b/users/internal/domain/errors.go
@@ -0,0 +1,11 @@
+package domain
+
+import "errors"
+
+// Domain validation errors. They describe an entity that cannot be constructed,
+// independent of storage or transport, and the HTTP layer maps them to 400.
+var (
+ ErrEmptyEmail = errors.New("email is required")
+ ErrInvalidEmail = errors.New("email is not a valid address")
+ ErrEmptyPasswordHash = errors.New("password hash is required")
+)
diff --git a/users/internal/domain/user.go b/users/internal/domain/user.go
new file mode 100644
index 0000000..77ca642
--- /dev/null
+++ b/users/internal/domain/user.go
@@ -0,0 +1,84 @@
+package domain
+
+import (
+ "net/mail"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Role string
+
+const (
+ RoleAdmin Role = "admin"
+ RoleUser Role = "user"
+)
+
+func (r Role) Valid() bool {
+ switch r {
+ case RoleAdmin, RoleUser:
+ return true
+ default:
+ return false
+ }
+}
+
+type User struct {
+ ID uuid.UUID
+ Email string
+ PasswordHash string
+ Role Role
+ // Verified marks a trusted contributor whose workers' results the
+ // coordinator accepts without quorum. Distinct from Role; granted by an
+ // admin, defaults to false.
+ Verified bool
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// NewUser builds a freshly registered account. It normalises the email and
+// enforces every invariant a row must satisfy, so an invalid User cannot be
+// constructed. The caller supplies the already-hashed password — hashing is an
+// adapter's job, not the domain's.
+//
+// Registration always produces a plain user; promotion to admin is a manual,
+// out-of-band operation, never something a request can trigger.
+func NewUser(email, passwordHash string, now time.Time) (*User, error) {
+ email = NormalizeEmail(email)
+ if err := validateEmail(email); err != nil {
+ return nil, err
+ }
+ if passwordHash == "" {
+ return nil, ErrEmptyPasswordHash
+ }
+ return &User{
+ ID: uuid.New(),
+ Email: email,
+ PasswordHash: passwordHash,
+ Role: RoleUser,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }, nil
+}
+
+// NormalizeEmail lower-cases and trims an address so that "Bob@X.com " and
+// "bob@x.com" resolve to the same account. Every lookup and every insert must
+// pass through here, matching the ck_users_email_lower database constraint.
+func NormalizeEmail(email string) string {
+ return strings.ToLower(strings.TrimSpace(email))
+}
+
+func validateEmail(email string) error {
+ if email == "" {
+ return ErrEmptyEmail
+ }
+ // A minimal shape check, not full RFC 5322: real deliverability is proven by
+ // sending mail, not by a regex. mail.ParseAddress also accepts the
+ // "Name " form, so we insist the parsed address equals the input.
+ addr, err := mail.ParseAddress(email)
+ if err != nil || addr.Address != email {
+ return ErrInvalidEmail
+ }
+ return nil
+}
diff --git a/users/internal/domain/user_test.go b/users/internal/domain/user_test.go
new file mode 100644
index 0000000..9894cd2
--- /dev/null
+++ b/users/internal/domain/user_test.go
@@ -0,0 +1,62 @@
+package domain
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+func TestNewUserNormalisesAndValidates(t *testing.T) {
+ now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
+
+ u, err := NewUser(" Bob@Example.COM ", "hashed", now)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if u.Email != "bob@example.com" {
+ t.Errorf("email not normalised: got %q", u.Email)
+ }
+ if u.Role != RoleUser {
+ t.Errorf("new user must default to RoleUser, got %q", u.Role)
+ }
+ if u.ID == uuid.Nil {
+ t.Error("new user must get an id")
+ }
+ if !u.CreatedAt.Equal(now) || !u.UpdatedAt.Equal(now) {
+ t.Error("timestamps not set from clock")
+ }
+}
+
+func TestNewUserRejectsBadInput(t *testing.T) {
+ now := time.Now()
+ cases := []struct {
+ name string
+ email string
+ hash string
+ wantErr error
+ }{
+ {"empty email", "", "h", ErrEmptyEmail},
+ {"no domain", "bob", "h", ErrInvalidEmail},
+ {"name form", "Bob ", "h", ErrInvalidEmail},
+ {"empty hash", "bob@x.com", "", ErrEmptyPasswordHash},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := NewUser(tc.email, tc.hash, now)
+ if !errors.Is(err, tc.wantErr) {
+ t.Errorf("got %v, want %v", err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestRoleValid(t *testing.T) {
+ if !RoleUser.Valid() || !RoleAdmin.Valid() {
+ t.Error("user and admin must be valid")
+ }
+ if Role("root").Valid() {
+ t.Error("unknown role must be invalid")
+ }
+}
diff --git a/users/internal/infra/clock.go b/users/internal/infra/clock.go
new file mode 100644
index 0000000..e326bce
--- /dev/null
+++ b/users/internal/infra/clock.go
@@ -0,0 +1,13 @@
+// Clock: the real implementation of the usecase.Clock port. It lives out here
+// because reading the system clock is infrastructure; tests substitute a fixed one.
+package infra
+
+import "time"
+
+type System struct{}
+
+func NewClock() System { return System{} }
+
+// Now returns UTC so every timestamp this service writes is comparable
+// regardless of the host's timezone.
+func (System) Now() time.Time { return time.Now().UTC() }
diff --git a/users/internal/infra/config.go b/users/internal/infra/config.go
new file mode 100644
index 0000000..cc8fb74
--- /dev/null
+++ b/users/internal/infra/config.go
@@ -0,0 +1,160 @@
+// Config: userservice settings, read only from the environment, so the same
+// binary behaves identically in CI, local, and prod.
+package infra
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "math"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/joho/godotenv"
+)
+
+// defaultEnvFile is loaded by LoadConfig unless ENV_FILE points elsewhere.
+const defaultEnvFile = ".env"
+
+type Config struct {
+ // HTTP listen address, e.g. ":8081".
+ Addr string
+ // PostgreSQL connection string (pgx format / libpq URL).
+ DatabaseURL string
+
+ // Shared HS256 secret used to sign JWTs. The coordinator verifies tokens
+ // with this same secret, so the two values MUST match. This is the only
+ // secret shared between the services.
+ JWTSecret string
+ // How long an issued token stays valid.
+ TokenTTL time.Duration
+ // bcrypt work factor. 0 falls back to the library default (currently 10).
+ BcryptCost int
+
+ // Optional first-admin bootstrap. When both are set and no such account
+ // exists, the service creates it with role=admin on startup — the only way
+ // to get the first admin, since /register always makes a plain user and
+ // promotion needs an existing admin. Idempotent: a no-op once created.
+ BootstrapAdminEmail string
+ BootstrapAdminPassword string
+
+ // Minimum log level: debug, info, warn, error.
+ LogLevel string
+ // Path to a rotated log file. Empty logs to stdout only.
+ LogFile string
+
+ // Connection pool upper bound.
+ DBMaxConns int32
+ // How long to keep retrying the initial database connection at startup
+ // before giving up. Covers a Postgres container that is still booting.
+ DBConnectTimeout time.Duration
+ // Per-request timeout applied to every handler.
+ RequestTimeout time.Duration
+}
+
+// LoadConfig reads the environment and fails fast on anything required-but-
+// missing or malformed, so a misconfigured process never limps along half-wired.
+//
+// A .env file (path overridable via ENV_FILE) is loaded first as a local-dev
+// convenience. It only fills variables the environment does not already define.
+func LoadConfig() (Config, error) {
+ envFile := os.Getenv("ENV_FILE")
+ if envFile == "" {
+ envFile = defaultEnvFile
+ }
+ // godotenv.Load never overwrites variables already present in the
+ // environment, so an orchestrator's values always beat the file. A missing
+ // file is expected in production, where env vars are injected directly.
+ if err := godotenv.Load(envFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return Config{}, fmt.Errorf("load env file %q: %w", envFile, err)
+ }
+
+ cfg := Config{
+ Addr: getEnv("USERSERVICE_ADDR", ":8081"),
+ DatabaseURL: os.Getenv("DATABASE_URL"),
+ JWTSecret: os.Getenv("JWT_SECRET"),
+ BootstrapAdminEmail: os.Getenv("BOOTSTRAP_ADMIN_EMAIL"),
+ BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
+ LogLevel: getEnv("LOG_LEVEL", "info"),
+ LogFile: os.Getenv("LOG_FILE"),
+ TokenTTL: 24 * time.Hour,
+ DBMaxConns: 10,
+ DBConnectTimeout: 30 * time.Second,
+ RequestTimeout: 15 * time.Second,
+ }
+
+ if cfg.DatabaseURL == "" {
+ return Config{}, fmt.Errorf("DATABASE_URL is required")
+ }
+ if cfg.JWTSecret == "" {
+ return Config{}, fmt.Errorf("JWT_SECRET is required")
+ }
+ // A short secret makes the HMAC brute-forceable; refuse to start with one.
+ if len(cfg.JWTSecret) < 32 {
+ return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
+ }
+
+ var err error
+ if cfg.TokenTTL, err = getEnvDuration("JWT_TTL", cfg.TokenTTL); err != nil {
+ return Config{}, err
+ }
+ if cfg.BcryptCost, err = getEnvInt("BCRYPT_COST", cfg.BcryptCost); err != nil {
+ return Config{}, err
+ }
+ if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
+ return Config{}, err
+ }
+ if cfg.DBConnectTimeout, err = getEnvDuration("DB_CONNECT_TIMEOUT", cfg.DBConnectTimeout); err != nil {
+ return Config{}, err
+ }
+ if cfg.RequestTimeout, err = getEnvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout); err != nil {
+ return Config{}, err
+ }
+
+ return cfg, nil
+}
+
+func getEnv(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func getEnvInt(key string, def int) (int, error) {
+ v := os.Getenv(key)
+ if v == "" {
+ return def, nil
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ return 0, fmt.Errorf("%s: %w", key, err)
+ }
+ return n, nil
+}
+
+func getEnvInt32(key string, def int32) (int32, error) {
+ n, err := getEnvInt(key, int(def))
+ if err != nil {
+ return 0, err
+ }
+ // On 64-bit builds int is wider than int32, so an oversized value would
+ // wrap silently — DB_MAX_CONNS=2147483648 becoming a negative pool size.
+ if n < math.MinInt32 || n > math.MaxInt32 {
+ return 0, fmt.Errorf("%s: %d is out of range for int32", key, n)
+ }
+ return int32(n), nil
+}
+
+func getEnvDuration(key string, def time.Duration) (time.Duration, error) {
+ v := os.Getenv(key)
+ if v == "" {
+ return def, nil
+ }
+ d, err := time.ParseDuration(v)
+ if err != nil {
+ return 0, fmt.Errorf("%s: %w", key, err)
+ }
+ return d, nil
+}
diff --git a/users/internal/infra/config_test.go b/users/internal/infra/config_test.go
new file mode 100644
index 0000000..57840b2
--- /dev/null
+++ b/users/internal/infra/config_test.go
@@ -0,0 +1,101 @@
+package infra
+
+import (
+ "testing"
+ "time"
+)
+
+const validSecret = "a-secret-that-is-at-least-32-bytes!!"
+
+// setBaseEnv wires the minimum valid environment. ENV_FILE points at a path that
+// does not exist so a developer's stray .env never leaks into the test.
+func setBaseEnv(t *testing.T) {
+ t.Helper()
+ t.Setenv("ENV_FILE", "/nonexistent/.env")
+ t.Setenv("DATABASE_URL", "postgres://u:p@localhost:5432/db?sslmode=disable")
+ t.Setenv("JWT_SECRET", validSecret)
+}
+
+func TestLoadConfigDefaults(t *testing.T) {
+ setBaseEnv(t)
+
+ cfg, err := LoadConfig()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Addr != ":8081" {
+ t.Errorf("Addr default = %q, want :8081", cfg.Addr)
+ }
+ if cfg.TokenTTL != 24*time.Hour {
+ t.Errorf("TokenTTL default = %v, want 24h", cfg.TokenTTL)
+ }
+ if cfg.JWTSecret != validSecret {
+ t.Errorf("JWTSecret = %q", cfg.JWTSecret)
+ }
+}
+
+func TestLoadConfigRequiresDatabaseURL(t *testing.T) {
+ setBaseEnv(t)
+ t.Setenv("DATABASE_URL", "")
+
+ if _, err := LoadConfig(); err == nil {
+ t.Error("expected error when DATABASE_URL is empty")
+ }
+}
+
+func TestLoadConfigRequiresJWTSecret(t *testing.T) {
+ setBaseEnv(t)
+ t.Setenv("JWT_SECRET", "")
+
+ if _, err := LoadConfig(); err == nil {
+ t.Error("expected error when JWT_SECRET is empty")
+ }
+}
+
+func TestLoadConfigRejectsShortJWTSecret(t *testing.T) {
+ setBaseEnv(t)
+ t.Setenv("JWT_SECRET", "too-short")
+
+ if _, err := LoadConfig(); err == nil {
+ t.Error("expected error when JWT_SECRET is under 32 bytes")
+ }
+}
+
+func TestLoadConfigOverrides(t *testing.T) {
+ setBaseEnv(t)
+ t.Setenv("USERSERVICE_ADDR", ":9000")
+ t.Setenv("JWT_TTL", "1h")
+ t.Setenv("BCRYPT_COST", "6")
+
+ cfg, err := LoadConfig()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Addr != ":9000" {
+ t.Errorf("Addr = %q, want :9000", cfg.Addr)
+ }
+ if cfg.TokenTTL != time.Hour {
+ t.Errorf("TokenTTL = %v, want 1h", cfg.TokenTTL)
+ }
+ if cfg.BcryptCost != 6 {
+ t.Errorf("BcryptCost = %d, want 6", cfg.BcryptCost)
+ }
+}
+
+func TestLoadConfigRejectsMalformedDuration(t *testing.T) {
+ setBaseEnv(t)
+ t.Setenv("JWT_TTL", "not-a-duration")
+
+ if _, err := LoadConfig(); err == nil {
+ t.Error("expected error for malformed JWT_TTL")
+ }
+}
+
+func TestLoadConfigRejectsMalformedInt(t *testing.T) {
+ setBaseEnv(t)
+ t.Setenv("BCRYPT_COST", "abc")
+
+ if _, err := LoadConfig(); err == nil {
+ t.Error("expected error for malformed BCRYPT_COST")
+ }
+}
diff --git a/users/internal/infra/db.go b/users/internal/infra/db.go
new file mode 100644
index 0000000..4a09674
--- /dev/null
+++ b/users/internal/infra/db.go
@@ -0,0 +1,65 @@
+// DB: the PostgreSQL connection pool.
+package infra
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// NewPool builds the single shared pool. The caller owns its lifetime and must
+// Close() it on shutdown.
+func NewPool(ctx context.Context, cfg Config, log *slog.Logger) (*pgxpool.Pool, error) {
+ poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
+ if err != nil {
+ return nil, err
+ }
+ poolCfg.MaxConns = cfg.DBMaxConns
+
+ pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
+ if err != nil {
+ return nil, err
+ }
+ // pgxpool.New is lazy, so a ping is needed to actually reach the server.
+ // It is retried because at startup — especially under docker-compose, where
+ // this service can boot before Postgres is accepting connections — a
+ // service should wait for its database rather than crash-loop.
+ if err := pingWithRetry(ctx, pool, cfg.DBConnectTimeout, log); err != nil {
+ pool.Close()
+ return nil, err
+ }
+ return pool, nil
+}
+
+// pingWithRetry waits for the database to accept connections, backing off
+// between attempts until the budget elapses or ctx is cancelled.
+//
+// Unlike the transaction retry in storage/postgres, this retries *any* ping
+// error: at startup a "connection refused" is the expected, retryable state,
+// not an anomaly.
+func pingWithRetry(ctx context.Context, pool *pgxpool.Pool, budget time.Duration, log *slog.Logger) error {
+ b := backoff.NewExponentialBackOff()
+ b.InitialInterval = 200 * time.Millisecond
+ b.MaxInterval = 3 * time.Second
+ b.MaxElapsedTime = budget
+
+ attempt := 0
+ return backoff.RetryNotify(
+ func() error {
+ // A bounded per-attempt timeout so one hung dial cannot eat the
+ // whole budget in a single try.
+ pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ return pool.Ping(pingCtx)
+ },
+ backoff.WithContext(b, ctx),
+ func(err error, next time.Duration) {
+ attempt++
+ log.Warn("database not ready, retrying",
+ "attempt", attempt, "retry_in", next.String(), "err", err)
+ },
+ )
+}
diff --git a/users/internal/infra/logging.go b/users/internal/infra/logging.go
new file mode 100644
index 0000000..b5f49e3
--- /dev/null
+++ b/users/internal/infra/logging.go
@@ -0,0 +1,65 @@
+package infra
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "gopkg.in/natefinch/lumberjack.v2"
+)
+
+// NewLogger builds the process logger.
+//
+// It always writes JSON to stdout, so `docker logs` and any 12-factor log
+// collector keep working. When LogFile is set it *also* writes to a
+// size-rotated file, so logs survive a container rebuild instead of vanishing
+// with the previous stdout stream. Rotation is delegated to lumberjack rather
+// than hand-rolled.
+//
+// The returned Closer flushes and closes the file; call it on shutdown.
+func NewLogger(cfg Config) (*slog.Logger, io.Closer, error) {
+ opts := &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}
+
+ var (
+ out io.Writer = os.Stdout
+ closer io.Closer = noopCloser{}
+ )
+
+ if cfg.LogFile != "" {
+ if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0o750); err != nil {
+ return nil, nil, fmt.Errorf("create log directory: %w", err)
+ }
+ rotator := &lumberjack.Logger{
+ Filename: cfg.LogFile,
+ MaxSize: 50, // megabytes before a rotation
+ MaxBackups: 5, // keep this many rotated files
+ MaxAge: 30, // days
+ Compress: true,
+ }
+ // Tee to both: the console stays live while the file is the durable copy.
+ out = io.MultiWriter(os.Stdout, rotator)
+ closer = rotator
+ }
+
+ return slog.New(slog.NewJSONHandler(out, opts)), closer, nil
+}
+
+func parseLevel(s string) slog.Level {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "debug":
+ return slog.LevelDebug
+ case "warn", "warning":
+ return slog.LevelWarn
+ case "error":
+ return slog.LevelError
+ default:
+ return slog.LevelInfo
+ }
+}
+
+type noopCloser struct{}
+
+func (noopCloser) Close() error { return nil }
diff --git a/users/internal/infra/server.go b/users/internal/infra/server.go
new file mode 100644
index 0000000..80999e2
--- /dev/null
+++ b/users/internal/infra/server.go
@@ -0,0 +1,44 @@
+// Server: the HTTP listener, shut down cleanly on a signal.
+package infra
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "time"
+)
+
+const shutdownGrace = 15 * time.Second
+
+// RunServer serves handler until ctx is cancelled, then drains in-flight requests.
+func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler) error {
+ srv := &http.Server{
+ Addr: addr,
+ Handler: handler,
+ ReadHeaderTimeout: 5 * time.Second,
+ }
+
+ // Buffered so this goroutine can exit even when nobody reads the channel
+ // (the ctx.Done branch below) — an unbuffered send would leak it forever.
+ errCh := make(chan error, 1)
+ go func() {
+ log.Info("userservice listening", "addr", addr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ errCh <- err
+ }
+ }()
+
+ select {
+ case err := <-errCh:
+ return err
+ case <-ctx.Done():
+ log.Info("shutdown signal received")
+ }
+
+ // A fresh context: ctx is already cancelled, and reusing it would abort the
+ // very requests we are trying to let finish.
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+}
diff --git a/users/internal/memstore/memstore.go b/users/internal/memstore/memstore.go
new file mode 100644
index 0000000..96307e9
--- /dev/null
+++ b/users/internal/memstore/memstore.go
@@ -0,0 +1,90 @@
+// Package memstore provides in-memory implementations of the usecase ports for
+// fast, deterministic tests that need no database.
+package memstore
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+// UserRepo is an in-memory usecase.UserRepository. It stores copies, so callers
+// mutating a returned user cannot corrupt the store.
+type UserRepo struct {
+ mu sync.Mutex
+ byID map[uuid.UUID]domain.User
+ byEmail map[string]uuid.UUID
+}
+
+func NewUserRepo() *UserRepo {
+ return &UserRepo{
+ byID: make(map[uuid.UUID]domain.User),
+ byEmail: make(map[string]uuid.UUID),
+ }
+}
+
+func (r *UserRepo) Insert(_ context.Context, u *domain.User) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if _, ok := r.byEmail[u.Email]; ok {
+ return usecase.ErrEmailExists
+ }
+ r.byID[u.ID] = *u
+ r.byEmail[u.Email] = u.ID
+ return nil
+}
+
+func (r *UserRepo) GetByEmail(_ context.Context, email string) (*domain.User, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ id, ok := r.byEmail[email]
+ if !ok {
+ return nil, usecase.ErrUserNotFound
+ }
+ u := r.byID[id]
+ return &u, nil
+}
+
+func (r *UserRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.User, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ u, ok := r.byID[id]
+ if !ok {
+ return nil, usecase.ErrUserNotFound
+ }
+ return &u, nil
+}
+
+func (r *UserRepo) SetVerified(_ context.Context, id uuid.UUID, verified bool) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ u, ok := r.byID[id]
+ if !ok {
+ return usecase.ErrUserNotFound
+ }
+ u.Verified = verified
+ r.byID[id] = u
+ return nil
+}
+
+func (r *UserRepo) SetRole(_ context.Context, id uuid.UUID, role domain.Role) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ u, ok := r.byID[id]
+ if !ok {
+ return usecase.ErrUserNotFound
+ }
+ u.Role = role
+ r.byID[id] = u
+ return nil
+}
+
+// Clock is a fixed usecase.Clock for deterministic tests.
+type Clock struct{ T time.Time }
+
+func (c Clock) Now() time.Time { return c.T }
diff --git a/users/internal/storage/postgres/builder.go b/users/internal/storage/postgres/builder.go
new file mode 100644
index 0000000..0025347
--- /dev/null
+++ b/users/internal/storage/postgres/builder.go
@@ -0,0 +1,11 @@
+package postgres
+
+import sq "github.com/Masterminds/squirrel"
+
+// psql is the shared statement builder, fixed to PostgreSQL $N placeholders so
+// no call site repeats PlaceholderFormat(sq.Dollar).
+//
+// Not everything goes through it. Two genuinely set-based statements stay as
+// raw SQL — claimNext (a FOR UPDATE SKIP LOCKED CTE) and expireLeases (CASE
+// logic in the SET) — because a builder would obscure them, not clarify them.
+var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
diff --git a/users/internal/storage/postgres/integration_test.go b/users/internal/storage/postgres/integration_test.go
new file mode 100644
index 0000000..89d37e5
--- /dev/null
+++ b/users/internal/storage/postgres/integration_test.go
@@ -0,0 +1,165 @@
+//go:build integration
+
+// Integration tests run against a real PostgreSQL instance supplied through
+// TEST_DATABASE_URL, with the userservice migrations already applied. A real DB
+// is required because the guarantees under test — the unique-email constraint
+// mapping to ErrEmailExists, the ck_users_email_lower check — are properties of
+// Postgres, not of the Go code.
+//
+// docker compose up -d
+// TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable' \
+// go test -tags=integration ./internal/storage/postgres/ -v
+package postgres
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+func testPool(t *testing.T) *pgxpool.Pool {
+ t.Helper()
+ url := os.Getenv("TEST_DATABASE_URL")
+ if url == "" {
+ t.Skip("TEST_DATABASE_URL is not set")
+ }
+ pool, err := pgxpool.New(context.Background(), url)
+ if err != nil {
+ t.Fatalf("connect: %v", err)
+ }
+ t.Cleanup(pool.Close)
+ return pool
+}
+
+// seedUser inserts a user with a unique email and removes it afterwards, so
+// tests stay independent of each other and of leftovers from earlier runs.
+func seedUser(t *testing.T, repo *UserRepo) *domain.User {
+ t.Helper()
+ email := fmt.Sprintf("it-%s@example.com", uuid.NewString())
+ u, err := domain.NewUser(email, "$2a$04$abcdefghijklmnopqrstuv", time.Now().UTC())
+ if err != nil {
+ t.Fatalf("build user: %v", err)
+ }
+ if err := repo.Insert(context.Background(), u); err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+ t.Cleanup(func() {
+ _, _ = repo.pool.Exec(context.Background(), "DELETE FROM users WHERE id = $1", u.ID)
+ })
+ return u
+}
+
+func TestUserRepoInsertAndGet(t *testing.T) {
+ repo := NewUserRepo(testPool(t))
+ ctx := context.Background()
+ want := seedUser(t, repo)
+
+ byEmail, err := repo.GetByEmail(ctx, want.Email)
+ if err != nil {
+ t.Fatalf("GetByEmail: %v", err)
+ }
+ if byEmail.ID != want.ID || byEmail.Email != want.Email || byEmail.Role != domain.RoleUser {
+ t.Errorf("GetByEmail mismatch: %+v", byEmail)
+ }
+
+ byID, err := repo.GetByID(ctx, want.ID)
+ if err != nil {
+ t.Fatalf("GetByID: %v", err)
+ }
+ if byID.Email != want.Email {
+ t.Errorf("GetByID mismatch: %+v", byID)
+ }
+}
+
+func TestUserRepoDuplicateEmail(t *testing.T) {
+ repo := NewUserRepo(testPool(t))
+ existing := seedUser(t, repo)
+
+ // A second user with the same email must hit the unique constraint and map
+ // to the port's sentinel error.
+ dup, err := domain.NewUser(existing.Email, "$2a$04$abcdefghijklmnopqrstuv", time.Now().UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = repo.Insert(context.Background(), dup)
+ if !errors.Is(err, usecase.ErrEmailExists) {
+ t.Errorf("got %v, want ErrEmailExists", err)
+ }
+}
+
+func TestUserRepoNotFound(t *testing.T) {
+ repo := NewUserRepo(testPool(t))
+ ctx := context.Background()
+
+ if _, err := repo.GetByID(ctx, uuid.New()); !errors.Is(err, usecase.ErrUserNotFound) {
+ t.Errorf("GetByID unknown: got %v, want ErrUserNotFound", err)
+ }
+ if _, err := repo.GetByEmail(ctx, "ghost@example.com"); !errors.Is(err, usecase.ErrUserNotFound) {
+ t.Errorf("GetByEmail unknown: got %v, want ErrUserNotFound", err)
+ }
+}
+
+func TestUserRepoSetVerified(t *testing.T) {
+ repo := NewUserRepo(testPool(t))
+ ctx := context.Background()
+ u := seedUser(t, repo)
+
+ // A fresh row defaults to unverified.
+ got, err := repo.GetByID(ctx, u.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Verified {
+ t.Fatal("new user must default to unverified")
+ }
+
+ if err := repo.SetVerified(ctx, u.ID, true); err != nil {
+ t.Fatalf("grant: %v", err)
+ }
+ got, _ = repo.GetByID(ctx, u.ID)
+ if !got.Verified {
+ t.Error("verified flag not persisted")
+ }
+
+ if err := repo.SetVerified(ctx, u.ID, false); err != nil {
+ t.Fatalf("revoke: %v", err)
+ }
+ got, _ = repo.GetByID(ctx, u.ID)
+ if got.Verified {
+ t.Error("verified flag not cleared")
+ }
+}
+
+func TestUserRepoSetVerifiedUnknown(t *testing.T) {
+ repo := NewUserRepo(testPool(t))
+ if err := repo.SetVerified(context.Background(), uuid.New(), true); !errors.Is(err, usecase.ErrUserNotFound) {
+ t.Errorf("got %v, want ErrUserNotFound", err)
+ }
+}
+
+func TestUserRepoSetRole(t *testing.T) {
+ repo := NewUserRepo(testPool(t))
+ ctx := context.Background()
+ u := seedUser(t, repo)
+
+ if err := repo.SetRole(ctx, u.ID, domain.RoleAdmin); err != nil {
+ t.Fatalf("promote: %v", err)
+ }
+ got, _ := repo.GetByID(ctx, u.ID)
+ if got.Role != domain.RoleAdmin {
+ t.Errorf("role = %q, want admin", got.Role)
+ }
+
+ if err := repo.SetRole(ctx, uuid.New(), domain.RoleAdmin); !errors.Is(err, usecase.ErrUserNotFound) {
+ t.Errorf("unknown user: got %v, want ErrUserNotFound", err)
+ }
+}
diff --git a/users/internal/storage/postgres/retry.go b/users/internal/storage/postgres/retry.go
new file mode 100644
index 0000000..3bd35ce
--- /dev/null
+++ b/users/internal/storage/postgres/retry.go
@@ -0,0 +1,82 @@
+package postgres
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ "github.com/jackc/pgx/v5/pgconn"
+)
+
+// Transient PostgreSQL failures. Under concurrent writes these are expected
+// rather than exceptional: two service instances touching neighbouring rows can
+// deadlock or fail to serialize, and the correct response is to try again.
+const (
+ codeSerializationFailure = "40001"
+ codeDeadlockDetected = "40P01"
+ codeTooManyConnections = "53300"
+ codeCannotConnectNow = "57P03"
+)
+
+// Retry budget: short and bounded. A worker polling for tasks would rather get
+// a fast error and poll again than have its request hang for half a minute.
+const (
+ retryInitialInterval = 50 * time.Millisecond
+ retryMaxInterval = 1 * time.Second
+ retryMaxElapsedTime = 5 * time.Second
+)
+
+// isTransient reports whether err is worth retrying.
+//
+// The default is *not* to retry: a constraint violation or a syntax error will
+// fail identically every time, and retrying it only multiplies the damage.
+func isTransient(err error) bool {
+ if err == nil {
+ return false
+ }
+ // A cancelled caller does not want another attempt.
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return false
+ }
+
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) {
+ switch pgErr.Code {
+ case codeSerializationFailure, codeDeadlockDetected,
+ codeTooManyConnections, codeCannotConnectNow:
+ return true
+ default:
+ return false
+ }
+ }
+
+ // Connection-level trouble (dropped socket, closed pool). pgconn knows
+ // whether the query could have been executed before the failure — retrying
+ // a maybe-executed write would risk duplicating it.
+ return pgconn.SafeToRetry(err)
+}
+
+// withRetry runs op, retrying only transient database failures with
+// exponential backoff and jitter, and giving up as soon as ctx is done.
+//
+// Jitter matters here: without it, several instances that collide once will
+// retry in lockstep and collide again at exactly the same moment.
+func withRetry(ctx context.Context, op func(context.Context) error) error {
+ b := backoff.NewExponentialBackOff()
+ b.InitialInterval = retryInitialInterval
+ b.MaxInterval = retryMaxInterval
+ b.MaxElapsedTime = retryMaxElapsedTime
+ // RandomizationFactor defaults to 0.5, which is the jitter.
+
+ return backoff.Retry(func() error {
+ err := op(ctx)
+ if err == nil {
+ return nil
+ }
+ if !isTransient(err) {
+ return backoff.Permanent(err) // stop now, do not burn the budget
+ }
+ return err
+ }, backoff.WithContext(b, ctx))
+}
diff --git a/users/internal/storage/postgres/retry_test.go b/users/internal/storage/postgres/retry_test.go
new file mode 100644
index 0000000..bdf77ec
--- /dev/null
+++ b/users/internal/storage/postgres/retry_test.go
@@ -0,0 +1,98 @@
+package postgres
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgconn"
+)
+
+func TestIsTransient(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"nil", nil, false},
+ {"serialization failure", &pgconn.PgError{Code: codeSerializationFailure}, true},
+ {"deadlock", &pgconn.PgError{Code: codeDeadlockDetected}, true},
+ {"too many connections", &pgconn.PgError{Code: codeTooManyConnections}, true},
+ // A unique-violation repeats identically forever — retrying is pointless.
+ {"unique violation", &pgconn.PgError{Code: "23505"}, false},
+ {"syntax error", &pgconn.PgError{Code: "42601"}, false},
+ {"context cancelled", context.Canceled, false},
+ {"deadline exceeded", context.DeadlineExceeded, false},
+ {"unknown error", errors.New("boom"), false},
+ // Wrapping must not hide the cause: errors.As walks the chain.
+ {"wrapped deadlock", errors2Wrap(&pgconn.PgError{Code: codeDeadlockDetected}), true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isTransient(tt.err); got != tt.want {
+ t.Errorf("isTransient(%v) = %v, want %v", tt.err, got, tt.want)
+ }
+ })
+ }
+}
+
+func errors2Wrap(err error) error {
+ return errors.Join(errors.New("query failed"), err)
+}
+
+func TestWithRetrySucceedsAfterTransientFailures(t *testing.T) {
+ calls := 0
+ err := withRetry(context.Background(), func(context.Context) error {
+ calls++
+ if calls < 3 {
+ return &pgconn.PgError{Code: codeSerializationFailure}
+ }
+ return nil
+ })
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if calls != 3 {
+ t.Errorf("calls = %d, want 3", calls)
+ }
+}
+
+func TestWithRetryStopsOnPermanentError(t *testing.T) {
+ permanent := &pgconn.PgError{Code: "23505"} // unique violation
+ calls := 0
+
+ err := withRetry(context.Background(), func(context.Context) error {
+ calls++
+ return permanent
+ })
+
+ if !errors.Is(err, permanent) {
+ t.Errorf("err = %v, want the original error", err)
+ }
+ if calls != 1 {
+ t.Errorf("calls = %d, want 1 — a permanent error must not be retried", calls)
+ }
+}
+
+func TestWithRetryHonoursContextCancellation(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ calls := 0
+ start := time.Now()
+ err := withRetry(ctx, func(context.Context) error {
+ calls++
+ return &pgconn.PgError{Code: codeDeadlockDetected}
+ })
+
+ if err == nil {
+ t.Fatal("expected an error once the context expired")
+ }
+ // Must abort at the deadline, not run the full 5s retry budget.
+ if elapsed := time.Since(start); elapsed > time.Second {
+ t.Errorf("took %v, expected to stop at the context deadline", elapsed)
+ }
+}
diff --git a/users/internal/storage/postgres/tx.go b/users/internal/storage/postgres/tx.go
new file mode 100644
index 0000000..b5ccc1c
--- /dev/null
+++ b/users/internal/storage/postgres/tx.go
@@ -0,0 +1,83 @@
+// Package postgres implements the usecase repository ports on PostgreSQL.
+// SQL and pgx types never escape this package.
+package postgres
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// querier is satisfied by both *pgxpool.Pool and pgx.Tx, letting every
+// repository method run identically inside or outside a transaction.
+type querier interface {
+ Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
+ QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
+ Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
+ SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults
+}
+
+// txKey is an unexported struct type, so no other package can collide with it
+// or reach the transaction we stash in the context.
+type txKey struct{}
+
+// TxManager implements usecase.TxManager.
+type TxManager struct {
+ pool *pgxpool.Pool
+}
+
+func NewTxManager(pool *pgxpool.Pool) *TxManager {
+ return &TxManager{pool: pool}
+}
+
+// WithinTx runs fn inside one transaction, committing on success and rolling
+// back on any error or panic.
+//
+// The transaction travels in the context rather than in fn's signature, which
+// is what lets the usecase layer express "do these repository calls atomically"
+// without its port ever mentioning pgx.
+// Retrying happens here, around the whole transaction, and deliberately not
+// inside the repositories. Once Postgres aborts a transaction with a
+// serialization failure or deadlock, every further statement in it fails too —
+// replaying a single query would accomplish nothing. The unit of retry is
+// Begin → fn → Commit.
+//
+// This is safe because fn re-reads its rows (via GetForUpdate) on each attempt,
+// so a retry starts from the current state rather than stale entities.
+func (m *TxManager) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error {
+ if _, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
+ // Already inside a transaction — join it. Retrying here would be wrong
+ // twice over: the outer transaction owns the retry, and re-running fn
+ // alone cannot undo what the outer one already wrote.
+ return fn(ctx)
+ }
+
+ return withRetry(ctx, func(ctx context.Context) error {
+ return m.runTx(ctx, fn)
+ })
+}
+
+func (m *TxManager) runTx(ctx context.Context, fn func(ctx context.Context) error) error {
+ tx, err := m.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ // Rollback after a successful Commit is a no-op, so this defer is safe and
+ // also covers the panic path.
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ if err := fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+// conn returns the transaction bound to ctx, or the pool when there is none.
+func conn(ctx context.Context, pool *pgxpool.Pool) querier {
+ if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
+ return tx
+ }
+ return pool
+}
diff --git a/users/internal/storage/postgres/user_repo.go b/users/internal/storage/postgres/user_repo.go
new file mode 100644
index 0000000..cb786fd
--- /dev/null
+++ b/users/internal/storage/postgres/user_repo.go
@@ -0,0 +1,127 @@
+package postgres
+
+import (
+ "context"
+ "errors"
+
+ sq "github.com/Masterminds/squirrel"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+// uniqueViolation is PostgreSQL's SQLSTATE for a unique-constraint breach.
+const uniqueViolation = "23505"
+
+var userColumns = []string{"id", "email", "password_hash", "role", "verified", "created_at", "updated_at"}
+
+// UserRepo implements usecase.UserRepository on PostgreSQL.
+type UserRepo struct {
+ pool *pgxpool.Pool
+}
+
+func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
+ return &UserRepo{pool: pool}
+}
+
+func (r *UserRepo) Insert(ctx context.Context, u *domain.User) error {
+ sql, args, err := psql.Insert("users").
+ Columns(userColumns...).
+ Values(u.ID, u.Email, u.PasswordHash, string(u.Role), u.Verified, u.CreatedAt, u.UpdatedAt).
+ ToSql()
+ if err != nil {
+ return err
+ }
+ if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil {
+ // A concurrent insert of the same email surfaces as a unique violation
+ // on uq_users_email; translate it to the port's sentinel so the use
+ // case never sees a driver type.
+ if isUniqueViolation(err) {
+ return usecase.ErrEmailExists
+ }
+ return err
+ }
+ return nil
+}
+
+func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
+ return r.getBy(ctx, sq.Eq{"email": email})
+}
+
+func (r *UserRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
+ return r.getBy(ctx, sq.Eq{"id": id})
+}
+
+func (r *UserRepo) getBy(ctx context.Context, pred sq.Sqlizer) (*domain.User, error) {
+ sql, args, err := psql.Select(userColumns...).From("users").Where(pred).ToSql()
+ if err != nil {
+ return nil, err
+ }
+ return scanUser(conn(ctx, r.pool).QueryRow(ctx, sql, args...))
+}
+
+// SetVerified flips the verified flag and returns ErrUserNotFound when the id
+// matches no row (so an admin verifying a deleted user gets a clean 404).
+func (r *UserRepo) SetVerified(ctx context.Context, id uuid.UUID, verified bool) error {
+ sql, args, err := psql.Update("users").
+ Set("verified", verified).
+ Set("updated_at", sq.Expr("now()")).
+ Where(sq.Eq{"id": id}).
+ ToSql()
+ if err != nil {
+ return err
+ }
+ tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return usecase.ErrUserNotFound
+ }
+ return nil
+}
+
+// SetRole changes a user's role and returns ErrUserNotFound when the id matches
+// no row.
+func (r *UserRepo) SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error {
+ sql, args, err := psql.Update("users").
+ Set("role", string(role)).
+ Set("updated_at", sq.Expr("now()")).
+ Where(sq.Eq{"id": id}).
+ ToSql()
+ if err != nil {
+ return err
+ }
+ tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return usecase.ErrUserNotFound
+ }
+ return nil
+}
+
+func scanUser(row pgx.Row) (*domain.User, error) {
+ var (
+ u domain.User
+ role string
+ )
+ if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &u.Verified, &u.CreatedAt, &u.UpdatedAt); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, usecase.ErrUserNotFound
+ }
+ return nil, err
+ }
+ u.Role = domain.Role(role)
+ return &u, nil
+}
+
+func isUniqueViolation(err error) bool {
+ var pgErr *pgconn.PgError
+ return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
+}
diff --git a/users/internal/transport/http/dto.go b/users/internal/transport/http/dto.go
new file mode 100644
index 0000000..808ee3d
--- /dev/null
+++ b/users/internal/transport/http/dto.go
@@ -0,0 +1,43 @@
+package http
+
+import (
+ "time"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+// registerRequest / loginRequest are the JSON bodies clients POST. Kept separate
+// from the domain so the wire format can evolve without touching the entity.
+type registerRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+type loginRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+// userResponse is the public view of a user. It never carries the password hash.
+type userResponse struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ Verified bool `json:"verified"`
+ CreatedAt string `json:"created_at"`
+}
+
+type loginResponse struct {
+ Token string `json:"token"`
+ User userResponse `json:"user"`
+}
+
+func toUserResponse(u *domain.User) userResponse {
+ return userResponse{
+ ID: u.ID.String(),
+ Email: u.Email,
+ Role: string(u.Role),
+ Verified: u.Verified,
+ CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339),
+ }
+}
diff --git a/users/internal/transport/http/errors.go b/users/internal/transport/http/errors.go
new file mode 100644
index 0000000..8ccbe7e
--- /dev/null
+++ b/users/internal/transport/http/errors.go
@@ -0,0 +1,63 @@
+package http
+
+import (
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+// maxJSONBody caps a request body. Credentials are tiny; anything larger is a
+// mistake or an attack, so reject it before allocating.
+const maxJSONBody = 1 << 20 // 1 MiB
+
+type errorResponse struct {
+ Error string `json:"error"`
+ RequestID string `json:"request_id,omitempty"`
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+// writeError maps a domain or use-case error to an HTTP status and a safe
+// message, logging only genuine server faults (5xx). Client errors (4xx) are
+// expected and stay out of the error log.
+func writeError(w http.ResponseWriter, r *http.Request, log *slog.Logger, err error) {
+ status, msg := statusForError(err)
+ if status >= http.StatusInternalServerError {
+ log.Error("request failed",
+ "err", err,
+ "request_id", requestIDFrom(r.Context()),
+ "path", r.URL.Path,
+ )
+ }
+ writeJSON(w, status, errorResponse{Error: msg, RequestID: requestIDFrom(r.Context())})
+}
+
+func statusForError(err error) (int, string) {
+ switch {
+ case errors.Is(err, usecase.ErrEmailExists):
+ return http.StatusConflict, "email already registered"
+ case errors.Is(err, usecase.ErrInvalidCredentials):
+ return http.StatusUnauthorized, "invalid email or password"
+ case errors.Is(err, usecase.ErrUserNotFound):
+ return http.StatusNotFound, "user not found"
+ case errors.Is(err, usecase.ErrPasswordTooShort):
+ return http.StatusBadRequest, "password must be at least 8 characters"
+ case errors.Is(err, usecase.ErrPasswordTooLong):
+ return http.StatusBadRequest, "password must be at most 72 bytes"
+ case errors.Is(err, usecase.ErrInvalidRole):
+ return http.StatusBadRequest, "invalid role"
+ case errors.Is(err, domain.ErrEmptyEmail), errors.Is(err, domain.ErrInvalidEmail):
+ return http.StatusBadRequest, "email is not a valid address"
+ default:
+ // Don't leak internals; the real error is in the log under request_id.
+ return http.StatusInternalServerError, "internal error"
+ }
+}
diff --git a/users/internal/transport/http/handlers.go b/users/internal/transport/http/handlers.go
new file mode 100644
index 0000000..beaca96
--- /dev/null
+++ b/users/internal/transport/http/handlers.go
@@ -0,0 +1,131 @@
+package http
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+// Handlers holds the use cases each endpoint drives.
+type Handlers struct {
+ register *usecase.Register
+ login *usecase.Login
+ setVerified *usecase.SetVerified
+ setRole *usecase.SetRole
+ users usecase.UserRepository
+ log *slog.Logger
+}
+
+// handleHealth is an unauthenticated liveness probe for the container and load
+// balancer.
+func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// handleRegister creates an account. It returns 201 with the public user view,
+// 409 if the email is taken, or 400 on a malformed body / weak password.
+func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
+ var req registerRequest
+ if !decodeJSON(w, r, &req) {
+ return
+ }
+ u, err := h.register.Execute(r.Context(), req.Email, req.Password)
+ if err != nil {
+ writeError(w, r, h.log, err)
+ return
+ }
+ writeJSON(w, http.StatusCreated, toUserResponse(u))
+}
+
+// handleLogin verifies credentials and returns a signed token plus the user.
+func (h *Handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
+ var req loginRequest
+ if !decodeJSON(w, r, &req) {
+ return
+ }
+ token, u, err := h.login.Execute(r.Context(), req.Email, req.Password)
+ if err != nil {
+ writeError(w, r, h.log, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, loginResponse{Token: token, User: toUserResponse(u)})
+}
+
+// handleMe returns the caller's own account, proving the token works end to end.
+// It reads the user id the JWT middleware stashed in the context.
+func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) {
+ id, ok := userIDFrom(r.Context())
+ if !ok {
+ unauthorized(w, r)
+ return
+ }
+ u, err := h.users.GetByID(r.Context(), id)
+ if err != nil {
+ writeError(w, r, h.log, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, toUserResponse(u))
+}
+
+// handleSetVerified grants (verified=true) or revokes (false) the trusted-
+// contributor badge for the user in the path. Admin-only; the withAdmin
+// middleware has already enforced the role by the time this runs.
+func (h *Handlers) handleSetVerified(verified bool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, errorResponse{
+ Error: "invalid user id",
+ RequestID: requestIDFrom(r.Context()),
+ })
+ return
+ }
+ if err := h.setVerified.Execute(r.Context(), id, verified); err != nil {
+ writeError(w, r, h.log, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// handleSetRole promotes (admin) or demotes (user) the user in the path. Admin-
+// only; the withAdmin middleware has already enforced the caller's role.
+func (h *Handlers) handleSetRole(role domain.Role) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, errorResponse{
+ Error: "invalid user id",
+ RequestID: requestIDFrom(r.Context()),
+ })
+ return
+ }
+ if err := h.setRole.Execute(r.Context(), id, role); err != nil {
+ writeError(w, r, h.log, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields.
+// It writes a 400 and returns false on any problem, so callers can `if
+// !decodeJSON(...) { return }`.
+func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
+ r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
+ dec := json.NewDecoder(r.Body)
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(dst); err != nil {
+ writeJSON(w, http.StatusBadRequest, errorResponse{
+ Error: "invalid JSON body",
+ RequestID: requestIDFrom(r.Context()),
+ })
+ return false
+ }
+ return true
+}
diff --git a/users/internal/transport/http/middleware.go b/users/internal/transport/http/middleware.go
new file mode 100644
index 0000000..266b1b6
--- /dev/null
+++ b/users/internal/transport/http/middleware.go
@@ -0,0 +1,149 @@
+package http
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/auth"
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+type ctxKey string
+
+const (
+ requestIDKey ctxKey = "request_id"
+ userIDKey ctxKey = "user_id"
+ roleKey ctxKey = "role"
+)
+
+// withRequestID stamps every request with an ID for correlated logs and error
+// bodies. It wraps the auth middleware rather than the other way round, so even
+// a rejected request carries an ID the caller can quote in a bug report.
+func withRequestID(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id := newRequestID()
+ w.Header().Set("X-Request-ID", id)
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
+ })
+}
+
+func requestIDFrom(ctx context.Context) string {
+ if v, ok := ctx.Value(requestIDKey).(string); ok {
+ return v
+ }
+ return ""
+}
+
+func newRequestID() string {
+ var b [8]byte
+ _, _ = rand.Read(b[:])
+ return hex.EncodeToString(b[:])
+}
+
+// tokenVerifier is the slice of auth.Issuer the JWT middleware needs. Taking an
+// interface keeps the middleware testable with a stub verifier.
+type tokenVerifier interface {
+ Verify(token string) (*auth.Claims, error)
+}
+
+// withJWT verifies the Bearer token and stashes the caller's id and role in the
+// request context. It rejects any request without a valid, unexpired HS256
+// token — this is what protects endpoints that act on a specific user.
+func withJWT(v tokenVerifier) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
+ if raw == "" {
+ unauthorized(w, r)
+ return
+ }
+ claims, err := v.Verify(raw)
+ if err != nil {
+ unauthorized(w, r)
+ return
+ }
+ id, err := uuid.Parse(claims.Subject)
+ if err != nil {
+ unauthorized(w, r)
+ return
+ }
+ ctx := context.WithValue(r.Context(), userIDKey, id)
+ ctx = context.WithValue(ctx, roleKey, claims.Role)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+func unauthorized(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("WWW-Authenticate", "Bearer")
+ writeJSON(w, http.StatusUnauthorized, errorResponse{
+ Error: "unauthorized",
+ RequestID: requestIDFrom(r.Context()),
+ })
+}
+
+// userIDFrom returns the authenticated caller's id, set by withJWT.
+func userIDFrom(ctx context.Context) (uuid.UUID, bool) {
+ id, ok := ctx.Value(userIDKey).(uuid.UUID)
+ return id, ok
+}
+
+// withAdmin rejects any caller whose token role is not admin. It must sit inside
+// withJWT, which stamps the role after verifying the token.
+func withAdmin(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if role, ok := r.Context().Value(roleKey).(domain.Role); !ok || role != domain.RoleAdmin {
+ writeJSON(w, http.StatusForbidden, errorResponse{
+ Error: "admin role required",
+ RequestID: requestIDFrom(r.Context()),
+ })
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// statusRecorder captures the status code for the access log.
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+}
+
+func (s *statusRecorder) WriteHeader(code int) {
+ s.status = code
+ s.ResponseWriter.WriteHeader(code)
+}
+
+// withAccessLog records one structured line per request — the minimum needed to
+// debug a distributed system after the fact.
+func withAccessLog(log *slog.Logger) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rec, r)
+ log.Info("request",
+ "request_id", requestIDFrom(r.Context()),
+ "method", r.Method,
+ "path", r.URL.Path,
+ "status", rec.status,
+ "duration_ms", time.Since(start).Milliseconds(),
+ )
+ })
+ }
+}
+
+// chain applies middleware so that the first argument is the outermost layer.
+func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
+ for i := len(mw) - 1; i >= 0; i-- {
+ h = mw[i](h)
+ }
+ return h
+}
diff --git a/users/internal/transport/http/server.go b/users/internal/transport/http/server.go
new file mode 100644
index 0000000..3a57af7
--- /dev/null
+++ b/users/internal/transport/http/server.go
@@ -0,0 +1,57 @@
+// Package http exposes the userservice over HTTP: registration, login, and a
+// token-protected /me. It owns routing, request decoding, and error mapping;
+// business rules live in the usecase layer.
+package http
+
+import (
+ "log/slog"
+ "net/http"
+
+ "github.com/emil28092005/SciMesh/users/internal/auth"
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+// UseCases bundles the application services the handlers drive.
+type UseCases struct {
+ Register *usecase.Register
+ Login *usecase.Login
+ SetVerified *usecase.SetVerified
+ SetRole *usecase.SetRole
+ Users usecase.UserRepository
+}
+
+// NewServer wires the routes and the middleware stack and returns the handler.
+// The issuer verifies tokens for the JWT-protected routes.
+func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
+ h := &Handlers{
+ register: uc.Register,
+ login: uc.Login,
+ setVerified: uc.SetVerified,
+ setRole: uc.SetRole,
+ users: uc.Users,
+ log: log,
+ }
+
+ mux := http.NewServeMux()
+ // Method-aware patterns (Go 1.22+): a GET to /register is a 405, not a match.
+ mux.HandleFunc("GET /health", h.handleHealth)
+ mux.HandleFunc("POST /register", h.handleRegister)
+ mux.HandleFunc("POST /login", h.handleLogin)
+ // /me proves a token round-trips; it sits behind JWT auth.
+ mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer)))
+
+ // Admin-only: grant or revoke the trusted-contributor badge. withAdmin sits
+ // inside withJWT so the role is available from the verified token.
+ mux.Handle("POST /users/{id}/verify",
+ chain(h.handleSetVerified(true), withJWT(issuer), withAdmin))
+ mux.Handle("POST /users/{id}/unverify",
+ chain(h.handleSetVerified(false), withJWT(issuer), withAdmin))
+ mux.Handle("POST /users/{id}/promote",
+ chain(h.handleSetRole(domain.RoleAdmin), withJWT(issuer), withAdmin))
+ mux.Handle("POST /users/{id}/demote",
+ chain(h.handleSetRole(domain.RoleUser), withJWT(issuer), withAdmin))
+
+ // Outermost first: every request gets an ID and an access-log line.
+ return chain(mux, withRequestID, withAccessLog(log))
+}
diff --git a/users/internal/transport/http/server_test.go b/users/internal/transport/http/server_test.go
new file mode 100644
index 0000000..f0ee300
--- /dev/null
+++ b/users/internal/transport/http/server_test.go
@@ -0,0 +1,372 @@
+package http_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/auth"
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/memstore"
+ apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+const secret = "server-test-secret-32-bytes-long!!!!"
+
+func newTestServer() http.Handler {
+ users := memstore.NewUserRepo()
+ hasher := auth.NewHasher(4)
+ clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
+ // Real clock for the issuer so tokens are valid at verification time.
+ issuer := auth.NewIssuer(secret, time.Hour, nil)
+
+ uc := apihttp.UseCases{
+ Register: usecase.NewRegister(users, hasher, clk),
+ Login: usecase.NewLogin(users, hasher, issuer),
+ SetVerified: usecase.NewSetVerified(users),
+ SetRole: usecase.NewSetRole(users),
+ Users: users,
+ }
+ log := slog.New(slog.NewTextHandler(io.Discard, nil))
+ return apihttp.NewServer(log, uc, issuer)
+}
+
+func do(t *testing.T, h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ var buf bytes.Buffer
+ if body != nil {
+ if err := json.NewEncoder(&buf).Encode(body); err != nil {
+ t.Fatal(err)
+ }
+ }
+ req, err := http.NewRequestWithContext(context.Background(), method, path, &buf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestRegisterThenLoginThenMe(t *testing.T) {
+ h := newTestServer()
+ creds := map[string]string{"email": "flow@example.com", "password": "password123"}
+
+ // Register -> 201
+ rec := do(t, h, http.MethodPost, "/register", "", creds)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("register: got %d, body %s", rec.Code, rec.Body)
+ }
+
+ // Login -> 200 with a token
+ rec = do(t, h, http.MethodPost, "/login", "", creds)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("login: got %d, body %s", rec.Code, rec.Body)
+ }
+ var lr struct {
+ Token string `json:"token"`
+ User struct {
+ Email string `json:"email"`
+ Role string `json:"role"`
+ } `json:"user"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil {
+ t.Fatal(err)
+ }
+ if lr.Token == "" || lr.User.Email != "flow@example.com" || lr.User.Role != "user" {
+ t.Fatalf("unexpected login body: %+v", lr)
+ }
+
+ // /me with the token -> 200, same user
+ rec = do(t, h, http.MethodGet, "/me", lr.Token, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("me: got %d, body %s", rec.Code, rec.Body)
+ }
+ var me struct {
+ Email string `json:"email"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
+ t.Fatal(err)
+ }
+ if me.Email != "flow@example.com" {
+ t.Errorf("me email = %q", me.Email)
+ }
+}
+
+func TestRegisterDuplicate(t *testing.T) {
+ h := newTestServer()
+ creds := map[string]string{"email": "dup@example.com", "password": "password123"}
+ _ = do(t, h, http.MethodPost, "/register", "", creds)
+
+ rec := do(t, h, http.MethodPost, "/register", "", creds)
+ if rec.Code != http.StatusConflict {
+ t.Errorf("duplicate register: got %d, want 409", rec.Code)
+ }
+}
+
+func TestRegisterValidation(t *testing.T) {
+ h := newTestServer()
+ cases := []struct {
+ name string
+ body map[string]string
+ }{
+ {"weak password", map[string]string{"email": "a@b.com", "password": "short"}},
+ {"bad email", map[string]string{"email": "nope", "password": "password123"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := do(t, h, http.MethodPost, "/register", "", tc.body)
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("got %d, want 400", rec.Code)
+ }
+ })
+ }
+}
+
+func TestRegisterRejectsUnknownFields(t *testing.T) {
+ h := newTestServer()
+ rec := do(t, h, http.MethodPost, "/register", "", map[string]string{
+ "email": "a@b.com", "password": "password123", "role": "admin",
+ })
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("unknown field must be rejected: got %d", rec.Code)
+ }
+}
+
+func TestLoginWrongPassword(t *testing.T) {
+ h := newTestServer()
+ _ = do(t, h, http.MethodPost, "/register", "", map[string]string{
+ "email": "x@example.com", "password": "password123",
+ })
+ rec := do(t, h, http.MethodPost, "/login", "", map[string]string{
+ "email": "x@example.com", "password": "wrongpass1",
+ })
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("got %d, want 401", rec.Code)
+ }
+}
+
+func TestMeRequiresToken(t *testing.T) {
+ h := newTestServer()
+ if rec := do(t, h, http.MethodGet, "/me", "", nil); rec.Code != http.StatusUnauthorized {
+ t.Errorf("no token: got %d, want 401", rec.Code)
+ }
+ if rec := do(t, h, http.MethodGet, "/me", "garbage.token.here", nil); rec.Code != http.StatusUnauthorized {
+ t.Errorf("bad token: got %d, want 401", rec.Code)
+ }
+}
+
+func TestHealth(t *testing.T) {
+ h := newTestServer()
+ if rec := do(t, h, http.MethodGet, "/health", "", nil); rec.Code != http.StatusOK {
+ t.Errorf("health: got %d", rec.Code)
+ }
+}
+
+// failingUsers is a UserRepository whose reads fail with an unexpected (non-
+// sentinel) error, so the handler must map it to 500 and not leak internals.
+type failingUsers struct{ usecase.UserRepository }
+
+func (failingUsers) GetByID(context.Context, uuid.UUID) (*domain.User, error) {
+ return nil, errors.New("db exploded")
+}
+
+func TestMeInternalError(t *testing.T) {
+ hasher := auth.NewHasher(4)
+ clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
+ issuer := auth.NewIssuer(secret, time.Hour, nil)
+
+ users := failingUsers{UserRepository: memstore.NewUserRepo()}
+ uc := apihttp.UseCases{
+ Register: usecase.NewRegister(users, hasher, clk),
+ Login: usecase.NewLogin(users, hasher, issuer),
+ SetVerified: usecase.NewSetVerified(users),
+ SetRole: usecase.NewSetRole(users),
+ Users: users,
+ }
+ h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer)
+
+ // A structurally valid token for a caller the failing repo can't load.
+ token, err := issuer.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rec := do(t, h, http.MethodGet, "/me", token, nil)
+ if rec.Code != http.StatusInternalServerError {
+ t.Errorf("got %d, want 500", rec.Code)
+ }
+ // The body must not disclose the underlying error.
+ if bytes.Contains(rec.Body.Bytes(), []byte("db exploded")) {
+ t.Error("internal error leaked to the client")
+ }
+}
+
+// mintToken issues a token with the package secret for a synthetic caller of the
+// given role — enough to drive the admin-gated endpoints.
+func mintToken(t *testing.T, role domain.Role) string {
+ t.Helper()
+ token, err := auth.NewIssuer(secret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: role})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return token
+}
+
+// registerUser creates an account and returns its id.
+func registerUser(t *testing.T, h http.Handler, email string) string {
+ t.Helper()
+ rec := do(t, h, http.MethodPost, "/register", "", map[string]string{"email": email, "password": "password123"})
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("register: %d", rec.Code)
+ }
+ var reg struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), ®); err != nil {
+ t.Fatal(err)
+ }
+ return reg.ID
+}
+
+func TestAdminVerifiesUserEndToEnd(t *testing.T) {
+ h := newTestServer()
+ id := registerUser(t, h, "contrib@example.com")
+
+ // Admin grants the badge.
+ rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleAdmin), nil)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("admin verify: got %d, body %s", rec.Code, rec.Body)
+ }
+
+ // The change is visible when the contributor logs in.
+ rec = do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "contrib@example.com", "password": "password123"})
+ var lr struct {
+ User struct {
+ Verified bool `json:"verified"`
+ } `json:"user"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil {
+ t.Fatal(err)
+ }
+ if !lr.User.Verified {
+ t.Error("verified badge not reflected after admin granted it")
+ }
+}
+
+func TestVerifyRequiresAdminRole(t *testing.T) {
+ h := newTestServer()
+ id := registerUser(t, h, "someone@example.com")
+
+ // A plain user token must not be able to grant the badge.
+ rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleUser), nil)
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("plain user: got %d, want 403", rec.Code)
+ }
+}
+
+func TestVerifyRequiresAuth(t *testing.T) {
+ h := newTestServer()
+ rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", "", nil)
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("no token: got %d, want 401", rec.Code)
+ }
+}
+
+func TestVerifyInvalidID(t *testing.T) {
+ h := newTestServer()
+ rec := do(t, h, http.MethodPost, "/users/not-a-uuid/verify", mintToken(t, domain.RoleAdmin), nil)
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("bad id: got %d, want 400", rec.Code)
+ }
+}
+
+func TestVerifyUnknownUser(t *testing.T) {
+ h := newTestServer()
+ rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", mintToken(t, domain.RoleAdmin), nil)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("unknown user: got %d, want 404", rec.Code)
+ }
+}
+
+func TestAdminPromotesAndDemotes(t *testing.T) {
+ h := newTestServer()
+ id := registerUser(t, h, "promote@example.com")
+ admin := mintToken(t, domain.RoleAdmin)
+
+ if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", admin, nil); rec.Code != http.StatusNoContent {
+ t.Fatalf("promote: got %d, body %s", rec.Code, rec.Body)
+ }
+ // The promoted user now logs in as an admin.
+ rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "promote@example.com", "password": "password123"})
+ var lr struct {
+ User struct {
+ Role string `json:"role"`
+ } `json:"user"`
+ }
+ _ = json.Unmarshal(rec.Body.Bytes(), &lr)
+ if lr.User.Role != "admin" {
+ t.Errorf("role after promote = %q, want admin", lr.User.Role)
+ }
+
+ if rec := do(t, h, http.MethodPost, "/users/"+id+"/demote", admin, nil); rec.Code != http.StatusNoContent {
+ t.Fatalf("demote: got %d", rec.Code)
+ }
+}
+
+func TestPromoteRequiresAdmin(t *testing.T) {
+ h := newTestServer()
+ id := registerUser(t, h, "target@example.com")
+
+ if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden {
+ t.Errorf("plain user promote: got %d, want 403", rec.Code)
+ }
+ if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", "", nil); rec.Code != http.StatusUnauthorized {
+ t.Errorf("no token: got %d, want 401", rec.Code)
+ }
+}
+
+func TestPromoteUnknownUser(t *testing.T) {
+ h := newTestServer()
+ rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/promote", mintToken(t, domain.RoleAdmin), nil)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("unknown user promote: got %d, want 404", rec.Code)
+ }
+}
+
+func TestUnverifyRevokes(t *testing.T) {
+ h := newTestServer()
+ id := registerUser(t, h, "revoke@example.com")
+ admin := mintToken(t, domain.RoleAdmin)
+
+ if rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", admin, nil); rec.Code != http.StatusNoContent {
+ t.Fatalf("verify: %d", rec.Code)
+ }
+ if rec := do(t, h, http.MethodPost, "/users/"+id+"/unverify", admin, nil); rec.Code != http.StatusNoContent {
+ t.Fatalf("unverify: %d", rec.Code)
+ }
+
+ rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "revoke@example.com", "password": "password123"})
+ var lr struct {
+ User struct {
+ Verified bool `json:"verified"`
+ } `json:"user"`
+ }
+ _ = json.Unmarshal(rec.Body.Bytes(), &lr)
+ if lr.User.Verified {
+ t.Error("verified should be false after unverify")
+ }
+}
diff --git a/users/internal/usecase/bootstrap.go b/users/internal/usecase/bootstrap.go
new file mode 100644
index 0000000..37b602f
--- /dev/null
+++ b/users/internal/usecase/bootstrap.go
@@ -0,0 +1,65 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+// BootstrapAdmin seeds the first admin account. It exists because there is no
+// other way to create one: /register always makes a plain user, and promoting a
+// user to admin requires an already-existing admin. Running it at startup with
+// operator-supplied credentials breaks that chicken-and-egg.
+type BootstrapAdmin struct {
+ users UserRepository
+ hasher PasswordHasher
+ clk Clock
+}
+
+func NewBootstrapAdmin(users UserRepository, hasher PasswordHasher, clk Clock) *BootstrapAdmin {
+ return &BootstrapAdmin{users: users, hasher: hasher, clk: clk}
+}
+
+// Execute creates the admin if it does not already exist, reporting whether it
+// created one. It is idempotent: a second run (a restart) finds the account and
+// does nothing, so it is safe to call on every boot.
+func (uc *BootstrapAdmin) Execute(ctx context.Context, email, password string) (created bool, err error) {
+ email = domain.NormalizeEmail(email)
+
+ if _, err := uc.users.GetByEmail(ctx, email); err == nil {
+ return false, nil // already bootstrapped
+ } else if !errors.Is(err, ErrUserNotFound) {
+ return false, err
+ }
+
+ if len(password) < minPasswordLen {
+ return false, ErrPasswordTooShort
+ }
+ if len(password) > maxPasswordLen {
+ return false, ErrPasswordTooLong
+ }
+
+ hash, err := uc.hasher.Hash(password)
+ if err != nil {
+ return false, err
+ }
+ u, err := domain.NewUser(email, hash, uc.clk.Now())
+ if err != nil {
+ return false, err
+ }
+ // Direct role assignment is safe here: this is a trusted server-side seed,
+ // not a request. A root admin is also a trusted contributor.
+ u.Role = domain.RoleAdmin
+ u.Verified = true
+
+ if err := uc.users.Insert(ctx, u); err != nil {
+ // A concurrent bootstrap (two replicas booting at once) is fine: whoever
+ // lost the race just observes the account now exists.
+ if errors.Is(err, ErrEmailExists) {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
diff --git a/users/internal/usecase/bootstrap_test.go b/users/internal/usecase/bootstrap_test.go
new file mode 100644
index 0000000..a8ce1a2
--- /dev/null
+++ b/users/internal/usecase/bootstrap_test.go
@@ -0,0 +1,71 @@
+package usecase_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/emil28092005/SciMesh/users/internal/auth"
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/memstore"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+func newBootstrap() (*usecase.BootstrapAdmin, *memstore.UserRepo) {
+ users := memstore.NewUserRepo()
+ hasher := auth.NewHasher(4)
+ clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
+ return usecase.NewBootstrapAdmin(users, hasher, clk), users
+}
+
+func TestBootstrapCreatesAdmin(t *testing.T) {
+ bs, users := newBootstrap()
+
+ created, err := bs.Execute(context.Background(), "Root@Example.com", "rootpassword")
+ if err != nil {
+ t.Fatalf("bootstrap: %v", err)
+ }
+ if !created {
+ t.Fatal("expected an admin to be created")
+ }
+
+ u, err := users.GetByEmail(context.Background(), "root@example.com")
+ if err != nil {
+ t.Fatalf("admin not persisted: %v", err)
+ }
+ if u.Role != domain.RoleAdmin {
+ t.Errorf("role = %q, want admin", u.Role)
+ }
+ if !u.Verified {
+ t.Error("bootstrap admin should be verified")
+ }
+}
+
+func TestBootstrapIsIdempotent(t *testing.T) {
+ bs, users := newBootstrap()
+ ctx := context.Background()
+
+ if _, err := bs.Execute(ctx, "root@example.com", "rootpassword"); err != nil {
+ t.Fatal(err)
+ }
+ created, err := bs.Execute(ctx, "root@example.com", "rootpassword")
+ if err != nil {
+ t.Fatalf("second run: %v", err)
+ }
+ if created {
+ t.Error("second run must not create a duplicate admin")
+ }
+
+ // The account must still be a single admin.
+ if u, _ := users.GetByEmail(ctx, "root@example.com"); u.Role != domain.RoleAdmin {
+ t.Errorf("role changed: %q", u.Role)
+ }
+}
+
+func TestBootstrapRejectsWeakPassword(t *testing.T) {
+ bs, _ := newBootstrap()
+ if _, err := bs.Execute(context.Background(), "root@example.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) {
+ t.Errorf("got %v, want ErrPasswordTooShort", err)
+ }
+}
diff --git a/users/internal/usecase/errorpaths_test.go b/users/internal/usecase/errorpaths_test.go
new file mode 100644
index 0000000..4fa9a1e
--- /dev/null
+++ b/users/internal/usecase/errorpaths_test.go
@@ -0,0 +1,109 @@
+package usecase_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+// These stubs let a test inject failures the happy-path memstore never produces,
+// so the use cases' error branches are exercised too.
+
+var errBoom = errors.New("boom")
+
+type stubRepo struct {
+ getByEmail func() (*domain.User, error)
+ insert func() error
+}
+
+func (s stubRepo) Insert(context.Context, *domain.User) error { return s.insert() }
+func (s stubRepo) GetByEmail(context.Context, string) (*domain.User, error) {
+ return s.getByEmail()
+}
+func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) {
+ return nil, usecase.ErrUserNotFound
+}
+func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error {
+ return usecase.ErrUserNotFound
+}
+func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error {
+ return usecase.ErrUserNotFound
+}
+
+type stubHasher struct {
+ hashErr error
+ compareErr error
+}
+
+func (s stubHasher) Hash(string) (string, error) {
+ if s.hashErr != nil {
+ return "", s.hashErr
+ }
+ return "hashed", nil
+}
+func (s stubHasher) Compare(string, string) error { return s.compareErr }
+
+type stubIssuer struct{ err error }
+
+func (s stubIssuer) Issue(*domain.User) (string, error) {
+ if s.err != nil {
+ return "", s.err
+ }
+ return "token", nil
+}
+
+func TestRegisterPropagatesHasherError(t *testing.T) {
+ clk := stubClock{time.Now()}
+ reg := usecase.NewRegister(stubRepo{}, stubHasher{hashErr: errBoom}, clk)
+
+ _, err := reg.Execute(context.Background(), "a@b.com", "password123")
+ if !errors.Is(err, errBoom) {
+ t.Errorf("got %v, want errBoom", err)
+ }
+}
+
+func TestRegisterPropagatesInsertError(t *testing.T) {
+ clk := stubClock{time.Now()}
+ repo := stubRepo{insert: func() error { return errBoom }}
+ reg := usecase.NewRegister(repo, stubHasher{}, clk)
+
+ _, err := reg.Execute(context.Background(), "a@b.com", "password123")
+ if !errors.Is(err, errBoom) {
+ t.Errorf("got %v, want errBoom", err)
+ }
+}
+
+func TestLoginPropagatesRepoError(t *testing.T) {
+ // A non-ErrUserNotFound repo error must surface as-is, not be masked as
+ // ErrInvalidCredentials.
+ repo := stubRepo{getByEmail: func() (*domain.User, error) { return nil, errBoom }}
+ login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{})
+
+ _, _, err := login.Execute(context.Background(), "a@b.com", "password123")
+ if !errors.Is(err, errBoom) {
+ t.Errorf("got %v, want errBoom", err)
+ }
+}
+
+func TestLoginPropagatesIssuerError(t *testing.T) {
+ repo := stubRepo{getByEmail: func() (*domain.User, error) {
+ return &domain.User{ID: uuid.New(), Email: "a@b.com", Role: domain.RoleUser}, nil
+ }}
+ // Hasher accepts the password (nil compareErr) so we reach token issuance.
+ login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{err: errBoom})
+
+ _, _, err := login.Execute(context.Background(), "a@b.com", "password123")
+ if !errors.Is(err, errBoom) {
+ t.Errorf("got %v, want errBoom", err)
+ }
+}
+
+type stubClock struct{ t time.Time }
+
+func (c stubClock) Now() time.Time { return c.t }
diff --git a/users/internal/usecase/errors.go b/users/internal/usecase/errors.go
new file mode 100644
index 0000000..9a0b3e2
--- /dev/null
+++ b/users/internal/usecase/errors.go
@@ -0,0 +1,19 @@
+package usecase
+
+import "errors"
+
+var (
+ // Repository-contract errors, returned by UserRepository implementations.
+ ErrEmailExists = errors.New("email already registered")
+ ErrUserNotFound = errors.New("user not found")
+
+ // Use-case errors surfaced to the transport layer.
+ //
+ // ErrInvalidCredentials is deliberately returned for both an unknown email
+ // and a wrong password, so an attacker cannot use the response to learn
+ // which emails are registered.
+ ErrInvalidCredentials = errors.New("invalid email or password")
+ ErrPasswordTooShort = errors.New("password too short")
+ ErrPasswordTooLong = errors.New("password too long")
+ ErrInvalidRole = errors.New("invalid role")
+)
diff --git a/users/internal/usecase/login.go b/users/internal/usecase/login.go
new file mode 100644
index 0000000..9b8351d
--- /dev/null
+++ b/users/internal/usecase/login.go
@@ -0,0 +1,42 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+// Login verifies credentials and issues a signed token.
+type Login struct {
+ users UserRepository
+ hasher PasswordHasher
+ tokens TokenIssuer
+}
+
+func NewLogin(users UserRepository, hasher PasswordHasher, tokens TokenIssuer) *Login {
+ return &Login{users: users, hasher: hasher, tokens: tokens}
+}
+
+// Execute returns a signed token and the user on success. It returns
+// ErrInvalidCredentials for both an unknown email and a wrong password so the
+// two cases are indistinguishable to a caller probing for valid accounts.
+func (l *Login) Execute(ctx context.Context, email, password string) (string, *domain.User, error) {
+ u, err := l.users.GetByEmail(ctx, domain.NormalizeEmail(email))
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ return "", nil, ErrInvalidCredentials
+ }
+ return "", nil, err
+ }
+
+ if err := l.hasher.Compare(u.PasswordHash, password); err != nil {
+ return "", nil, ErrInvalidCredentials
+ }
+
+ token, err := l.tokens.Issue(u)
+ if err != nil {
+ return "", nil, err
+ }
+ return token, u, nil
+}
diff --git a/users/internal/usecase/ports.go b/users/internal/usecase/ports.go
new file mode 100644
index 0000000..d94bfd1
--- /dev/null
+++ b/users/internal/usecase/ports.go
@@ -0,0 +1,48 @@
+// Package usecase holds the application logic — registration and login — plus
+// the ports (interfaces) it depends on. The concrete adapters (PostgreSQL,
+// bcrypt, JWT) are injected from cmd, so this package never imports them.
+package usecase
+
+import (
+ "context"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+// UserRepository persists and looks up users. Implementations return the
+// sentinel errors in errors.go so the use cases can react without knowing about
+// SQL or driver types.
+type UserRepository interface {
+ // Insert stores a new user, returning ErrEmailExists if the email is taken.
+ Insert(ctx context.Context, u *domain.User) error
+ // GetByEmail returns the user with the (normalised) email, or ErrUserNotFound.
+ GetByEmail(ctx context.Context, email string) (*domain.User, error)
+ // GetByID returns the user with id, or ErrUserNotFound.
+ GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error)
+ // SetVerified toggles the verified flag, returning ErrUserNotFound if no
+ // such user exists.
+ SetVerified(ctx context.Context, id uuid.UUID, verified bool) error
+ // SetRole changes a user's role, returning ErrUserNotFound if no such user
+ // exists.
+ SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error
+}
+
+// PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it.
+type PasswordHasher interface {
+ Hash(password string) (string, error)
+ Compare(hash, password string) error
+}
+
+// TokenIssuer mints a signed access token for an authenticated user. It takes
+// the whole user so trust-bearing claims (role, verified) travel in the token.
+type TokenIssuer interface {
+ Issue(u *domain.User) (string, error)
+}
+
+// Clock reads the current time; a fake one makes tests deterministic.
+type Clock interface {
+ Now() time.Time
+}
diff --git a/users/internal/usecase/register.go b/users/internal/usecase/register.go
new file mode 100644
index 0000000..1eef927
--- /dev/null
+++ b/users/internal/usecase/register.go
@@ -0,0 +1,56 @@
+package usecase
+
+import (
+ "context"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+const (
+ // minPasswordLen is a floor, not a policy engine — enough to reject the
+ // obviously weak without pretending to measure real strength.
+ minPasswordLen = 8
+ // maxPasswordLen is bcrypt's hard input limit: it ignores bytes past 72, so
+ // accepting a longer password would silently hash only its prefix.
+ maxPasswordLen = 72
+)
+
+// Register creates a new account: it validates the password, hashes it, builds
+// the domain user, and persists it.
+type Register struct {
+ users UserRepository
+ hasher PasswordHasher
+ clk Clock
+}
+
+func NewRegister(users UserRepository, hasher PasswordHasher, clk Clock) *Register {
+ return &Register{users: users, hasher: hasher, clk: clk}
+}
+
+// Execute registers email/password and returns the persisted user. The returned
+// user carries no plaintext password, only its hash.
+func (r *Register) Execute(ctx context.Context, email, password string) (*domain.User, error) {
+ if len(password) < minPasswordLen {
+ return nil, ErrPasswordTooShort
+ }
+ if len(password) > maxPasswordLen {
+ return nil, ErrPasswordTooLong
+ }
+
+ hash, err := r.hasher.Hash(password)
+ if err != nil {
+ return nil, err
+ }
+
+ // NewUser normalises the email and enforces its shape; it returns a domain
+ // validation error the transport layer maps to 400.
+ u, err := domain.NewUser(email, hash, r.clk.Now())
+ if err != nil {
+ return nil, err
+ }
+
+ if err := r.users.Insert(ctx, u); err != nil {
+ return nil, err
+ }
+ return u, nil
+}
diff --git a/users/internal/usecase/role.go b/users/internal/usecase/role.go
new file mode 100644
index 0000000..b756c47
--- /dev/null
+++ b/users/internal/usecase/role.go
@@ -0,0 +1,28 @@
+package usecase
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+)
+
+// SetRole promotes or demotes a user. Only an admin may call this (enforced in
+// the transport layer); the use case validates the target role and applies it.
+type SetRole struct {
+ users UserRepository
+}
+
+func NewSetRole(users UserRepository) *SetRole {
+ return &SetRole{users: users}
+}
+
+// Execute assigns role to the user, returning ErrInvalidRole for an unknown role
+// or ErrUserNotFound if the user does not exist.
+func (uc *SetRole) Execute(ctx context.Context, id uuid.UUID, role domain.Role) error {
+ if !role.Valid() {
+ return ErrInvalidRole
+ }
+ return uc.users.SetRole(ctx, id, role)
+}
diff --git a/users/internal/usecase/usecase_test.go b/users/internal/usecase/usecase_test.go
new file mode 100644
index 0000000..2e470b8
--- /dev/null
+++ b/users/internal/usecase/usecase_test.go
@@ -0,0 +1,133 @@
+package usecase_test
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/emil28092005/SciMesh/users/internal/auth"
+ "github.com/emil28092005/SciMesh/users/internal/domain"
+ "github.com/emil28092005/SciMesh/users/internal/memstore"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+const secret = "usecase-test-secret-32-bytes-long!!!"
+
+func newFixtures() (*usecase.Register, *usecase.Login, *memstore.UserRepo) {
+ users := memstore.NewUserRepo()
+ hasher := auth.NewHasher(4) // low cost keeps tests fast
+ clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
+ // The issuer uses the real clock (nil): token expiry is validated against
+ // wall-clock time, so a fixed issue-time would make tokens instantly stale.
+ issuer := auth.NewIssuer(secret, time.Hour, nil)
+
+ reg := usecase.NewRegister(users, hasher, clk)
+ login := usecase.NewLogin(users, hasher, issuer)
+ return reg, login, users
+}
+
+func TestRegisterSuccess(t *testing.T) {
+ reg, _, users := newFixtures()
+
+ u, err := reg.Execute(context.Background(), "Alice@Example.com", "password123")
+ if err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ if u.Email != "alice@example.com" {
+ t.Errorf("email not normalised: %q", u.Email)
+ }
+ if u.Role != domain.RoleUser {
+ t.Errorf("role = %q, want user", u.Role)
+ }
+ if strings.Contains(u.PasswordHash, "password123") {
+ t.Error("password stored in cleartext")
+ }
+ if _, err := users.GetByEmail(context.Background(), "alice@example.com"); err != nil {
+ t.Errorf("user not persisted: %v", err)
+ }
+}
+
+func TestRegisterDuplicateEmail(t *testing.T) {
+ reg, _, _ := newFixtures()
+ ctx := context.Background()
+
+ if _, err := reg.Execute(ctx, "dup@example.com", "password123"); err != nil {
+ t.Fatalf("first register: %v", err)
+ }
+ _, err := reg.Execute(ctx, "Dup@example.com", "password123") // different case, same email
+ if !errors.Is(err, usecase.ErrEmailExists) {
+ t.Errorf("got %v, want ErrEmailExists", err)
+ }
+}
+
+func TestRegisterPasswordPolicy(t *testing.T) {
+ reg, _, _ := newFixtures()
+ ctx := context.Background()
+
+ if _, err := reg.Execute(ctx, "a@b.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) {
+ t.Errorf("short password: got %v", err)
+ }
+ long := strings.Repeat("x", 73)
+ if _, err := reg.Execute(ctx, "a@b.com", long); !errors.Is(err, usecase.ErrPasswordTooLong) {
+ t.Errorf("long password: got %v", err)
+ }
+}
+
+func TestRegisterInvalidEmail(t *testing.T) {
+ reg, _, _ := newFixtures()
+ _, err := reg.Execute(context.Background(), "not-an-email", "password123")
+ if !errors.Is(err, domain.ErrInvalidEmail) {
+ t.Errorf("got %v, want ErrInvalidEmail", err)
+ }
+}
+
+func TestLoginSuccess(t *testing.T) {
+ reg, login, _ := newFixtures()
+ ctx := context.Background()
+ if _, err := reg.Execute(ctx, "user@example.com", "password123"); err != nil {
+ t.Fatal(err)
+ }
+
+ token, u, err := login.Execute(ctx, "User@Example.com", "password123")
+ if err != nil {
+ t.Fatalf("login: %v", err)
+ }
+ if token == "" {
+ t.Error("empty token")
+ }
+ if u.Email != "user@example.com" {
+ t.Errorf("wrong user returned: %q", u.Email)
+ }
+
+ // The token must verify and carry this user's id.
+ claims, err := auth.NewIssuer(secret, time.Hour, nil).Verify(token)
+ if err != nil {
+ t.Fatalf("issued token does not verify: %v", err)
+ }
+ if claims.Subject != u.ID.String() {
+ t.Errorf("token sub = %q, want %q", claims.Subject, u.ID.String())
+ }
+}
+
+func TestLoginWrongPassword(t *testing.T) {
+ reg, login, _ := newFixtures()
+ ctx := context.Background()
+ if _, err := reg.Execute(ctx, "user@example.com", "password123"); err != nil {
+ t.Fatal(err)
+ }
+
+ _, _, err := login.Execute(ctx, "user@example.com", "wrongpass1")
+ if !errors.Is(err, usecase.ErrInvalidCredentials) {
+ t.Errorf("got %v, want ErrInvalidCredentials", err)
+ }
+}
+
+func TestLoginUnknownEmailIsIndistinguishable(t *testing.T) {
+ _, login, _ := newFixtures()
+ _, _, err := login.Execute(context.Background(), "ghost@example.com", "password123")
+ if !errors.Is(err, usecase.ErrInvalidCredentials) {
+ t.Errorf("unknown email must return ErrInvalidCredentials, got %v", err)
+ }
+}
diff --git a/users/internal/usecase/verify.go b/users/internal/usecase/verify.go
new file mode 100644
index 0000000..2a4fc94
--- /dev/null
+++ b/users/internal/usecase/verify.go
@@ -0,0 +1,24 @@
+package usecase
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+)
+
+// SetVerified grants or revokes a user's trusted-contributor badge. Only an
+// admin may call this (enforced in the transport layer); the use case itself
+// just applies the change.
+type SetVerified struct {
+ users UserRepository
+}
+
+func NewSetVerified(users UserRepository) *SetVerified {
+ return &SetVerified{users: users}
+}
+
+// Execute sets the verified flag on the target user, returning ErrUserNotFound
+// if the user does not exist.
+func (uc *SetVerified) Execute(ctx context.Context, id uuid.UUID, verified bool) error {
+ return uc.users.SetVerified(ctx, id, verified)
+}
diff --git a/users/internal/usecase/verify_test.go b/users/internal/usecase/verify_test.go
new file mode 100644
index 0000000..1932ba6
--- /dev/null
+++ b/users/internal/usecase/verify_test.go
@@ -0,0 +1,73 @@
+package usecase_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/users/internal/memstore"
+ "github.com/emil28092005/SciMesh/users/internal/usecase"
+)
+
+func TestSetVerifiedGrantsAndRevokes(t *testing.T) {
+ reg, _, users := newFixtures()
+ ctx := context.Background()
+
+ u, err := reg.Execute(ctx, "contrib@example.com", "password123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.Verified {
+ t.Fatal("a fresh account must be unverified")
+ }
+
+ sv := usecase.NewSetVerified(users)
+
+ if err := sv.Execute(ctx, u.ID, true); err != nil {
+ t.Fatalf("grant: %v", err)
+ }
+ got, _ := users.GetByID(ctx, u.ID)
+ if !got.Verified {
+ t.Error("verified flag not set")
+ }
+
+ if err := sv.Execute(ctx, u.ID, false); err != nil {
+ t.Fatalf("revoke: %v", err)
+ }
+ got, _ = users.GetByID(ctx, u.ID)
+ if got.Verified {
+ t.Error("verified flag not cleared")
+ }
+}
+
+func TestSetVerifiedUnknownUser(t *testing.T) {
+ users := memstore.NewUserRepo()
+ sv := usecase.NewSetVerified(users)
+
+ if err := sv.Execute(context.Background(), uuid.New(), true); !errors.Is(err, usecase.ErrUserNotFound) {
+ t.Errorf("got %v, want ErrUserNotFound", err)
+ }
+}
+
+func TestLoginTokenCarriesVerified(t *testing.T) {
+ reg, login, users := newFixtures()
+ ctx := context.Background()
+
+ u, err := reg.Execute(ctx, "trusted@example.com", "password123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := usecase.NewSetVerified(users).Execute(ctx, u.ID, true); err != nil {
+ t.Fatal(err)
+ }
+
+ _, loggedIn, err := login.Execute(ctx, "trusted@example.com", "password123")
+ if err != nil {
+ t.Fatalf("login: %v", err)
+ }
+ if !loggedIn.Verified {
+ t.Error("login must reflect the granted verified flag")
+ }
+}
diff --git a/users/migrations/0001_users.down.sql b/users/migrations/0001_users.down.sql
new file mode 100644
index 0000000..86f518c
--- /dev/null
+++ b/users/migrations/0001_users.down.sql
@@ -0,0 +1,6 @@
+BEGIN;
+
+DROP TABLE IF EXISTS users;
+DROP TYPE IF EXISTS user_role;
+
+COMMIT;
diff --git a/users/migrations/0001_users.up.sql b/users/migrations/0001_users.up.sql
new file mode 100644
index 0000000..f0a76f1
--- /dev/null
+++ b/users/migrations/0001_users.up.sql
@@ -0,0 +1,26 @@
+BEGIN;
+
+-- Static permission bundles. Roles rarely change, so the role→permission
+-- mapping lives in code (auth middleware), not in a table. New role = deploy.
+CREATE TYPE user_role AS ENUM ('user','admin');
+
+-- One human account. The id is the stable identity that ends up in the JWT
+-- `sub` claim; the coordinator stores it as jobs.owner_id.
+CREATE TABLE users (
+ id uuid PRIMARY KEY,
+ -- Login handle. App lowercases before insert/lookup, so uniqueness is
+ -- case-insensitive in practice.
+ email text NOT NULL,
+ -- Output of bcrypt/argon2. The salt and cost parameters are embedded in
+ -- this string, so there is NO separate salt column to store.
+ password_hash text NOT NULL,
+ role user_role NOT NULL DEFAULT 'user',
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ -- No two accounts share a login.
+ CONSTRAINT uq_users_email UNIQUE (email),
+ CONSTRAINT ck_users_email_lower CHECK (email = lower(email))
+);
+
+COMMIT;
diff --git a/users/migrations/0002_user_verified.down.sql b/users/migrations/0002_user_verified.down.sql
new file mode 100644
index 0000000..b52a55a
--- /dev/null
+++ b/users/migrations/0002_user_verified.down.sql
@@ -0,0 +1,5 @@
+BEGIN;
+
+ALTER TABLE users DROP COLUMN IF EXISTS verified;
+
+COMMIT;
diff --git a/users/migrations/0002_user_verified.up.sql b/users/migrations/0002_user_verified.up.sql
new file mode 100644
index 0000000..7b8fe9e
--- /dev/null
+++ b/users/migrations/0002_user_verified.up.sql
@@ -0,0 +1,9 @@
+BEGIN;
+
+-- A "verified" account is a trusted contributor: the coordinator accepts its
+-- workers' results directly, without quorum cross-checking. Distinct from role
+-- (which governs what a user may do with their own jobs). Granted by an admin,
+-- never self-served; defaults to false, so a fresh account is untrusted.
+ALTER TABLE users ADD COLUMN verified boolean NOT NULL DEFAULT false;
+
+COMMIT;
diff --git a/users/scripts/smoke.sh b/users/scripts/smoke.sh
new file mode 100755
index 0000000..d2e3614
--- /dev/null
+++ b/users/scripts/smoke.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# End-to-end smoke test against a running userservice. Exercises the full auth
+# flow and exits non-zero on the first unexpected status.
+#
+# HOST=http://localhost:8081 ./scripts/smoke.sh
+set -euo pipefail
+
+HOST="${HOST:-http://localhost:8081}"
+EMAIL="smoke-$(date +%s)-$RANDOM@example.com"
+PASSWORD="password123"
+
+pass() { printf ' ok %s\n' "$1"; }
+fail() { printf ' FAIL %s\n' "$1" >&2; exit 1; }
+
+# expect METHOD PATH WANT_STATUS [JSON_BODY] [BEARER]
+# Prints the response body to stdout so callers can parse it.
+expect() {
+ local method="$1" path="$2" want="$3" body="${4:-}" token="${5:-}"
+ local args=(-s -o /tmp/smoke_body -w '%{http_code}' -X "$method" "$HOST$path")
+ [ -n "$body" ] && args+=(-H 'Content-Type: application/json' -d "$body")
+ [ -n "$token" ] && args+=(-H "Authorization: Bearer $token")
+ local code
+ code="$(curl "${args[@]}")"
+ if [ "$code" != "$want" ]; then
+ printf 'body: %s\n' "$(cat /tmp/smoke_body)" >&2
+ fail "$method $path -> $code (want $want)"
+ fi
+ pass "$method $path -> $code"
+ cat /tmp/smoke_body
+}
+
+echo "smoke: $HOST (user $EMAIL)"
+
+expect GET /health 200 >/dev/null
+
+expect POST /register 201 "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" >/dev/null
+expect POST /register 409 "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" >/dev/null
+expect POST /register 400 "{\"email\":\"$EMAIL\",\"password\":\"short\"}" >/dev/null
+
+# Login and capture the token (extract the "token" JSON string field).
+login_body="$(expect POST /login 200 "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")"
+TOKEN="$(printf '%s' "$login_body" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')"
+[ -n "$TOKEN" ] || fail "login returned no token"
+pass "captured token"
+
+expect POST /login 401 "{\"email\":\"$EMAIL\",\"password\":\"wrongpass1\"}" >/dev/null
+
+expect GET /me 200 "" "$TOKEN" >/dev/null
+expect GET /me 401 "" >/dev/null
+expect GET /me 401 "" "not-a-token" >/dev/null
+
+echo "smoke: all checks passed ✓"