Compare commits

..
Author SHA1 Message Date
Emil fa76133efc Secure user worker operations
users / test (push) Waiting to run
coordinator / test (push) Waiting to run
2026-07-27 22:23:08 +03:00
Emil 7d8998408c Merge branch 'main' into feat/users 2026-07-27 22:19:43 +03:00
Emil 87a483c2fb Plan user service integration 2026-07-27 01:39:26 +03:00
Emil f5ead0a450 Document team and scaling roadmap 2026-07-26 20:40:25 +03:00
46 changed files with 304 additions and 1889 deletions
+72 -3
View File
@@ -66,7 +66,8 @@ Coordinator reducer -> final artifact -> download/status API
- cloud object storage, Kubernetes, autoscaling, and multi-region operation;
- arbitrary shell commands sent by coordinator to workers;
- user accounts, multi-tenancy, billing, or sophisticated authorization;
- user accounts, multi-tenancy, billing, or sophisticated authorization
(planned after the first release in CTX-15);
- GPU scheduling and multiprocessing inside a worker;
- Docker as a required runtime dependency;
- video/CV processing implementation;
@@ -820,6 +821,71 @@ CTX-09 enables final result downloads.
- failure/retry scenarios have automated coverage;
- README contains architecture diagram, security caveat, and troubleshooting.
### CTX-13 — In-worker CPU parallelism
**Goal:** Allow a worker to use a bounded, configured number of CPU threads or
processes while preserving the existing one-task-per-lease coordinator model.
**Depends on:** CTX-12.
**Acceptance criteria:**
- worker concurrency is an explicit configuration value with a safe default of
one;
- a task's internal parallel execution has bounded memory and does not build a
dense N×N similarity matrix;
- CPU-parallel `similarity-search` and `similarity-graph` outputs match the
single-threaded local reference byte-for-byte where ordering is observable;
- result ordering is deterministic across worker counts and block sizes;
- cancellation, lease loss, and worker failure stop child work safely and do
not report a successful result;
- benchmarks and tests cover one-worker and multi-worker configurations.
### CTX-14 — GPU-accelerated workload execution
**Goal:** Add an optional GPU execution backend for supported molecular
workloads, while retaining the validated CPU implementation as the reference
and fallback.
**Depends on:** CTX-13.
**Acceptance criteria:**
- GPU capability and backend version are advertised explicitly by a worker;
- the coordinator schedules GPU work only to compatible workers and CPU-only
workers continue to claim CPU tasks;
- unsupported hardware, unavailable drivers, and GPU execution errors produce
sanitized failures or a documented CPU fallback;
- GPU results match the CPU reference within a documented, tested numerical
tolerance and preserve deterministic output ordering;
- GPU memory use is bounded and no dense N×N similarity matrix is created;
- CPU-only CI verifies backend selection and contract behavior, with GPU
integration tests documented for compatible runners.
### CTX-15 — User Service and access control
**Goal:** Introduce a dedicated User Service for user identity and access
control, without coupling workers to user credentials or moving scientific
workload logic into the service.
**Depends on:** CTX-12.
**Acceptance criteria:**
- 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
explicit, validated boundary;
- authorization restricts access to jobs and artifacts to the intended user or
project;
- unauthenticated, expired-token, and cross-user access attempts have
automated failure tests;
- the existing single-operator demo remains usable through a documented local
development configuration.
---
## 10. Suggested assignment bundles
@@ -948,12 +1014,15 @@ Before merging a task, reviewer checks:
Do not start these before CTX-12 is accepted.
- Replace local artifact storage with S3/MinIO behind an `ArtifactStore` API.
- Add worker labels/capacity-aware scheduling and concurrency > 1.
- Add worker labels and capacity-aware scheduling.
- Implement CTX-13 for bounded in-worker CPU parallelism.
- Implement CTX-14 for optional GPU-accelerated workload execution.
- Implement CTX-15 for the User Service and authenticated user/project access.
- Add cancellation propagation to workers.
- Add image outputs and final PDF reporting to job artifacts.
- Add CV/video workloads using the same planner/runner/reducer contract.
- Add observability export (Prometheus/OpenTelemetry).
- Add per-user/project authorization and signed artifact URLs.
- Add signed artifact URLs.
- Add shard caching and content-addressed input deduplication.
- Add job priority and fair scheduling.
- Add a CLI for submitting and monitoring remote jobs.
+2 -1
View File
@@ -137,5 +137,6 @@ The package separates common dataset parsing and fingerprints from independent w
- [Emil](https://github.com/emil28092005) — Project Lead
- [Kristina](https://github.com/kristtma) — Tech Lead
- [Veniamin](https://t.me/Veniamin_Kt) — Scientific Lead
- [Arkhip](https://github.com/hIpa-ussr) — Programmer
- [Makar](https://github.com/RERAN4K) — Programmer
- [Reranchik](https://github.com/RERAN4K) — Programmer
+6 -2
View File
@@ -1,7 +1,7 @@
# SciMesh Status
**Updated:** 2026-07-24
**Branch baseline:** `main` at `6e67daa` (distributed similarity-search)
**Updated:** 2026-07-27
**Branch baseline:** `main` at `f5ead0a` (team and scaling-roadmap documentation)
## Current state
@@ -23,6 +23,9 @@ are reduced once into a checksum-protected final CSV, which is downloadable
through the coordinator. The full Go checks (including a fresh migration and
real PostgreSQL smoke test) passed on 2026-07-24.
A User Service is being developed on a separate programmer branch. It is not
yet merged, reviewed, or integrated with the coordinator/worker contract.
## Milestone tracker
| CTX | Status | Notes |
@@ -40,6 +43,7 @@ real PostgreSQL smoke test) passed on 2026-07-24.
| CTX-10 Distributed similarity-graph | Not started | Local reference exists. |
| CTX-11 Dashboard/operator view | Implemented | Protected live control room: recent-run/worker overview, real pipeline-stage visualization, shard attempts and safe failures, validated similarity-search upload, coordinator artifacts, final-result download, and bounded polling. |
| CTX-12 Reliability, security, CI | In progress | Unit, race, PostgreSQL integration, and smoke checks exist; CI hardening remains. |
| CTX-15 User Service and access control | In progress (separate branch) | Proposed implementation is under development; API, security review, tests, and integration are pending. |
## Next recommended assignment
+1 -11
View File
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke demo-ui demo-down demo-reset demo-logs
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke demo-ui demo-down demo-logs
# `check` deliberately uses its own Compose project and host ports. This keeps
# it from connecting to or replacing a developer's local PostgreSQL instance.
@@ -33,7 +33,6 @@ help:
' make demo-ui [WORKERS=3] Start isolated UI demo services and local workers.' \
' make demo-logs Follow coordinator logs for the UI demo.' \
' make demo-down Stop the demo services and workers.' \
' make demo-reset Stop the demo and wipe its data volumes.' \
' make test / make vet Run Go verification.' \
'' \
'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
@@ -57,15 +56,6 @@ demo-down:
DEMO_DIR="$(DEMO_DIR)" \
./scripts/demo-ui.sh stop
demo-reset:
@DEMO_PROJECT="$(DEMO_PROJECT)" \
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
DEMO_DIR="$(DEMO_DIR)" \
./scripts/demo-ui.sh reset
demo-logs:
@DEMO_PROJECT="$(DEMO_PROJECT)" \
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
+3 -3
View File
@@ -78,10 +78,10 @@ func run() error {
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
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),
@@ -121,7 +121,7 @@ func run() error {
// 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, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL)
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
-5
View File
@@ -64,8 +64,3 @@ services:
environment:
JWT_SECRET: ${JWT_SECRET}
USERSERVICE_URL: http://userservice:8081
# Browser/host-facing URLs for the "add your machine" command. A user's
# worker runs on the host, so it reaches the published ports on localhost,
# not the in-cluster service names.
PUBLIC_COORDINATOR_URL: http://localhost:${COORDINATOR_PORT:-8080}
PUBLIC_USERSERVICE_URL: http://localhost:${USERSERVICE_PORT:-8081}
+17 -26
View File
@@ -37,13 +37,6 @@ type Config struct {
// login/registration (cookie session) instead of the static UI_AUTH_TOKEN
// basic auth. Empty keeps the basic-auth UI.
UserserviceURL string
// Browser-facing base URLs used to render the "add your machine" command on
// the UI. They must be reachable from a user's own machine, which is not
// necessarily the in-cluster address the coordinator uses for UserserviceURL.
// PublicCoordinatorURL empty lets the page fall back to its own origin;
// PublicUserserviceURL empty falls back to UserserviceURL.
PublicCoordinatorURL string
PublicUserserviceURL string
// Minimum log level: debug, info, warn, error.
LogLevel string
@@ -99,25 +92,23 @@ func LoadConfig() (Config, error) {
DatabaseURL: os.Getenv("DATABASE_URL"),
// COORDINATOR_TOKEN is the contract name; WORKER_AUTH_TOKEN is the
// 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"),
PublicCoordinatorURL: os.Getenv("PUBLIC_COORDINATOR_URL"),
PublicUserserviceURL: getEnv("PUBLIC_USERSERVICE_URL", os.Getenv("USERSERVICE_URL")),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
MaxUploadBytes: 1 << 30, // 1 GiB
DBMaxConns: 10,
DBConnectTimeout: 30 * time.Second,
RequestTimeout: 15 * time.Second,
HeartbeatInterval: 15 * time.Second,
LeaseDuration: 2 * time.Minute,
DefaultMaxAttempts: 3,
QuorumSize: 2,
ReaperInterval: 30 * time.Second,
WorkerOfflineAfter: 1 * time.Minute,
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"),
MaxUploadBytes: 1 << 30, // 1 GiB
DBMaxConns: 10,
DBConnectTimeout: 30 * time.Second,
RequestTimeout: 15 * time.Second,
HeartbeatInterval: 15 * time.Second,
LeaseDuration: 2 * time.Minute,
DefaultMaxAttempts: 3,
QuorumSize: 2,
ReaperInterval: 30 * time.Second,
WorkerOfflineAfter: 1 * time.Minute,
}
if cfg.DatabaseURL == "" {
+4 -32
View File
@@ -87,44 +87,16 @@ func (r *UIReadRepo) ListWorkers(_ context.Context, limit int) ([]domain.Worker,
copy.Capabilities = append([]string(nil), worker.Capabilities...)
out = append(out, copy)
}
sortWorkers(out)
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *UIReadRepo) ListWorkersByOwner(_ context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
r.workers.mu.Lock()
defer r.workers.mu.Unlock()
out := []domain.Worker{}
for _, worker := range r.workers.workers {
if worker.OwnerID == nil || *worker.OwnerID != owner {
continue
}
copy := *worker
copy.Capabilities = append([]string(nil), worker.Capabilities...)
out = append(out, copy)
}
sortWorkers(out)
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// sortWorkers orders workers most-recently-seen first, breaking ties on id so
// the order is deterministic across calls.
func sortWorkers(out []domain.Worker) {
sort.Slice(out, func(i, j int) bool {
if out[i].LastHeartbeatAt.Equal(out[j].LastHeartbeatAt) {
return out[i].ID.String() > out[j].ID.String()
}
return out[i].LastHeartbeatAt.After(out[j].LastHeartbeatAt)
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *UIReadRepo) ListArtifactsByJob(_ context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
r.artifacts.mu.Lock()
@@ -128,32 +128,6 @@ func (r *UIReadRepo) ListWorkers(ctx context.Context, limit int) ([]domain.Worke
return workers, rows.Err()
}
func (r *UIReadRepo) ListWorkersByOwner(ctx context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) {
if limit < 1 || limit > 100 {
return nil, domain.ErrInvalidInput
}
sql, args, err := psql.Select(workerColumns...).From("workers").
Where(sq.Eq{"owner_id": owner}).
OrderBy("last_heartbeat_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
if err != nil {
return nil, err
}
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("list workers by owner: %w", err)
}
defer rows.Close()
workers := make([]domain.Worker, 0)
for rows.Next() {
worker, err := scanWorker(rows)
if err != nil {
return nil, err
}
workers = append(workers, *worker)
}
return workers, rows.Err()
}
func (r *UIReadRepo) ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
sql, args, err := psql.Select(artifactColumns...).From("artifacts").Where(sq.Eq{"job_id": jobID}).OrderBy("created_at ASC", "id ASC").ToSql()
if err != nil {
+11 -36
View File
@@ -49,11 +49,6 @@ type Server struct {
// userserviceURL is the base URL the UI proxies login/registration to. Empty
// keeps the static basic-auth UI.
userserviceURL string
// publicCoordinatorURL / publicUserserviceURL are the browser-facing URLs
// rendered into the worker-enrollment command. Either may be empty; the
// template falls back (own origin / userserviceURL respectively).
publicCoordinatorURL string
publicUserserviceURL string
// httpClient makes the login/register calls to the userservice.
httpClient *http.Client
// metrics holds the Prometheus registry and HTTP instrumentation.
@@ -64,33 +59,21 @@ type Server struct {
}
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error,
publicURLs ...string) *Server {
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error) *Server {
if m == nil {
m = metrics.New()
}
// publicURLs is variadic so existing callers/tests need no change: [0] is the
// public coordinator URL, [1] the public userservice URL; both optional.
var publicCoordinatorURL, publicUserserviceURL string
if len(publicURLs) > 0 {
publicCoordinatorURL = strings.TrimRight(publicURLs[0], "/")
}
if len(publicURLs) > 1 {
publicUserserviceURL = strings.TrimRight(publicURLs[1], "/")
}
return &Server{
uc: uc,
log: log,
requestTimeout: requestTimeout,
heartbeatInterval: heartbeatInterval,
maxUploadBytes: maxUploadBytes,
verifier: tokenpkg.NewVerifier(jwtSecret),
userserviceURL: strings.TrimRight(userserviceURL, "/"),
publicCoordinatorURL: publicCoordinatorURL,
publicUserserviceURL: publicUserserviceURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
metrics: m,
ready: ready,
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,
}
}
@@ -156,14 +139,6 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
ui.Handle(rt.pattern, gate(rt.handler))
}
ui.Handle("GET /ui/profile", gate(http.HandlerFunc(s.handleUIProfile)))
// Worker enrollment: a user creates/lists/revokes their own worker keys
// and copies a ready-to-run command. Session-only — it proxies to the
// userservice with the caller's token, so it has no meaning under basic
// auth (which has no userservice).
ui.Handle("GET /ui/workers/new", gate(http.HandlerFunc(s.handleUIAddWorker)))
ui.Handle("GET /ui/api/worker-keys", gate(http.HandlerFunc(s.handleUIWorkerKeysList)))
ui.Handle("POST /ui/api/worker-keys", gate(http.HandlerFunc(s.handleUIWorkerKeyCreate)))
ui.Handle("POST /ui/api/worker-keys/{id}/revoke", gate(http.HandlerFunc(s.handleUIWorkerKeyRevoke)))
// 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))
@@ -52,11 +52,11 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
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)),
@@ -1,56 +0,0 @@
{{define "add-worker.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Add your machine · SciMesh</title>
<style>
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:980px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.back{text-decoration:none}.eyebrow{margin:28px 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;color:#f4f8ff;font-size:clamp(2rem,5vw,3.25rem);letter-spacing:-.055em}.lead{max-width:720px;margin:10px 0 0;color:#aabed9;font-size:1.06rem}.layout{display:grid;grid-template-columns:1.45fr .8fr;gap:15px;margin-top:28px}.card,.aside,.notice{border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021}.card{padding:22px}.aside,.notice{padding:18px}.aside h2,.notice h2{margin:0;color:#f1f6ff;font-size:1rem}.aside p,.notice p{color:#9fb3cf}.aside ol{margin:13px 0 0;padding-left:20px;color:#aebfda}.aside li{margin:10px 0}label{display:block;margin:18px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.hint{margin:5px 0;color:#92a9c6;font-size:.87rem}.button{display:inline-flex;margin-top:16px;border:0;border-radius:10px;padding:11px 15px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.button.secondary{background:#23344d;color:#dce8ff}.button:disabled{opacity:.6;cursor:wait}.command{margin-top:18px;border:1px solid #2c8c70;border-radius:11px;padding:14px;background:#0c2b2a;color:#a8f1d0}.command strong{color:#e6fff4}.command pre{margin:10px 0 0;padding:12px;overflow-x:auto;border-radius:8px;background:#061a19;color:#c8ffe8;font:.82rem/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre;word-break:normal}.keys{margin-top:14px;display:grid;gap:9px}.key{display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid #294662;border-radius:11px;padding:12px 14px;background:#0a1626}.key .kn{color:#f3f7ff;font-weight:700}.key .kp{margin-top:3px;color:#8196b3;font-family:ui-monospace,SFMono-Regular,monospace;font-size:.76rem}.key .kd{color:#8fa6c3;font-size:.8rem}.revoke{border:1px solid #6a2a3a;border-radius:8px;padding:7px 11px;background:#2a1420;color:#ff9bad;font:inherit;font-weight:700;cursor:pointer}.empty{padding:20px;border:1px dashed #35516f;border-radius:12px;color:#9ab0cb;text-align:center}.error{margin:12px 0 0;color:#ffacba}.warn{color:#ffd08a}.hidden{display:none}.cap{display:inline-block;margin:4px 5px 0 0;border:1px solid #375978;border-radius:5px;padding:2px 6px;color:#b5d3f5;font:.76rem ui-monospace,SFMono-Regular,monospace}@media(max-width:720px){.layout{grid-template-columns:1fr}.page{padding:22px 14px}}
</style>
</head>
<body data-coordinator="{{.CoordinatorURL}}" data-userservice="{{.UserserviceURL}}">
<main class="page">
<a class="back" href="/ui">← Back to control room</a><p class="eyebrow">Contribute compute</p><h1>Turn this computer into a worker</h1><p class="lead">Create a key, install the worker, and run one command. The worker binds to your account and pulls tasks whenever it is online.</p>
<div class="layout">
<section class="card">
<h2 style="margin:0 0 4px;color:#f1f6ff">Your worker keys</h2>
<p class="hint">A key is long-lived and does not expire like a login. The worker trades it for short-lived tokens automatically. Revoke a key to stop its machines.</p>
<form id="create" novalidate>
<label for="key-name">Name this machine <small>(optional)</small></label>
<input id="key-name" name="name" maxlength="100" placeholder="e.g. home-desktop" autocomplete="off">
<button class="button" id="create-btn" type="submit">Create key →</button>
<p id="error" class="error" role="alert"></p>
</form>
<div id="command" class="command hidden"></div>
<div id="keys" class="keys"></div>
</section>
<aside class="aside">
<h2>Set it up</h2>
<ol>
<li><strong>Create a key</strong><br>Use the form; copy the command it generates.</li>
<li><strong>Paste it in a terminal</strong><br>The command clones the project, sets up a Python environment, installs the worker, and starts it. The machine then appears under <a href="/ui">My machines</a>.</li>
</ol>
<h2 style="margin-top:24px">Will my results count?</h2>
<p>Your worker is <strong>untrusted</strong> by default: its results are cross-checked and accepted once a second independent worker computes the same answer (quorum), or once an admin marks your account <strong>verified</strong> — then your workers are trusted and results count immediately.</p>
<p><span class="cap">similarity-search</span> is the only workload a volunteer worker runs today.</p>
</aside>
</div>
</main>
<script>
const coord=(document.body.dataset.coordinator||location.origin).replace(/\/+$/,'');
const users=(document.body.dataset.userservice||'').replace(/\/+$/,'');
const keysBox=document.querySelector('#keys'),cmdBox=document.querySelector('#command'),form=document.querySelector('#create'),nameInput=document.querySelector('#key-name'),createBtn=document.querySelector('#create-btn'),error=document.querySelector('#error');
const node=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n};
const shq=s=>"'"+String(s).replace(/'/g,"'\\''")+"'";
const buildCommand=(key,name)=>['git clone https://github.com/emil28092005/SciMesh.git','cd SciMesh','python -m venv .venv','source .venv/bin/activate','pip install -e .','','SCIMESH_COORDINATOR_URL='+coord+' \\','SCIMESH_USERSERVICE_URL='+(users||'<your userservice URL>')+' \\','SCIMESH_WORKER_KEY='+key+' \\','scimesh-worker --worker-name '+shq(name||'my-machine')].join('\n');
const showCommand=(key,name)=>{cmdBox.replaceChildren();cmdBox.append(node('strong','Key created — copy it now, it is shown only once.'));const pre=node('pre',buildCommand(key,name));cmdBox.append(pre);const copy=node('button','Copy command','button secondary');copy.type='button';copy.addEventListener('click',()=>{navigator.clipboard&&navigator.clipboard.writeText(buildCommand(key,name)).then(()=>{copy.textContent='Copied ✓'},()=>{copy.textContent='Press Ctrl+C to copy'})});cmdBox.append(document.createElement('br'),copy);if(!users){cmdBox.append(node('p','Set SCIMESH_USERSERVICE_URL to a userservice URL your machine can reach.','warn'))}cmdBox.classList.remove('hidden')};
const revoke=async id=>{const r=await fetch('/ui/api/worker-keys/'+encodeURIComponent(id)+'/revoke',{method:'POST'});if(r.status===204||r.ok){loadKeys()}else{error.textContent='Could not revoke the key.'}};
const renderKeys=keys=>{keysBox.replaceChildren();if(!keys.length){keysBox.append(node('div','No keys yet. Create one above to connect a machine.','empty'));return}for(const k of keys){const row=node('div',undefined,'key'),left=node('div');left.append(node('div',k.name||'unnamed','kn'),node('div',k.prefix+'…','kp'),node('div','Created '+new Date(k.created_at).toLocaleString()+(k.last_used_at?' · last used '+new Date(k.last_used_at).toLocaleString():' · never used'),'kd'));const btn=node('button','Revoke','revoke');btn.type='button';btn.addEventListener('click',()=>revoke(k.id));row.append(left,btn);keysBox.append(row)}};
const loadKeys=async()=>{try{const r=await fetch('/ui/api/worker-keys',{headers:{Accept:'application/json'}});if(!r.ok)throw Error();const data=await r.json();renderKeys(data.worker_keys||[])}catch(_){keysBox.replaceChildren(node('div','Could not load your keys.','empty'))}};
form.addEventListener('submit',async event=>{event.preventDefault();error.textContent='';createBtn.disabled=true;const machineName=nameInput.value.trim();try{const r=await fetch('/ui/api/worker-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:machineName})}),data=await r.json().catch(()=>({}));if(!r.ok)throw Error(data.error||'Could not create the key.');showCommand(data.key,machineName);nameInput.value='';loadKeys()}catch(err){error.textContent=err.message}finally{createBtn.disabled=false}});
loadKeys();
</script>
</body>
</html>
{{end}}
@@ -13,7 +13,7 @@
<main class="page">
<header class="top">
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}{{if .Session}}<a class="button" href="/ui/workers/new" style="background:#23344d;color:#dce8ff;box-shadow:none">🖥 Add your machine</a>{{end}}<a class="button" href="/ui/jobs/new"> New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}<a class="button" href="/ui/jobs/new"> New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
</header>
<section class="summary" aria-label="Pipeline summary">
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
@@ -23,7 +23,6 @@
</section>
<section><div class="section-head"><h2>Recent computations</h2><p id="job-count">{{len .Jobs}} shown · newest first</p></div><div id="jobs" class="jobs">{{range .Jobs}}<a class="job" href="/ui/jobs/{{.ID}}"><div><div class="job-name">{{workloadLabel .Workload}}</div><div class="job-id">{{.ID}}</div></div><div><span class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><div class="job-id">{{statusHint .Status}}</div></div><div><div class="numbers"><b>{{.Completed}}</b> / {{.Total}} shards complete{{if gt .Failed 0}} · <span class="offline">{{.Failed}} failed</span>{{end}}</div><div class="bar"><span style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div></div><span class="arrow" aria-hidden="true"></span></a>{{else}}<div class="empty"><strong>No computations yet.</strong><br>Start a small similarity search, then keep one or more workers running to watch this dashboard come alive.</div>{{end}}</div></section>
{{if and .Session (ne .Session.Role "admin")}}<section><div class="section-head"><h2>My machines</h2><p>Workers you registered. <a href="/ui/workers/new" style="color:#79aaff">Add your machine →</a></p></div><div id="my-workers" class="workers">{{range .MyWorkers}}<article class="worker"><div class="worker-head"><strong>{{.Name}}</strong><span class="badge badge-{{workerStatusClass .Status}}">{{workerStatusLabel .Status}}</span></div><small>{{.ID}}</small><p>{{range .Capabilities}}<span class="cap">{{.}}</span>{{end}}</p><p>Last signal · {{time .LastHeartbeatAt}}</p></article>{{else}}<div class="empty"><strong>No machine of yours is connected.</strong><br><a href="/ui/workers/new" style="color:#79aaff">Turn this computer into a worker →</a></div>{{end}}</div></section>{{end}}
<section><div class="section-head"><h2>Worker fleet</h2><p>Workers register themselves; this page never controls their processes.</p></div><div id="workers" class="workers">{{range .Workers}}<article class="worker"><div class="worker-head"><strong>{{.Name}}</strong><span class="badge badge-{{workerStatusClass .Status}}">{{workerStatusLabel .Status}}</span></div><small>{{.ID}}</small><p>{{range .Capabilities}}<span class="cap">{{.}}</span>{{end}}</p><p>Last signal · {{time .LastHeartbeatAt}}</p></article>{{else}}<div class="empty"><strong>No worker is registered.</strong><br>Start <code>scimesh-worker</code> in another terminal, then return here.</div>{{end}}</div></section>
</main>
<script>
@@ -31,10 +30,8 @@
const pct=j=>j.total?Math.min(100,Math.floor((j.completed+j.failed+j.cancelled)*100/j.total)):0;
const node=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n};
const renderJobs=jobs=>{const box=document.querySelector('#jobs');box.replaceChildren();if(!jobs.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No computations yet.'),document.createElement('br'),document.createTextNode('Start a small similarity search, then keep one or more workers running to watch this dashboard come alive.'));box.append(empty);return}for(const job of jobs){const info=statusInfo[job.status]||[job.status,'waiting'],link=node('a',undefined,'job');link.href='/ui/jobs/'+encodeURIComponent(job.id);const intro=node('div');intro.append(node('div',job.workload==='similarity-search'?'Molecule similarity search':job.workload,'job-name'),node('div',job.id,'job-id'));const state=node('div');state.append(node('span',info[0],'badge badge-'+info[1]),node('div',job.status==='reducing'?'Every shard is complete; coordinator is ranking the global top-k.':'Live coordinator state','job-id'));const progress=node('div'),numbers=node('div',undefined,'numbers');numbers.append(node('b',String(job.completed)),document.createTextNode(' / '+job.total+' shards complete'));if(job.failed){numbers.append(document.createTextNode(' · '),node('span',job.failed+' failed','offline'))}const bar=node('div',undefined,'bar'),fill=node('span');fill.style.width=pct(job)+'%';bar.append(fill);progress.append(numbers,bar);link.append(intro,state,progress,node('span','→','arrow'));box.append(link)}};
const workerCard=worker=>{const card=node('article',undefined,'worker'),head=node('div',undefined,'worker-head'),left=node('div'),workerInfo=worker.status==='online'?['Available','success']:worker.status==='busy'?['Busy','active']:['Offline','waiting'];left.append(node('strong',worker.name),node('small',worker.id));head.append(left,node('span',workerInfo[0],'badge badge-'+workerInfo[1]));const caps=node('p');for(const capability of worker.capabilities||[])caps.append(node('span',capability,'cap'));card.append(head,caps,node('p','Last signal · '+new Date(worker.last_heartbeat_at).toLocaleString()));return card};
const renderWorkers=workers=>{const box=document.querySelector('#workers');box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No worker is registered.'),document.createElement('br'),document.createTextNode('Start scimesh-worker in another terminal, then return here.'));box.append(empty);return}for(const worker of workers)box.append(workerCard(worker))};
const renderMyWorkers=workers=>{const box=document.querySelector('#my-workers');if(!box)return;box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty'),link=node('a','Turn this computer into a worker →');link.href='/ui/workers/new';link.style.color='#79aaff';empty.append(node('strong','No machine of yours is connected.'),document.createElement('br'),link);box.append(empty);return}for(const worker of workers)box.append(workerCard(worker))};
let timer;const refresh=async()=>{try{const response=await fetch('/ui/api/overview',{headers:{Accept:'application/json'}});if(!response.ok)throw Error();const view=await response.json();document.querySelector('#active-jobs').textContent=view.active_jobs;document.querySelector('#online-workers').textContent=view.online_workers;document.querySelector('#finished-jobs').textContent=view.finished_jobs;document.querySelector('#job-count').textContent=view.jobs.length+' shown · newest first';renderJobs(view.jobs);renderWorkers(view.workers);renderMyWorkers(view.my_workers||[]);document.querySelector('#refresh-state').textContent='Live overview · updated just now'}catch(_){document.querySelector('#refresh-state').textContent='Connection interrupted · trying again automatically'}};
const renderWorkers=workers=>{const box=document.querySelector('#workers');box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No worker is registered.'),document.createElement('br'),document.createTextNode('Start scimesh-worker in another terminal, then return here.'));box.append(empty);return}for(const worker of workers){const card=node('article',undefined,'worker'),head=node('div',undefined,'worker-head'),left=node('div'),workerInfo=worker.status==='online'?['Available','success']:worker.status==='busy'?['Busy','active']:['Offline','waiting'];left.append(node('strong',worker.name),node('small',worker.id));head.append(left,node('span',workerInfo[0],'badge badge-'+workerInfo[1]));const caps=node('p');for(const capability of worker.capabilities||[])caps.append(node('span',capability,'cap'));card.append(head,caps,node('p','Last signal · '+new Date(worker.last_heartbeat_at).toLocaleString()));box.append(card)}};
let timer;const refresh=async()=>{try{const response=await fetch('/ui/api/overview',{headers:{Accept:'application/json'}});if(!response.ok)throw Error();const view=await response.json();document.querySelector('#active-jobs').textContent=view.active_jobs;document.querySelector('#online-workers').textContent=view.online_workers;document.querySelector('#finished-jobs').textContent=view.finished_jobs;document.querySelector('#job-count').textContent=view.jobs.length+' shown · newest first';renderJobs(view.jobs);renderWorkers(view.workers);document.querySelector('#refresh-state').textContent='Live overview · updated just now'}catch(_){document.querySelector('#refresh-state').textContent='Connection interrupted · trying again automatically'}};
const start=()=>{if(!timer&&!document.hidden)timer=setInterval(refresh,2000)};document.addEventListener('visibilitychange',()=>{if(document.hidden&&timer){clearInterval(timer);timer=undefined}else start()});start();
</script>
</body>
@@ -1,118 +0,0 @@
package http
import (
"bytes"
"context"
"io"
"net/http"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
// handleUIAddWorker renders the "add your machine" page: instructions, the
// user's existing worker keys, and a ready-to-run command carrying a freshly
// minted key. All key operations happen client-side against the JSON endpoints
// below; this handler only supplies the browser-facing URLs.
func (s *Server) handleUIAddWorker(w http.ResponseWriter, r *http.Request) {
data := map[string]any{
"CoordinatorURL": s.publicCoordinatorURL,
"UserserviceURL": s.publicUserserviceURL,
}
if req, ok := authctx.From(r.Context()); ok {
data["Session"] = &usecase.SessionView{Role: req.Role, Verified: req.Verified}
}
s.renderUI(w, "add-worker.html", data)
}
// handleUIWorkerKeysList proxies the caller's live worker keys from the
// userservice, forwarding their session token.
func (s *Server) handleUIWorkerKeysList(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"})
return
}
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/worker-keys", c.Value)
if err != nil {
s.log.Error("worker-keys list proxy", "err", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "userservice unavailable"})
return
}
proxyJSON(w, status, body)
}
// handleUIWorkerKeyCreate mints a new worker key via the userservice and returns
// its response — including the one-time plaintext key — straight to the browser.
func (s *Server) handleUIWorkerKeyCreate(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"})
return
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<12))
if len(body) == 0 {
body = []byte("{}")
}
status, respBody, err := s.callUserserviceAuthedBody(r.Context(), http.MethodPost, "/worker-keys", c.Value, body)
if err != nil {
s.log.Error("worker-key create proxy", "err", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "userservice unavailable"})
return
}
proxyJSON(w, status, respBody)
}
// handleUIWorkerKeyRevoke retires one of the caller's keys via the userservice.
// The id is validated as a UUID so the proxied path can never be attacker-shaped.
func (s *Server) handleUIWorkerKeyRevoke(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := uuid.Parse(id); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid worker key id"})
return
}
c, err := r.Cookie(sessionCookie)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"})
return
}
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodDelete, "/worker-keys/"+id, c.Value)
if err != nil {
s.log.Error("worker-key revoke proxy", "err", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "userservice unavailable"})
return
}
w.WriteHeader(status)
}
// proxyJSON forwards a userservice JSON response verbatim, preserving its status.
func proxyJSON(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(body)
}
// callUserserviceAuthedBody is callUserserviceAuthed with a JSON request body,
// used for the create call. Kept separate so the bodyless admin/profile callers
// stay unchanged.
func (s *Server) callUserserviceAuthedBody(ctx context.Context, method, path, bearer string, body []byte) (int, []byte, error) {
req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, bytes.NewReader(body)) //nolint:gosec // G704: path is a fixed literal, host is config
if err != nil {
return 0, nil, err
}
req.Header.Set("Authorization", "Bearer "+bearer)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above
if err != nil {
return 0, nil, err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return 0, nil, err
}
return resp.StatusCode, respBody, nil
}
@@ -1,106 +0,0 @@
package http
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWorkerKeyCreateProxiesWithBody(t *testing.T) {
var gotAuth, gotPath, gotMethod, gotBody string
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth, gotPath, gotMethod = r.Header.Get("Authorization"), r.URL.Path, r.Method
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","name":"box","prefix":"scimesh_wk_live_ab","created_at":"2026-07-26T00:00:00Z","key":"scimesh_wk_live_secret"}`))
}))
defer stub.Close()
s := newLoginServer(stub)
req := newReq(http.MethodPost, "/ui/api/worker-keys", strings.NewReader(`{"name":"box"}`))
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
rec := httptest.NewRecorder()
s.handleUIWorkerKeyCreate(rec, req)
if gotAuth != "Bearer my.jwt" || gotPath != "/worker-keys" || gotMethod != http.MethodPost {
t.Fatalf("proxy: auth=%q path=%q method=%q", gotAuth, gotPath, gotMethod)
}
if !strings.Contains(gotBody, `"name":"box"`) {
t.Errorf("request body not forwarded: %q", gotBody)
}
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), "scimesh_wk_live_secret") {
t.Errorf("response not passed through: %d %s", rec.Code, rec.Body.String())
}
}
func TestWorkerKeysListProxies(t *testing.T) {
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/worker-keys" {
t.Errorf("unexpected upstream call %s %s", r.Method, r.URL.Path)
}
_, _ = w.Write([]byte(`{"worker_keys":[{"id":"1","name":"box","prefix":"scimesh_wk_live_ab","created_at":"2026-07-26T00:00:00Z"}]}`))
}))
defer stub.Close()
s := newLoginServer(stub)
req := newReq(http.MethodGet, "/ui/api/worker-keys", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
rec := httptest.NewRecorder()
s.handleUIWorkerKeysList(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "worker_keys") {
t.Errorf("list not passed through: %d %s", rec.Code, rec.Body.String())
}
}
func TestWorkerKeyRevokeProxiesDelete(t *testing.T) {
const id = "22222222-2222-2222-2222-222222222222"
var gotPath, gotMethod string
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod = r.URL.Path, r.Method
w.WriteHeader(http.StatusNoContent)
}))
defer stub.Close()
s := newLoginServer(stub)
req := newReq(http.MethodPost, "/ui/api/worker-keys/"+id+"/revoke", nil)
req.SetPathValue("id", id)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
rec := httptest.NewRecorder()
s.handleUIWorkerKeyRevoke(rec, req)
if gotMethod != http.MethodDelete || gotPath != "/worker-keys/"+id {
t.Fatalf("proxy: method=%q path=%q", gotMethod, gotPath)
}
if rec.Code != http.StatusNoContent {
t.Errorf("revoke status = %d, want 204", rec.Code)
}
}
func TestWorkerKeyRevokeRejectsBadID(t *testing.T) {
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("must not call userservice for an invalid id")
})))
req := newReq(http.MethodPost, "/ui/api/worker-keys/not-a-uuid/revoke", nil)
req.SetPathValue("id", "not-a-uuid")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
rec := httptest.NewRecorder()
s.handleUIWorkerKeyRevoke(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("bad id: got %d, want 400", rec.Code)
}
}
func TestWorkerKeysRequireSession(t *testing.T) {
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("must not call userservice without a session cookie")
})))
rec := httptest.NewRecorder()
s.handleUIWorkerKeysList(rec, newReq(http.MethodGet, "/ui/api/worker-keys", nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("no cookie: got %d, want 401", rec.Code)
}
}
+6 -2
View File
@@ -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
+18 -12
View File
@@ -7,7 +7,6 @@ import (
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
@@ -59,11 +58,8 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl
// 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 r, ok := authctx.From(ctx); ok {
if worker.OwnerID == nil || *worker.OwnerID != r.UserID {
// Don't disclose that another user's worker exists.
return nil, domain.ErrWorkerNotFound
}
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.
@@ -126,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 {
@@ -186,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 {
@@ -317,19 +319,23 @@ func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UU
// --- 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 {
+3 -31
View File
@@ -21,9 +21,6 @@ type UIReadRepository interface {
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)
// ListWorkersByOwner returns the most recent workers registered by one user,
// for the "my machines" section of the dashboard.
ListWorkersByOwner(ctx context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error)
ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error)
}
@@ -86,11 +83,8 @@ type WorkerCard struct {
}
type DashboardView struct {
Jobs []JobCard `json:"jobs"`
Workers []WorkerCard `json:"workers"`
// MyWorkers is the signed-in user's own registered workers. Empty for an
// admin or a basic-auth operator, who instead see the whole fleet in Workers.
MyWorkers []WorkerCard `json:"my_workers"`
Jobs []JobCard `json:"jobs"`
Workers []WorkerCard `json:"workers"`
ActiveJobs int `json:"active_jobs"`
FinishedJobs int `json:"finished_jobs"`
OnlineWorkers int `json:"online_workers"`
@@ -158,37 +152,15 @@ func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, err
}
}
for _, worker := range workers {
out.Workers = append(out.Workers, workerCard(worker))
out.Workers = append(out.Workers, WorkerCard{ID: worker.ID.String(), Name: worker.Name, Status: string(worker.Status), Capabilities: worker.Capabilities, LastHeartbeatAt: worker.LastHeartbeatAt})
if worker.Status == domain.WorkerOnline || worker.Status == domain.WorkerBusy {
out.OnlineWorkers++
}
}
// A plain user also gets a dedicated "my machines" list scoped to their own
// registrations; an admin/operator sees only the fleet above.
if owner := uiOwnerFilter(ctx); owner != nil {
mine, err := d.read.ListWorkersByOwner(ctx, *owner, limit)
if err != nil {
return DashboardView{}, err
}
out.MyWorkers = make([]WorkerCard, 0, len(mine))
for _, worker := range mine {
out.MyWorkers = append(out.MyWorkers, workerCard(worker))
}
}
out.Session = sessionViewFrom(ctx)
return out, nil
}
func workerCard(w domain.Worker) WorkerCard {
return WorkerCard{
ID: w.ID.String(),
Name: w.Name,
Status: string(w.Status),
Capabilities: w.Capabilities,
LastHeartbeatAt: w.LastHeartbeatAt,
}
}
func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailView, error) {
job, err := d.read.GetJob(ctx, jobID)
if err != nil {
@@ -1,66 +0,0 @@
package usecase_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
func newDashboardWithWorkers() (*usecase.Dashboard, *memstore.WorkerRepo) {
jobs := memstore.NewJobRepo()
tasks := memstore.NewTaskRepo()
workers := memstore.NewWorkerRepo()
artifacts := memstore.NewArtifactRepo()
return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts)), workers
}
func seedWorker(t *testing.T, workers *memstore.WorkerRepo, owner *uuid.UUID, name string) {
t.Helper()
w := &domain.Worker{
ID: uuid.New(),
Name: name,
Capabilities: []string{"similarity-search"},
Status: domain.WorkerOnline,
OwnerID: owner,
LastHeartbeatAt: time.Now().UTC(),
}
if err := workers.Insert(context.Background(), w); err != nil {
t.Fatalf("insert worker: %v", err)
}
}
func TestOverviewSplitsMyWorkers(t *testing.T) {
dash, workers := newDashboardWithWorkers()
alice, bob := uuid.New(), uuid.New()
seedWorker(t, workers, &alice, "alice-box")
seedWorker(t, workers, &bob, "bob-box")
seedWorker(t, workers, nil, "lab-shared") // owner-less shared-token worker
// A plain user sees the whole fleet, but MyWorkers holds only their own.
v, err := dash.Overview(userCtx(alice, "user"), 20)
if err != nil {
t.Fatal(err)
}
if len(v.Workers) != 3 {
t.Errorf("fleet shows %d workers, want 3", len(v.Workers))
}
if len(v.MyWorkers) != 1 || v.MyWorkers[0].Name != "alice-box" {
t.Errorf("MyWorkers = %+v, want only alice-box", v.MyWorkers)
}
// An admin is not owner-scoped: they get the fleet and no personal list.
if av, _ := dash.Overview(userCtx(uuid.New(), "admin"), 20); len(av.MyWorkers) != 0 || len(av.Workers) != 3 {
t.Errorf("admin MyWorkers=%d Workers=%d, want 0 and 3", len(av.MyWorkers), len(av.Workers))
}
// A basic-auth operator (no requester) also gets no personal list.
if ov, _ := dash.Overview(context.Background(), 20); len(ov.MyWorkers) != 0 {
t.Errorf("operator MyWorkers=%d, want 0", len(ov.MyWorkers))
}
}
+33 -3
View File
@@ -77,11 +77,11 @@ func newHarness() *harness {
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, h.work, h.taskResults, tx, h.clk, 2)
h.fail = usecase.NewFailTask(h.tasks, h.jobs, tx, h.clk)
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)
@@ -318,6 +318,36 @@ func TestJWTCallerCannotClaimAsAnotherUsersWorker(t *testing.T) {
}
}
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)
@@ -551,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{
@@ -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
}
+1 -8
View File
@@ -177,19 +177,12 @@ case "$action" in
compose down
echo "SciMesh manual demo stopped."
;;
reset)
# Like stop, but also drops the data volumes so the next start is pristine
# (empty Postgres, no leftover workers/jobs/tasks, no cached artifacts).
stop_workers
compose down -v
echo "SciMesh manual demo stopped and data volumes removed."
;;
logs)
echo "Worker logs: $logs_dir"
compose logs -f coordinator
;;
*)
echo "Usage: $0 {start|stop|reset|logs}" >&2
echo "Usage: $0 {start|stop|logs}" >&2
exit 2
;;
esac
+3 -52
View File
@@ -33,61 +33,14 @@ Everything below fills in the details.
## 0. Auth
Every request except `GET /health` carries a bearer token:
```
Authorization: Bearer <token>
```
There are two ways to obtain that token.
### Shared coordinator token (lab / operator workers)
Every request except `GET /health` carries a shared bearer token:
```
Authorization: Bearer <COORDINATOR_TOKEN>
```
The token is handed to you out of band (env var / secret) — the same string the
coordinator was started with. A worker using it registers **owner-less and
trusted**: its results are accepted without quorum. Never log it, never send it
in an error body.
### Worker key (run a worker bound to your own account)
Any signed-in user can turn their machine into a worker without the shared
secret:
1. In the web UI, open **“Add your machine”** (`/ui/workers/new`) and create a
**worker key** (`scimesh_wk_live_…`). It is shown once — copy it.
2. Install and run the reference worker with the copied command:
```
git clone https://github.com/emil28092005/SciMesh.git
cd SciMesh
python -m venv .venv
source .venv/bin/activate
pip install -e .
SCIMESH_COORDINATOR_URL=<coordinator> \
SCIMESH_USERSERVICE_URL=<userservice> \
SCIMESH_WORKER_KEY=scimesh_wk_live_xxx \
scimesh-worker --worker-name my-machine
```
The worker ships in this repository, not on PyPI, so it is installed from a
clone (`pip install -e .`) rather than `pip install scimesh`.
Under the hood the worker trades the key at `POST /worker-tokens/exchange` for a
short-lived JWT and refreshes it automatically before it expires — so unlike a
raw login token, a worker key keeps a long-running worker authenticated. Revoke
the key in the UI to cut a machine off.
**Trust and quorum.** A worker registered with a plain user's key is
**untrusted**: its result is quarantined and only accepted once a second,
independent worker (a different owner) computes the same answer — the quorum
(default 2). If an admin marks your account **verified**, your workers become
trusted and their results count immediately; re-register the worker after being
verified so it picks up the upgraded trust.
coordinator was started with. Never log it, never send it in an error body.
## 1. Register (once, at startup)
@@ -238,9 +191,7 @@ Per the worker contract, at minimum:
- `SCIMESH_COORDINATOR_URL` (e.g. `http://coordinator:8080`)
- worker name (the coordinator returns its `worker_id` at registration;
`SCIMESH_WORKER_ID` is only a legacy/test override)
- the credential — either `SCIMESH_BEARER_TOKEN` (shared token or a raw JWT) or
`SCIMESH_WORKER_KEY` together with `SCIMESH_USERSERVICE_URL` (a worker key the
worker exchanges and refreshes; see §0)
- the bearer token
- poll interval and request timeout
- a working directory for downloaded inputs and generated outputs
+47
View File
@@ -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.
+7 -43
View File
@@ -7,11 +7,9 @@ import http.client
import json
from pathlib import Path
from typing import Protocol
from urllib.error import HTTPError
from urllib.parse import quote, urljoin, urlsplit
from urllib.request import Request, build_opener
from .auth import StaticTokenProvider, TokenProvider
from .coordinator import CoordinatorConflictError
from .models import ClaimedTask, ProducedArtifact, UploadedArtifact
from .transport import SameOriginAuthRedirectHandler, origin
@@ -31,51 +29,23 @@ class ArtifactClient(Protocol):
class HttpArtifactClient:
"""Transfers artifacts through the coordinator without leaking credentials."""
def __init__(
self,
coordinator_url: str,
timeout: float,
bearer_token: str | None = None,
*,
token_provider: TokenProvider | None = None,
) -> None:
def __init__(self, coordinator_url: str, timeout: float, bearer_token: str | None = None) -> None:
self.coordinator_url = coordinator_url.rstrip("/")
self.timeout = timeout
self._tokens: TokenProvider = token_provider or StaticTokenProvider(bearer_token)
self.bearer_token = bearer_token
self.coordinator_origin = origin(coordinator_url)
self._opener = build_opener(SameOriginAuthRedirectHandler(self.coordinator_origin))
@property
def bearer_token(self) -> str | None:
return self._tokens.token()
def download(self, uri: str, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
resolved_uri = urljoin(f"{self.coordinator_url}/", uri)
self._download_once(resolved_uri, destination, allow_refresh=True)
def _download_once(self, resolved_uri: str, destination: Path, *, allow_refresh: bool) -> None:
request = Request(resolved_uri, headers=self._auth_headers_for(resolved_uri))
try:
with self._opener.open(request, timeout=self.timeout) as response, destination.open("wb") as target:
while chunk := response.read(1024 * 1024):
target.write(chunk)
except HTTPError as error:
# Refresh an expired token and retry once, mirroring the coordinator
# client, so a token that lapses mid-task does not fail the download.
if error.code == 401 and allow_refresh:
self._tokens.refresh()
self._download_once(resolved_uri, destination, allow_refresh=False)
return
raise
with self._opener.open(request, timeout=self.timeout) as response, destination.open("wb") as target:
while chunk := response.read(1024 * 1024):
target.write(chunk)
def upload(
self, task: ClaimedTask, worker_id: str, artifact: ProducedArtifact
) -> UploadedArtifact:
return self._upload_once(task, worker_id, artifact, allow_refresh=True)
def _upload_once(
self, task: ClaimedTask, worker_id: str, artifact: ProducedArtifact, *, allow_refresh: bool
) -> UploadedArtifact:
"""Stream an artifact and require durable coordinator-owned metadata."""
url = (
@@ -106,11 +76,6 @@ class HttpArtifactClient:
connection.send(chunk)
response = connection.getresponse()
body = response.read()
if response.status == 401 and allow_refresh:
# Token lapsed mid-task: refresh and retry the upload once.
self._tokens.refresh()
connection.close()
return self._upload_once(task, worker_id, artifact, allow_refresh=False)
if response.status == 409:
raise CoordinatorConflictError("artifact upload rejected because the task lease was lost")
if response.status != 200:
@@ -128,9 +93,8 @@ class HttpArtifactClient:
def _auth_headers_for(self, uri: str) -> dict[str, str]:
"""Only coordinator-owned URLs receive the coordinator bearer token."""
token = self._tokens.token()
if token and origin(uri) == self.coordinator_origin:
return {"Authorization": f"Bearer {token}"}
if self.bearer_token and origin(uri) == self.coordinator_origin:
return {"Authorization": f"Bearer {self.bearer_token}"}
return {}
def sha256_file(path: Path) -> str:
-128
View File
@@ -1,128 +0,0 @@
"""Bearer-token strategies for the worker's coordinator calls.
A worker authenticates in one of two ways:
* a *static* token the shared service token or a directly supplied JWT, fixed
for the life of the process; or
* a *worker key* a long-lived per-user credential the worker trades for a
short-lived JWT at the userservice, refreshing before that JWT expires.
Both are exposed through the small ``TokenProvider`` protocol so the HTTP
clients neither know nor care which one is in play.
"""
from __future__ import annotations
import json
import time
from typing import Callable, Protocol
from urllib.error import HTTPError, URLError
from urllib.request import Request, build_opener
from .transport import NoRedirectHandler
class TokenExchangeError(RuntimeError):
"""The userservice refused or failed to exchange a worker key."""
class TokenProvider(Protocol):
def token(self) -> str | None:
"""Return the current bearer token, refreshing it if necessary."""
def refresh(self) -> None:
"""Force the next token to be re-fetched (e.g. after a 401)."""
class StaticTokenProvider:
"""Serves a fixed token forever. ``None`` means "send no Authorization"."""
def __init__(self, token: str | None) -> None:
self._token = token
def token(self) -> str | None:
return self._token
def refresh(self) -> None: # noqa: D401 - nothing to refresh
return None
class WorkerKeyTokenProvider:
"""Exchanges a long-lived worker key for short-lived JWTs and refreshes them.
The token is cached until roughly ``1 - refresh_leeway`` of its lifetime has
elapsed, so the worker renews ahead of expiry instead of waiting for a 401.
A monotonic clock is injectable to keep tests deterministic.
"""
def __init__(
self,
userservice_url: str,
worker_key: str,
timeout: float,
*,
refresh_leeway: float = 0.2,
now: Callable[[], float] = time.monotonic,
) -> None:
self._url = userservice_url.rstrip("/")
self._key = worker_key
self._timeout = timeout
self._leeway = refresh_leeway
self._now = now
self._token: str | None = None
self._refresh_at: float = 0.0
self._opener = build_opener(NoRedirectHandler())
def token(self) -> str:
if self._token is None or self._now() >= self._refresh_at:
self._exchange()
assert self._token is not None # _exchange sets it or raises
return self._token
def refresh(self) -> None:
self._exchange()
def _exchange(self) -> None:
request = Request(
f"{self._url}/worker-tokens/exchange",
data=json.dumps({"key": self._key}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with self._opener.open(request, timeout=self._timeout) as response:
raw = response.read()
data = json.loads(raw) if raw else {}
except HTTPError as error:
# A revoked or unknown key is a permanent 401; there is nothing the
# worker can do but stop, so surface it rather than retry forever.
raise TokenExchangeError(
f"worker key exchange rejected with status {error.code}"
) from error
except (URLError, TimeoutError, json.JSONDecodeError) as error:
raise TokenExchangeError("worker key exchange request failed") from error
token = data.get("token")
if not isinstance(token, str) or not token:
raise TokenExchangeError("worker key exchange response is missing a token")
expires_in = data.get("expires_in")
ttl = float(expires_in) if isinstance(expires_in, (int, float)) and expires_in > 0 else 0.0
self._token = token
# Renew once ~(1 - leeway) of the lifetime is gone. An unknown TTL falls
# back to re-exchanging on the next call — correct, just chattier.
self._refresh_at = self._now() + ttl * (1.0 - self._leeway)
def provider_from_config(
*,
worker_key: str | None,
userservice_url: str | None,
bearer_token: str | None,
request_timeout: float,
) -> TokenProvider:
"""Pick the token strategy: a worker key (exchange mode) wins over a static
bearer token, which in turn wins over no credential at all."""
if worker_key and userservice_url:
return WorkerKeyTokenProvider(userservice_url, worker_key, request_timeout)
return StaticTokenProvider(bearer_token)
+4 -26
View File
@@ -7,7 +7,6 @@ import logging
from pathlib import Path
from .artifacts import HttpArtifactClient
from .auth import provider_from_config
from .config import WorkerConfig
from .coordinator import HttpCoordinatorClient
from .daemon import WorkerDaemon
@@ -23,23 +22,14 @@ def build_parser() -> argparse.ArgumentParser:
"SCIMESH_WORKER_NAME, SCIMESH_CPU_COUNT, SCIMESH_MEMORY_MB, "
"SCIMESH_POLL_INTERVAL, SCIMESH_REQUEST_TIMEOUT, "
"SCIMESH_HEARTBEAT_INTERVAL, SCIMESH_CLEANUP_AFTER_SECONDS, "
"SCIMESH_MAX_TASKS, SCIMESH_BEARER_TOKEN, SCIMESH_WORKER_KEY, and "
"SCIMESH_USERSERVICE_URL. SCIMESH_WORKER_ID is a legacy/test override."
"SCIMESH_MAX_TASKS, and SCIMESH_BEARER_TOKEN. "
"SCIMESH_WORKER_ID is a legacy/test override."
),
)
parser.add_argument("--coordinator-url")
parser.add_argument("--worker-id")
parser.add_argument("--work-dir")
parser.add_argument("--worker-name")
parser.add_argument(
"--worker-key",
help="Long-lived worker key from the web UI; the worker exchanges it for "
"short-lived tokens, binding it to your account. Requires --userservice-url.",
)
parser.add_argument(
"--userservice-url",
help="Base URL of the userservice that issues tokens for --worker-key",
)
parser.add_argument("--cpu-count", type=int)
parser.add_argument("--memory-mb", type=int)
parser.add_argument("--poll-interval", type=float)
@@ -78,23 +68,11 @@ def main(argv: list[str] | None = None) -> int:
except (TypeError, ValueError) as error:
parser.error(str(error))
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# One shared token strategy backs both clients: a worker key (exchanged and
# refreshed) or a static bearer token, decided by what the config carries.
tokens = provider_from_config(
worker_key=config.worker_key,
userservice_url=config.userservice_url,
bearer_token=config.bearer_token,
request_timeout=config.request_timeout,
)
client = HttpCoordinatorClient(
config.coordinator_url, config.request_timeout, token_provider=tokens
)
client = HttpCoordinatorClient(config.coordinator_url, config.request_timeout, config.bearer_token)
completed_without_interruption = WorkerDaemon(
config,
client,
HttpArtifactClient(
config.coordinator_url, config.request_timeout, token_provider=tokens
),
HttpArtifactClient(config.coordinator_url, config.request_timeout, config.bearer_token),
SciMeshRunner(),
).run_forever()
return 0 if completed_without_interruption else 130
-21
View File
@@ -11,14 +11,6 @@ from typing import Mapping
from urllib.parse import urlsplit
def _clean_url(value: object | None) -> str | None:
"""Normalise an optional URL: drop a blank one, strip a trailing slash."""
if value is None:
return None
text = str(value).strip()
return text.rstrip("/") or None
def _positive_number(value: object, name: str, *, allow_zero: bool = False) -> None:
if (
isinstance(value, bool)
@@ -43,11 +35,6 @@ class WorkerConfig:
request_timeout: float = 30.0
heartbeat_interval: float = 15.0
bearer_token: str | None = None
# A long-lived per-user credential. When set (with userservice_url), the
# worker exchanges it for short-lived JWTs instead of using bearer_token,
# binding the worker to that user's account.
worker_key: str | None = None
userservice_url: str | None = None
cleanup_after_seconds: float | None = None
max_tasks: int | None = None
exit_when_idle: bool = False
@@ -67,12 +54,6 @@ class WorkerConfig:
raise ValueError("coordinator_url must be an absolute HTTP(S) URL")
if not isinstance(self.worker_name, str) or not self.worker_name.strip():
raise ValueError("worker_name must be non-empty")
if self.userservice_url is not None:
us = urlsplit(self.userservice_url)
if us.scheme not in {"http", "https"} or not us.hostname:
raise ValueError("userservice_url must be an absolute HTTP(S) URL")
if self.worker_key is not None and not self.userservice_url:
raise ValueError("worker_key requires userservice_url (SCIMESH_USERSERVICE_URL)")
if isinstance(self.cpu_count, bool) or not isinstance(self.cpu_count, int) or self.cpu_count < 1:
raise ValueError("cpu_count must be positive")
if self.worker_id is not None and not isinstance(self.worker_id, str):
@@ -133,8 +114,6 @@ class WorkerConfig:
request_timeout=float(value("request_timeout", "SCIMESH_REQUEST_TIMEOUT", "30")),
heartbeat_interval=float(value("heartbeat_interval", "SCIMESH_HEARTBEAT_INTERVAL", "15")),
bearer_token=value("bearer_token", "SCIMESH_BEARER_TOKEN"),
worker_key=value("worker_key", "SCIMESH_WORKER_KEY"),
userservice_url=_clean_url(value("userservice_url", "SCIMESH_USERSERVICE_URL")),
cleanup_after_seconds=float(cleanup) if cleanup else None,
max_tasks=int(max_tasks) if max_tasks is not None else None,
exit_when_idle=bool(values.get("exit_when_idle", False)),
+3 -28
View File
@@ -7,7 +7,6 @@ from typing import Any, Protocol
from urllib.error import HTTPError, URLError
from urllib.request import Request, build_opener
from .auth import StaticTokenProvider, TokenProvider
from .models import ClaimedTask, RegisteredWorker
from .transport import NoRedirectHandler
@@ -39,25 +38,12 @@ class CoordinatorClient(Protocol):
class HttpCoordinatorClient:
def __init__(
self,
base_url: str,
timeout: float,
bearer_token: str | None = None,
*,
token_provider: TokenProvider | None = None,
) -> None:
def __init__(self, base_url: str, timeout: float, bearer_token: str | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
# A bearer_token argument keeps older call sites working; internally
# everything goes through a provider so refresh is uniform.
self._tokens: TokenProvider = token_provider or StaticTokenProvider(bearer_token)
self.bearer_token = bearer_token
self._opener = build_opener(NoRedirectHandler())
@property
def bearer_token(self) -> str | None:
return self._tokens.token()
def register(
self, name: str, capabilities: tuple[str, ...], cpu_count: int, memory_mb: int | None
) -> RegisteredWorker:
@@ -116,11 +102,6 @@ class HttpCoordinatorClient:
return lease_expires_at
def _request(self, method: str, path: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
return self._request_once(method, path, payload, allow_refresh=True)
def _request_once(
self, method: str, path: str, payload: dict[str, Any], *, allow_refresh: bool
) -> tuple[int, dict[str, Any]]:
request = Request(
f"{self.base_url}{path}", data=json.dumps(payload).encode(), method=method,
headers={"Content-Type": "application/json", **self._auth_header()},
@@ -133,11 +114,6 @@ class HttpCoordinatorClient:
except json.JSONDecodeError as error:
raise CoordinatorError("coordinator returned invalid JSON") from error
except HTTPError as error:
# A 401 usually means the short-lived JWT expired; mint a fresh one
# and retry exactly once so an in-flight worker rides over the gap.
if error.code == 401 and allow_refresh:
self._tokens.refresh()
return self._request_once(method, path, payload, allow_refresh=False)
if error.code >= 500:
raise CoordinatorTransientError(f"coordinator returned {error.code}") from error
return error.code, {}
@@ -145,5 +121,4 @@ class HttpCoordinatorClient:
raise CoordinatorTransientError("coordinator request failed") from error
def _auth_header(self) -> dict[str, str]:
token = self._tokens.token()
return {"Authorization": f"Bearer {token}"} if token else {}
return {"Authorization": f"Bearer {self.bearer_token}"} if self.bearer_token else {}
-205
View File
@@ -1,205 +0,0 @@
"""Tests for worker token strategies and the client's 401 refresh."""
from __future__ import annotations
import json
from pathlib import Path
from urllib.error import HTTPError
import pytest
from scimesh.worker.auth import (
StaticTokenProvider,
TokenExchangeError,
WorkerKeyTokenProvider,
provider_from_config,
)
from scimesh.worker.config import WorkerConfig
from scimesh.worker.coordinator import HttpCoordinatorClient
class FakeResponse:
def __init__(self, status: int, body: bytes) -> None:
self.status = status
self._body = body
def read(self) -> bytes:
return self._body
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *exc) -> bool:
return False
class SeqOpener:
"""Returns/raises a scripted sequence of responses, recording each request."""
def __init__(self, actions: list) -> None:
self.actions = list(actions)
self.requests: list = []
def open(self, request, timeout=None):
self.requests.append(request)
action = self.actions.pop(0)
if isinstance(action, Exception):
raise action
return action
def _exchange_response(token: str, expires_in: int) -> FakeResponse:
return FakeResponse(200, json.dumps({"token": token, "expires_in": expires_in}).encode())
def test_static_provider_returns_fixed_token_and_never_refreshes():
provider = StaticTokenProvider("tok")
assert provider.token() == "tok"
provider.refresh()
assert provider.token() == "tok"
def test_static_provider_none_means_no_auth():
assert StaticTokenProvider(None).token() is None
def test_worker_key_provider_exchanges_once_then_caches():
clock = {"t": 1000.0}
provider = WorkerKeyTokenProvider(
"http://users", "scimesh_wk_live_x", timeout=5, now=lambda: clock["t"]
)
provider._opener = SeqOpener([_exchange_response("jwt-1", 100)])
# First call exchanges; a second call well within the TTL reuses the cache.
assert provider.token() == "jwt-1"
clock["t"] = 1050.0 # 50s later, TTL 100s with 0.2 leeway → refresh at +80s
assert provider.token() == "jwt-1"
assert len(provider._opener.requests) == 1
def test_worker_key_provider_refreshes_after_leeway():
clock = {"t": 0.0}
provider = WorkerKeyTokenProvider(
"http://users", "k", timeout=5, now=lambda: clock["t"]
)
provider._opener = SeqOpener([
_exchange_response("jwt-1", 100),
_exchange_response("jwt-2", 100),
])
assert provider.token() == "jwt-1"
clock["t"] = 85.0 # past the 80s refresh point
assert provider.token() == "jwt-2"
assert len(provider._opener.requests) == 2
def test_worker_key_provider_force_refresh():
provider = WorkerKeyTokenProvider("http://users", "k", timeout=5, now=lambda: 0.0)
provider._opener = SeqOpener([
_exchange_response("jwt-1", 100),
_exchange_response("jwt-2", 100),
])
assert provider.token() == "jwt-1"
provider.refresh()
assert provider.token() == "jwt-2"
def test_worker_key_provider_raises_on_rejected_key():
provider = WorkerKeyTokenProvider("http://users", "bad", timeout=5, now=lambda: 0.0)
provider._opener = SeqOpener([HTTPError("http://users", 401, "unauthorized", {}, None)])
with pytest.raises(TokenExchangeError):
provider.token()
def test_worker_key_provider_raises_when_token_missing():
provider = WorkerKeyTokenProvider("http://users", "k", timeout=5, now=lambda: 0.0)
provider._opener = SeqOpener([FakeResponse(200, json.dumps({"expires_in": 100}).encode())])
with pytest.raises(TokenExchangeError):
provider.token()
def test_provider_from_config_selects_worker_key_mode():
provider = provider_from_config(
worker_key="scimesh_wk_live_x",
userservice_url="http://users",
bearer_token="ignored",
request_timeout=5,
)
assert isinstance(provider, WorkerKeyTokenProvider)
def test_provider_from_config_falls_back_to_static():
provider = provider_from_config(
worker_key=None, userservice_url=None, bearer_token="tok", request_timeout=5
)
assert isinstance(provider, StaticTokenProvider)
assert provider.token() == "tok"
class RefreshCountingProvider:
def __init__(self) -> None:
self.tokens = ["stale", "fresh"]
self.index = 0
self.refreshes = 0
def token(self) -> str:
return self.tokens[min(self.index, len(self.tokens) - 1)]
def refresh(self) -> None:
self.refreshes += 1
self.index += 1
def test_coordinator_client_refreshes_and_retries_once_on_401():
provider = RefreshCountingProvider()
client = HttpCoordinatorClient("http://coord", timeout=5, token_provider=provider)
client._opener = SeqOpener([
HTTPError("http://coord/tasks/claim", 401, "unauthorized", {}, None),
FakeResponse(204, b""),
])
status, _ = client._request("POST", "/tasks/claim", {"worker_id": "w"})
assert status == 204
assert provider.refreshes == 1
# The retry carried the refreshed token.
assert provider.index == 1
def test_coordinator_client_does_not_loop_on_persistent_401():
provider = RefreshCountingProvider()
client = HttpCoordinatorClient("http://coord", timeout=5, token_provider=provider)
client._opener = SeqOpener([
HTTPError("http://coord/x", 401, "unauthorized", {}, None),
HTTPError("http://coord/x", 401, "unauthorized", {}, None),
])
status, _ = client._request("POST", "/x", {})
# One refresh, one retry, then the second 401 is surfaced rather than retried.
assert status == 401
assert provider.refreshes == 1
def _base_config(**extra) -> dict:
return {
"coordinator_url": "http://coord",
"worker_id": None,
"work_dir": Path("."),
**extra,
}
def test_worker_key_requires_userservice_url():
with pytest.raises(ValueError, match="userservice_url"):
WorkerConfig(**_base_config(worker_key="scimesh_wk_live_x"))
def test_worker_key_with_userservice_url_is_valid():
cfg = WorkerConfig(**_base_config(worker_key="scimesh_wk_live_x", userservice_url="http://users"))
assert cfg.worker_key == "scimesh_wk_live_x"
assert cfg.userservice_url == "http://users"
def test_userservice_url_must_be_absolute():
with pytest.raises(ValueError, match="userservice_url"):
WorkerConfig(**_base_config(userservice_url="not-a-url"))
+3
View File
@@ -5,6 +5,9 @@ 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:
+5 -10
View File
@@ -49,21 +49,16 @@ func run() error {
// Adapters implementing the usecase ports.
users := postgres.NewUserRepo(pool)
workerKeys := postgres.NewWorkerKeyRepo(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),
CreateWorkerKey: usecase.NewCreateWorkerKey(workerKeys, clock),
ListWorkerKeys: usecase.NewListWorkerKeys(workerKeys),
RevokeWorkerKey: usecase.NewRevokeWorkerKey(workerKeys),
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(workerKeys, users, issuer, cfg.TokenTTL),
Users: users,
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.
-2
View File
@@ -8,6 +8,4 @@ var (
ErrEmptyEmail = errors.New("email is required")
ErrInvalidEmail = errors.New("email is not a valid address")
ErrEmptyPasswordHash = errors.New("password hash is required")
ErrWorkerKeyNameTooLong = errors.New("worker key name is too long")
)
-84
View File
@@ -1,84 +0,0 @@
package domain
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"time"
"github.com/google/uuid"
)
const (
// workerKeyLabel makes a key self-describing when it turns up in a log or an
// env var, and lets a client sanity-check the shape before exchanging it.
workerKeyLabel = "scimesh_wk_live_"
// workerKeyRandomBytes is the entropy behind the secret. 24 bytes (192 bits)
// is far beyond guessable, which is why the stored hash needs no salt.
workerKeyRandomBytes = 24
// workerKeyPrefixChars is how much of the random tail we keep, alongside the
// label, as the non-secret identifier shown in the UI.
workerKeyPrefixChars = 8
// workerKeyNameMax caps the user-supplied label.
workerKeyNameMax = 100
// workerKeyDefaultName is used when the caller supplies no label.
workerKeyDefaultName = "my machine"
)
// WorkerKey is a long-lived, per-user credential for running a worker. The
// secret itself is never stored — only TokenHash — so the plaintext returned by
// NewWorkerKey is the one and only chance to show it to the user.
type WorkerKey struct {
ID uuid.UUID
UserID uuid.UUID
Name string
TokenHash string
Prefix string
CreatedAt time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
}
// NewWorkerKey mints a key for a user and returns both the entity (carrying only
// the hash) and the one-time plaintext to hand back to the caller. The label is
// trimmed and defaulted; an over-long one is rejected.
func NewWorkerKey(userID uuid.UUID, name string, now time.Time) (*WorkerKey, string, error) {
name = strings.TrimSpace(name)
if name == "" {
name = workerKeyDefaultName
}
if len(name) > workerKeyNameMax {
return nil, "", ErrWorkerKeyNameTooLong
}
b := make([]byte, workerKeyRandomBytes)
if _, err := rand.Read(b); err != nil {
return nil, "", err
}
// URL-safe, unpadded: the key rides in env vars and shell commands, so it
// must contain no '=', '+', or '/' that a shell might mangle.
raw := workerKeyLabel + base64.RawURLEncoding.EncodeToString(b)
key := &WorkerKey{
ID: uuid.New(),
UserID: userID,
Name: name,
TokenHash: HashWorkerKey(raw),
Prefix: raw[:len(workerKeyLabel)+workerKeyPrefixChars],
CreatedAt: now,
}
return key, raw, nil
}
// HashWorkerKey returns the hex SHA-256 of a presented key. Exchange hashes the
// incoming key the same way and looks the row up by it, so the plaintext never
// has to be compared directly.
func HashWorkerKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// Revoked reports whether the key has been retired and must no longer exchange.
func (k *WorkerKey) Revoked() bool { return k.RevokedAt != nil }
-62
View File
@@ -1,62 +0,0 @@
package domain_test
import (
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/users/internal/domain"
)
func TestNewWorkerKeyShape(t *testing.T) {
owner := uuid.New()
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
key, raw, err := domain.NewWorkerKey(owner, "home-desktop", now)
if err != nil {
t.Fatalf("NewWorkerKey: %v", err)
}
if !strings.HasPrefix(raw, "scimesh_wk_live_") {
t.Errorf("raw key has no recognisable label: %q", raw)
}
if key.TokenHash != domain.HashWorkerKey(raw) {
t.Error("stored hash does not match the plaintext")
}
if key.TokenHash == raw || strings.Contains(key.TokenHash, raw) {
t.Error("plaintext leaked into the stored hash")
}
if !strings.HasPrefix(raw, key.Prefix) {
t.Errorf("prefix %q is not a leading slice of the key", key.Prefix)
}
if key.UserID != owner || key.CreatedAt != now || key.Revoked() {
t.Errorf("unexpected key metadata: %+v", key)
}
}
func TestNewWorkerKeyDefaultsBlankName(t *testing.T) {
key, _, err := domain.NewWorkerKey(uuid.New(), " ", time.Now())
if err != nil {
t.Fatalf("NewWorkerKey: %v", err)
}
if key.Name == "" {
t.Error("blank name was not defaulted")
}
}
func TestNewWorkerKeyRejectsLongName(t *testing.T) {
_, _, err := domain.NewWorkerKey(uuid.New(), strings.Repeat("x", 101), time.Now())
if !errors.Is(err, domain.ErrWorkerKeyNameTooLong) {
t.Errorf("got %v, want ErrWorkerKeyNameTooLong", err)
}
}
func TestNewWorkerKeyUniquePerCall(t *testing.T) {
a, rawA, _ := domain.NewWorkerKey(uuid.New(), "a", time.Now())
b, rawB, _ := domain.NewWorkerKey(uuid.New(), "b", time.Now())
if rawA == rawB || a.TokenHash == b.TokenHash || a.ID == b.ID {
t.Error("two keys collided; generation is not random")
}
}
@@ -1,123 +0,0 @@
package postgres
import (
"context"
"errors"
sq "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/emil28092005/SciMesh/users/internal/domain"
"github.com/emil28092005/SciMesh/users/internal/usecase"
)
var workerKeyColumns = []string{
"id", "user_id", "name", "token_hash", "prefix", "created_at", "last_used_at", "revoked_at",
}
// WorkerKeyRepo implements usecase.WorkerKeyRepository on PostgreSQL.
type WorkerKeyRepo struct {
pool *pgxpool.Pool
}
func NewWorkerKeyRepo(pool *pgxpool.Pool) *WorkerKeyRepo {
return &WorkerKeyRepo{pool: pool}
}
func (r *WorkerKeyRepo) Insert(ctx context.Context, k *domain.WorkerKey) error {
sql, args, err := psql.Insert("worker_keys").
Columns(workerKeyColumns...).
Values(k.ID, k.UserID, k.Name, k.TokenHash, k.Prefix, k.CreatedAt, k.LastUsedAt, k.RevokedAt).
ToSql()
if err != nil {
return err
}
_, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
return err
}
func (r *WorkerKeyRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
sql, args, err := psql.Select(workerKeyColumns...).
From("worker_keys").
Where(sq.Eq{"user_id": userID, "revoked_at": nil}).
OrderBy("created_at DESC").
ToSql()
if err != nil {
return nil, err
}
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
if err != nil {
return nil, err
}
defer rows.Close()
keys := []*domain.WorkerKey{}
for rows.Next() {
k, err := scanWorkerKey(rows)
if err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
func (r *WorkerKeyRepo) GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error) {
sql, args, err := psql.Select(workerKeyColumns...).
From("worker_keys").
Where(sq.Eq{"token_hash": tokenHash, "revoked_at": nil}).
ToSql()
if err != nil {
return nil, err
}
return scanWorkerKey(conn(ctx, r.pool).QueryRow(ctx, sql, args...))
}
// Revoke retires a live key the user owns. Scoping the UPDATE to both id and
// user_id means one user can never revoke another's key, and the revoked_at IS
// NULL guard makes a double-revoke a clean 404 rather than a silent success.
func (r *WorkerKeyRepo) Revoke(ctx context.Context, id, userID uuid.UUID) error {
sql, args, err := psql.Update("worker_keys").
Set("revoked_at", sq.Expr("now()")).
Where(sq.Eq{"id": id, "user_id": userID, "revoked_at": nil}).
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.ErrWorkerKeyNotFound
}
return nil
}
func (r *WorkerKeyRepo) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
sql, args, err := psql.Update("worker_keys").
Set("last_used_at", sq.Expr("now()")).
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
_, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
return err
}
func scanWorkerKey(row pgx.Row) (*domain.WorkerKey, error) {
var k domain.WorkerKey
if err := row.Scan(
&k.ID, &k.UserID, &k.Name, &k.TokenHash, &k.Prefix,
&k.CreatedAt, &k.LastUsedAt, &k.RevokedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, usecase.ErrWorkerKeyNotFound
}
return nil, err
}
return &k, nil
}
-50
View File
@@ -41,53 +41,3 @@ func toUserResponse(u *domain.User) userResponse {
CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339),
}
}
// createWorkerKeyRequest is the body for minting a worker key. Name is an
// optional human label; the domain defaults it when blank.
type createWorkerKeyRequest struct {
Name string `json:"name"`
}
// exchangeWorkerKeyRequest trades a worker key for a short-lived JWT.
type exchangeWorkerKeyRequest struct {
Key string `json:"key"`
}
type exchangeWorkerKeyResponse struct {
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
}
// workerKeyResponse is the public view of a key. It never carries the secret —
// only the non-secret prefix used to identify a row.
type workerKeyResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
}
// createdWorkerKeyResponse extends the public view with the one-time plaintext,
// returned only from the create call and never again.
type createdWorkerKeyResponse struct {
workerKeyResponse
Key string `json:"key"`
}
type workerKeysResponse struct {
WorkerKeys []workerKeyResponse `json:"worker_keys"`
}
func toWorkerKeyResponse(k *domain.WorkerKey) workerKeyResponse {
resp := workerKeyResponse{
ID: k.ID.String(),
Name: k.Name,
Prefix: k.Prefix,
CreatedAt: k.CreatedAt.UTC().Format(time.RFC3339),
}
if k.LastUsedAt != nil {
resp.LastUsedAt = k.LastUsedAt.UTC().Format(time.RFC3339)
}
return resp
}
-6
View File
@@ -48,12 +48,6 @@ func statusForError(err error) (int, string) {
return http.StatusUnauthorized, "invalid email or password"
case errors.Is(err, usecase.ErrUserNotFound):
return http.StatusNotFound, "user not found"
case errors.Is(err, usecase.ErrWorkerKeyNotFound):
return http.StatusNotFound, "worker key not found"
case errors.Is(err, usecase.ErrInvalidWorkerKey):
return http.StatusUnauthorized, "invalid worker key"
case errors.Is(err, domain.ErrWorkerKeyNameTooLong):
return http.StatusBadRequest, "worker key name is too long"
case errors.Is(err, usecase.ErrPasswordTooShort):
return http.StatusBadRequest, "password must be at least 8 characters"
case errors.Is(err, usecase.ErrPasswordTooLong):
+6 -106
View File
@@ -2,7 +2,6 @@ package http
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
@@ -14,16 +13,12 @@ import (
// Handlers holds the use cases each endpoint drives.
type Handlers struct {
register *usecase.Register
login *usecase.Login
setVerified *usecase.SetVerified
setRole *usecase.SetRole
createWorkerKey *usecase.CreateWorkerKey
listWorkerKeys *usecase.ListWorkerKeys
revokeWorkerKey *usecase.RevokeWorkerKey
exchangeWorkerKey *usecase.ExchangeWorkerKey
users usecase.UserRepository
log *slog.Logger
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
@@ -118,101 +113,6 @@ func (h *Handlers) handleSetRole(role domain.Role) http.HandlerFunc {
}
}
// handleCreateWorkerKey mints a long-lived worker key for the authenticated
// caller and returns it once, plaintext included. The user copies it into their
// worker's SCIMESH_WORKER_KEY; it is never retrievable again.
func (h *Handlers) handleCreateWorkerKey(w http.ResponseWriter, r *http.Request) {
id, ok := userIDFrom(r.Context())
if !ok {
unauthorized(w, r)
return
}
// A signed JWT can outlive a demo reset or an account deletion. Check that
// its subject still exists before attempting the insert, otherwise the
// worker_keys foreign key would turn a stale session into an internal error.
if _, err := h.users.GetByID(r.Context(), id); err != nil {
if errors.Is(err, usecase.ErrUserNotFound) {
unauthorized(w, r)
return
}
writeError(w, r, h.log, err)
return
}
var req createWorkerKeyRequest
if !decodeJSON(w, r, &req) {
return
}
key, raw, err := h.createWorkerKey.Execute(r.Context(), id, req.Name)
if err != nil {
writeError(w, r, h.log, err)
return
}
writeJSON(w, http.StatusCreated, createdWorkerKeyResponse{
workerKeyResponse: toWorkerKeyResponse(key),
Key: raw,
})
}
// handleListWorkerKeys returns the caller's live keys (no secrets) for display
// and revocation.
func (h *Handlers) handleListWorkerKeys(w http.ResponseWriter, r *http.Request) {
id, ok := userIDFrom(r.Context())
if !ok {
unauthorized(w, r)
return
}
keys, err := h.listWorkerKeys.Execute(r.Context(), id)
if err != nil {
writeError(w, r, h.log, err)
return
}
out := make([]workerKeyResponse, 0, len(keys))
for _, k := range keys {
out = append(out, toWorkerKeyResponse(k))
}
writeJSON(w, http.StatusOK, workerKeysResponse{WorkerKeys: out})
}
// handleRevokeWorkerKey retires one of the caller's keys. The repository scopes
// the delete to the owner, so a mismatched id is a clean 404, not another user's
// key.
func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFrom(r.Context())
if !ok {
unauthorized(w, r)
return
}
keyID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "invalid worker key id",
RequestID: requestIDFrom(r.Context()),
})
return
}
if err := h.revokeWorkerKey.Execute(r.Context(), userID, keyID); err != nil {
writeError(w, r, h.log, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleExchangeWorkerKey trades a worker key for a short-lived JWT. It is
// unauthenticated: the key itself is the credential. A worker calls this on
// startup and again to refresh before the JWT expires.
func (h *Handlers) handleExchangeWorkerKey(w http.ResponseWriter, r *http.Request) {
var req exchangeWorkerKeyRequest
if !decodeJSON(w, r, &req) {
return
}
token, expiresIn, err := h.exchangeWorkerKey.Execute(r.Context(), req.Key)
if err != nil {
writeError(w, r, h.log, err)
return
}
writeJSON(w, http.StatusOK, exchangeWorkerKeyResponse{Token: token, ExpiresIn: expiresIn})
}
// 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 }`.
+11 -27
View File
@@ -14,31 +14,23 @@ import (
// UseCases bundles the application services the handlers drive.
type UseCases struct {
Register *usecase.Register
Login *usecase.Login
SetVerified *usecase.SetVerified
SetRole *usecase.SetRole
CreateWorkerKey *usecase.CreateWorkerKey
ListWorkerKeys *usecase.ListWorkerKeys
RevokeWorkerKey *usecase.RevokeWorkerKey
ExchangeWorkerKey *usecase.ExchangeWorkerKey
Users usecase.UserRepository
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,
createWorkerKey: uc.CreateWorkerKey,
listWorkerKeys: uc.ListWorkerKeys,
revokeWorkerKey: uc.RevokeWorkerKey,
exchangeWorkerKey: uc.ExchangeWorkerKey,
users: uc.Users,
log: log,
register: uc.Register,
login: uc.Login,
setVerified: uc.SetVerified,
setRole: uc.SetRole,
users: uc.Users,
log: log,
}
mux := http.NewServeMux()
@@ -49,14 +41,6 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
// /me proves a token round-trips; it sits behind JWT auth.
mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer)))
// Worker keys: a user mints a long-lived key (JWT-protected), and a worker
// trades it for a short-lived JWT on the public exchange endpoint — the key
// itself is the credential there, so no prior token is required.
mux.HandleFunc("POST /worker-tokens/exchange", h.handleExchangeWorkerKey)
mux.Handle("POST /worker-keys", chain(http.HandlerFunc(h.handleCreateWorkerKey), withJWT(issuer)))
mux.Handle("GET /worker-keys", chain(http.HandlerFunc(h.handleListWorkerKeys), withJWT(issuer)))
mux.Handle("DELETE /worker-keys/{id}", chain(http.HandlerFunc(h.handleRevokeWorkerKey), 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",
-8
View File
@@ -7,14 +7,6 @@ var (
ErrEmailExists = errors.New("email already registered")
ErrUserNotFound = errors.New("user not found")
// ErrWorkerKeyNotFound is returned by WorkerKeyRepository when no live key
// matches (by id for revoke, by hash for exchange).
ErrWorkerKeyNotFound = errors.New("worker key not found")
// ErrInvalidWorkerKey is surfaced to the transport layer for a key that does
// not exchange (unknown, revoked, or owner gone). Deliberately opaque so a
// caller cannot distinguish the cases while probing.
ErrInvalidWorkerKey = errors.New("invalid worker key")
// Use-case errors surfaced to the transport layer.
//
// ErrInvalidCredentials is deliberately returned for both an unknown email
-19
View File
@@ -30,25 +30,6 @@ type UserRepository interface {
SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error
}
// WorkerKeyRepository persists and looks up the long-lived worker keys a user
// creates to run a worker bound to their account. Implementations return the
// sentinel errors in errors.go so the use cases stay free of SQL types.
type WorkerKeyRepository interface {
// Insert stores a freshly minted key.
Insert(ctx context.Context, k *domain.WorkerKey) error
// ListByUser returns a user's live (non-revoked) keys, newest first.
ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error)
// GetActiveByHash returns the non-revoked key with the given hash, or
// ErrWorkerKeyNotFound.
GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error)
// Revoke retires a key the user owns, returning ErrWorkerKeyNotFound when no
// live key with that id belongs to the user.
Revoke(ctx context.Context, id, userID uuid.UUID) error
// TouchLastUsed records a successful exchange. Best-effort: a failure here
// must not fail the exchange itself.
TouchLastUsed(ctx context.Context, id uuid.UUID) error
}
// PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it.
type PasswordHasher interface {
Hash(password string) (string, error)
-112
View File
@@ -1,112 +0,0 @@
package usecase
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/users/internal/domain"
)
// CreateWorkerKey mints a long-lived worker key for a user and returns the
// one-time plaintext to show once.
type CreateWorkerKey struct {
keys WorkerKeyRepository
clock Clock
}
func NewCreateWorkerKey(keys WorkerKeyRepository, clock Clock) *CreateWorkerKey {
return &CreateWorkerKey{keys: keys, clock: clock}
}
// Execute returns the stored key (hash only) and the plaintext secret. The
// secret is never persisted, so this is the sole moment it can be surfaced.
func (uc *CreateWorkerKey) Execute(ctx context.Context, userID uuid.UUID, name string) (*domain.WorkerKey, string, error) {
key, raw, err := domain.NewWorkerKey(userID, name, uc.clock.Now())
if err != nil {
return nil, "", err
}
if err := uc.keys.Insert(ctx, key); err != nil {
return nil, "", err
}
return key, raw, nil
}
// ListWorkerKeys returns a user's live keys for display and management.
type ListWorkerKeys struct {
keys WorkerKeyRepository
}
func NewListWorkerKeys(keys WorkerKeyRepository) *ListWorkerKeys {
return &ListWorkerKeys{keys: keys}
}
func (uc *ListWorkerKeys) Execute(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
return uc.keys.ListByUser(ctx, userID)
}
// RevokeWorkerKey retires one of the caller's keys.
type RevokeWorkerKey struct {
keys WorkerKeyRepository
}
func NewRevokeWorkerKey(keys WorkerKeyRepository) *RevokeWorkerKey {
return &RevokeWorkerKey{keys: keys}
}
func (uc *RevokeWorkerKey) Execute(ctx context.Context, userID, id uuid.UUID) error {
return uc.keys.Revoke(ctx, id, userID)
}
// ExchangeWorkerKey trades a valid worker key for a short-lived JWT. The JWT
// carries the owner's current role and verified flag, so a worker that refreshes
// after an admin verifies the owner picks up the upgraded trust on its next
// registration.
type ExchangeWorkerKey struct {
keys WorkerKeyRepository
users UserRepository
tokens TokenIssuer
ttl time.Duration
}
func NewExchangeWorkerKey(keys WorkerKeyRepository, users UserRepository, tokens TokenIssuer, ttl time.Duration) *ExchangeWorkerKey {
return &ExchangeWorkerKey{keys: keys, users: users, tokens: tokens, ttl: ttl}
}
// Execute returns a signed token and its lifetime in seconds. Every failure to
// resolve the key to a usable owner collapses to ErrInvalidWorkerKey so a caller
// cannot tell an unknown key from a revoked one or a deleted owner.
func (uc *ExchangeWorkerKey) Execute(ctx context.Context, rawKey string) (string, int, error) {
if rawKey == "" {
return "", 0, ErrInvalidWorkerKey
}
key, err := uc.keys.GetActiveByHash(ctx, domain.HashWorkerKey(rawKey))
if err != nil {
if errors.Is(err, ErrWorkerKeyNotFound) {
return "", 0, ErrInvalidWorkerKey
}
return "", 0, err
}
u, err := uc.users.GetByID(ctx, key.UserID)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return "", 0, ErrInvalidWorkerKey
}
return "", 0, err
}
token, err := uc.tokens.Issue(u)
if err != nil {
return "", 0, err
}
// Best-effort: a failed timestamp update must not sink an otherwise valid
// exchange the worker depends on to keep running.
_ = uc.keys.TouchLastUsed(ctx, key.ID)
return token, int(uc.ttl.Seconds()), nil
}
-186
View File
@@ -1,186 +0,0 @@
package usecase_test
import (
"context"
"errors"
"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"
"github.com/emil28092005/SciMesh/users/internal/usecase"
)
// fakeKeyRepo is an in-memory WorkerKeyRepository for the use-case tests.
type fakeKeyRepo struct {
byHash map[string]*domain.WorkerKey
byID map[uuid.UUID]*domain.WorkerKey
touched []uuid.UUID
}
func newFakeKeyRepo() *fakeKeyRepo {
return &fakeKeyRepo{byHash: map[string]*domain.WorkerKey{}, byID: map[uuid.UUID]*domain.WorkerKey{}}
}
func (r *fakeKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error {
r.byHash[k.TokenHash] = k
r.byID[k.ID] = k
return nil
}
func (r *fakeKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
var out []*domain.WorkerKey
for _, k := range r.byID {
if k.UserID == userID && !k.Revoked() {
out = append(out, k)
}
}
return out, nil
}
func (r *fakeKeyRepo) GetActiveByHash(_ context.Context, hash string) (*domain.WorkerKey, error) {
k, ok := r.byHash[hash]
if !ok || k.Revoked() {
return nil, usecase.ErrWorkerKeyNotFound
}
return k, nil
}
func (r *fakeKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error {
k, ok := r.byID[id]
if !ok || k.UserID != userID || k.Revoked() {
return usecase.ErrWorkerKeyNotFound
}
now := time.Now()
k.RevokedAt = &now
return nil
}
func (r *fakeKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
r.touched = append(r.touched, id)
return nil
}
func newKeyFixtures(t *testing.T) (*usecase.CreateWorkerKey, *usecase.ExchangeWorkerKey, *usecase.RevokeWorkerKey, *usecase.ListWorkerKeys, *fakeKeyRepo, *domain.User) {
t.Helper()
users := memstore.NewUserRepo()
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)
keys := newFakeKeyRepo()
u, err := usecase.NewRegister(users, hasher, clk).Execute(context.Background(), "worker@example.com", "password123")
if err != nil {
t.Fatalf("seed user: %v", err)
}
return usecase.NewCreateWorkerKey(keys, clk),
usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour),
usecase.NewRevokeWorkerKey(keys),
usecase.NewListWorkerKeys(keys),
keys, u
}
func TestCreateAndExchangeWorkerKey(t *testing.T) {
create, exchange, _, _, keys, u := newKeyFixtures(t)
ctx := context.Background()
key, raw, err := create.Execute(ctx, u.ID, "home-desktop")
if err != nil {
t.Fatalf("create: %v", err)
}
if key.Name != "home-desktop" || raw == "" {
t.Fatalf("unexpected key %+v raw=%q", key, raw)
}
token, expiresIn, err := exchange.Execute(ctx, raw)
if err != nil {
t.Fatalf("exchange: %v", err)
}
if expiresIn != int((time.Hour).Seconds()) {
t.Errorf("expires_in = %d, want 3600", expiresIn)
}
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 owner %q", claims.Subject, u.ID)
}
if len(keys.touched) != 1 || keys.touched[0] != key.ID {
t.Errorf("exchange did not record last-used, touched=%v", keys.touched)
}
}
func TestExchangeUnknownKeyIsInvalid(t *testing.T) {
_, exchange, _, _, _, _ := newKeyFixtures(t)
if _, _, err := exchange.Execute(context.Background(), "scimesh_wk_live_nope"); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
t.Errorf("got %v, want ErrInvalidWorkerKey", err)
}
}
func TestExchangeEmptyKeyIsInvalid(t *testing.T) {
_, exchange, _, _, _, _ := newKeyFixtures(t)
if _, _, err := exchange.Execute(context.Background(), ""); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
t.Errorf("got %v, want ErrInvalidWorkerKey", err)
}
}
func TestExchangeRevokedKeyIsInvalid(t *testing.T) {
create, exchange, revoke, _, _, u := newKeyFixtures(t)
ctx := context.Background()
key, raw, err := create.Execute(ctx, u.ID, "laptop")
if err != nil {
t.Fatal(err)
}
if err := revoke.Execute(ctx, u.ID, key.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, _, err := exchange.Execute(ctx, raw); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
t.Errorf("revoked key still exchanges: %v", err)
}
}
func TestRevokeIsScopedToOwner(t *testing.T) {
create, _, revoke, _, _, u := newKeyFixtures(t)
ctx := context.Background()
key, _, err := create.Execute(ctx, u.ID, "laptop")
if err != nil {
t.Fatal(err)
}
// A different user must not be able to revoke this key.
if err := revoke.Execute(ctx, uuid.New(), key.ID); !errors.Is(err, usecase.ErrWorkerKeyNotFound) {
t.Errorf("cross-owner revoke returned %v, want ErrWorkerKeyNotFound", err)
}
}
func TestListReturnsOnlyLiveKeys(t *testing.T) {
create, _, revoke, list, _, u := newKeyFixtures(t)
ctx := context.Background()
live, _, err := create.Execute(ctx, u.ID, "keep")
if err != nil {
t.Fatal(err)
}
dead, _, err := create.Execute(ctx, u.ID, "drop")
if err != nil {
t.Fatal(err)
}
if err := revoke.Execute(ctx, u.ID, dead.ID); err != nil {
t.Fatal(err)
}
got, err := list.Execute(ctx, u.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 1 || got[0].ID != live.ID {
t.Errorf("list = %d keys, want only the live one", len(got))
}
}
@@ -1,5 +0,0 @@
BEGIN;
DROP TABLE IF EXISTS worker_keys;
COMMIT;
-31
View File
@@ -1,31 +0,0 @@
BEGIN;
-- A worker key is a long-lived credential a user creates to run a worker on
-- their own machine. Unlike the 24h login JWT, it does not expire on its own:
-- the worker presents it to /worker-tokens/exchange to mint a short-lived JWT
-- and refreshes as needed. Only a SHA-256 hash is stored, never the key itself,
-- so a database leak cannot be replayed as a credential.
CREATE TABLE worker_keys (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Human label so a user can tell their machines apart when revoking.
name text NOT NULL,
-- Hex SHA-256 of the presented key. The key is high-entropy, so a fast hash
-- is enough — no per-key salt or bcrypt cost is needed here.
token_hash text NOT NULL,
-- The leading, non-secret slice of the key, shown in the UI to identify a
-- row without ever revealing the secret again.
prefix text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
-- Last successful exchange; NULL until first use.
last_used_at timestamptz,
-- Set when the user revokes the key; a revoked key never exchanges again.
revoked_at timestamptz,
CONSTRAINT uq_worker_keys_token_hash UNIQUE (token_hash)
);
-- Listing and revoking are always scoped to one owner's live keys.
CREATE INDEX ix_worker_keys_user_active ON worker_keys (user_id) WHERE revoked_at IS NULL;
COMMIT;