@@ -244,6 +244,14 @@ func buildCollectionPrompt(x win, traits collectionTraits) string {
|
||||
if avoid == "" {
|
||||
avoid = "No additional task-specific avoid list."
|
||||
}
|
||||
beaconDirection := "No Beacon Hunt trait for this winning card."
|
||||
if strings.TrimSpace(x.BeaconPath) != "" {
|
||||
match := "did not match the boosted path"
|
||||
if strings.EqualFold(x.BeaconPath, x.BeaconBoostedPath) {
|
||||
match = "matched the boosted path"
|
||||
}
|
||||
beaconDirection = fmt.Sprintf("Winning Beacon path: %s; boosted path: %s; drand round: %d; the chosen path %s. Translate this subtly into lighting, motion, particles or accessory accents. Do not render the path name or round as text.", x.BeaconPath, x.BeaconBoostedPath, x.BeaconRound, match)
|
||||
}
|
||||
return fmt.Sprintf(`Create one original premium full-art collectible character illustration.
|
||||
|
||||
REFERENCE IMAGES — SEPARATE IDENTITY FROM STYLE:
|
||||
@@ -285,6 +293,9 @@ Follow Image 2 decisively for the rendering language. Recreate its level of styl
|
||||
FULL-ART ENERGY:
|
||||
The environment should visually merge with the character through thematic particles, light, fabric motion, mist, sparks, petals, snow, dust or other scene-appropriate elements. Create many small visual discoveries while keeping RIFT instantly readable.
|
||||
|
||||
BEACON HUNT WIN TRAIT:
|
||||
%s
|
||||
|
||||
TASK-SPECIFIC CREATIVE DIRECTION:
|
||||
%s
|
||||
|
||||
@@ -302,6 +313,7 @@ Deterministic creative fingerprint only, never render it as text: task=%s winner
|
||||
traits.Pose,
|
||||
traits.Mood,
|
||||
traits.Atmosphere,
|
||||
beaconDirection,
|
||||
taskDirection,
|
||||
avoid,
|
||||
shortHash(x.ID), shortHash(x.Winner), x.RangeBits, shortHash(x.Seed),
|
||||
|
||||
+36
-29
@@ -64,14 +64,17 @@ func envDuration(k string, d time.Duration) time.Duration {
|
||||
}
|
||||
|
||||
type win struct {
|
||||
ID, Seed, Winner, Signature, Guess string
|
||||
DisplayName string
|
||||
RangeBits int
|
||||
Completed time.Time
|
||||
PublicJWK json.RawMessage
|
||||
PromptInstructions string
|
||||
NegativePrompt string
|
||||
StyleReference string
|
||||
ID, Seed, Winner, Worker, Signature, Guess string
|
||||
DisplayName string
|
||||
RangeBits int
|
||||
Completed time.Time
|
||||
PublicJWK json.RawMessage
|
||||
PromptInstructions string
|
||||
NegativePrompt string
|
||||
StyleReference string
|
||||
BeaconPath string
|
||||
BeaconBoostedPath string
|
||||
BeaconRound uint64
|
||||
}
|
||||
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
@@ -98,9 +101,9 @@ func (w *Worker) claim(ctx context.Context) (win, error) {
|
||||
var x win
|
||||
var completedMS int64
|
||||
var raw string
|
||||
err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,t.winner_client_id,t.winner_signature,t.winning_guess,t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference
|
||||
FROM tasks t JOIN clients c ON c.id=t.winner_client_id
|
||||
WHERE t.artifact_status='pending' ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference)
|
||||
err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,t.winner_client_id,COALESCE(t.winner_worker_client_id,t.winner_client_id),t.winner_signature,t.winning_guess,t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.winner_beacon_path,t.winner_beacon_boosted_path,t.winner_beacon_round
|
||||
FROM tasks t JOIN clients c ON c.id=COALESCE(t.winner_worker_client_id,t.winner_client_id)
|
||||
WHERE t.artifact_status='pending' ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.Worker, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference, &x.BeaconPath, &x.BeaconBoostedPath, &x.BeaconRound)
|
||||
if err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
@@ -206,24 +209,28 @@ func (w *Worker) one(ctx context.Context) error {
|
||||
finalSum := sha256.Sum256(finalBytes)
|
||||
|
||||
manifest := map[string]any{
|
||||
"artifact_id": artifactID,
|
||||
"artifact_preset": preset,
|
||||
"task_id": x.ID,
|
||||
"task_display_name": x.DisplayName,
|
||||
"task_range_bits": x.RangeBits,
|
||||
"winner_client_id": x.Winner,
|
||||
"winner_public_jwk": json.RawMessage(x.PublicJWK),
|
||||
"winning_guess": x.Guess,
|
||||
"winner_guess_signature": x.Signature,
|
||||
"completed_at": x.Completed,
|
||||
"image_sha256": hex.EncodeToString(finalSum[:]),
|
||||
"raw_art_sha256": hex.EncodeToString(rawArtSum[:]),
|
||||
"prompt_sha256": hex.EncodeToString(promptSum[:]),
|
||||
"task_prompt_instructions": x.PromptInstructions,
|
||||
"task_style_reference": x.StyleReference,
|
||||
"provider": img.Provider,
|
||||
"provider_meta": img.Meta,
|
||||
"note": "winner_guess_signature authenticates the winning guess; image_sha256 binds the final programmatically laid-out collectible card into the server manifest",
|
||||
"artifact_id": artifactID,
|
||||
"artifact_preset": preset,
|
||||
"task_id": x.ID,
|
||||
"task_display_name": x.DisplayName,
|
||||
"task_range_bits": x.RangeBits,
|
||||
"winner_client_id": x.Winner,
|
||||
"winner_worker_client_id": x.Worker,
|
||||
"winning_beacon_path": x.BeaconPath,
|
||||
"winning_beacon_boosted_path": x.BeaconBoostedPath,
|
||||
"winning_beacon_round": x.BeaconRound,
|
||||
"winning_worker_public_jwk": json.RawMessage(x.PublicJWK),
|
||||
"winning_guess": x.Guess,
|
||||
"winner_guess_signature": x.Signature,
|
||||
"completed_at": x.Completed,
|
||||
"image_sha256": hex.EncodeToString(finalSum[:]),
|
||||
"raw_art_sha256": hex.EncodeToString(rawArtSum[:]),
|
||||
"prompt_sha256": hex.EncodeToString(promptSum[:]),
|
||||
"task_prompt_instructions": x.PromptInstructions,
|
||||
"task_style_reference": x.StyleReference,
|
||||
"provider": img.Provider,
|
||||
"provider_meta": img.Meta,
|
||||
"note": "winner_client_id is the reward owner; winner_worker_client_id and winning_worker_public_jwk authenticate the actual worker guess; image_sha256 binds the final card",
|
||||
}
|
||||
if preset == collectionPresetRaccoon {
|
||||
manifest["collection_character"] = "RIFT"
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DockerClient struct {
|
||||
hc *http.Client
|
||||
base string
|
||||
}
|
||||
|
||||
func NewDockerClient(raw string) (*DockerClient, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
raw = "unix:///var/run/docker.sock"
|
||||
}
|
||||
if strings.HasPrefix(raw, "unix://") {
|
||||
sock := strings.TrimPrefix(raw, "unix://")
|
||||
tr := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", sock)
|
||||
}}
|
||||
return &DockerClient{hc: &http.Client{Transport: tr, Timeout: 30 * time.Second}, base: "http://docker"}, nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, errors.New("DOCKER_HOST must be unix://, http:// or https://")
|
||||
}
|
||||
return &DockerClient{hc: &http.Client{Timeout: 30 * time.Second}, base: strings.TrimRight(raw, "/")}, nil
|
||||
}
|
||||
|
||||
func (d *DockerClient) req(ctx context.Context, method, path string, in, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, d.base+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := d.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("docker API %s %s HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
if out != nil && len(bytes.TrimSpace(b)) > 0 {
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (d *DockerClient) Ping(ctx context.Context) error {
|
||||
return d.req(ctx, http.MethodGet, "/_ping", nil, nil)
|
||||
}
|
||||
|
||||
// ImageExists checks the local Docker image cache without pulling anything.
|
||||
func (d *DockerClient) ImageExists(ctx context.Context, image string) (bool, error) {
|
||||
image = strings.TrimSpace(image)
|
||||
if image == "" {
|
||||
return false, errors.New("worker image is empty")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.base+"/images/"+url.PathEscape(image)+"/json", nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resp, err := d.hc.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return false, fmt.Errorf("docker image inspect HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// PullImage asks Docker Engine to pull a public/configured registry image.
|
||||
// Private-registry credentials are passed as Docker's X-Registry-Auth header.
|
||||
// The stream is inspected for daemon-side pull errors.
|
||||
func (d *DockerClient) PullImage(ctx context.Context, image, registryAuth string) error {
|
||||
image = strings.TrimSpace(image)
|
||||
if image == "" {
|
||||
return errors.New("worker image is empty")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.base+"/images/create?fromImage="+url.QueryEscape(image), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(registryAuth) != "" {
|
||||
req.Header.Set("X-Registry-Auth", strings.TrimSpace(registryAuth))
|
||||
}
|
||||
pullClient := *d.hc
|
||||
pullClient.Timeout = 10 * time.Minute
|
||||
resp, err := pullClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
return fmt.Errorf("docker image pull HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
dec := json.NewDecoder(io.LimitReader(resp.Body, 32<<20))
|
||||
for {
|
||||
var msg struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("docker image pull stream: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(msg.Error) != "" {
|
||||
return fmt.Errorf("docker image pull: %s", strings.TrimSpace(msg.Error))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegistryAuthHeader builds Docker Engine's X-Registry-Auth value. Use a
|
||||
// registry-scoped read-only deploy token instead of a personal password.
|
||||
func RegistryAuthHeader(username, password, serverAddress string) (string, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
password = strings.TrimSpace(password)
|
||||
serverAddress = strings.TrimSpace(serverAddress)
|
||||
if username == "" && password == "" && serverAddress == "" {
|
||||
return "", nil
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return "", errors.New("both worker registry username and password/token are required")
|
||||
}
|
||||
payload := map[string]string{"username": username, "password": password}
|
||||
if serverAddress != "" {
|
||||
payload["serveraddress"] = serverAddress
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (d *DockerClient) EnsureImage(ctx context.Context, image string, autoPull bool, registryAuth string) error {
|
||||
ok, err := d.ImageExists(ctx, image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
if !autoPull {
|
||||
return fmt.Errorf("worker image %q is not present on the Docker host and CS_WORKER_AUTO_PULL is disabled", image)
|
||||
}
|
||||
if err := d.PullImage(ctx, image, registryAuth); err != nil {
|
||||
return fmt.Errorf("pull worker image %q: %w", image, err)
|
||||
}
|
||||
ok, err = d.ImageExists(ctx, image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("worker image %q is still unavailable after pull", image)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (d *DockerClient) CreateVolume(ctx context.Context, name string) error {
|
||||
var out map[string]any
|
||||
return d.req(ctx, http.MethodPost, "/volumes/create", map[string]any{"Name": name, "Labels": map[string]string{"neuralhunt.managed": "true"}}, &out)
|
||||
}
|
||||
|
||||
type WorkerContainerConfig struct {
|
||||
Image, Entrypoint, Network, GameURL, RegisterURL, WorkerID, RegisterToken, TaskID, BeaconPath, Volume, Name string
|
||||
}
|
||||
|
||||
func (d *DockerClient) CreateWorker(ctx context.Context, c WorkerContainerConfig) (string, error) {
|
||||
name := url.QueryEscape(c.Name)
|
||||
body := map[string]any{
|
||||
"Image": c.Image,
|
||||
// Named Docker volumes are root-owned when first mounted. Managed workers
|
||||
// therefore run uid 0 only inside their own locked-down container so they
|
||||
// can create the 0600 identity file. They receive no Docker socket, all
|
||||
// Linux capabilities are dropped and the image root filesystem is read-only.
|
||||
"User": "0:0",
|
||||
"Cmd": []string{"-url", c.GameURL, "-identity", "/identity/identity.json", "-non-interactive", "-quiet", "-task", c.TaskID, "-beacon-path", c.BeaconPath},
|
||||
"Env": []string{
|
||||
"NEURALHUNT_WORKER_REGISTER_URL=" + c.RegisterURL,
|
||||
"NEURALHUNT_WORKER_LEASE_URL=" + strings.TrimSuffix(c.RegisterURL, "/register") + "/lease",
|
||||
"NEURALHUNT_WORKER_REGISTER_TOKEN=" + c.RegisterToken,
|
||||
"NEURALHUNT_WORKER_ID=" + c.WorkerID,
|
||||
},
|
||||
"Labels": map[string]string{"neuralhunt.managed": "true", "neuralhunt.worker_id": c.WorkerID},
|
||||
"HostConfig": map[string]any{
|
||||
"Mounts": []map[string]any{{"Type": "volume", "Source": c.Volume, "Target": "/identity"}},
|
||||
"NetworkMode": c.Network,
|
||||
"ReadonlyRootfs": true,
|
||||
"CapDrop": []string{"ALL"},
|
||||
"SecurityOpt": []string{"no-new-privileges"},
|
||||
"PidsLimit": 128,
|
||||
"Memory": 256 * 1024 * 1024,
|
||||
"NanoCpus": int64(1_000_000_000),
|
||||
},
|
||||
}
|
||||
// A dedicated worker image already declares /app/neuralhunt-client as its
|
||||
// ENTRYPOINT. Leaving Entrypoint unset makes CS_WORKER_IMAGE genuinely
|
||||
// pluggable. CS_WORKER_ENTRYPOINT exists only as a compatibility override
|
||||
// for older monolithic images.
|
||||
if strings.TrimSpace(c.Entrypoint) != "" {
|
||||
body["Entrypoint"] = []string{strings.TrimSpace(c.Entrypoint)}
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
if err := d.req(ctx, http.MethodPost, "/containers/create?name="+name, body, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.ID == "" {
|
||||
return "", errors.New("docker returned empty container id")
|
||||
}
|
||||
return out.ID, nil
|
||||
}
|
||||
func (d *DockerClient) Start(ctx context.Context, id string) error {
|
||||
return d.req(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/start", nil, nil)
|
||||
}
|
||||
func (d *DockerClient) Stop(ctx context.Context, id string, seconds int) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if seconds < 1 {
|
||||
seconds = 10
|
||||
}
|
||||
return d.req(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?t="+fmt.Sprint(seconds), nil, nil)
|
||||
}
|
||||
func (d *DockerClient) Remove(ctx context.Context, id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
err := d.req(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id)+"?force=true&v=false", nil, nil)
|
||||
if err != nil && strings.Contains(err.Error(), "404") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (d *DockerClient) Running(ctx context.Context, id string) (bool, error) {
|
||||
var out struct {
|
||||
State struct {
|
||||
Running bool `json:"Running"`
|
||||
} `json:"State"`
|
||||
}
|
||||
if err := d.req(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, &out); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return out.State.Running, nil
|
||||
}
|
||||
|
||||
func (d *DockerClient) GetFile(ctx context.Context, containerID, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.base+"/containers/"+url.PathEscape(containerID)+"/archive?path="+url.QueryEscape(path), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := d.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("docker archive HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
tr := tar.NewReader(io.LimitReader(resp.Body, 8<<20))
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filepath.Base(h.Name) == filepath.Base(path) && h.Typeflag == tar.TypeReg {
|
||||
return io.ReadAll(io.LimitReader(tr, 2<<20))
|
||||
}
|
||||
}
|
||||
return nil, errors.New("identity file not found in container volume")
|
||||
}
|
||||
func (d *DockerClient) PutFile(ctx context.Context, containerID, dir, name string, data []byte) error {
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, d.base+"/containers/"+url.PathEscape(containerID)+"/archive?path="+url.QueryEscape(dir), bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-tar")
|
||||
resp, err := d.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("docker put archive HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DockerClient) RemoveVolume(ctx context.Context, name string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
err := d.req(ctx, http.MethodDelete, "/volumes/"+url.PathEscape(name)+"?force=true", nil, nil)
|
||||
if err != nil && strings.Contains(err.Error(), "404") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureImagePullsMissingImage(t *testing.T) {
|
||||
var present atomic.Bool
|
||||
var pulls atomic.Int32
|
||||
image := "registry.example.com/neuralhunt/worker:v4.1"
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/images/") && strings.HasSuffix(r.URL.Path, "/json"):
|
||||
if !present.Load() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Id":"sha256:test"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/images/create":
|
||||
if got := r.URL.Query().Get("fromImage"); got != image {
|
||||
t.Fatalf("fromImage=%q want %q", got, image)
|
||||
}
|
||||
pulls.Add(1)
|
||||
present.Store(true)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{\"status\":\"Pull complete\"}\n"))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
d := &DockerClient{hc: ts.Client(), base: ts.URL}
|
||||
if err := d.EnsureImage(context.Background(), image, true, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pulls.Load() != 1 {
|
||||
t.Fatalf("pulls=%d want 1", pulls.Load())
|
||||
}
|
||||
if err := d.EnsureImage(context.Background(), image, true, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pulls.Load() != 1 {
|
||||
t.Fatalf("second ensure pulled again: pulls=%d", pulls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureImageCanRequirePrePulledImage(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer ts.Close()
|
||||
d := &DockerClient{hc: ts.Client(), base: ts.URL}
|
||||
if err := d.EnsureImage(context.Background(), "neuralhunt-worker:local", false, ""); err == nil || !strings.Contains(err.Error(), "CS_WORKER_AUTO_PULL") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAuthHeader(t *testing.T) {
|
||||
h, err := RegistryAuthHeader("robot", "token", "registry.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h == "" {
|
||||
t.Fatal("expected registry auth header")
|
||||
}
|
||||
if _, err := RegistryAuthHeader("robot", "", "registry.example.com"); err == nil {
|
||||
t.Fatal("incomplete registry credentials should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkerUsesImageEntrypointByDefault(t *testing.T) {
|
||||
var got map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/containers/create" {
|
||||
http.Error(w, "unexpected", 400)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Id":"container-1"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
d := &DockerClient{hc: ts.Client(), base: ts.URL}
|
||||
|
||||
cfg := WorkerContainerConfig{Image: "neuralhunt-worker:local", Network: "nh", GameURL: "http://app:8080", RegisterURL: "http://cs:8092/internal/workers/register", WorkerID: "wrk_1", RegisterToken: "secret", TaskID: "task_1", BeaconPath: "auto", Volume: "vol_1", Name: "worker-1"}
|
||||
if _, err := d.CreateWorker(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := got["Entrypoint"]; exists {
|
||||
t.Fatalf("dedicated worker image should keep its image ENTRYPOINT: %#v", got["Entrypoint"])
|
||||
}
|
||||
|
||||
cfg.Name = "worker-2"
|
||||
cfg.Entrypoint = "/app/neuralhunt-client"
|
||||
if _, err := d.CreateWorker(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := got["Entrypoint"]; !exists {
|
||||
t.Fatal("compatibility Entrypoint override was not sent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"crypto/elliptic"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
)
|
||||
|
||||
var identityRawURL = base64.RawURLEncoding
|
||||
|
||||
type rawPrivateJWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
D string `json:"d"`
|
||||
}
|
||||
|
||||
type rawIdentityFile struct {
|
||||
Version int `json:"version"`
|
||||
PublicJWK auth.PublicJWK `json:"publicJwk"`
|
||||
PrivateJWK rawPrivateJWK `json:"privateJwk"`
|
||||
}
|
||||
|
||||
func padP256(b []byte) []byte {
|
||||
out := make([]byte, 32)
|
||||
if len(b) > 32 {
|
||||
b = b[len(b)-32:]
|
||||
}
|
||||
copy(out[32-len(b):], b)
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateRawIdentity validates the portable raw CLI identity before it is
|
||||
// written into a managed worker's private Docker volume. It intentionally does
|
||||
// not accept the encrypted browser-export envelope because the service never
|
||||
// needs or asks for the customer's export passphrase.
|
||||
func ValidateRawIdentity(b []byte) error {
|
||||
var id rawIdentityFile
|
||||
if err := json.Unmarshal(b, &id); err != nil {
|
||||
return fmt.Errorf("invalid identity JSON: %w", err)
|
||||
}
|
||||
if id.Version != 1 || id.PublicJWK.Kty != "EC" || id.PublicJWK.Crv != "P-256" || id.PrivateJWK.Kty != "EC" || id.PrivateJWK.Crv != "P-256" || id.PrivateJWK.D == "" {
|
||||
return errors.New("unsupported identity; expected Neural Hunt version 1 P-256 raw identity")
|
||||
}
|
||||
db, err := identityRawURL.DecodeString(id.PrivateJWK.D)
|
||||
if err != nil {
|
||||
return errors.New("invalid private JWK encoding")
|
||||
}
|
||||
d := new(big.Int).SetBytes(db)
|
||||
curve := elliptic.P256()
|
||||
if d.Sign() <= 0 || d.Cmp(curve.Params().N) >= 0 {
|
||||
return errors.New("invalid P-256 private scalar")
|
||||
}
|
||||
x, y := curve.ScalarBaseMult(padP256(db))
|
||||
xs := identityRawURL.EncodeToString(padP256(x.Bytes()))
|
||||
ys := identityRawURL.EncodeToString(padP256(y.Bytes()))
|
||||
if xs != id.PublicJWK.X || ys != id.PublicJWK.Y || xs != id.PrivateJWK.X || ys != id.PrivateJWK.Y {
|
||||
return errors.New("identity public/private key mismatch")
|
||||
}
|
||||
if _, err := auth.ClientID(id.PublicJWK); err != nil {
|
||||
return fmt.Errorf("invalid public identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PayPalClient struct {
|
||||
ClientID, Secret, BaseURL string
|
||||
hc *http.Client
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
func NewPayPalClient(clientID, secret, environment string) *PayPalClient {
|
||||
base := "https://api-m.sandbox.paypal.com"
|
||||
if strings.EqualFold(strings.TrimSpace(environment), "live") {
|
||||
base = "https://api-m.paypal.com"
|
||||
}
|
||||
return &PayPalClient{ClientID: strings.TrimSpace(clientID), Secret: strings.TrimSpace(secret), BaseURL: base, hc: &http.Client{Timeout: 20 * time.Second}}
|
||||
}
|
||||
func (p *PayPalClient) Ready() bool { return p.ClientID != "" && p.Secret != "" }
|
||||
func (p *PayPalClient) accessToken(ctx context.Context) (string, error) {
|
||||
p.mu.Lock()
|
||||
if p.token != "" && time.Until(p.tokenExp) > time.Minute {
|
||||
v := p.token
|
||||
p.mu.Unlock()
|
||||
return v, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if !p.Ready() {
|
||||
return "", errors.New("PayPal client ID/secret not configured")
|
||||
}
|
||||
form := url.Values{"grant_type": {"client_credentials"}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+"/v1/oauth2/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.SetBasicAuth(p.ClientID, p.Secret)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := p.hc.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return "", fmt.Errorf("PayPal OAuth HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.AccessToken == "" {
|
||||
return "", errors.New("PayPal OAuth returned empty token")
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.token = out.AccessToken
|
||||
p.tokenExp = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
|
||||
p.mu.Unlock()
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
func (p *PayPalClient) call(ctx context.Context, method, path string, in, out any) error {
|
||||
tok, err := p.accessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var rd io.Reader
|
||||
if in != nil {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.BaseURL+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("PayPal API %s HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
if out != nil && len(bytes.TrimSpace(b)) > 0 {
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PayPalOrderView struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Links []struct{ Href, Rel, Method string } `json:"links"`
|
||||
PurchaseUnits []struct {
|
||||
Amount struct {
|
||||
CurrencyCode string `json:"currency_code"`
|
||||
Value string `json:"value"`
|
||||
} `json:"amount"`
|
||||
Payments struct {
|
||||
Captures []struct {
|
||||
ID, Status string
|
||||
Amount struct {
|
||||
CurrencyCode string `json:"currency_code"`
|
||||
Value string `json:"value"`
|
||||
} `json:"amount"`
|
||||
} `json:"captures"`
|
||||
} `json:"payments"`
|
||||
} `json:"purchase_units"`
|
||||
}
|
||||
|
||||
func (p *PayPalClient) CreateOrder(ctx context.Context, reference, description, amount, currency, returnURL, cancelURL string) (PayPalOrderView, error) {
|
||||
body := map[string]any{"intent": "CAPTURE", "purchase_units": []map[string]any{{"reference_id": reference, "description": description, "amount": map[string]string{"currency_code": currency, "value": amount}}}, "payment_source": map[string]any{"paypal": map[string]any{"experience_context": map[string]any{"shipping_preference": "NO_SHIPPING", "user_action": "PAY_NOW", "return_url": returnURL, "cancel_url": cancelURL}}}}
|
||||
var out PayPalOrderView
|
||||
err := p.call(ctx, http.MethodPost, "/v2/checkout/orders", body, &out)
|
||||
return out, err
|
||||
}
|
||||
func (p *PayPalClient) CaptureOrder(ctx context.Context, id string) (PayPalOrderView, error) {
|
||||
var out PayPalOrderView
|
||||
err := p.call(ctx, http.MethodPost, "/v2/checkout/orders/"+url.PathEscape(id)+"/capture", map[string]any{}, &out)
|
||||
return out, err
|
||||
}
|
||||
func (p *PayPalClient) GetOrder(ctx context.Context, id string) (PayPalOrderView, error) {
|
||||
var out PayPalOrderView
|
||||
err := p.call(ctx, http.MethodGet, "/v2/checkout/orders/"+url.PathEscape(id), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func ApprovalURL(o PayPalOrderView) string {
|
||||
for _, l := range o.Links {
|
||||
if l.Rel == "payer-action" || l.Rel == "approve" {
|
||||
return l.Href
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func CaptureID(o PayPalOrderView) string {
|
||||
for _, u := range o.PurchaseUnits {
|
||||
for _, c := range u.Payments.Captures {
|
||||
if strings.EqualFold(c.Status, "COMPLETED") {
|
||||
return c.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *PayPalClient) VerifyWebhook(ctx context.Context, webhookID string, h http.Header, event json.RawMessage) (bool, error) {
|
||||
if strings.TrimSpace(webhookID) == "" {
|
||||
return false, errors.New("PAYPAL_WEBHOOK_ID not configured")
|
||||
}
|
||||
var ev any
|
||||
if err := json.Unmarshal(event, &ev); err != nil {
|
||||
return false, err
|
||||
}
|
||||
body := map[string]any{"auth_algo": h.Get("PAYPAL-AUTH-ALGO"), "cert_url": h.Get("PAYPAL-CERT-URL"), "transmission_id": h.Get("PAYPAL-TRANSMISSION-ID"), "transmission_sig": h.Get("PAYPAL-TRANSMISSION-SIG"), "transmission_time": h.Get("PAYPAL-TRANSMISSION-TIME"), "webhook_id": webhookID, "webhook_event": ev}
|
||||
var out struct {
|
||||
VerificationStatus string `json:"verification_status"`
|
||||
}
|
||||
if err := p.call(ctx, http.MethodPost, "/v1/notifications/verify-webhook-signature", body, &out); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.EqualFold(out.VerificationStatus, "SUCCESS"), nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var rawURL = base64.RawURLEncoding
|
||||
|
||||
func RandomToken(n int) string {
|
||||
if n < 16 {
|
||||
n = 16
|
||||
}
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return rawURL.EncodeToString(b)
|
||||
}
|
||||
|
||||
func NewPasswordHash(password string) (salt string, hash string, err error) {
|
||||
if len(strings.TrimSpace(password)) < 12 {
|
||||
return "", "", errors.New("password must be at least 12 characters")
|
||||
}
|
||||
s := make([]byte, 16)
|
||||
if _, err := rand.Read(s); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
h := pbkdf2SHA256([]byte(password), s, 310000, 32)
|
||||
return rawURL.EncodeToString(s), rawURL.EncodeToString(h), nil
|
||||
}
|
||||
func VerifyPassword(password, salt, hash string) bool {
|
||||
s, err := rawURL.DecodeString(salt)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := rawURL.DecodeString(hash)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := pbkdf2SHA256([]byte(password), s, 310000, len(want))
|
||||
return len(got) == len(want) && subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
|
||||
hLen := sha256.Size
|
||||
blocks := (keyLen + hLen - 1) / hLen
|
||||
out := make([]byte, 0, blocks*hLen)
|
||||
for block := 1; block <= blocks; block++ {
|
||||
mac := hmac.New(sha256.New, password)
|
||||
mac.Write(salt)
|
||||
var n [4]byte
|
||||
binary.BigEndian.PutUint32(n[:], uint32(block))
|
||||
mac.Write(n[:])
|
||||
u := mac.Sum(nil)
|
||||
t := append([]byte(nil), u...)
|
||||
for i := 1; i < iterations; i++ {
|
||||
mac = hmac.New(sha256.New, password)
|
||||
mac.Write(u)
|
||||
u = mac.Sum(nil)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
out = append(out, t...)
|
||||
}
|
||||
return out[:keyLen]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
package customer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseMoneyCentsExact(t *testing.T) {
|
||||
cases := map[string]int64{
|
||||
"0": 0,
|
||||
"1": 100,
|
||||
"1.2": 120,
|
||||
"1.20": 120,
|
||||
"499.99": 49999,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := parseMoneyCents(in)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("parseMoneyCents(%q) = %d, %v; want %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "-1.00", "+1.00", "1.234", "1,00", "abc"} {
|
||||
if _, err := parseMoneyCents(in); err == nil {
|
||||
t.Fatalf("parseMoneyCents(%q) should fail", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS customers(
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_salt TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
reward_client_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS customer_sessions(
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS customer_sessions_exp_idx ON customer_sessions(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS credit_ledger(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
delta_micros INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
reference TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS credit_ledger_customer_idx ON credit_ledger(customer_id,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS workers(
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
task_id TEXT NOT NULL,
|
||||
beacon_path TEXT NOT NULL DEFAULT 'auto',
|
||||
docker_container_id TEXT NOT NULL DEFAULT '',
|
||||
docker_volume TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'stopped' CHECK(status IN ('stopped','starting','running','error')),
|
||||
worker_client_id TEXT NOT NULL DEFAULT '',
|
||||
register_token TEXT NOT NULL,
|
||||
rate_micros_per_minute INTEGER NOT NULL,
|
||||
last_charge_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS workers_customer_idx ON workers(customer_id,created_at);
|
||||
CREATE TABLE IF NOT EXISTS paypal_orders(
|
||||
order_id TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
package_id TEXT NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
credits_micros INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
capture_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS paypal_orders_customer_idx ON paypal_orders(customer_id,created_at DESC);
|
||||
`
|
||||
|
||||
type Store struct{ DB *sql.DB }
|
||||
|
||||
func Open(ctx context.Context, path string) (*Store, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = "/customer-data/customer-service.db"
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := &url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}
|
||||
q := u.Query()
|
||||
q.Add("_pragma", "busy_timeout(10000)")
|
||||
q.Add("_pragma", "foreign_keys(ON)")
|
||||
q.Add("_pragma", "synchronous(NORMAL)")
|
||||
q.Set("_txlock", "immediate")
|
||||
u.RawQuery = q.Encode()
|
||||
db, err := sql.Open("sqlite", u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(4)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
for i, stmt := range strings.Split(schema, ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("customer schema %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return &Store{DB: db}, nil
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
RewardClientID string `json:"reward_client_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
ID string `json:"id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
BeaconPath string `json:"beacon_path"`
|
||||
ContainerID string `json:"container_id"`
|
||||
Volume string `json:"volume"`
|
||||
Status string `json:"status"`
|
||||
WorkerClientID string `json:"worker_client_id"`
|
||||
RegisterToken string `json:"-"`
|
||||
RateMicrosPerMinute int64 `json:"rate_micros_per_minute"`
|
||||
LastChargeAt *time.Time `json:"last_charge_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateCustomer(ctx context.Context, id, username, salt, hash string) error {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO customers(id,username,password_salt,password_hash,created_at,updated_at) VALUES(?,?,?,?,?,?)`, id, strings.TrimSpace(username), salt, hash, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CustomerByUsername(ctx context.Context, username string) (Customer, string, string, error) {
|
||||
var c Customer
|
||||
var salt, hash string
|
||||
var created int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,created_at,password_salt,password_hash FROM customers WHERE username=?`, strings.TrimSpace(username)).Scan(&c.ID, &c.Username, &c.RewardClientID, &created, &salt, &hash)
|
||||
c.CreatedAt = time.UnixMilli(created).UTC()
|
||||
return c, salt, hash, err
|
||||
}
|
||||
func (s *Store) CustomerByID(ctx context.Context, id string) (Customer, error) {
|
||||
var c Customer
|
||||
var created int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,created_at FROM customers WHERE id=?`, id).Scan(&c.ID, &c.Username, &c.RewardClientID, &created)
|
||||
c.CreatedAt = time.UnixMilli(created).UTC()
|
||||
return c, err
|
||||
}
|
||||
func (s *Store) SetRewardClientID(ctx context.Context, id, cid string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE customers SET reward_client_id=?,updated_at=? WHERE id=?`, strings.TrimSpace(cid), time.Now().UTC().UnixMilli(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, sid, cid string, ttl time.Duration) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO customer_sessions(id,customer_id,expires_at,created_at) VALUES(?,?,?,?)`, sid, cid, now.Add(ttl).UnixMilli(), now.UnixMilli())
|
||||
return err
|
||||
}
|
||||
func (s *Store) SessionCustomer(ctx context.Context, sid string) (string, error) {
|
||||
var cid string
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT customer_id FROM customer_sessions WHERE id=? AND expires_at>?`, sid, time.Now().UTC().UnixMilli()).Scan(&cid)
|
||||
return cid, err
|
||||
}
|
||||
func (s *Store) DeleteSession(ctx context.Context, sid string) {
|
||||
_, _ = s.DB.ExecContext(ctx, `DELETE FROM customer_sessions WHERE id=?`, sid)
|
||||
}
|
||||
|
||||
func (s *Store) BalanceMicros(ctx context.Context, cid string) (int64, error) {
|
||||
var v sql.NullInt64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT sum(delta_micros) FROM credit_ledger WHERE customer_id=?`, cid).Scan(&v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v.Int64, nil
|
||||
}
|
||||
func (s *Store) AddLedger(ctx context.Context, cid string, delta int64, reason, ref string) error {
|
||||
if delta == 0 {
|
||||
return errors.New("zero credit change")
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO credit_ledger(customer_id,delta_micros,reason,reference,created_at) VALUES(?,?,?,?,?)`, cid, delta, reason, ref, time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
type LedgerItem struct {
|
||||
DeltaMicros int64 `json:"delta_micros"`
|
||||
Reason string `json:"reason"`
|
||||
Reference string `json:"reference"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *Store) Ledger(ctx context.Context, cid string, limit int) ([]LedgerItem, error) {
|
||||
if limit < 1 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT delta_micros,reason,reference,created_at FROM credit_ledger WHERE customer_id=? ORDER BY id DESC LIMIT ?`, cid, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LedgerItem
|
||||
for rows.Next() {
|
||||
var x LedgerItem
|
||||
var ms int64
|
||||
if err := rows.Scan(&x.DeltaMicros, &x.Reason, &x.Reference, &ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.CreatedAt = time.UnixMilli(ms).UTC()
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanWorker(row interface{ Scan(...any) error }) (Worker, error) {
|
||||
var w Worker
|
||||
var last sql.NullInt64
|
||||
var created, updated int64
|
||||
err := row.Scan(&w.ID, &w.CustomerID, &w.TaskID, &w.BeaconPath, &w.ContainerID, &w.Volume, &w.Status, &w.WorkerClientID, &w.RegisterToken, &w.RateMicrosPerMinute, &last, &created, &updated, &w.LastError)
|
||||
if err != nil {
|
||||
return w, err
|
||||
}
|
||||
if last.Valid {
|
||||
v := time.UnixMilli(last.Int64).UTC()
|
||||
w.LastChargeAt = &v
|
||||
}
|
||||
w.CreatedAt = time.UnixMilli(created).UTC()
|
||||
w.UpdatedAt = time.UnixMilli(updated).UTC()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
const workerCols = `id,customer_id,task_id,beacon_path,docker_container_id,docker_volume,status,worker_client_id,register_token,rate_micros_per_minute,last_charge_at,created_at,updated_at,last_error`
|
||||
|
||||
func (s *Store) CreateWorker(ctx context.Context, w Worker) error {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO workers(id,customer_id,task_id,beacon_path,docker_volume,status,register_token,rate_micros_per_minute,created_at,updated_at) VALUES(?,?,?,?,?,'stopped',?,?,?,?)`, w.ID, w.CustomerID, w.TaskID, w.BeaconPath, w.Volume, w.RegisterToken, w.RateMicrosPerMinute, now, now)
|
||||
return err
|
||||
}
|
||||
func (s *Store) Worker(ctx context.Context, cid, wid string) (Worker, error) {
|
||||
return scanWorker(s.DB.QueryRowContext(ctx, `SELECT `+workerCols+` FROM workers WHERE id=? AND customer_id=?`, wid, cid))
|
||||
}
|
||||
func (s *Store) WorkerByID(ctx context.Context, wid string) (Worker, error) {
|
||||
return scanWorker(s.DB.QueryRowContext(ctx, `SELECT `+workerCols+` FROM workers WHERE id=?`, wid))
|
||||
}
|
||||
func (s *Store) Workers(ctx context.Context, cid string) ([]Worker, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+workerCols+` FROM workers WHERE customer_id=? ORDER BY created_at`, cid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Worker
|
||||
for rows.Next() {
|
||||
w, err := scanWorker(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) WorkerCount(ctx context.Context, cid string, runningOnly bool) (int, error) {
|
||||
q := `SELECT count(*) FROM workers WHERE customer_id=?`
|
||||
if runningOnly {
|
||||
q += ` AND status='running'`
|
||||
}
|
||||
var n int
|
||||
err := s.DB.QueryRowContext(ctx, q, cid).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
func (s *Store) TotalWorkerCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM workers`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) RunningWorkerCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM workers WHERE status='running'`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) RunningWorkers(ctx context.Context) ([]Worker, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT `+workerCols+` FROM workers WHERE status='running' ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Worker
|
||||
for rows.Next() {
|
||||
w, err := scanWorker(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (s *Store) ClaimWorkerStart(ctx context.Context, cid, wid string) (bool, error) {
|
||||
res, err := s.DB.ExecContext(ctx, `UPDATE workers SET status='starting',last_error='',updated_at=? WHERE id=? AND customer_id=? AND status IN ('stopped','error')`, time.Now().UTC().UnixMilli(), wid, cid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
func (s *Store) RecoverStartingWorkers(ctx context.Context) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE workers SET status='stopped',last_error='recovered after Customer Service restart',updated_at=? WHERE status='starting'`, time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetWorkerRuntime(ctx context.Context, wid, status, containerID, lastErr string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE workers SET status=?,docker_container_id=?,last_error=?,updated_at=? WHERE id=?`, status, containerID, lastErr, time.Now().UTC().UnixMilli(), wid)
|
||||
return err
|
||||
}
|
||||
func (s *Store) SetWorkerClient(ctx context.Context, wid, clientID string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE workers SET worker_client_id=?,updated_at=? WHERE id=?`, clientID, time.Now().UTC().UnixMilli(), wid)
|
||||
return err
|
||||
}
|
||||
func (s *Store) UpdateWorkerConfig(ctx context.Context, cid, wid, taskID, beaconPath string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE workers SET task_id=?,beacon_path=?,updated_at=? WHERE id=? AND customer_id=?`, taskID, beaconPath, time.Now().UTC().UnixMilli(), wid, cid)
|
||||
return err
|
||||
}
|
||||
func (s *Store) DeleteWorker(ctx context.Context, cid, wid string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM workers WHERE id=? AND customer_id=?`, wid, cid)
|
||||
return err
|
||||
}
|
||||
func (s *Store) MarkWorkerCharged(ctx context.Context, wid string, when time.Time) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE workers SET last_charge_at=?,updated_at=? WHERE id=?`, when.UTC().UnixMilli(), time.Now().UTC().UnixMilli(), wid)
|
||||
return err
|
||||
}
|
||||
|
||||
// ChargeWorkerMinute debits one prepaid minute atomically. It never allows a
|
||||
// negative balance, so a billing loop can stop the worker as soon as funding is
|
||||
// exhausted.
|
||||
func (s *Store) ChargeWorkerMinute(ctx context.Context, w Worker, minute time.Time) (bool, error) {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var bal sql.NullInt64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT sum(delta_micros) FROM credit_ledger WHERE customer_id=?`, w.CustomerID).Scan(&bal); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if bal.Int64 < w.RateMicrosPerMinute {
|
||||
return false, nil
|
||||
}
|
||||
ref := fmt.Sprintf("worker:%s:%d", w.ID, minute.UTC().UnixMilli())
|
||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO credit_ledger(customer_id,delta_micros,reason,reference,created_at) VALUES(?,?,?,?,?)`, w.CustomerID, -w.RateMicrosPerMinute, "worker_minute", ref, time.Now().UTC().UnixMilli()); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE workers SET last_charge_at=?,updated_at=? WHERE id=?`, minute.UTC().UnixMilli(), time.Now().UTC().UnixMilli(), w.ID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) RefundWorkerStartMinute(ctx context.Context, w Worker, chargedAt time.Time, detail string) error {
|
||||
ref := fmt.Sprintf("worker_start_refund:%s:%d", w.ID, chargedAt.UTC().UnixMilli())
|
||||
reason := "worker_start_refund"
|
||||
if strings.TrimSpace(detail) != "" {
|
||||
reason += ":" + strings.TrimSpace(detail)
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO credit_ledger(customer_id,delta_micros,reason,reference,created_at) VALUES(?,?,?,?,?)`, w.CustomerID, w.RateMicrosPerMinute, reason, ref, time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertPayPalOrder(ctx context.Context, orderID, cid, pkg string, cents int64, currency string, credits int64, status string) error {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO paypal_orders(order_id,customer_id,package_id,amount_cents,currency,credits_micros,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(order_id) DO UPDATE SET status=excluded.status,updated_at=excluded.updated_at`, orderID, cid, pkg, cents, currency, credits, status, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
type PayPalOrder struct {
|
||||
OrderID, CustomerID, PackageID, Currency, Status, CaptureID string
|
||||
AmountCents, CreditsMicros int64
|
||||
}
|
||||
|
||||
func (s *Store) PayPalOrder(ctx context.Context, orderID string) (PayPalOrder, error) {
|
||||
var o PayPalOrder
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT order_id,customer_id,package_id,amount_cents,currency,credits_micros,status,capture_id FROM paypal_orders WHERE order_id=?`, orderID).Scan(&o.OrderID, &o.CustomerID, &o.PackageID, &o.AmountCents, &o.Currency, &o.CreditsMicros, &o.Status, &o.CaptureID)
|
||||
return o, err
|
||||
}
|
||||
func (s *Store) CompletePayPalOrder(ctx context.Context, orderID, captureID string) error {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var o PayPalOrder
|
||||
if err := tx.QueryRowContext(ctx, `SELECT order_id,customer_id,package_id,amount_cents,currency,credits_micros,status,capture_id FROM paypal_orders WHERE order_id=?`, orderID).Scan(&o.OrderID, &o.CustomerID, &o.PackageID, &o.AmountCents, &o.Currency, &o.CreditsMicros, &o.Status, &o.CaptureID); err != nil {
|
||||
return err
|
||||
}
|
||||
ref := "paypal:" + orderID
|
||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO credit_ledger(customer_id,delta_micros,reason,reference,created_at) VALUES(?,?,?,?,?)`, o.CustomerID, o.CreditsMicros, "paypal_topup", ref, time.Now().UTC().UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE paypal_orders SET status='COMPLETED',capture_id=?,updated_at=? WHERE order_id=?`, captureID, time.Now().UTC().UnixMilli(), orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package customerui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed dist/public/* dist/admin/*
|
||||
var dist embed.FS
|
||||
|
||||
func handler(sub string) http.Handler {
|
||||
root, err := fs.Sub(dist, sub)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return http.FileServer(http.FS(root))
|
||||
}
|
||||
|
||||
func Public() http.Handler { return handler("dist/public") }
|
||||
func Admin() http.Handler { return handler("dist/admin") }
|
||||
+1
@@ -0,0 +1 @@
|
||||
const $=id=>document.getElementById(id);let manual=false;function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function msg(t,e=false){$('msg').textContent=t;$('msg').className='msg'+(e?' err':'');$('msg').classList.remove('hidden')}async function api(p,o={}){if(o.body&&typeof o.body!=='string'){o.headers={'Content-Type':'application/json',...(o.headers||{})};o.body=JSON.stringify(o.body)}const r=await fetch(p,{credentials:'same-origin',...o});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||`HTTP ${r.status}`);return x}const cr=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});async function load(){const x=await api('/api/admin/overview');manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER TEST-CREDIT-BYPASS IST AKTIV. Nur auf dem privaten Listener verwenden.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';$('customers').innerHTML=(x.customers||[]).map(c=>`<div class="customer" data-id="${esc(c.id)}"><div><strong>${esc(c.username)}</strong><div class="small">${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}</div></div><div>${cr(c.balance_micros)} Credits</div><div>${c.running}/${c.workers} Worker aktiv</div>${manual?'<div class="grant"><input class="amount" type="number" min="0.001" step="1" placeholder="Credits"><button class="grantBtn">+ TEST</button></div>':'<div></div>'}</div>`).join('');document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Test-Credits gebucht`);await load()}catch(e){msg(e.message,true)}})}$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};load().catch(()=>{});
|
||||
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Admin</title><link rel="stylesheet" href="/styles.css"></head><body><main><div class="eyebrow">NEURAL HUNT · PRIVATE CONTROL PLANE</div><h1>Customer Service Admin</h1><div id="msg" class="msg hidden"></div><section id="loginBox"><label>Admin<input id="user"></label><label>Passwort<input id="pass" type="password"></label><button id="login">ANMELDEN</button></section><section id="panel" class="hidden"><div class="head"><p>Dieser Listener gehört ausschließlich hinter VPN / privates Netz.</p><button id="logout">ABMELDEN</button></div><div id="manual" class="notice"></div><div id="customers"></div></section></main><script src="/app.js" defer></script></body></html>
|
||||
+1
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark;--bg:#080b10;--card:#111821;--line:#293746;--text:#edf4fb;--muted:#8fa1b2;--accent:#54f0a6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,monospace}main{max-width:1100px;margin:auto;padding:34px 20px}.eyebrow{color:var(--accent);letter-spacing:.2em}h1{font:700 38px system-ui}section,.customer{border:1px solid var(--line);background:var(--card);border-radius:12px;padding:16px;margin:12px 0}label{display:block;color:var(--muted);margin:10px 0}input{display:block;width:100%;margin-top:5px;background:#090e14;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:10px}button{background:var(--accent);border:0;border-radius:8px;padding:9px 12px;font-weight:900;cursor:pointer}.hidden{display:none!important}.head,.row{display:flex;justify-content:space-between;gap:12px;align-items:center}.customer{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:10px;align-items:center}.small{font-size:12px;color:var(--muted);word-break:break-all}.grant{display:flex;gap:6px}.grant input{margin:0}.msg,.notice{padding:10px;border-radius:8px;background:#18261f;margin:10px 0}.msg.err{background:#32171d}@media(max-width:800px){.customer{grid-template-columns:1fr}.head,.row{align-items:flex-start;flex-direction:column}}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
const $=id=>document.getElementById(id);let state={me:null,tasks:[],workers:[]};
|
||||
function msg(t,err=false){const e=$('msg');e.textContent=t;e.className='msg'+(err?' err':'');e.classList.remove('hidden');setTimeout(()=>e.classList.add('hidden'),5000)}
|
||||
async function api(path,opt={}){const o={credentials:'same-origin',...opt};if(o.body&&typeof o.body!=='string'){o.headers={...(o.headers||{}),'Content-Type':'application/json'};o.body=JSON.stringify(o.body)}const r=await fetch(path,o);const ct=r.headers.get('content-type')||'';const b=ct.includes('json')?await r.json():await r.text();if(!r.ok)throw new Error(b?.error||b||`HTTP ${r.status}`);return b}
|
||||
const credits=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});
|
||||
async function boot(){try{await load()}catch(e){$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden')}const q=new URLSearchParams(location.search);if(q.get('paypal')==='return'&&q.get('token')){try{await api('/api/billing/paypal/capture',{method:'POST',body:{order_id:q.get('token')}});history.replaceState({},'',location.pathname);msg('PayPal-Zahlung verbucht');await load()}catch(e){msg(e.message,true)}}}
|
||||
async function load(){const me=await api('/api/me');state.me=me;const [tasks,workers,ledger,packages]=await Promise.all([api('/api/tasks'),api('/api/workers'),api('/api/ledger'),api('/api/billing/packages')]);state.tasks=Array.isArray(tasks)?tasks:[];state.workers=workers||[];$('auth').classList.add('hidden');$('portal').classList.remove('hidden');$('logout').classList.remove('hidden');$('balance').textContent=credits(me.balance_micros);$('rate').textContent=credits(me.worker_rate_micros_per_minute);$('workerCount').textContent=workers.length;$('runningCount').textContent=`${workers.filter(x=>x.status==='running').length} aktiv`;$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt';renderTasks();renderWorkers();renderLedger(ledger||[]);renderPackages(packages)}
|
||||
function taskName(t){return t.display_name||`Task ${String(t.id).slice(-8)}`}
|
||||
function renderTasks(){const html=state.tasks.map(t=>`<option value="${esc(t.id)}">${esc(taskName(t))} · ${t.range_bits} bit${t.paused?' · PAUSED':''}</option>`).join('');$('newTask').innerHTML=html}
|
||||
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function renderWorkers(){const host=$('workers');if(!state.workers.length){host.innerHTML='<article class="muted">Noch keine Worker angelegt.</article>';return}host.innerHTML=state.workers.map(w=>{const opts=state.tasks.map(t=>`<option value="${esc(t.id)}" ${t.id===w.task_id?'selected':''}>${esc(taskName(t))}</option>`).join('');return `<article class="worker" data-id="${esc(w.id)}"><span class="status ${w.status==='error'?'error':''}">${esc(w.status)}</span><h2>${esc(w.id)}</h2><div class="meta">Worker-ID: ${esc(w.worker_client_id||'wird beim ersten Start erzeugt')}</div><label>Task<select class="task">${opts}</select></label><label>Beacon<select class="path"><option value="auto" ${w.beacon_path==='auto'?'selected':''}>AUTO</option><option value="pulse" ${w.beacon_path==='pulse'?'selected':''}>PULSE</option><option value="flux" ${w.beacon_path==='flux'?'selected':''}>FLUX</option><option value="orbit" ${w.beacon_path==='orbit'?'selected':''}>ORBIT</option></select></label><div class="meta">${w.last_error?`Fehler: ${esc(w.last_error)}`:`Rate: ${credits(w.rate_micros_per_minute)} Credits/min`}</div><div class="buttons"><button data-act="save" class="ghost">ZUORDNUNG</button>${w.status==='running'?'<button data-act="stop" class="ghost">STOP</button>':'<button data-act="start">START</button>'}<button data-act="getid" class="ghost">IDENTITY ↓</button><button data-act="putid" class="ghost">IDENTITY ↑</button><button data-act="delete" class="danger">LÖSCHEN</button><input class="idfile hidden" type="file" accept="application/json,.json"></div></article>`}).join('');host.querySelectorAll('button[data-act]').forEach(b=>b.onclick=()=>workerAction(b.closest('.worker'),b.dataset.act));host.querySelectorAll('.idfile').forEach(i=>i.onchange=()=>uploadIdentity(i.closest('.worker'),i.files?.[0]))}
|
||||
async function workerAction(card,act){const id=card.dataset.id;try{if(act==='save')await api(`/api/workers/${encodeURIComponent(id)}`,{method:'PUT',body:{TaskID:card.querySelector('.task').value,BeaconPath:card.querySelector('.path').value}});if(act==='start')await api(`/api/workers/${encodeURIComponent(id)}/start`,{method:'POST'});if(act==='stop')await api(`/api/workers/${encodeURIComponent(id)}/stop`,{method:'POST'});if(act==='delete'){if(!confirm('Worker UND seine private Identity dauerhaft löschen? Vorher Identity herunterladen, falls sie erhalten bleiben soll.'))return;await api(`/api/workers/${encodeURIComponent(id)}`,{method:'DELETE'})}if(act==='getid'){const r=await fetch(`/api/workers/${encodeURIComponent(id)}/identity`,{credentials:'same-origin'});if(!r.ok){const x=await r.json().catch(()=>({error:'Download fehlgeschlagen'}));throw new Error(x.error)}const blob=await r.blob();const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${id}-identity.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);return}if(act==='putid'){card.querySelector('.idfile').click();return}msg('Worker aktualisiert');await load()}catch(e){msg(e.message,true)}}
|
||||
async function uploadIdentity(card,file){if(!file)return;if(!confirm('Die aktuelle Worker-Identity wird ersetzt. Der Worker wird dabei gestoppt. Fortfahren?'))return;try{const r=await fetch(`/api/workers/${encodeURIComponent(card.dataset.id)}/identity`,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:await file.text()});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||'Upload fehlgeschlagen');msg('Identity ersetzt');await load()}catch(e){msg(e.message,true)}}
|
||||
function renderLedger(xs){$('ledger').innerHTML=xs.length?xs.map(x=>`<div class="ledger-row"><span>${esc(x.reason)}</span><span>${new Date(x.created_at).toLocaleString()}</span><strong class="${x.delta_micros>=0?'credit':'debit'}">${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}</strong></div>`).join(''):'<div class="muted">Noch keine Buchungen.</div>'}
|
||||
function renderPackages(v){const h=$('packages');$('paypalNote').textContent=v.paypal_enabled?`PayPal ${v.environment||''} · Credits werden erst nach serverseitig bestätigtem Capture verbucht.`:'PayPal ist derzeit deaktiviert.';h.innerHTML=(v.packages||[]).map(p=>`<div class="package"><span><strong>${credits(p.credits_micros)} Credits</strong><br><small>${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}</small></span><button data-pkg="${esc(p.id)}" ${v.paypal_enabled?'':'disabled'}>PAYPAL</button></div>`).join('');h.querySelectorAll('[data-pkg]').forEach(b=>b.onclick=async()=>{try{const x=await api('/api/billing/paypal/order',{method:'POST',body:{package_id:b.dataset.pkg}});location.href=x.approval_url}catch(e){msg(e.message,true)}})}
|
||||
$('login').onclick=async()=>{try{await api('/api/login',{method:'POST',body:{Username:$('loginUser').value,Password:$('loginPass').value}});await load()}catch(e){msg(e.message,true)}};$('register').onclick=async()=>{try{await api('/api/register',{method:'POST',body:{Username:$('regUser').value,Password:$('regPass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/logout',{method:'POST'}).catch(()=>{});location.reload()};$('saveReward').onclick=async()=>{try{const code=$('rewardLinkCode').value.trim();if(!code)throw new Error('Hosted-Code einfügen');await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg('Haupt-Identität sicher gekoppelt');await load()}catch(e){msg(e.message,true)}};$('clearReward').onclick=async()=>{if(!confirm('Reward-Kopplung wirklich lösen? Laufende Worker sollten vorher gestoppt werden.'))return;try{await api('/api/reward-identity',{method:'PUT',body:{clear:true}});msg('Reward-Kopplung gelöst');await load()}catch(e){msg(e.message,true)}};$('newWorker').onclick=()=> $('newWorkerBox').classList.toggle('hidden');$('createWorker').onclick=async()=>{try{await api('/api/workers',{method:'POST',body:{TaskID:$('newTask').value,BeaconPath:$('newPath').value}});$('newWorkerBox').classList.add('hidden');await load()}catch(e){msg(e.message,true)}};boot();
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Service</title><link rel="stylesheet" href="/styles.css"></head>
|
||||
<body><main>
|
||||
<header><div><div class="eyebrow">NEURAL HUNT</div><h1>Customer Service</h1><p class="muted">PrePaid Worker · Task-Zuordnung · Reward Management</p></div><button id="logout" class="ghost hidden">ABMELDEN</button></header>
|
||||
<div id="msg" class="msg hidden"></div>
|
||||
<section id="auth" class="grid auth-grid">
|
||||
<article><h2>Anmelden</h2><label>Benutzername<input id="loginUser" autocomplete="username"></label><label>Passwort<input id="loginPass" type="password" autocomplete="current-password"></label><button id="login">ANMELDEN</button></article>
|
||||
<article><h2>Konto erstellen</h2><label>Benutzername<input id="regUser" autocomplete="username"></label><label>Passwort · min. 12 Zeichen<input id="regPass" type="password" autocomplete="new-password"></label><button id="register">REGISTRIEREN</button></article>
|
||||
</section>
|
||||
<div id="portal" class="hidden">
|
||||
<section class="stats"><article><span>GUTHABEN</span><strong id="balance">—</strong><small>PrePaid Credits</small></article><article><span>KOSTEN</span><strong id="rate">—</strong><small>pro laufendem Worker / Minute</small></article><article><span>WORKER</span><strong id="workerCount">—</strong><small id="runningCount">— aktiv</small></article></section>
|
||||
<section class="grid two">
|
||||
<article><h2>Haupt-Identität / Rewards</h2><p class="muted">Gewinne aller verwalteten Worker werden deiner Haupt-Identität zugeordnet. Aus Sicherheitsgründen reicht eine öffentliche Client-ID nicht: Erzeuge im Neural-Hunt-Spiel als eingeloggte Haupt-Identität einen einmaligen <b>HOSTED CODE</b> und füge ihn hier ein.</p><div class="meta">Aktuell: <code id="rewardId">noch nicht gekoppelt</code></div><label>Einmaliger Hosted-Code<input id="rewardLinkCode" autocomplete="off" placeholder="nhlink_…"></label><div class="buttons"><button id="saveReward">IDENTITÄT KOPPELN</button><button id="clearReward" class="ghost">KOPPLUNG LÖSEN</button></div><p class="hint">Der Code gilt 10 Minuten und nur einmal. Dein privater P-256-Schlüssel bleibt im Browser/CLI und wird niemals an Customer Service übertragen. Worker behalten jeweils eine eigene kryptografische Identität und Presence.</p></article>
|
||||
<article><h2>PrePaid aufladen</h2><div id="packages" class="packages"></div><p id="paypalNote" class="hint"></p></article>
|
||||
</section>
|
||||
<section><div class="section-head"><div><h2>Worker</h2><p class="muted">Jeder Worker hat eine eigene persistente Identity und kann unabhängig einem Task zugeordnet werden.</p></div><button id="newWorker">+ WORKER</button></div><div id="newWorkerBox" class="new-worker hidden"><label>Task<select id="newTask"></select></label><label>Beacon-Pfad<select id="newPath"><option value="auto">AUTO</option><option value="pulse">PULSE</option><option value="flux">FLUX</option><option value="orbit">ORBIT</option></select></label><button id="createWorker">ERSTELLEN</button></div><div id="workers" class="worker-grid"></div></section>
|
||||
<section><h2>Abrechnung</h2><p class="muted">Abgerechnet wird in bezahlten Worker-Zeitfenstern. Wenn das PrePaid-Guthaben nicht mehr für die nächste Minute reicht, stoppt der Service den Worker automatisch.</p><div id="ledger" class="ledger"></div></section>
|
||||
</div>
|
||||
</main><script src="/app.js" defer></script></body></html>
|
||||
+1
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark;--bg:#070a0f;--card:#101720;--line:#263443;--text:#eef5fb;--muted:#8da0b1;--accent:#54f0a6;--warn:#ffca56;--danger:#ff6978}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#112318 0,#070a0f 38%);color:var(--text);font:15px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}main{max-width:1180px;margin:auto;padding:32px 20px 80px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:28px}h1{font:700 clamp(32px,5vw,58px)/1 system-ui;margin:4px 0}h2{font:700 21px system-ui;margin:0 0 12px}.eyebrow{letter-spacing:.28em;color:var(--accent);font-weight:800}.muted,.hint,small{color:var(--muted)}article,section>article,.new-worker,.worker,.ledger-row{background:rgba(16,23,32,.88);border:1px solid var(--line);border-radius:14px;padding:18px}.grid{display:grid;gap:18px}.auth-grid,.two{grid-template-columns:repeat(2,minmax(0,1fr))}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}.stats article{display:flex;flex-direction:column}.stats span{font-size:12px;letter-spacing:.18em;color:var(--muted)}.stats strong{font:700 30px system-ui;margin:5px 0}.section-head{display:flex;justify-content:space-between;align-items:end;margin:28px 0 12px}.section-head h2{margin:0}label{display:flex;flex-direction:column;gap:6px;color:var(--muted);margin:12px 0}input,select,button{font:inherit}input,select{width:100%;background:#080d13;color:var(--text);border:1px solid #344555;border-radius:9px;padding:11px}button{background:var(--accent);color:#04110b;border:0;border-radius:9px;padding:11px 14px;font-weight:900;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.ghost{background:#1a2530;color:var(--text);border:1px solid var(--line)}.danger{background:#3a171c;color:#ffbac2;border:1px solid #67313a}.hidden{display:none!important}.msg{position:sticky;top:12px;z-index:5;padding:12px 15px;border:1px solid var(--line);background:#14211b;border-radius:10px;margin-bottom:14px}.msg.err{background:#2c1418;color:#ffd5da}.packages{display:grid;gap:8px}.package{display:flex;align-items:center;justify-content:space-between;background:#0b1118;border:1px solid var(--line);padding:11px;border-radius:9px}.worker-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:14px}.worker{position:relative}.worker .status{display:inline-flex;padding:4px 8px;border-radius:999px;background:#15231d;color:var(--accent);font-size:12px;text-transform:uppercase}.worker .status.error{background:#30171b;color:#ff8c98}.worker .meta{font-size:12px;color:var(--muted);word-break:break-all}.buttons{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.buttons button{padding:8px 10px;font-size:12px}.new-worker{display:grid;grid-template-columns:2fr 1fr auto;gap:12px;align-items:end;margin-bottom:14px}.new-worker label{margin:0}.ledger{display:grid;gap:7px}.ledger-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;padding:10px 12px}.credit{color:var(--accent)}.debit{color:var(--warn)}section{margin-top:18px}@media(max-width:760px){.auth-grid,.two,.stats{grid-template-columns:1fr}.new-worker{grid-template-columns:1fr}.ledger-row{grid-template-columns:1fr}header{align-items:flex-start}}
|
||||
@@ -32,6 +32,10 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
created_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
winner_client_id TEXT REFERENCES clients(id),
|
||||
winner_worker_client_id TEXT REFERENCES clients(id),
|
||||
winner_beacon_path TEXT NOT NULL DEFAULT '',
|
||||
winner_beacon_boosted_path TEXT NOT NULL DEFAULT '',
|
||||
winner_beacon_round INTEGER NOT NULL DEFAULT 0,
|
||||
winner_signature TEXT,
|
||||
winning_guess TEXT,
|
||||
artifact_status TEXT NOT NULL DEFAULT 'none' CHECK (artifact_status IN ('none','pending','generating','ready','error')),
|
||||
@@ -128,3 +132,44 @@ CREATE TABLE IF NOT EXISTS artifact_api_usage (
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_created_idx ON artifact_api_usage(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_kind_created_idx ON artifact_api_usage(kind, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_task_idx ON artifact_api_usage(task_id);
|
||||
|
||||
-- Publicly auditable Beacon Hunt draws. Each row records the externally
|
||||
-- sourced drand reveal used for the weighted path/draw decision.
|
||||
CREATE TABLE IF NOT EXISTS beacon_draws (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
window_end INTEGER NOT NULL,
|
||||
beacon_id TEXT NOT NULL,
|
||||
beacon_round INTEGER NOT NULL,
|
||||
randomness TEXT NOT NULL,
|
||||
signature TEXT NOT NULL DEFAULT '',
|
||||
boosted_path TEXT NOT NULL,
|
||||
ticket_count INTEGER NOT NULL,
|
||||
selected_count INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(task_id, window_end)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS beacon_draws_task_idx ON beacon_draws(task_id, window_end DESC);
|
||||
|
||||
|
||||
-- A hosted worker uses its own cryptographic identity/presence, while prizes can
|
||||
-- be delegated to a durable customer-owned reward identity. The worker remains
|
||||
-- auditable on the completed task through winner_worker_client_id.
|
||||
CREATE TABLE IF NOT EXISTS identity_delegations (
|
||||
worker_client_id TEXT PRIMARY KEY REFERENCES clients(id) ON DELETE CASCADE,
|
||||
owner_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS identity_delegations_owner_idx ON identity_delegations(owner_client_id);
|
||||
|
||||
-- One-shot pairing codes let a logged-in owner prove control of the reward
|
||||
-- identity to Customer Service without sharing the P-256 private key. Only the
|
||||
-- SHA-256 token hash is stored and codes expire quickly.
|
||||
CREATE TABLE IF NOT EXISTS customer_link_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS customer_link_tokens_exp_idx ON customer_link_tokens(expires_at);
|
||||
|
||||
+185
-9
@@ -109,6 +109,10 @@ func OpenSQLite(ctx context.Context, path string) (*sql.DB, error) {
|
||||
{"nft_prompt_instructions", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"nft_negative_prompt", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"nft_style_reference", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"winner_worker_client_id", "TEXT REFERENCES clients(id)"},
|
||||
{"winner_beacon_path", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"winner_beacon_boosted_path", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"winner_beacon_round", "INTEGER NOT NULL DEFAULT 0"},
|
||||
} {
|
||||
if err := ensureColumn(ctx, db, "tasks", m.name, m.def); err != nil {
|
||||
db.Close()
|
||||
@@ -220,22 +224,23 @@ type Task struct {
|
||||
CreatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
WinnerClientID *string
|
||||
WinnerWorkerClientID *string
|
||||
ArtifactStatus string
|
||||
ArtifactURI *string
|
||||
ArtifactManifestURI *string
|
||||
}
|
||||
|
||||
const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,created_at,completed_at,winner_client_id,artifact_status,artifact_uri,artifact_manifest_uri`
|
||||
const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,created_at,completed_at,winner_client_id,winner_worker_client_id,artifact_status,artifact_uri,artifact_manifest_uri`
|
||||
|
||||
func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, string, error) {
|
||||
var t Task
|
||||
var created int64
|
||||
var completed sql.NullInt64
|
||||
var winner, artifactURI, manifestURI, parent sql.NullString
|
||||
var winner, winnerWorker, artifactURI, manifestURI, parent sql.NullString
|
||||
var guessMin, clientSubmit sql.NullInt64
|
||||
var paused int
|
||||
var secret string
|
||||
args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &created, &completed, &winner, &t.ArtifactStatus, &artifactURI, &manifestURI}
|
||||
args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &created, &completed, &winner, &winnerWorker, &t.ArtifactStatus, &artifactURI, &manifestURI}
|
||||
if withSecret {
|
||||
args = append(args, &secret)
|
||||
}
|
||||
@@ -264,6 +269,10 @@ func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, s
|
||||
v := winner.String
|
||||
t.WinnerClientID = &v
|
||||
}
|
||||
if winnerWorker.Valid {
|
||||
v := winnerWorker.String
|
||||
t.WinnerWorkerClientID = &v
|
||||
}
|
||||
if artifactURI.Valid {
|
||||
v := artifactURI.String
|
||||
t.ArtifactURI = &v
|
||||
@@ -864,6 +873,62 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) (
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OwnedArtifact is returned only to the authenticated winner. Unlike the
|
||||
// public gallery it includes a private download URL for the original artifact.
|
||||
type OwnedArtifact struct {
|
||||
TaskID string `json:"task_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
DownloadURI string `json:"download_uri"`
|
||||
}
|
||||
|
||||
func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]OwnedArtifact, error) {
|
||||
if limit < 1 || limit > 200 {
|
||||
limit = 48
|
||||
}
|
||||
cid = strings.TrimSpace(cid)
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(display_name,''),range_bits,COALESCE(completed_at,created_at)
|
||||
FROM tasks
|
||||
WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND winner_client_id=?
|
||||
ORDER BY COALESCE(completed_at,created_at) DESC LIMIT ?`, cid, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]OwnedArtifact, 0)
|
||||
for rows.Next() {
|
||||
var a OwnedArtifact
|
||||
var completed int64
|
||||
if err := rows.Scan(&a.TaskID, &a.DisplayName, &a.RangeBits, &completed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.CompletedAt = fromUnixMS(completed)
|
||||
escaped := url.PathEscape(a.TaskID)
|
||||
a.PreviewURI = "/api/public/artifacts/" + escaped + "/preview"
|
||||
a.DownloadURI = "/api/me/artifacts/" + escaped + "/download"
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OwnedArtifactSource returns the original artifact only when taskID belongs to
|
||||
// cid. Keeping the ownership check in the database query makes it difficult for
|
||||
// future handlers to accidentally turn the private download endpoint into an
|
||||
// insecure direct object reference.
|
||||
func (s *Store) OwnedArtifactSource(ctx context.Context, taskID, cid string) (artifactURI string, ok bool, err error) {
|
||||
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri FROM tasks
|
||||
WHERE id=? AND winner_client_id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL`, taskID, cid).Scan(&artifactURI)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return artifactURI, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) PublicArtifactSource(ctx context.Context, taskID string) (artifactURI, winner string, ok bool, err error) {
|
||||
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri,winner_client_id FROM tasks
|
||||
WHERE id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND winner_client_id IS NOT NULL`, taskID).Scan(&artifactURI, &winner)
|
||||
@@ -1351,7 +1416,8 @@ func (s *Store) InactiveNonWinnerClients(ctx context.Context, cutoffMS int64) ([
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT c.id,c.last_seen
|
||||
FROM clients c
|
||||
WHERE c.last_seen < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=c.id OR d.owner_client_id=c.id)
|
||||
ORDER BY c.last_seen ASC`, cutoffMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1374,7 +1440,7 @@ func (s *Store) OldWinnerCount(ctx context.Context, cutoffMS int64) (int64, erro
|
||||
var n int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM clients c
|
||||
WHERE c.last_seen < ?
|
||||
AND EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id)`, cutoffMS).Scan(&n)
|
||||
AND EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id)`, cutoffMS).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -1401,7 +1467,8 @@ func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int
|
||||
_, _ = tx.ExecContext(ctx, `DELETE FROM presence_leases WHERE client_id=? AND expires_at<=?`, id, time.Now().UTC().UnixMilli())
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM clients
|
||||
WHERE id=? AND last_seen < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id)`, id, cutoffMS)
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id OR t.winner_worker_client_id=clients.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=clients.id OR d.owner_client_id=clients.id)`, id, cutoffMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1516,7 +1583,7 @@ func (s *Store) LoadGuessState(ctx context.Context, taskID, cid string) (GuessSt
|
||||
// sequence/count values include all losing guesses that happened in memory
|
||||
// since the previous checkpoint, so a restart resumes from the latest durable
|
||||
// improvement rather than writing every false guess.
|
||||
func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool) (Point, error) {
|
||||
func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewardOwner string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool, beaconPath, beaconBoostedPath string, beaconRound uint64) (Point, error) {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Point{}, err
|
||||
@@ -1543,7 +1610,10 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid string
|
||||
return Point{}, err
|
||||
}
|
||||
if correct {
|
||||
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, cid, sig, guess, t.ID)
|
||||
if rewardOwner == "" {
|
||||
rewardOwner = cid
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_worker_client_id=?,winner_beacon_path=?,winner_beacon_boosted_path=?,winner_beacon_round=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, rewardOwner, cid, beaconPath, beaconBoostedPath, int64(beaconRound), sig, guess, t.ID)
|
||||
if err != nil {
|
||||
return Point{}, err
|
||||
}
|
||||
@@ -1551,7 +1621,7 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid string
|
||||
if n != 1 {
|
||||
return Point{}, ErrTaskCompleted
|
||||
}
|
||||
if err := s.unlocksTx(ctx, tx, cid, t.ID, lastMS); err != nil {
|
||||
if err := s.unlocksTx(ctx, tx, rewardOwner, t.ID, lastMS); err != nil {
|
||||
return Point{}, err
|
||||
}
|
||||
}
|
||||
@@ -1608,3 +1678,109 @@ func (s *Store) PointsForClient(ctx context.Context, taskID, cid string, limit i
|
||||
}
|
||||
return append(ps, own), nil
|
||||
}
|
||||
|
||||
// RecordBeaconDraw stores the externally auditable randomness used by an
|
||||
// optional Beacon Hunt lottery window. Duplicate callbacks are idempotent.
|
||||
func (s *Store) RecordBeaconDraw(ctx context.Context, taskID string, windowEnd time.Time, beaconID string, round uint64, randomness, signature, boostedPath string, tickets, selected int) error {
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO beacon_draws(task_id,window_end,beacon_id,beacon_round,randomness,signature,boosted_path,ticket_count,selected_count,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(task_id,window_end) DO UPDATE SET beacon_id=excluded.beacon_id,beacon_round=excluded.beacon_round,randomness=excluded.randomness,signature=excluded.signature,boosted_path=excluded.boosted_path,ticket_count=excluded.ticket_count,selected_count=excluded.selected_count`,
|
||||
taskID, windowEnd.UTC().UnixMilli(), beaconID, int64(round), randomness, signature, boostedPath, tickets, selected, time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
type BeaconDraw struct {
|
||||
TaskID string `json:"task_id"`
|
||||
WindowEnd time.Time `json:"window_end"`
|
||||
BeaconID string `json:"beacon_id"`
|
||||
BeaconRound uint64 `json:"beacon_round"`
|
||||
Randomness string `json:"randomness"`
|
||||
Signature string `json:"signature"`
|
||||
BoostedPath string `json:"boosted_path"`
|
||||
TicketCount int `json:"ticket_count"`
|
||||
SelectedCount int `json:"selected_count"`
|
||||
}
|
||||
|
||||
func (s *Store) LatestBeaconDraw(ctx context.Context, taskID string) (BeaconDraw, error) {
|
||||
var d BeaconDraw
|
||||
var endMS int64
|
||||
var round int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT task_id,window_end,beacon_id,beacon_round,randomness,signature,boosted_path,ticket_count,selected_count FROM beacon_draws WHERE task_id=? ORDER BY window_end DESC LIMIT 1`, taskID).
|
||||
Scan(&d.TaskID, &endMS, &d.BeaconID, &round, &d.Randomness, &d.Signature, &d.BoostedPath, &d.TicketCount, &d.SelectedCount)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
d.WindowEnd = time.UnixMilli(endMS).UTC()
|
||||
d.BeaconRound = uint64(round)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetIdentityDelegation(ctx context.Context, workerClientID, ownerClientID string) error {
|
||||
workerClientID = strings.TrimSpace(workerClientID)
|
||||
ownerClientID = strings.TrimSpace(ownerClientID)
|
||||
if workerClientID == "" {
|
||||
return errors.New("worker_client_id required")
|
||||
}
|
||||
if ownerClientID == "" {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM identity_delegations WHERE worker_client_id=?`, workerClientID)
|
||||
return err
|
||||
}
|
||||
if workerClientID == ownerClientID {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM identity_delegations WHERE worker_client_id=?`, workerClientID)
|
||||
return err
|
||||
}
|
||||
if !s.ClientExists(ctx, workerClientID) || !s.ClientExists(ctx, ownerClientID) {
|
||||
return errors.New("worker and owner identities must already exist")
|
||||
}
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO identity_delegations(worker_client_id,owner_client_id,created_at,updated_at) VALUES(?,?,?,?)
|
||||
ON CONFLICT(worker_client_id) DO UPDATE SET owner_client_id=excluded.owner_client_id,updated_at=excluded.updated_at`, workerClientID, ownerClientID, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RewardOwnerForWorker(ctx context.Context, workerClientID string) string {
|
||||
var owner string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT owner_client_id FROM identity_delegations WHERE worker_client_id=?`, workerClientID).Scan(&owner); err == nil && owner != "" {
|
||||
return owner
|
||||
}
|
||||
return workerClientID
|
||||
}
|
||||
|
||||
// CreateCustomerLinkToken stores only a SHA-256 hash of the short-lived pairing
|
||||
// code. A customer can therefore prove control of a Neural Hunt identity to the
|
||||
// private hosted-service control plane without ever uploading its private key.
|
||||
func (s *Store) CreateCustomerLinkToken(ctx context.Context, tokenHash, clientID string, expiresAt time.Time) error {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, _ = tx.ExecContext(ctx, `DELETE FROM customer_link_tokens WHERE expires_at<=? OR client_id=?`, now, clientID)
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customer_link_tokens(token_hash,client_id,expires_at,created_at) VALUES(?,?,?,?)`, tokenHash, clientID, expiresAt.UTC().UnixMilli(), now); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ConsumeCustomerLinkToken is intentionally one-shot. The delete happens in
|
||||
// the same transaction as the lookup so the code cannot be replayed by a
|
||||
// second Customer Service request.
|
||||
func (s *Store) ConsumeCustomerLinkToken(ctx context.Context, tokenHash string) (string, error) {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var clientID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT client_id FROM customer_link_tokens WHERE token_hash=? AND expires_at>?`, tokenHash, now).Scan(&clientID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM customer_link_tokens WHERE token_hash=?`, tokenHash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return clientID, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,8 @@ type Runtime struct {
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
|
||||
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
|
||||
BeaconHuntEnabled int `json:"beacon_hunt_enabled"`
|
||||
BeaconBonusWeight int `json:"beacon_bonus_weight"`
|
||||
SybilProofOfWorkBits int `json:"sybil_pow_bits"`
|
||||
SybilWarmupSec int `json:"sybil_warmup_sec"`
|
||||
OpenAIMaxCalls1H int `json:"openai_max_calls_1h"`
|
||||
@@ -76,6 +78,8 @@ func Defaults() Runtime {
|
||||
ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11),
|
||||
GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60),
|
||||
GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0),
|
||||
BeaconHuntEnabled: envInt("DEFAULT_BEACON_HUNT_ENABLED", 0),
|
||||
BeaconBonusWeight: envInt("DEFAULT_BEACON_BONUS_WEIGHT", 2),
|
||||
SybilProofOfWorkBits: envInt("DEFAULT_SYBIL_POW_BITS", 15),
|
||||
SybilWarmupSec: envInt("DEFAULT_SYBIL_WARMUP_SEC", 15),
|
||||
OpenAIMaxCalls1H: envInt("DEFAULT_OPENAI_MAX_CALLS_1H", 20),
|
||||
@@ -230,6 +234,12 @@ func Validate(v Runtime) error {
|
||||
if v.GuessLotteryMaxAccepted < 0 || v.GuessLotteryMaxAccepted > 100000 {
|
||||
return fmt.Errorf("guess_lottery_max_accepted must be 0..100000 (0 disables lottery)")
|
||||
}
|
||||
if v.BeaconHuntEnabled < 0 || v.BeaconHuntEnabled > 1 {
|
||||
return fmt.Errorf("beacon_hunt_enabled must be 0 or 1")
|
||||
}
|
||||
if v.BeaconBonusWeight < 1 || v.BeaconBonusWeight > 10 {
|
||||
return fmt.Errorf("beacon_bonus_weight must be 1..10")
|
||||
}
|
||||
if v.SybilProofOfWorkBits < 0 || v.SybilProofOfWorkBits > 22 {
|
||||
return fmt.Errorf("sybil_pow_bits must be 0..22")
|
||||
}
|
||||
|
||||
Vendored
+35
-13
@@ -52,16 +52,26 @@ function requireWebCrypto(){
|
||||
}
|
||||
throw new Error('WebCrypto ist in diesem Browser nicht verfügbar. Bitte verwende einen aktuellen Browser mit aktivierter WebCrypto-Unterstützung.');
|
||||
}
|
||||
async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw){requireWebCrypto();return JSON.parse(raw)}const subtle=requireWebCrypto();const kp=await subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await subtle.exportKey('jwk',kp.publicKey),privateJwk:await subtle.exportKey('jwk',kp.privateKey)};localStorage.setItem(identityKey,JSON.stringify(b));return b}
|
||||
async function clientId(pub){const subtle=requireWebCrypto();const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await subtle.digest('SHA-256',new TextEncoder().encode(s)))}
|
||||
async function validateIdentityBundle(b){
|
||||
const subtle=requireWebCrypto();
|
||||
if(!b||Number(b.version)!==1||b.publicJwk?.kty!=='EC'||b.publicJwk?.crv!=='P-256'||b.privateJwk?.kty!=='EC'||b.privateJwk?.crv!=='P-256'||!b.privateJwk?.d)throw new Error('Ungültige Neural-Hunt-Identität');
|
||||
const pub=await subtle.importKey('jwk',b.publicJwk,{name:'ECDSA',namedCurve:'P-256'},false,['verify']);
|
||||
const priv=await subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);
|
||||
const probe=crypto.getRandomValues(new Uint8Array(32)),sig=await subtle.sign({name:'ECDSA',hash:'SHA-256'},priv,probe);
|
||||
if(!await subtle.verify({name:'ECDSA',hash:'SHA-256'},pub,sig,probe))throw new Error('Public/Private Key der Identität passen nicht zusammen');
|
||||
return clientId(b.publicJwk);
|
||||
}
|
||||
async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw){requireWebCrypto();const b=JSON.parse(raw);await validateIdentityBundle(b);return b}const subtle=requireWebCrypto();const kp=await subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await subtle.exportKey('jwk',kp.publicKey),privateJwk:await subtle.exportKey('jwk',kp.privateKey)};await validateIdentityBundle(b);localStorage.setItem(identityKey,JSON.stringify(b));return b}
|
||||
async function sign(message){const subtle=requireWebCrypto();const b=await ensureIdentity();const k=await subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))}
|
||||
async function responseError(r,fallback){try{const b=await r.json();return b?.error?`${fallback}: ${b.error}`:`${fallback} (HTTP ${r.status})`}catch{return `${fallback} (HTTP ${r.status})`}}
|
||||
function zeroBits(bytes){let n=0;for(const x of bytes){if(x===0){n+=8;continue}for(let m=0x80;m&&!(x&m);m>>=1)n++;break}return n}
|
||||
async function solveIdentityProof(challenge,cid,bits){bits=Number(bits||0);if(bits<=0)return '';const subtle=requireWebCrypto(),enc=new TextEncoder(),prefix=`nh-pow-v1|${challenge}|${cid}|`;let counter=0;const batch=96;while(true){const nums=Array.from({length:batch},(_,i)=>counter+i),hashes=await Promise.all(nums.map(n=>subtle.digest('SHA-256',enc.encode(prefix+n))));for(let i=0;i<hashes.length;i++)if(zeroBits(new Uint8Array(hashes[i]))>=bits)return String(nums[i]);counter+=batch;if(counter%3072===0)await new Promise(r=>setTimeout(r,0))}}
|
||||
async function loginIdentity(){const b=await ensureIdentity();const cr=await fetch('/api/auth/challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk})});if(!cr.ok)throw new Error(await responseError(cr,'Challenge fehlgeschlagen'));const c=await cr.json(),bits=Number(c.proof_of_work_bits||0);if(bits>0&&$('status'))$('status').textContent=`Neue Identität wird geprüft · ${bits}-Bit Proof-of-Work …`;const proof_of_work_counter=await solveIdentityProof(c.challenge,c.client_id,bits),signature=await sign(`login|${c.challenge}|${c.client_id}`);const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk,challenge:c.challenge,signature,proof_of_work_counter})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()}
|
||||
async function deterministicGuess(taskID,seed,cid,seq,bits){const subtle=requireWebCrypto();const h=new Uint8Array(await subtle.digest('SHA-256',new TextEncoder().encode(`${taskID}|${seed}|${cid}|${seq}`)));let n=0n;for(const x of h)n=(n<<8n)|BigInt(x);return (n%(1n<<BigInt(bits))).toString()}
|
||||
async function exportIdentity(passphrase){const subtle=requireWebCrypto();const b=await ensureIdentity();const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt,iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await subtle.encrypt({name:'AES-GCM',iv},aes,new TextEncoder().encode(JSON.stringify(b)));return JSON.stringify({version:1,salt:b64u(salt),iv:b64u(iv),ciphertext:b64u(ct)},null,2)}
|
||||
async function importIdentity(raw,passphrase){const subtle=requireWebCrypto();const x=JSON.parse(raw);const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);const pt=await subtle.decrypt({name:'AES-GCM',iv:ub64(x.iv)},aes,ub64(x.ciphertext));const b=JSON.parse(new TextDecoder().decode(pt));localStorage.setItem(identityKey,JSON.stringify(b));return b}
|
||||
const identityKdfIterations=250000;
|
||||
async function exportIdentity(passphrase){const subtle=requireWebCrypto();if(String(passphrase||'').length<12)throw new Error('Die Export-Passphrase muss mindestens 12 Zeichen lang sein.');const b=await ensureIdentity(),cid=await validateIdentityBundle(b);const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt,iterations:identityKdfIterations,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await subtle.encrypt({name:'AES-GCM',iv},aes,new TextEncoder().encode(JSON.stringify(b)));return JSON.stringify({version:1,format:'neuralhunt-identity-export',clientId:cid,kdf:'PBKDF2-HMAC-SHA256',iterations:identityKdfIterations,cipher:'AES-256-GCM',salt:b64u(salt),iv:b64u(iv),ciphertext:b64u(ct)},null,2)}
|
||||
async function importIdentity(raw,passphrase){const subtle=requireWebCrypto();const x=JSON.parse(raw);if(!x?.ciphertext||!x?.salt||!x?.iv)throw new Error('Bitte einen verschlüsselten Neural-Hunt-Identitäts-Export auswählen.');if(x.format&&x.format!=='neuralhunt-identity-export')throw new Error('Nicht unterstütztes Identitätsformat.');if(x.kdf&&x.kdf!=='PBKDF2-HMAC-SHA256')throw new Error('Nicht unterstützte KDF.');if(x.cipher&&x.cipher!=='AES-256-GCM')throw new Error('Nicht unterstützte Verschlüsselung.');const iterations=Number(x.iterations||identityKdfIterations);if(iterations<100000||iterations>2000000)throw new Error('Nicht unterstützte KDF-Konfiguration.');const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);let pt;try{pt=await subtle.decrypt({name:'AES-GCM',iv:ub64(x.iv)},aes,ub64(x.ciphertext))}catch{throw new Error('Identität konnte nicht entschlüsselt werden: falsche Passphrase oder beschädigter Export.')}const b=JSON.parse(new TextDecoder().decode(pt)),cid=await validateIdentityBundle(b);if(x.clientId&&x.clientId!==cid)throw new Error('Client-ID im Export stimmt nicht mit dem Schlüssel überein.');return {bundle:b,clientId:cid}}
|
||||
|
||||
function hashInt(s){let h=2166136261>>>0;s=String(s||'');for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619)}return h>>>0}
|
||||
function pseudo(s,o=0){return ((Math.sin((hashInt(`${s}:${o}`)+1)*0.00000137+o*12.345)*43758.5453123)%1+1)%1}
|
||||
@@ -302,8 +312,9 @@ function userShell(){
|
||||
<div class="panel-title"><span>DEIN SIGNAL</span><span class="chip" id="guessCountdown">—</span></div>
|
||||
<div class="rank-hero"><small>RANK</small><strong id="rank">#—</strong></div>
|
||||
<div class="signal-metrics"><div><span>Score</span><b id="score">0.00</b></div><div><span>Wins</span><b id="wins">0</b></div><div><span>Clients</span><b id="clientmetric">0</b></div></div>
|
||||
<div id="beaconChoice" class="beacon-choice hidden"><div class="leaderboard-head"><span>BEACON PATH</span><small id="beaconMeta">externer Zufallsimpuls</small></div><div class="beacon-buttons"><button data-beacon-path="PULSE">PULSE</button><button data-beacon-path="FLUX">FLUX</button><button data-beacon-path="ORBIT">ORBIT</button></div><small id="beaconLast">Wähle vor dem nächsten Los einen Pfad.</small></div>
|
||||
<div class="proximity-mini"><div class="leaderboard-head"><span>TARGET RADAR</span><small>100 = Task</small></div><div id="proximityRows"></div></div>
|
||||
<div class="identity-block"><span class="eyebrow">IDENTITÄT</span><code id="cid">…</code><div class="identity-actions"><button id="exportid">Export</button><label class="button">Import<input id="importid" hidden type="file" accept="application/json"></label></div><div class="chips" id="unlocks"></div></div>
|
||||
<div class="identity-block"><span class="eyebrow">IDENTITÄT</span><code id="cid">…</code><div class="identity-actions"><button id="exportid">SICHERN</button><label class="button">IMPORT<input id="importid" hidden type="file" accept="application/json"></label><button id="mynfts">MEINE NFTS</button><button id="hostedCode">HOSTED CODE</button></div><div id="myNftsPanel" class="identity-nfts hidden"></div><div class="chips" id="unlocks"></div></div>
|
||||
<div class="leaderboard-head"><span>LEADERBOARD</span><a href="/leaderboard">ECHTZEIT →</a></div><div class="leaderboard" id="leaders"></div>
|
||||
</aside>
|
||||
<div class="distance-legend glass"><b>TARGET FIELD</b><span>0 · WEIT</span><i></i><span>75</span><span>90</span><span>95</span><span>99+</span><span>100 · TASK</span></div>
|
||||
@@ -316,7 +327,7 @@ function userShell(){
|
||||
<div class="task-landing-inner">
|
||||
<div class="task-landing-head">
|
||||
<div><span class="eyebrow">CHOOSE YOUR FIELD</span><h1>Wähle deinen Task</h1><p>Jeder Task ist ein eigener Wahrscheinlichkeitsraum. Du kannst jederzeit wechseln; deine Identität und bereits erreichte Bestwerte bleiben erhalten.</p></div>
|
||||
<div class="task-landing-id glass"><span>DEINE IDENTITÄT</span><code id="landingCid">initialisiere …</code><button id="landingRefresh">AKTUALISIEREN</button></div>
|
||||
<div class="task-landing-id glass"><span>DEINE IDENTITÄT</span><code id="landingCid">initialisiere …</code><div class="landing-identity-actions"><button id="landingExportId">SICHERN</button><label class="button">IMPORT<input id="landingImportId" hidden type="file" accept="application/json"></label><button id="landingMyNfts">MEINE NFTS</button><button id="landingHostedCode">HOSTED CODE</button></div><div id="landingNftsPanel" class="landing-owned-nfts hidden"></div><button id="landingRefresh">AKTUALISIEREN</button></div>
|
||||
</div>
|
||||
<div id="taskCards" class="task-cards"><div class="task-card-loading">Tasks werden geladen …</div></div>
|
||||
<div class="task-landing-foot"><span>Ein Client kann immer nur mit <b>einem</b> Task aktiv verbunden sein.</span><a href="/leaderboard">Echtzeit-Leaderboard →</a></div>
|
||||
@@ -325,11 +336,12 @@ function userShell(){
|
||||
}
|
||||
|
||||
async function runUser(){
|
||||
userShell(); let task=null,points=[],cid='',scheduler=null,countdownTimer=null,ws=null,submitting=false,nextGuessAt=0,refreshing=false,landingBusy=false,landingTimer=null,wsReconnectTimer=null,wsBackoff=500;
|
||||
userShell(); let task=null,points=[],cid='',scheduler=null,countdownTimer=null,ws=null,submitting=false,nextGuessAt=0,refreshing=false,landingBusy=false,landingTimer=null,wsReconnectTimer=null,wsBackoff=500,beaconPath=localStorage.getItem('neuralhunt.beaconPath')||'PULSE';
|
||||
const map=new NeuralMap($('map'),{panelOffset:-115,onStats:s=>{if($('rendercount'))$('rendercount').textContent=s.render.toLocaleString('de-DE');if($('fpscount'))$('fpscount').textContent=s.fps}});
|
||||
let detailsOpen=false;
|
||||
const syncMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('toggleMobile').textContent=mobileButtonLabel();setActive('toggleMobile',on);$('signalPanel').classList.toggle('expanded',on&&detailsOpen);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.96);setActive('toggleEco',true);setActive('toggleLabels',false);setActive('toggleEdges',false);if(+$('maxnodes').value>1000){$('maxnodes').value=1000;$('maxnodesvalue').textContent='1.000';map.update(points,cid,1000)}}else{map.eco=false;setActive('toggleEco',false)}map.resize()};
|
||||
const status=(s,mode='living')=>{if($('status'))$('status').textContent=s;const m=$('visualMode');if(m){m.className=`mode-status ${mode}`;$('modeTitle').textContent=mode==='thinking'?'GUESS':mode==='researching'?'WIN':task?.paused?'PAUSED':'LIVING';$('modeDetail').textContent=s}};
|
||||
const syncBeacon=()=>{const on=Number(task?.beacon_hunt_enabled||0)===1&&Number(task?.guess_lottery_max_accepted||0)>0,box=$('beaconChoice');if(!box)return;box.classList.toggle('hidden',!on);box.querySelectorAll('[data-beacon-path]').forEach(b=>setActive(b,b.dataset.beaconPath===beaconPath));if(on)$('beaconMeta').textContent=`Treffer = Gewicht ×${Number(task.beacon_bonus_weight||2)}`};
|
||||
const renderRadar=()=>{const rows=proximityRows(points,cid);$('proximityRows').innerHTML=rows.map((p,i)=>`<div class="proximity-row ${p.client_id===cid?'self':''}"><span>${p.client_id===cid?'DU':`#${p.rank||i+1}`}</span><div><i style="width:${clamp(Number(p.score||0),0,100)}%"></i></div><b>${fmtScore(p.score)}</b></div>`).join('')||'<div class="empty small">Noch keine Signale</div>'};
|
||||
const render=()=>{points=Array.isArray(points)?points:[];const budget=Math.min(10000,Math.max(300,(+$('maxnodes').value||task?.default_max_nodes||2000)*3));if(points.length>budget){const own=points.find(p=>p.client_id===cid),top=points.filter(p=>p.client_id!==cid).sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,budget-(own?1:0));points=own?[...top,own]:top}if($('nodecount'))$('nodecount').textContent=points.length.toLocaleString('de-DE');if($('clientmetric'))$('clientmetric').textContent=points.length.toLocaleString('de-DE');map.update(points,cid,+$('maxnodes').value);renderRadar()};
|
||||
const countdown=()=>{let text='—';if(task?.paused)text='PAUSE';else if(task&&nextGuessAt){const sec=Math.max(0,Math.ceil((nextGuessAt-Date.now())/1000));text=sec?`${sec}s`:'jetzt'}$('guessCountdown').textContent=text;if($('guessMobile'))$('guessMobile').textContent=text};
|
||||
@@ -361,16 +373,26 @@ async function runUser(){
|
||||
if(e.code==='task_config_changed')await refreshTaskConfig(true);
|
||||
nextGuessAt=Date.now()+1500;status(e.code==='presence_required'?'Live-Verbindung wird automatisch wiederhergestellt …':'Client wird automatisch synchronisiert …')
|
||||
}
|
||||
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`);if(Number(task.guess_lottery_max_accepted||0)>0)status(`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='identity_warmup'){const wait=Math.max(1,Number(e.data?.retry_after_sec||task?.client_submit_interval_sec||15));nextGuessAt=Date.now()+wait*1000;status(`Anti-Sybil-Wartezeit · ${wait}s`,'living');return}if(e.code==='lottery_not_selected'){status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
|
||||
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;syncBeacon();if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),beaconOn=Number(task.beacon_hunt_enabled||0)===1&&Number(task.guess_lottery_max_accepted||0)>0,msg=beaconOn?`guess|${task.id}|${seq}|${guess}|${beaconPath}`:`guess|${task.id}|${seq}|${guess}`,signature=await sign(msg);if(Number(task.guess_lottery_max_accepted||0)>0)status(beaconOn?`Beacon ${beaconPath} committed · wartet auf externen Draw`:`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature,beacon_path:beaconOn?beaconPath:''})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;if(beaconOn){try{const d=await api(`/api/public/beacon/${encodeURIComponent(task.id)}/latest`);$('beaconLast').textContent=`Dein Pfad ${beaconPath} · Boost ${d.boosted_path} · drand #${d.beacon_round}`;status(correct?'Treffer — Task gelöst!':`Draw ${d.boosted_path} · Tipp gezogen & geprüft`,correct?'researching':'living')}catch{status(correct?'Treffer — Task gelöst!':'Beacon-Tipp gezogen & geprüft',correct?'researching':'living')}}else status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='identity_warmup'){const wait=Math.max(1,Number(e.data?.retry_after_sec||task?.client_submit_interval_sec||15));nextGuessAt=Date.now()+wait*1000;status(`Anti-Sybil-Wartezeit · ${wait}s`,'living');return}if(e.code==='lottery_not_selected'){if(e.data?.boosted_path&&$('beaconLast'))$('beaconLast').textContent=`Dein Pfad ${e.data.chosen_path} · Boost ${e.data.boosted_path} · Gewicht ×${e.data.weight||1} · drand #${e.data.beacon_round}`;status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='beacon_unavailable'){status('Randomness Beacon nicht erreichbar · kein Tipp ausgewertet','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
|
||||
function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?max_nodes=${encodeURIComponent(mx)}`,['neuralhunt.v1',`nh-auth.${getToken()}`]);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}}
|
||||
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
|
||||
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');syncBeacon();$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
|
||||
try{const id=await ensureIdentity();cid=await clientId(id.publicJwk);$('cid').textContent=cid;$('landingCid').textContent=cid;await ensureSession();syncMobile();await Promise.all([refreshLeaders()]);await showLanding()}catch(e){status(e.message||'Startfehler');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Startfehler</b><span>${esc(e.message||'')}</span></div>`}
|
||||
$('landingRefresh').onclick=()=>showLanding();$('chooseTask').onclick=()=>showLanding();
|
||||
document.querySelectorAll('[data-beacon-path]').forEach(b=>b.onclick=()=>{beaconPath=b.dataset.beaconPath;localStorage.setItem('neuralhunt.beaconPath',beaconPath);syncBeacon();status(`Beacon-Pfad ${beaconPath} gewählt`) });
|
||||
$('maxnodes').addEventListener('input',e=>{$('maxnodesvalue').textContent=Number(e.target.value).toLocaleString('de-DE');render()});
|
||||
$('toggleMobile').onclick=()=>toggleMobileMode();$('toggleDetails').onclick=()=>{detailsOpen=!detailsOpen;$('signalPanel').classList.toggle('expanded',detailsOpen);setActive('toggleDetails',detailsOpen)};window.addEventListener('neuralhunt-mobile-mode',syncMobile);
|
||||
$('toggleProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('toggleProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('toggleProximity',map.proximityFocus)};$('toggleRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('toggleRotate',map.autoRotate)};$('toggleLabels').onclick=()=>{map.labels=!map.labels;setActive('toggleLabels',map.labels)};$('toggleEdges').onclick=()=>{map.edges=!map.edges;setActive('toggleEdges',map.edges)};$('toggleShells').onclick=()=>{map.shells=!map.shells;setActive('toggleShells',map.shells)};$('toggleLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('toggleLOD',map.lodEnabled)};$('toggleEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('toggleEco',map.eco)};$('resetView').onclick=()=>map.resetView();
|
||||
$('exportid').onclick=async()=>{const p=prompt('Passphrase für den verschlüsselten Identitäts-Export');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}catch(e){status(e.message||'Export fehlgeschlagen')}};
|
||||
$('importid').onchange=async e=>{const f=e.target.files?.[0];if(!f)return;const p=prompt('Passphrase für diesen Identitäts-Export');if(!p)return;try{await importIdentity(await f.text(),p);clearToken();location.reload()}catch(err){status(err.message||'Import fehlgeschlagen')}};
|
||||
async function downloadMyNFT(n){const r=await fetch(n.download_uri,{headers:{Authorization:'Bearer '+getToken()},credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Original konnte nicht geladen werden'));const blob=await r.blob(),a=document.createElement('a'),ext=(blob.type==='image/svg+xml'?'.svg':blob.type==='image/png'?'.png':blob.type==='image/webp'?'.webp':blob.type==='image/jpeg'?'.jpg':'');a.href=URL.createObjectURL(blob);a.download=`neuralhunt-${n.task_id}${ext}`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}
|
||||
async function renderOwnedNFTs(host){host.innerHTML='<span class="identity-nft-empty">lade …</span>';try{const items=await api('/api/me/artifacts?limit=24');host.innerHTML=items?.length?items.map(n=>`<div class="identity-nft-row"><div><b>${esc(n.display_name||'WINNER NFT')}</b><small>${esc(shortID(n.task_id,12))} · ${Number(n.range_bits||0)} Bit · ${esc(fmtDate(n.completed_at))}</small></div><button data-my-nft="${esc(n.task_id)}">ORIGINAL</button></div>`).join(''):'<span class="identity-nft-empty">Noch keine fertigen Gewinner-Artefakte für diese Identität.</span>';host.querySelectorAll('[data-my-nft]').forEach(b=>b.onclick=async()=>{const n=items.find(x=>x.task_id===b.dataset.myNft);if(!n)return;b.disabled=true;try{await downloadMyNFT(n)}catch(e){status(e.message||'Download fehlgeschlagen')}finally{b.disabled=false}})}catch(e){host.innerHTML=`<span class="identity-nft-empty">${esc(e.message||'NFTs konnten nicht geladen werden')}</span>`}}
|
||||
async function toggleOwnedNFTs(host){if(!host)return;if(!host.classList.contains('hidden')){host.classList.add('hidden');return}host.classList.remove('hidden');await renderOwnedNFTs(host)}
|
||||
async function performIdentityExport(){const p=prompt('Passphrase für den verschlüsselten Identitäts-Export (mindestens 12 Zeichen). Bewahre Export und Passphrase getrennt auf.');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status('Identität verschlüsselt gesichert')}catch(e){status(e.message||'Export fehlgeschlagen')}}
|
||||
async function performHostedLinkCode(){try{const x=await api('/api/me/customer-link',{method:'POST',body:JSON.stringify({})});const code=x.code||'';if(!code)throw new Error('Kein Hosted-Code erhalten');try{await navigator.clipboard?.writeText(code)}catch{}prompt('Einmaliger Hosted-Code (10 Minuten gültig). Im Customer-Service-Portal unter Haupt-Identität einfügen:',code);status('Hosted-Code erzeugt · nur einmal verwendbar')}catch(e){status(e.message||'Hosted-Code konnte nicht erzeugt werden')}}
|
||||
async function performIdentityImport(input){const f=input?.files?.[0];if(!f)return;const p=prompt('Passphrase für diesen Identitäts-Export');if(!p){input.value='';return}try{const imported=await importIdentity(await f.text(),p),current=cid;if(imported.clientId===current){status('Diese Identität ist bereits aktiv');input.value='';return}if(!confirm(`Identität wechseln?\n\nAktuell: ${current}\nImport: ${imported.clientId}\n\nDie lokale Browser-Identität wird ersetzt. Sichere die aktuelle Identität vorher, wenn du sie später noch brauchst.`)){input.value='';return}localStorage.setItem(identityKey,JSON.stringify(imported.bundle));clearToken();location.reload()}catch(err){status(err.message||'Import fehlgeschlagen');input.value=''}}
|
||||
$('mynfts').onclick=()=>toggleOwnedNFTs($('myNftsPanel'));
|
||||
$('landingMyNfts').onclick=()=>toggleOwnedNFTs($('landingNftsPanel'));
|
||||
$('hostedCode').onclick=performHostedLinkCode;$('landingHostedCode').onclick=performHostedLinkCode;
|
||||
$('exportid').onclick=performIdentityExport;$('landingExportId').onclick=performIdentityExport;
|
||||
$('importid').onchange=e=>performIdentityImport(e.target);$('landingImportId').onchange=e=>performIdentityImport(e.target);
|
||||
addEventListener('beforeunload',()=>{stopTimers();clearWSReconnect();if(landingTimer)clearTimeout(landingTimer);if(ws){ws._plannedClose=true;ws.close()}window.removeEventListener('neuralhunt-mobile-mode',syncMobile);map.destroy()},{once:true});
|
||||
}
|
||||
|
||||
@@ -429,8 +451,8 @@ async function runAdmin(){
|
||||
const setAdminPanel=name=>{const grid=$('adminGrid');grid.classList.remove('mobile-show-map','mobile-show-tasks','mobile-show-control');grid.classList.add(`mobile-show-${name}`);document.querySelectorAll('[data-admin-panel]').forEach(b=>b.classList.toggle('active',b.dataset.adminPanel===name));if(name==='map')setTimeout(()=>map.resize(),30)};
|
||||
const syncAdminMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('adminMobileToggle').textContent=mobileButtonLabel();setActive('adminMobileToggle',on);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.94);setActive('adminEco',true);setActive('adminEdges',false);if(+$('adminmaxnodes').value>1500){$('adminmaxnodes').value=1500;$('adminmaxvalue').textContent='1.500'}}else{map.eco=false;setActive('adminEco',false)}map.resize()};
|
||||
$('adminMobileToggle').onclick=()=>toggleMobileMode();document.querySelectorAll('[data-admin-panel]').forEach(b=>b.onclick=()=>setAdminPanel(b.dataset.adminPanel));window.addEventListener('neuralhunt-mobile-mode',syncAdminMobile);syncAdminMobile();
|
||||
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision'];
|
||||
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'};
|
||||
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','beacon_hunt_enabled','beacon_bonus_weight','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision'];
|
||||
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',beacon_hunt_enabled:'Beacon Hunt (0 = aus, 1 = an)',beacon_bonus_weight:'Beacon Treffer-Gewicht (1–10)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'};
|
||||
const msg=s=>$('adminstatus').textContent=s;
|
||||
const saveDraft=()=>{try{draft.tab=tab;draft.selectedTaskId=selected?.id||draft.selectedTaskId||'';draft.filters={status:$('statusfilter')?.value||'',q:$('taskquery')?.value||''};localStorage.setItem(adminDraftKey,JSON.stringify(draft))}catch{}};
|
||||
const draftScope=()=>tab==='task'&&selected?`task:${selected.id}`:`global:${tab}`;
|
||||
@@ -443,7 +465,7 @@ async function runAdmin(){
|
||||
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
|
||||
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return `<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span class="artifact-state-wrap"><b class="artifact-state ${esc(t.artifact_status||'')}">${esc(t.artifact_status||'—')}</b>${aerr?`<small class="danger artifact-error-summary" title="${esc(aerr)}">${esc(aerr.length>76?aerr.slice(0,75)+'…':aerr)}</small>`:`<small>${t.parent_task_id?`↳ ${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small>`}<small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`}).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
|
||||
function renderRuntime(){
|
||||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert > 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
|
||||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert > 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Beacon Hunt:</b> Optional wählen Spieler PULSE, FLUX oder ORBIT. Der erste öffentliche drand-Round nach Fensterschluss bestimmt reproduzierbar den Boost-Pfad und die gewichtete Ziehung. Alle Reveal-Daten werden gespeichert und über die Public API nachvollziehbar gemacht.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
|
||||
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
|
||||
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
|
||||
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
|
||||
|
||||
Vendored
+10
@@ -104,3 +104,13 @@ html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-te
|
||||
|
||||
/* Artifact worker diagnostics (v3.6). */
|
||||
.artifact-state-wrap{gap:2px}.artifact-state{font-size:8px;text-transform:uppercase;letter-spacing:.06em}.artifact-state.ready{color:var(--green)}.artifact-state.generating,.artifact-state.pending{color:var(--amber)}.artifact-state.error{color:#ff9bad}.artifact-error-summary{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.artifact-diagnostic{margin:10px 0 12px;border:1px solid rgba(255,95,136,.32);background:rgba(255,95,136,.055);border-radius:11px;padding:10px;display:grid;gap:7px}.artifact-diagnostic>div{display:grid;gap:5px}.artifact-diagnostic b{color:#ff9bad;font-size:9px;letter-spacing:.08em}.artifact-diagnostic span{color:#ffd3dc;font-size:8px;line-height:1.55;overflow-wrap:anywhere}.artifact-diagnostic small{color:#9c7580;font-size:7px;line-height:1.5}
|
||||
.identity-actions{grid-template-columns:repeat(3,minmax(0,1fr))}.identity-actions button,.identity-actions .button{font-size:8px;padding:7px 5px}.identity-nfts{margin-top:8px;max-height:180px;overflow:auto;border:1px solid rgba(82,231,255,.12);border-radius:9px;background:rgba(2,8,16,.55)}.identity-nft-row{display:grid;grid-template-columns:1fr auto;gap:7px;align-items:center;padding:7px 8px;border-bottom:1px solid rgba(133,200,255,.08)}.identity-nft-row:last-child{border-bottom:0}.identity-nft-row b{display:block;font-size:8px;color:#dff8ff}.identity-nft-row small{display:block;margin-top:2px;font-size:7px;color:#68899d}.identity-nft-row button{font-size:7px;padding:6px}.identity-nft-empty{display:block;padding:8px;font-size:8px;color:#7f9bae;line-height:1.4}
|
||||
.landing-identity-actions{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.landing-identity-actions>*{text-align:center;font-size:8px;padding:7px 5px}.landing-owned-nfts{max-height:210px;overflow:auto;border:1px solid rgba(82,231,255,.12);border-radius:9px;background:rgba(2,8,16,.55)}
|
||||
|
||||
/* Optional externally-auditable Beacon Hunt path choice. */
|
||||
.beacon-choice{margin:12px 0;padding:12px;border:1px solid rgba(255,255,255,.12);border-radius:14px;background:rgba(3,9,19,.44)}
|
||||
.beacon-choice.hidden{display:none}
|
||||
.beacon-buttons{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:8px 0}
|
||||
.beacon-buttons button{min-width:0;padding:8px 5px;font-size:11px;letter-spacing:1px}
|
||||
.beacon-buttons button.active{border-color:#fff;box-shadow:0 0 18px rgba(255,255,255,.14);background:rgba(255,255,255,.13)}
|
||||
.beacon-choice>small{display:block;opacity:.68;line-height:1.35}
|
||||
|
||||
Reference in New Issue
Block a user