RC-7
release-tag / release-image (push) Failing after 2m44s

This commit is contained in:
2026-08-11 16:58:07 +02:00
parent 185ccf1101
commit bcfbef390f
44 changed files with 4778 additions and 172 deletions
+169
View File
@@ -0,0 +1,169 @@
package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
var beaconPaths = []string{"PULSE", "FLUX", "ORBIT"}
func normalizeBeaconPath(v string) string {
v = strings.ToUpper(strings.TrimSpace(v))
for _, p := range beaconPaths {
if v == p {
return p
}
}
return ""
}
type drandInfo struct {
Period int64 `json:"period"`
GenesisTime int64 `json:"genesis_time"`
}
type drandRound struct {
Round uint64 `json:"round"`
Signature string `json:"signature"`
}
type beaconReveal struct {
Source string
BeaconID string
Round uint64
Randomness string
Signature string
}
type beaconClient struct {
base string
beaconID string
hc *http.Client
mu sync.Mutex
info drandInfo
infoAt time.Time
}
func newBeaconClient() *beaconClient {
base := strings.TrimRight(strings.TrimSpace(os.Getenv("BEACON_DRAND_URL")), "/")
if base == "" {
base = "https://api.drand.sh"
}
id := strings.TrimSpace(os.Getenv("BEACON_DRAND_BEACON_ID"))
if id == "" {
id = "quicknet"
}
return &beaconClient{base: base, beaconID: id, hc: &http.Client{Timeout: 5 * time.Second}}
}
func (b *beaconClient) chainInfo(ctx context.Context) (drandInfo, error) {
b.mu.Lock()
if b.info.Period > 0 && time.Since(b.infoAt) < 6*time.Hour {
v := b.info
b.mu.Unlock()
return v, nil
}
b.mu.Unlock()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/v2/beacons/"+b.beaconID+"/info", nil)
if err != nil {
return drandInfo{}, err
}
resp, err := b.hc.Do(req)
if err != nil {
return drandInfo{}, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return drandInfo{}, fmt.Errorf("drand info HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var info drandInfo
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil {
return drandInfo{}, err
}
if info.Period <= 0 || info.GenesisTime <= 0 {
return drandInfo{}, errors.New("drand returned invalid chain info")
}
b.mu.Lock()
b.info, b.infoAt = info, time.Now()
b.mu.Unlock()
return info, nil
}
func roundTime(info drandInfo, round uint64) time.Time {
if round == 0 {
return time.Unix(info.GenesisTime, 0).UTC()
}
return time.Unix(info.GenesisTime+int64(round-1)*info.Period, 0).UTC()
}
// targetRound chooses the first beacon round that starts strictly after the
// guess window closes. That makes the beacon value unknowable while players
// are choosing PULSE/FLUX/ORBIT and submitting their ticket.
func targetRound(info drandInfo, windowEnd time.Time) uint64 {
if windowEnd.Unix() <= info.GenesisTime {
return 1
}
elapsed := windowEnd.Unix() - info.GenesisTime
return uint64(elapsed/info.Period) + 2
}
func (b *beaconClient) plan(ctx context.Context, windowEnd time.Time) (uint64, time.Time, error) {
info, err := b.chainInfo(ctx)
if err != nil {
return 0, time.Time{}, err
}
r := targetRound(info, windowEnd)
return r, roundTime(info, r), nil
}
func (b *beaconClient) reveal(ctx context.Context, round uint64) (beaconReveal, error) {
path := b.base + "/v2/beacons/" + b.beaconID + "/rounds/" + strconv.FormatUint(round, 10)
var last error
for attempt := 0; attempt < 6; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return beaconReveal{}, err
}
resp, err := b.hc.Do(req)
if err == nil {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
_ = resp.Body.Close()
if readErr == nil && resp.StatusCode/100 == 2 {
var rr drandRound
if json.Unmarshal(body, &rr) == nil && rr.Round == round && rr.Signature != "" {
sig, decErr := hex.DecodeString(rr.Signature)
if decErr != nil {
return beaconReveal{}, fmt.Errorf("decode drand signature: %w", decErr)
}
h := sha256.Sum256(sig)
return beaconReveal{Source: "drand", BeaconID: b.beaconID, Round: round, Randomness: hex.EncodeToString(h[:]), Signature: rr.Signature}, nil
}
}
last = fmt.Errorf("drand round HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
} else {
last = err
}
select {
case <-ctx.Done():
return beaconReveal{}, ctx.Err()
case <-time.After(time.Duration(attempt+1) * 750 * time.Millisecond):
}
}
if last == nil {
last = errors.New("drand round unavailable")
}
return beaconReveal{}, last
}
+183 -34
View File
@@ -3,6 +3,8 @@ package server
import (
"context"
crand "crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"math/big"
"sync"
@@ -10,85 +12,149 @@ import (
)
var (
errLotteryDuplicate = errors.New("guess already entered in current lottery window")
errLotteryFull = errors.New("guess lottery window is full")
errLotteryDuplicate = errors.New("guess already entered in current lottery window")
errLotteryFull = errors.New("guess lottery window is full")
errBeaconUnavailable = errors.New("external randomness beacon unavailable")
)
const maxLotteryTicketsPerWindow = 250000
type lotteryResult struct {
Selected bool `json:"selected"`
BeaconEnabled bool `json:"beacon_enabled"`
ChosenPath string `json:"chosen_path,omitempty"`
BoostedPath string `json:"boosted_path,omitempty"`
BeaconSource string `json:"beacon_source,omitempty"`
BeaconID string `json:"beacon_id,omitempty"`
BeaconRound uint64 `json:"beacon_round,omitempty"`
Randomness string `json:"randomness,omitempty"`
Weight int `json:"weight,omitempty"`
WindowEnd int64 `json:"window_end,omitempty"`
}
type lotteryTicket struct {
key string
path string
ctx context.Context
result chan bool
result chan lotteryDelivery
}
type lotteryDelivery struct {
result lotteryResult
err error
}
type lotteryBucket struct {
max int
end time.Time
tickets []*lotteryTicket
keys map[string]struct{}
max int
end time.Time
tickets []*lotteryTicket
keys map[string]struct{}
beacon bool
bonusWeight int
beaconRound uint64
revealAt time.Time
}
type beaconDrawAudit struct {
TaskID string
WindowEnd time.Time
BeaconID string
BeaconRound uint64
Randomness string
Signature string
BoostedPath string
Tickets int
Selected int
}
type guessLottery struct {
mu sync.Mutex
buckets map[string]*lotteryBucket
beacon *beaconClient
onDraw func(beaconDrawAudit)
}
func newGuessLottery() *guessLottery {
return &guessLottery{buckets: make(map[string]*lotteryBucket)}
func newGuessLottery(onDraw func(beaconDrawAudit)) *guessLottery {
return &guessLottery{buckets: make(map[string]*lotteryBucket), beacon: newBeaconClient(), onDraw: onDraw}
}
// enter batches all valid tips for a task into aligned time windows. At the
// window boundary exactly up to max tickets are selected uniformly at random.
// The request intentionally waits for the draw so later arrivals in the same
// window have the same chance as earlier arrivals.
func (l *guessLottery) enter(ctx context.Context, taskID, clientID string, seq int64, window time.Duration, max int) (bool, error) {
// enter batches all valid tips for a task into aligned time windows. When
// Beacon Hunt is enabled, players commit to PULSE/FLUX/ORBIT before the window
// closes. The first drand round after the boundary becomes the deterministic
// source for both the boosted path and the weighted draw.
func (l *guessLottery) enter(ctx context.Context, taskID, clientID string, seq int64, path string, window time.Duration, max int, beaconEnabled bool, bonusWeight int) (lotteryResult, error) {
if max <= 0 || window <= 0 {
return true, nil
return lotteryResult{Selected: true}, nil
}
if beaconEnabled {
path = normalizeBeaconPath(path)
if path == "" {
return lotteryResult{}, errors.New("beacon path required")
}
if bonusWeight < 1 {
bonusWeight = 1
}
}
now := time.Now().UTC()
windowNS := window.Nanoseconds()
if windowNS <= 0 {
return true, nil
return lotteryResult{Selected: true}, nil
}
idx := now.UnixNano() / windowNS
end := time.Unix(0, (idx+1)*windowNS).UTC()
bucketKey := taskID + "|" + end.Format(time.RFC3339Nano) + "|" + window.String()
ticketKey := clientID + "|" + big.NewInt(seq).String()
t := &lotteryTicket{key: ticketKey, ctx: ctx, result: make(chan bool, 1)}
t := &lotteryTicket{key: ticketKey, path: path, ctx: ctx, result: make(chan lotteryDelivery, 1)}
l.mu.Lock()
b := l.buckets[bucketKey]
if b == nil {
b = &lotteryBucket{max: max, end: end, keys: make(map[string]struct{})}
b = &lotteryBucket{max: max, end: end, keys: make(map[string]struct{}), beacon: beaconEnabled, bonusWeight: bonusWeight}
if beaconEnabled {
planCtx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
round, revealAt, err := l.beacon.plan(planCtx, end)
cancel()
if err != nil {
l.mu.Unlock()
return lotteryResult{}, errBeaconUnavailable
}
b.beaconRound, b.revealAt = round, revealAt
}
l.buckets[bucketKey] = b
delay := time.Until(end)
drawAt := end
if b.beacon && b.revealAt.After(drawAt) {
drawAt = b.revealAt.Add(700 * time.Millisecond)
}
delay := time.Until(drawAt)
if delay < 0 {
delay = 0
}
time.AfterFunc(delay, func() { l.draw(bucketKey) })
time.AfterFunc(delay, func() { l.draw(bucketKey, taskID) })
} else if b.beacon != beaconEnabled {
l.mu.Unlock()
return lotteryResult{}, errors.New("lottery mode changed during active window")
}
if _, exists := b.keys[ticketKey]; exists {
l.mu.Unlock()
return false, errLotteryDuplicate
return lotteryResult{}, errLotteryDuplicate
}
if len(b.tickets) >= maxLotteryTicketsPerWindow {
l.mu.Unlock()
return false, errLotteryFull
return lotteryResult{}, errLotteryFull
}
b.keys[ticketKey] = struct{}{}
b.tickets = append(b.tickets, t)
l.mu.Unlock()
select {
case selected := <-t.result:
return selected, nil
case d := <-t.result:
return d.result, d.err
case <-ctx.Done():
return false, ctx.Err()
return lotteryResult{}, ctx.Err()
}
}
func (l *guessLottery) draw(bucketKey string) {
func (l *guessLottery) draw(bucketKey, taskID string) {
l.mu.Lock()
b := l.buckets[bucketKey]
if b == nil {
@@ -97,39 +163,122 @@ func (l *guessLottery) draw(bucketKey string) {
}
delete(l.buckets, bucketKey)
tickets := append([]*lotteryTicket(nil), b.tickets...)
max := b.max
l.mu.Unlock()
// Canceled HTTP requests do not consume one of the scarce winning slots.
alive := tickets[:0]
for _, t := range tickets {
select {
case <-t.ctx.Done():
// skip
default:
alive = append(alive, t)
}
}
tickets = alive
max := b.max
if max > len(tickets) {
max = len(tickets)
}
// Partial Fisher-Yates with crypto/rand gives every ticket equal odds.
if b.beacon {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
reveal, err := l.beacon.reveal(ctx, b.beaconRound)
cancel()
if err != nil {
for _, t := range tickets {
select {
case t.result <- lotteryDelivery{err: errBeaconUnavailable}:
default:
}
}
return
}
boosted := beaconPathFromRandomness(reveal.Randomness, bucketKey)
selected := weightedBeaconDraw(tickets, max, boosted, b.bonusWeight, reveal.Randomness, bucketKey)
selectedCount := 0
for _, yes := range selected {
if yes {
selectedCount++
}
}
for i, t := range tickets {
weight := 1
if t.path == boosted {
weight = b.bonusWeight
}
res := lotteryResult{Selected: selected[i], BeaconEnabled: true, ChosenPath: t.path, BoostedPath: boosted, BeaconSource: reveal.Source, BeaconID: reveal.BeaconID, BeaconRound: reveal.Round, Randomness: reveal.Randomness, Weight: weight, WindowEnd: b.end.Unix()}
select {
case t.result <- lotteryDelivery{result: res}:
default:
}
}
if l.onDraw != nil {
l.onDraw(beaconDrawAudit{TaskID: taskID, WindowEnd: b.end, BeaconID: reveal.BeaconID, BeaconRound: reveal.Round, Randomness: reveal.Randomness, Signature: reveal.Signature, BoostedPath: boosted, Tickets: len(tickets), Selected: selectedCount})
}
return
}
// Legacy lottery: partial Fisher-Yates with crypto/rand.
for i := 0; i < max; i++ {
nBig, err := crand.Int(crand.Reader, big.NewInt(int64(len(tickets)-i)))
if err != nil {
// crypto/rand failure is extremely unusual; deterministic fallback still
// keeps the quota safe, but does not claim cryptographic randomness.
nBig = big.NewInt(0)
}
j := i + int(nBig.Int64())
tickets[i], tickets[j] = tickets[j], tickets[i]
}
for i, t := range tickets {
selected := i < max
select {
case t.result <- selected:
case t.result <- lotteryDelivery{result: lotteryResult{Selected: i < max}}:
default:
}
}
}
func beaconPathFromRandomness(randomness, bucketKey string) string {
h := sha256.Sum256([]byte("nh-beacon-path-v1|" + randomness + "|" + bucketKey))
return beaconPaths[int(h[0])%len(beaconPaths)]
}
func deterministicUint64(seed string, counter int) uint64 {
h := sha256.Sum256([]byte(seed + "|" + big.NewInt(int64(counter)).String()))
return binary.BigEndian.Uint64(h[:8])
}
func weightedBeaconDraw(tickets []*lotteryTicket, max int, boosted string, bonusWeight int, randomness, bucketKey string) []bool {
out := make([]bool, len(tickets))
remaining := make([]int, len(tickets))
for i := range tickets {
remaining[i] = i
}
seed := "nh-beacon-draw-v1|" + randomness + "|" + bucketKey
for pick := 0; pick < max && len(remaining) > 0; pick++ {
total := 0
for _, idx := range remaining {
w := 1
if tickets[idx].path == boosted {
w = bonusWeight
}
total += w
}
if total <= 0 {
break
}
r := int(deterministicUint64(seed, pick) % uint64(total))
chosenPos := 0
for pos, idx := range remaining {
w := 1
if tickets[idx].path == boosted {
w = bonusWeight
}
if r < w {
chosenPos = pos
break
}
r -= w
}
idx := remaining[chosenPos]
out[idx] = true
remaining = append(remaining[:chosenPos], remaining[chosenPos+1:]...)
}
return out
}
+42 -9
View File
@@ -8,20 +8,20 @@ import (
)
func TestGuessLotteryDrawSelectsExactQuota(t *testing.T) {
l := newGuessLottery()
l := newGuessLottery(nil)
const total = 40
const quota = 9
b := &lotteryBucket{max: quota, end: time.Now().Add(time.Second), keys: make(map[string]struct{})}
for i := 0; i < total; i++ {
ticket := &lotteryTicket{key: fmt.Sprintf("c-%d|0", i), ctx: context.Background(), result: make(chan bool, 1)}
ticket := &lotteryTicket{key: fmt.Sprintf("c-%d|0", i), ctx: context.Background(), result: make(chan lotteryDelivery, 1)}
b.tickets = append(b.tickets, ticket)
b.keys[ticket.key] = struct{}{}
}
l.buckets["test"] = b
l.draw("test")
l.draw("test", "task-test")
selected := 0
for _, ticket := range b.tickets {
if <-ticket.result {
if (<-ticket.result).result.Selected {
selected++
}
}
@@ -31,14 +31,47 @@ func TestGuessLotteryDrawSelectsExactQuota(t *testing.T) {
}
func TestGuessLotteryCanceledTicketDoesNotConsumeQuota(t *testing.T) {
l := newGuessLottery()
l := newGuessLottery(nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
canceled := &lotteryTicket{key: "canceled", ctx: ctx, result: make(chan bool, 1)}
alive := &lotteryTicket{key: "alive", ctx: context.Background(), result: make(chan bool, 1)}
canceled := &lotteryTicket{key: "canceled", ctx: ctx, result: make(chan lotteryDelivery, 1)}
alive := &lotteryTicket{key: "alive", ctx: context.Background(), result: make(chan lotteryDelivery, 1)}
l.buckets["test"] = &lotteryBucket{max: 1, tickets: []*lotteryTicket{canceled, alive}, keys: map[string]struct{}{"canceled": {}, "alive": {}}}
l.draw("test")
if got := <-alive.result; !got {
l.draw("test", "task-test")
if got := (<-alive.result).result.Selected; !got {
t.Fatal("live ticket should receive the available slot")
}
}
func TestWeightedBeaconDrawIsDeterministicAndRewardsBoostedPath(t *testing.T) {
tickets := []*lotteryTicket{
{key: "a", path: "PULSE"}, {key: "b", path: "FLUX"}, {key: "c", path: "PULSE"},
{key: "d", path: "ORBIT"}, {key: "e", path: "PULSE"}, {key: "f", path: "FLUX"},
}
a := weightedBeaconDraw(tickets, 3, "PULSE", 2, "deadbeef", "bucket")
b := weightedBeaconDraw(tickets, 3, "PULSE", 2, "deadbeef", "bucket")
if len(a) != len(b) {
t.Fatal("draw length mismatch")
}
count := 0
for i := range a {
if a[i] != b[i] {
t.Fatalf("draw is not deterministic at %d", i)
}
if a[i] {
count++
}
}
if count != 3 {
t.Fatalf("selected %d, want 3", count)
}
}
func TestBeaconTargetRoundIsStrictlyAfterWindow(t *testing.T) {
info := drandInfo{Period: 3, GenesisTime: 1000}
end := time.Unix(1006, 0).UTC() // exact round boundary
r := targetRound(info, end)
if !roundTime(info, r).After(end) {
t.Fatalf("round %d at %s must be after %s", r, roundTime(info, r), end)
}
}
+246 -26
View File
@@ -2,6 +2,10 @@ package server
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -42,6 +46,7 @@ type Server struct {
artifactWorker *artifact.Worker
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
internalServiceSecret string
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS int64
@@ -51,20 +56,23 @@ type Server struct {
func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server {
s := &Server{
store: store,
auth: a,
settings: sm,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
artifactDir: artifactDir,
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
store: store,
auth: a,
settings: sm,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(func(d beaconDrawAudit) {
_ = store.RecordBeaconDraw(context.Background(), d.TaskID, d.WindowEnd, d.BeaconID, d.BeaconRound, d.Randomness, d.Signature, d.BoostedPath, d.Tickets, d.Selected)
}),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
artifactDir: artifactDir,
internalServiceSecret: strings.TrimSpace(os.Getenv("CUSTOMER_SERVICE_SHARED_SECRET")),
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
}
s.upgrader = websocket.Upgrader{CheckOrigin: s.checkWSOrigin, Subprotocols: []string{"neuralhunt.v1"}}
return s
@@ -242,7 +250,7 @@ func (s *Server) PublicRoutes() http.Handler {
next := s.Routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.ToLower(r.URL.Path)
if p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") {
if p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/api/internal" || strings.HasPrefix(p, "/api/internal/") {
http.NotFound(w, r)
return
}
@@ -260,7 +268,7 @@ func (s *Server) AdminRoutes() http.Handler {
http.Redirect(w, r, "/admin", http.StatusTemporaryRedirect)
return
}
allowed := p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/healthz" || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/app.js" || p == "/styles.css" || p == "/index.html"
allowed := p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/healthz" || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/api/internal" || strings.HasPrefix(p, "/api/internal/") || p == "/app.js" || p == "/styles.css" || p == "/index.html"
if !allowed {
http.NotFound(w, r)
return
@@ -290,6 +298,8 @@ func (s *Server) Routes() http.Handler {
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
r.Get("/api/public/leaderboard", s.publicLeaderboard)
r.Get("/api/public/artifacts", s.publicArtifacts)
r.Get("/api/public/beacon/{id}/latest", s.latestBeaconDraw)
r.Get("/api/public/tasks", s.publicTaskCatalog)
r.Get("/api/public/artifacts/{id}/preview", s.publicArtifactPreview)
r.Get("/api/public/tasks/{id}/style-reference", s.publicTaskStyleReference)
r.Get("/api/leaderboard/ws", s.leaderboardWS)
@@ -297,6 +307,9 @@ func (s *Server) Routes() http.Handler {
r.Post("/api/auth/login", s.login)
r.Post("/api/admin/login", s.adminLogin)
r.Post("/api/admin/logout", s.adminLogout)
r.Post("/api/internal/delegations", s.internalDelegation)
r.Post("/api/internal/identity-exists", s.internalIdentityExists)
r.Post("/api/internal/customer-link/consume", s.internalCustomerLinkConsume)
r.Group(func(r chi.Router) {
r.Use(func(n http.Handler) http.Handler { return s.require("user", n) })
r.Get("/api/tasks", s.clientTasks)
@@ -305,6 +318,9 @@ func (s *Server) Routes() http.Handler {
r.Post("/api/tasks/{id}/guess", s.guess)
r.Get("/api/tasks/{id}/points", s.points)
r.Get("/api/me", s.me)
r.Get("/api/me/artifacts", s.myArtifacts)
r.Get("/api/me/artifacts/{id}/download", s.myArtifactDownload)
r.Post("/api/me/customer-link", s.customerLinkCode)
r.Get("/api/leaderboard", s.leaderboard)
})
r.Group(func(r chi.Router) {
@@ -532,6 +548,9 @@ func taskDTO(t data.Task, next int64, sm settings.Runtime) map[string]any {
"client_submit_interval_sec": clientSubmit,
"guess_lottery_window_sec": sm.GuessLotteryWindowSec,
"guess_lottery_max_accepted": sm.GuessLotteryMaxAccepted,
"beacon_hunt_enabled": sm.BeaconHuntEnabled,
"beacon_bonus_weight": sm.BeaconBonusWeight,
"beacon_paths": beaconPaths,
"default_max_nodes": sm.DefaultMaxNodes,
"paused": t.Paused,
"revision": t.Revision,
@@ -620,7 +639,10 @@ func (s *Server) currentTask(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, taskDTO(t, g.NextSeq, s.settings.Get()))
}
func guessMsg(taskID string, seq int64, guess string) string {
func guessMsg(taskID string, seq int64, guess, beaconPath string, beaconEnabled bool) string {
if beaconEnabled {
return fmt.Sprintf("guess|%s|%d|%s|%s", taskID, seq, guess, normalizeBeaconPath(beaconPath))
}
return fmt.Sprintf("guess|%s|%d|%s", taskID, seq, guess)
}
@@ -645,9 +667,10 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
var in struct {
Seq int64 `json:"seq"`
Guess string `json:"guess"`
Signature string `json:"signature"`
Seq int64 `json:"seq"`
Guess string `json:"guess"`
Signature string `json:"signature"`
BeaconPath string `json:"beacon_path,omitempty"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, false)
@@ -687,7 +710,12 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
pub, _ := auth.PublicKey(jwk)
if pub == nil || !auth.VerifyRaw(pub, guessMsg(id, in.Seq, in.Guess), in.Signature) {
beaconEnabled := s.settings.Get().BeaconHuntEnabled == 1 && s.settings.Get().GuessLotteryMaxAccepted > 0
if beaconEnabled && normalizeBeaconPath(in.BeaconPath) == "" {
jsonAPIError(w, http.StatusBadRequest, "beacon_path_required", "choose PULSE, FLUX or ORBIT before entering the draw", map[string]any{"paths": beaconPaths})
return
}
if pub == nil || !auth.VerifyRaw(pub, guessMsg(id, in.Seq, in.Guess, in.BeaconPath, beaconEnabled), in.Signature) {
jsonOut(w, 401, false)
return
}
@@ -714,14 +742,18 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
var draw lotteryResult
if cfg.GuessLotteryMaxAccepted > 0 {
selected, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted)
drawResult, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, in.BeaconPath, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted, cfg.BeaconHuntEnabled == 1, cfg.BeaconBonusWeight)
draw = drawResult
if drawErr != nil {
switch {
case errors.Is(drawErr, errLotteryDuplicate):
jsonAPIError(w, http.StatusConflict, "lottery_duplicate", "guess is already waiting for the current draw", nil)
case errors.Is(drawErr, errLotteryFull):
jsonAPIError(w, http.StatusTooManyRequests, "lottery_full", "guess lottery window is full", nil)
case errors.Is(drawErr, errBeaconUnavailable):
jsonAPIError(w, http.StatusServiceUnavailable, "beacon_unavailable", "external randomness beacon is temporarily unavailable; ticket was not evaluated", nil)
case errors.Is(drawErr, context.Canceled), errors.Is(drawErr, context.DeadlineExceeded):
return
default:
@@ -746,7 +778,7 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
t = fresh
if !selected {
if !draw.Selected {
next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval)
if skipErr != nil {
if errors.Is(skipErr, rtx.ErrBadSequence) {
@@ -758,7 +790,15 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
}
return
}
jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", map[string]any{"next_seq": next})
extra := map[string]any{"next_seq": next}
if draw.BeaconEnabled {
extra["chosen_path"] = draw.ChosenPath
extra["boosted_path"] = draw.BoostedPath
extra["beacon_round"] = draw.BeaconRound
extra["beacon_id"] = draw.BeaconID
extra["weight"] = draw.Weight
}
jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", extra)
return
}
}
@@ -788,7 +828,15 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
}
// Losing tips are intentionally ephemeral: no SQLite write and no websocket event.
if accepted.Improved || correct {
p, err := s.store.PersistImprovement(r.Context(), t, c.ClientID, accepted.State.NextSeq, accepted.State.GuessCount, accepted.State.LastGuess, accepted.State.BestScore, in.Guess, in.Signature, correct)
rewardOwner := c.ClientID
if correct {
rewardOwner = s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
}
beaconPath, beaconBoost, beaconRound := "", "", uint64(0)
if correct && draw.BeaconEnabled {
beaconPath, beaconBoost, beaconRound = draw.ChosenPath, draw.BoostedPath, draw.BeaconRound
}
p, err := s.store.PersistImprovement(r.Context(), t, c.ClientID, rewardOwner, accepted.State.NextSeq, accepted.State.GuessCount, accepted.State.LastGuess, accepted.State.BestScore, in.Guess, in.Signature, correct, beaconPath, beaconBoost, beaconRound)
if err != nil {
s.runtime.Restore(t.Task, c.ClientID, accepted.State.NextSeq, accepted.Previous)
switch {
@@ -806,7 +854,8 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
s.hub.PublishPoint(id, c.ClientID, p)
}
if correct {
dataOut := map[string]string{"winner_client_id": c.ClientID}
rewardOwner := s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
dataOut := map[string]string{"winner_client_id": rewardOwner, "winner_worker_client_id": c.ClientID}
if successor, succErr := s.store.EnsureSuccessorTask(r.Context(), id, s.settings.Get().TaskRangeBits); succErr == nil {
dataOut["successor_task_id"] = successor.ID
s.runtime.ReplaceTaskSelection(id, successor.ID)
@@ -816,9 +865,139 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_completed", TaskID: id, Data: dataOut})
_ = s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
}
if draw.BeaconEnabled {
w.Header().Set("X-NeuralHunt-Beacon-Path", draw.BoostedPath)
w.Header().Set("X-NeuralHunt-Beacon-Round", strconv.FormatUint(draw.BeaconRound, 10))
}
jsonOut(w, 200, correct)
}
func serviceTokenOK(secret, header string) bool {
provided := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
return secret != "" && len(secret) == len(provided) && subtle.ConstantTimeCompare([]byte(secret), []byte(provided)) == 1
}
func customerLinkHash(code string) string {
h := sha256.Sum256([]byte("nh-customer-link-v1|" + strings.TrimSpace(code)))
return fmt.Sprintf("%x", h[:])
}
// customerLinkCode issues a short-lived one-shot proof that the authenticated
// browser/CLI controls this exact P-256 identity. Customer Service redeems the
// code over the private 8081 control plane; the private key never leaves the
// owner device.
func (s *Server) customerLinkCode(w http.ResponseWriter, r *http.Request) {
c := claims(r)
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
jsonOut(w, 500, map[string]string{"error": "could not create pairing code"})
return
}
code := "nhlink_" + base64.RawURLEncoding.EncodeToString(b)
expires := time.Now().UTC().Add(10 * time.Minute)
if err := s.store.CreateCustomerLinkToken(r.Context(), customerLinkHash(code), c.ClientID, expires); err != nil {
jsonOut(w, 500, map[string]string{"error": "could not store pairing code"})
return
}
w.Header().Set("Cache-Control", "no-store")
jsonOut(w, 201, map[string]any{"code": code, "client_id": c.ClientID, "expires_at": expires})
}
func (s *Server) internalCustomerLinkConsume(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.internalServiceSecret)
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var in struct {
Code string `json:"code"`
}
if err := decode(r, &in); err != nil || !strings.HasPrefix(strings.TrimSpace(in.Code), "nhlink_") {
jsonOut(w, 400, map[string]string{"error": "valid pairing code required"})
return
}
cid, err := s.store.ConsumeCustomerLinkToken(r.Context(), customerLinkHash(in.Code))
if err != nil {
jsonOut(w, 404, map[string]string{"error": "pairing code expired, invalid, or already used"})
return
}
jsonOut(w, 200, map[string]string{"client_id": cid})
}
func (s *Server) internalIdentityExists(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.internalServiceSecret)
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var in struct {
ClientID string `json:"client_id"`
}
if err := decode(r, &in); err != nil || strings.TrimSpace(in.ClientID) == "" {
jsonOut(w, 400, map[string]string{"error": "client_id required"})
return
}
jsonOut(w, 200, map[string]bool{"exists": s.store.ClientExists(r.Context(), strings.TrimSpace(in.ClientID))})
}
func (s *Server) internalDelegation(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.internalServiceSecret)
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var in struct {
WorkerClientID string `json:"worker_client_id"`
OwnerClientID string `json:"owner_client_id"`
}
if err := decode(r, &in); err != nil {
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
return
}
if err := s.store.SetIdentityDelegation(r.Context(), in.WorkerClientID, in.OwnerClientID); err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "worker_client_id": in.WorkerClientID, "owner_client_id": in.OwnerClientID})
}
func (s *Server) publicTaskCatalog(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ActiveTasksForClient(r.Context(), "")
if err != nil {
jsonOut(w, 500, map[string]string{"error": "tasks failed"})
return
}
cfg := s.settings.Get()
out := make([]map[string]any, 0, len(items))
for _, t := range items {
out = append(out, map[string]any{
"id": t.ID, "display_name": t.DisplayName, "description": t.Description,
"range_bits": t.RangeBits, "paused": t.Paused,
"guess_lottery_window_sec": cfg.GuessLotteryWindowSec,
"guess_lottery_max_accepted": cfg.GuessLotteryMaxAccepted,
"beacon_hunt_enabled": cfg.BeaconHuntEnabled,
"beacon_bonus_weight": cfg.BeaconBonusWeight,
"beacon_paths": beaconPaths,
"style_reference_uri": "/api/public/tasks/" + url.PathEscape(t.ID) + "/style-reference",
})
}
jsonOut(w, 200, out)
}
func (s *Server) latestBeaconDraw(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "id")
d, err := s.store.LatestBeaconDraw(r.Context(), taskID)
if err != nil {
if data.IsNoRows(err) {
jsonOut(w, 404, map[string]string{"error": "no beacon draw yet"})
return
}
jsonOut(w, 500, map[string]string{"error": "beacon draw lookup failed"})
return
}
jsonOut(w, 200, d)
}
func (s *Server) points(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
@@ -857,6 +1036,47 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, m)
}
func (s *Server) myArtifacts(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := s.store.OwnedArtifacts(r.Context(), claims(r).ClientID, limit)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "owned artifacts failed"})
return
}
jsonOut(w, 200, items)
}
func (s *Server) myArtifactDownload(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
jsonOut(w, 400, map[string]string{"error": "task id required"})
return
}
uri, ok, err := s.store.OwnedArtifactSource(r.Context(), id, claims(r).ClientID)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "artifact lookup failed"})
return
}
if !ok {
// Deliberately use 404 rather than revealing that another identity owns it.
jsonOut(w, 404, map[string]string{"error": "artifact not found"})
return
}
path, err := artifactLocalPath(s.artifactDir, uri)
if err != nil {
jsonOut(w, 404, map[string]string{"error": "artifact file unavailable"})
return
}
ext := filepath.Ext(path)
if ext == "" {
ext = ".bin"
}
name := "neuralhunt-" + id + ext
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, name))
http.ServeFile(w, r, path)
}
func (s *Server) leaderboard(w http.ResponseWriter, r *http.Request) {
l, err := s.store.Leaderboard(r.Context(), 100)
if err != nil {