This commit is contained in:
84
.env.example
84
.env.example
@@ -1,4 +1,8 @@
|
||||
# Core
|
||||
# Docker image names. The project builds three independent runtime images.
|
||||
NEURALHUNT_SERVER_IMAGE=neuralhunt-server:local
|
||||
NEURALHUNT_CUSTOMER_IMAGE=neuralhunt-customer-service:local
|
||||
|
||||
# Public listener: put this behind your normal HTTPS reverse proxy.
|
||||
HTTP_ADDR=:8080
|
||||
# Private control-plane listener: expose only through VPN/private reverse proxy.
|
||||
@@ -85,3 +89,83 @@ A1111_USER=
|
||||
A1111_PASSWORD=
|
||||
A1111_SAMPLER=
|
||||
A1111_CFG_SCALE=7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# OPTIONAL BEACON HUNT
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 0 = legacy random lottery, 1 = PULSE/FLUX/ORBIT choice + future drand reveal.
|
||||
# These defaults are stored in SQLite on first start and can then be changed in
|
||||
# the game Admin UI under Runtime.
|
||||
DEFAULT_BEACON_HUNT_ENABLED=0
|
||||
DEFAULT_BEACON_BONUS_WEIGHT=2
|
||||
# Public randomness beacon. Defaults are normally sufficient.
|
||||
BEACON_DRAND_URL=https://api.drand.sh
|
||||
BEACON_DRAND_BEACON_ID=quicknet
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# OPTIONAL HOSTED CUSTOMER SERVICE / PREPAID WORKERS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Same secret is read by the game server and Customer Service. Generate a
|
||||
# separate random value; do not reuse JWT_SECRET.
|
||||
CUSTOMER_SERVICE_SHARED_SECRET=replace-with-a-separate-32-plus-char-random-secret
|
||||
|
||||
# Customer portal (8090), private Customer-Service admin (8091), and the
|
||||
# Docker-network-only worker registration listener (8092).
|
||||
CS_HTTP_ADDR=:8090
|
||||
CS_ADMIN_HTTP_ADDR=:8091
|
||||
CS_INTERNAL_ADDR=:8092
|
||||
CS_PUBLIC_BASE_URL=https://customers.example.com
|
||||
CS_COOKIE_SECURE=true
|
||||
CS_SESSION_TTL=24h
|
||||
CS_ADMIN_USER=admin
|
||||
CS_ADMIN_PASSWORD=replace-with-another-strong-unique-password
|
||||
|
||||
# Manual prepaid top-ups are a deliberately gated test/support bypass. Keep 0
|
||||
# in production unless you explicitly need private admin grants.
|
||||
CS_ALLOW_MANUAL_CREDITS=0
|
||||
|
||||
# Hosted worker billing: 1.0 means one credit is consumed for each paid minute
|
||||
# of each running worker. Billing is prepaid; the worker stops before a minute
|
||||
# that cannot be funded.
|
||||
CS_WORKER_CREDITS_PER_MINUTE=1.0
|
||||
# id:price-in-cents:CURRENCY:credits ; entries separated by semicolons.
|
||||
CS_CREDIT_PACKAGES=starter:499:EUR:60;plus:1999:EUR:300;power:4999:EUR:900
|
||||
|
||||
# Docker worker orchestration. CS_WORKER_IMAGE is now a dedicated worker image
|
||||
# and may point directly at a registry tag, e.g. registry.example.com/neuralhunt/worker:v4.1.
|
||||
# When CS_WORKER_AUTO_PULL=true, Customer Service asks Docker Engine to pull the
|
||||
# image if it is not already present. Public registries need no extra values.
|
||||
# For a private registry, use a read-only deploy/robot token; credentials are
|
||||
# sent only to Docker Engine as X-Registry-Auth and never to worker containers.
|
||||
CS_WORKER_IMAGE=neuralhunt-worker:local
|
||||
CS_WORKER_AUTO_PULL=true
|
||||
CS_WORKER_REGISTRY_SERVER=
|
||||
CS_WORKER_REGISTRY_USERNAME=
|
||||
CS_WORKER_REGISTRY_PASSWORD=
|
||||
# Leave empty for the dedicated worker image. Compatibility override for old
|
||||
# monolithic V4.0 images: /app/neuralhunt-client
|
||||
CS_WORKER_ENTRYPOINT=
|
||||
CS_WORKER_NETWORK=neuralhunt_backend
|
||||
CS_WORKER_REGISTER_URL=http://customer-service:8092/internal/workers/register
|
||||
DOCKER_HOST=unix:///var/run/docker.sock
|
||||
|
||||
# Game endpoints as seen from Customer Service. Compose sets them to app:8080/8081.
|
||||
CS_GAME_PUBLIC_URL=http://app:8080
|
||||
CS_GAME_ADMIN_URL=http://app:8081
|
||||
CUSTOMER_SQLITE_PATH=/customer-data/customer-service.db
|
||||
|
||||
# PayPal. Start in sandbox. Live mode is additionally locked in code until the
|
||||
# explicit approval acknowledgement is set because paid prize/chance products
|
||||
# may require PayPal approval depending on their exact commercial design.
|
||||
PAYPAL_ENABLED=false
|
||||
PAYPAL_ENVIRONMENT=sandbox
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_CLIENT_SECRET=
|
||||
PAYPAL_WEBHOOK_ID=
|
||||
# Only after provider/legal approval for the live product:
|
||||
# PAYPAL_LIVE_APPROVAL_ACK=I_HAVE_PAYPAL_APPROVAL
|
||||
# Host capacity guards independent of reverse-proxy request limits.
|
||||
CS_MAX_WORKERS_PER_CUSTOMER=20
|
||||
CS_MAX_WORKERS_GLOBAL=1000
|
||||
CS_MAX_RUNNING_WORKERS_PER_CUSTOMER=10
|
||||
CS_MAX_RUNNING_WORKERS_GLOBAL=100
|
||||
|
||||
@@ -48,4 +48,40 @@ jobs:
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.server
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:server_${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:server_${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.customer-service
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:customer_${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:customer_${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.worker
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:worker_${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:worker_${{ env.DOCKER_LATEST }}
|
||||
26
Dockerfile.customer-service
Normal file
26
Dockerfile.customer-service
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Resolve dependencies before copying source. Do not run `go mod tidy` here.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath -ldflags="-s -w" \
|
||||
-o /out/neuralhunt-customer-service ./cmd/customer-service
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN adduser -D -h /home/app app \
|
||||
&& mkdir -p /app /customer-data \
|
||||
&& chown -R app:app /app /customer-data
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /out/neuralhunt-customer-service /app/neuralhunt-customer-service
|
||||
|
||||
USER app
|
||||
ENV CUSTOMER_SQLITE_PATH=/customer-data/customer-service.db
|
||||
EXPOSE 8090 8091 8092
|
||||
ENTRYPOINT ["/app/neuralhunt-customer-service"]
|
||||
31
Dockerfile.server
Normal file
31
Dockerfile.server
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Resolve dependencies before copying source. Do not run `go mod tidy` here.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
|
||||
# Fail early if repository ignore rules accidentally remove runtime sources.
|
||||
RUN test -f /src/internal/data/store.go \
|
||||
&& test -f /src/internal/data/schema.sql
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath -ldflags="-s -w" \
|
||||
-o /out/neuralhunt ./cmd/server
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN adduser -D -h /home/app app \
|
||||
&& mkdir -p /app /data \
|
||||
&& chown -R app:app /app /data
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /out/neuralhunt /app/neuralhunt
|
||||
|
||||
USER app
|
||||
ENV SQLITE_PATH=/data/neuralhunt.db \
|
||||
ARTIFACT_DIR=/data/artifacts
|
||||
EXPOSE 8080 8081
|
||||
ENTRYPOINT ["/app/neuralhunt"]
|
||||
27
Dockerfile.worker
Normal file
27
Dockerfile.worker
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Resolve dependencies before copying source. Do not run `go mod tidy` here.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath -ldflags="-s -w" \
|
||||
-o /out/neuralhunt-client ./cmd/client
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN adduser -D -h /home/app app \
|
||||
&& mkdir -p /app /identity \
|
||||
&& chown -R app:app /app /identity
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /out/neuralhunt-client /app/neuralhunt-client
|
||||
|
||||
USER app
|
||||
ENV HOME=/home/app
|
||||
VOLUME ["/identity"]
|
||||
ENTRYPOINT ["/app/neuralhunt-client"]
|
||||
CMD ["-help"]
|
||||
232
HOSTED_SERVICE.md
Normal file
232
HOSTED_SERVICE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Neural Hunt Hosted Service (optional)
|
||||
|
||||
V4.1 splits the deployment into three dedicated Docker images. V4.0 added an optional commercial control plane without changing the normal game
|
||||
server. The default `docker compose up` still starts only `app`. For a local source build,
|
||||
build all three images first and then start the hosted layer:
|
||||
|
||||
```bash
|
||||
docker compose --profile images build app customer-service worker-image
|
||||
docker compose --profile hosted up -d
|
||||
```
|
||||
|
||||
With Docker Buildx the three images can also be built/tagged in one operation:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.server -t neuralhunt-server:local .
|
||||
docker build -f Dockerfile.customer-service -t neuralhunt-customer-service:local .
|
||||
docker build -f Dockerfile.worker -t neuralhunt-worker:local .
|
||||
```
|
||||
|
||||
|
||||
## Three Docker images
|
||||
|
||||
V4.1 no longer ships one monolithic runtime image. `Dockerfile` has three final
|
||||
targets:
|
||||
|
||||
- `server` -> `neuralhunt-server:local` (game/API + private game admin)
|
||||
- `customer-service` -> `neuralhunt-customer-service:local` (portal/billing/control plane)
|
||||
- `worker` -> `neuralhunt-worker:local` (CLI hunting agent only)
|
||||
|
||||
The tags are configurable through `.env` / the build environment:
|
||||
|
||||
```env
|
||||
NEURALHUNT_SERVER_IMAGE=registry.example.com/neuralhunt/server:v4.1
|
||||
NEURALHUNT_CUSTOMER_IMAGE=registry.example.com/neuralhunt/customer-service:v4.1
|
||||
CS_WORKER_IMAGE=registry.example.com/neuralhunt/worker:v4.1
|
||||
```
|
||||
|
||||
`CS_WORKER_IMAGE` is both the worker build tag and the exact image reference the
|
||||
Customer Service passes to Docker Engine. With `CS_WORKER_AUTO_PULL=true` (the
|
||||
default), a missing worker image is pulled lazily when a customer first needs a
|
||||
worker. For a private registry, configure a read-only deploy/robot credential:
|
||||
|
||||
```env
|
||||
CS_WORKER_REGISTRY_SERVER=registry.example.com
|
||||
CS_WORKER_REGISTRY_USERNAME=neuralhunt-worker-pull
|
||||
CS_WORKER_REGISTRY_PASSWORD=<read-only-token>
|
||||
```
|
||||
|
||||
Those credentials are converted to Docker's `X-Registry-Auth` header only for
|
||||
the image-pull request and are never passed to managed worker containers. Set
|
||||
`CS_WORKER_AUTO_PULL=false` if production policy requires images to be
|
||||
pre-pulled instead.
|
||||
|
||||
The dedicated worker image already declares `/app/neuralhunt-client` as its
|
||||
ENTRYPOINT. `CS_WORKER_ENTRYPOINT` should therefore stay empty. It exists only
|
||||
as a compatibility override for older monolithic images.
|
||||
|
||||
For registry publishing with Buildx:
|
||||
|
||||
```bash
|
||||
export NEURALHUNT_SERVER_IMAGE=registry.example.com/neuralhunt/server:v4.1
|
||||
export NEURALHUNT_CUSTOMER_IMAGE=registry.example.com/neuralhunt/customer-service:v4.1
|
||||
export CS_WORKER_IMAGE=registry.example.com/neuralhunt/worker:v4.1
|
||||
# CI/CD: Dockerfile.server, Dockerfile.customer-service und Dockerfile.worker getrennt bauen/pushen.
|
||||
```
|
||||
|
||||
## Ports and trust zones
|
||||
|
||||
- `app:8080` — public Neural Hunt game
|
||||
- `app:8081` — private game admin + private Customer-Service delegation API
|
||||
- `customer-service:8090` — customer portal (put behind HTTPS)
|
||||
- `customer-service:8091` — private/VPN Customer-Service admin
|
||||
- `customer-service:8092` — Docker-network-only worker registration
|
||||
|
||||
Do not route 8081, 8091 or 8092 to the public Internet.
|
||||
|
||||
## PrePaid billing model
|
||||
|
||||
The reference implementation bills **running worker time**, not individual
|
||||
submitted guesses. This makes the commercial unit explicit and predictable, but
|
||||
it is only a billing design; it does not by itself determine the legal/payment-
|
||||
provider classification of a paid prize/chance product. `CS_WORKER_CREDITS_PER_MINUTE=1.0` means one paid minute of
|
||||
one worker consumes one credit. The next minute is debited atomically before it
|
||||
is allowed to continue; when there is insufficient balance the service stops
|
||||
the worker. A 2-second reconciliation loop also catches containers that stop or
|
||||
exit outside the portal. If Docker fails before a newly purchased start minute
|
||||
actually begins, that start debit is automatically refunded to the ledger.
|
||||
|
||||
Credit packages are configured server-side:
|
||||
|
||||
```env
|
||||
CS_CREDIT_PACKAGES=starter:499:EUR:60;plus:1999:EUR:300;power:4999:EUR:900
|
||||
```
|
||||
|
||||
The four fields are `id:price_cents:CURRENCY:credits`. These are examples, not a
|
||||
pricing recommendation.
|
||||
|
||||
## PayPal
|
||||
|
||||
PayPal is disabled by default and should first be configured in sandbox:
|
||||
|
||||
```env
|
||||
PAYPAL_ENABLED=true
|
||||
PAYPAL_ENVIRONMENT=sandbox
|
||||
PAYPAL_CLIENT_ID=...
|
||||
PAYPAL_CLIENT_SECRET=...
|
||||
PAYPAL_WEBHOOK_ID=...
|
||||
```
|
||||
|
||||
The browser never receives the PayPal secret. Customer Service creates Orders
|
||||
server-side, redirects the user to the returned approval URL, captures the
|
||||
Order server-side after return and reconciles the captured amount/currency
|
||||
against the package stored before crediting the ledger. Webhooks are verified
|
||||
through PayPal's webhook-signature verification API. Ledger references make a
|
||||
completed Order idempotent.
|
||||
|
||||
Live PayPal is additionally locked until:
|
||||
|
||||
```env
|
||||
PAYPAL_LIVE_APPROVAL_ACK=I_HAVE_PAYPAL_APPROVAL
|
||||
```
|
||||
|
||||
is set. This is intentional: products involving payment plus prizes/chance can
|
||||
require provider approval and legal review. Do not use that flag as a substitute
|
||||
for the approval/review itself.
|
||||
|
||||
## Manual test/support credits
|
||||
|
||||
Manual top-ups are a separate protected bypass and are **off by default**:
|
||||
|
||||
```env
|
||||
CS_ALLOW_MANUAL_CREDITS=1
|
||||
```
|
||||
|
||||
They are available only from the private Customer-Service admin listener on
|
||||
8091 after its own HttpOnly admin session. Each grant is written to the same
|
||||
immutable-style credit ledger with a unique `manual:*` reference. Keep the flag
|
||||
off on a normal public production deployment unless support operations require
|
||||
it.
|
||||
|
||||
## Multiple workers and reward ownership
|
||||
|
||||
Each managed worker gets:
|
||||
|
||||
- its own P-256 Neural Hunt identity in a dedicated Docker named volume;
|
||||
- its own normal one-identity/one-presence lease;
|
||||
- its own task assignment and Beacon path (`auto`, `PULSE`, `FLUX`, `ORBIT`);
|
||||
- an authenticated private registration token known only to Customer Service.
|
||||
|
||||
The customer configures one **main reward identity** in the portal, but the
|
||||
portal does not trust a pasted public Client-ID. While logged into the game with
|
||||
the intended owner identity, the customer requests a **HOSTED CODE** (or runs
|
||||
`hosted-code` in the CLI). The game stores only the SHA-256 hash of this random
|
||||
one-time code; it expires after 10 minutes. Customer Service redeems the code
|
||||
over the private game control plane and receives the proven Client-ID. The
|
||||
P-256 private key never leaves the browser/CLI.
|
||||
|
||||
When a worker starts, it registers its own Client-ID with Customer Service,
|
||||
which installs a delegation on the private game control plane:
|
||||
|
||||
```text
|
||||
worker identity -> main reward identity
|
||||
```
|
||||
|
||||
A winning task records both values:
|
||||
|
||||
- `winner_client_id` = durable reward owner
|
||||
- `winner_worker_client_id` = cryptographic identity that actually submitted the winning guess
|
||||
|
||||
Thus several workers can run concurrently without disabling the game's normal
|
||||
single-presence rule. The original NFT is retrieved later by logging into the
|
||||
browser/CLI with the main reward identity.
|
||||
|
||||
Worker raw identity files can be downloaded or replaced from the portal. They
|
||||
contain a private key and must be treated as secrets. Replacing an identity
|
||||
stops the worker and forces private delegation registration again on next start.
|
||||
Deleting a worker also deletes its identity volume, so back it up first if it
|
||||
must remain portable.
|
||||
|
||||
## Worker containment
|
||||
|
||||
Managed worker containers receive no Docker socket and no published ports. The
|
||||
reference Docker Engine request additionally uses:
|
||||
|
||||
- read-only root filesystem
|
||||
- all Linux capabilities dropped
|
||||
- `no-new-privileges`
|
||||
- 256 MiB memory limit
|
||||
- 1 CPU limit
|
||||
- PID limit 128
|
||||
- only the private identity volume writable
|
||||
|
||||
The Customer Service itself needs Docker Engine control. Directly mounting
|
||||
`/var/run/docker.sock` effectively gives this service host-level Docker control.
|
||||
The provided Compose setup supports it for a simple self-hosted deployment, but
|
||||
a production setup should preferably put a narrowly permissioned Docker Socket
|
||||
Proxy in front and set `DOCKER_HOST` to that proxy instead.
|
||||
|
||||
Capacity is also bounded independently of reverse-proxy rate limiting. Stopped worker records do not allocate a Docker volume until the worker is actually started (or an identity is explicitly uploaded):
|
||||
|
||||
```env
|
||||
CS_MAX_WORKERS_PER_CUSTOMER=20
|
||||
CS_MAX_WORKERS_GLOBAL=1000
|
||||
CS_MAX_RUNNING_WORKERS_PER_CUSTOMER=10
|
||||
CS_MAX_RUNNING_WORKERS_GLOBAL=100
|
||||
```
|
||||
|
||||
## Optional Beacon Hunt
|
||||
|
||||
Enable from the game Admin Runtime settings or defaults:
|
||||
|
||||
```env
|
||||
DEFAULT_BEACON_HUNT_ENABLED=1
|
||||
DEFAULT_BEACON_BONUS_WEIGHT=2
|
||||
```
|
||||
|
||||
Players/agents choose PULSE, FLUX or ORBIT before the current lottery window
|
||||
ends. Neural Hunt plans the **first drand round strictly after that boundary**.
|
||||
When it becomes available, `SHA-256(signature)` is used to derive both the
|
||||
boosted path and the weighted selection. A matching path receives the configured
|
||||
weight; a non-matching path still has weight 1.
|
||||
|
||||
Successful draws are stored in `beacon_draws` with beacon ID, round, signature,
|
||||
derived randomness, boosted path, ticket count and selected count, and the latest
|
||||
record is available from `/api/public/beacon/<task-id>/latest` for independent
|
||||
audit.
|
||||
|
||||
The current implementation relies on HTTPS to the configured drand endpoint and
|
||||
stores the returned signature for external verification. It does **not** yet
|
||||
perform local BLS signature verification against the drand chain public key.
|
||||
If cryptographic self-verification is a product requirement, add a vetted drand
|
||||
client/verifier before making that claim in customer-facing material.
|
||||
23
Makefile
23
Makefile
@@ -1,17 +1,30 @@
|
||||
.PHONY: dev local client build test
|
||||
.PHONY: dev local client build test images images-compose hosted-up
|
||||
|
||||
dev:
|
||||
docker compose up --build
|
||||
go run ./cmd/server
|
||||
|
||||
local:
|
||||
go run ./cmd/server
|
||||
|
||||
client:
|
||||
go run ./cmd/client
|
||||
go run ./cmd/client -help
|
||||
|
||||
build:
|
||||
go build -o neuralhunt ./cmd/server
|
||||
go build -o neuralhunt-client ./cmd/client
|
||||
go build ./cmd/server
|
||||
go build ./cmd/client
|
||||
go build ./cmd/customer-service
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# Build the same three artifacts that are intended for three independent CI jobs.
|
||||
images:
|
||||
docker build -f Dockerfile.server -t $${NEURALHUNT_SERVER_IMAGE:-neuralhunt-server:local} .
|
||||
docker build -f Dockerfile.customer-service -t $${NEURALHUNT_CUSTOMER_IMAGE:-neuralhunt-customer-service:local} .
|
||||
docker build -f Dockerfile.worker -t $${CS_WORKER_IMAGE:-neuralhunt-worker:local} .
|
||||
|
||||
images-compose:
|
||||
docker compose --profile images build app customer-service worker-image
|
||||
|
||||
hosted-up: images
|
||||
docker compose --profile hosted up -d
|
||||
|
||||
93
README.md
93
README.md
@@ -1,7 +1,12 @@
|
||||
> **V3.5 Random Guess Lottery:** Optional kann die Zahl der tatsächlich ausgewerteten Tipps pro Zeitfenster begrenzt werden. Im Admin-Tab **RUNTIME** steuern `Lotterie-Zeitfenster (s)` und `Max. gezogene Tipps je Task/Fenster` die Funktion; `0` deaktiviert sie vollständig. Die Lotterie läuft getrennt pro aktivem Task. Alle gültigen, signierten Tipps werden bis zum Ende des Zeitfensters gesammelt und anschließend mit `crypto/rand` gleichberechtigt zufällig gezogen. Nur gezogene Tipps werden gegen das geheime Ziel ausgewertet und können Score/Winner/NFT auslösen. Nicht gezogene Tipps verbrauchen ihre Sequenz, damit beim nächsten Fenster ein neuer deterministischer Tipp entsteht, zählen aber nicht als akzeptierter `guess_count`. Abgebrochene HTTP-Requests verbrauchen keinen Lotterie-Slot. Dadurch wird die mögliche Task-Abschluss-/NFT-Rate gedrosselt, ohne Gewinner oder Styles direkt zu manipulieren.
|
||||
> **V4.2 Pipeline Dockerfiles:** Server, Customer Service und Worker besitzen jetzt jeweils ein eigenes Dockerfile (`Dockerfile.server`, `Dockerfile.customer-service`, `Dockerfile.worker`). Das bestehende `Dockerfile` baut weiterhin den Server, damit vorhandene Single-Image-Pipelines kompatibel bleiben. `CS_WORKER_IMAGE` zeigt weiterhin direkt auf das veröffentlichte Worker-Image.
|
||||
|
||||
# Neural Hunt — V3.1 RIFT Task-Style Collection + Profile Cleanup
|
||||
> **V4.0 Beacon Hunt + Hosted PrePaid Service:** Optional kann die Task-Lotterie jetzt PULSE/FLUX/ORBIT als vorab signierte Spielerentscheidung verwenden. Der Draw nutzt einen erst nach Fensterschluss verfügbaren drand-Round und speichert Round, Signatur, abgeleitete Randomness und Boost für Audit/Collectible-Traits. Zusätzlich gibt es einen separat aktivierbaren Customer-Service mit PrePaid-Zeitabrechnung, PayPal-Sandbox/Orders-v2-Flow, mehreren Docker-Workern pro Kunde, portablen Worker-Identitäten und delegiertem Reward-Owner. Details: `HOSTED_SERVICE.md`.
|
||||
|
||||
> **V3.9 Portable Identity + Winner Originals:** Der Shell-Client verwendet standardmäßig dauerhaft `~/.neuralhunt/identity.json` (`0600`) und kann dieselbe P-256-Identität als passwortgeschützten, browser-kompatiblen JSON-Export sichern. Browser und CLI validieren beim Import Public/Private-Key-Paar und Client-ID. Nach dem Import derselben Identität sieht der Browser wieder dieselben serverseitig gebundenen Wins. Authentifizierte Gewinner erhalten unter **MEINE NFTS** Zugriff auf ihr unverändertes Original-Artefakt; andere Identitäten erhalten dafür nur `404`. Der CLI besitzt dafür `my-nfts` und `nft original <task-id> <datei>`. Öffentliche Leaderboards bleiben weiterhin ausschließlich bei Wasserzeichen-Previews.
|
||||
|
||||
> **V3.5 Random Guess Lottery:** Optional kann die Zahl der tatsächlich ausgewerteten Tipps pro Zeitfenster begrenzt werden. Im Admin-Tab **RUNTIME** steuern `Lotterie-Zeitfenster (s)` und `Max. gezogene Tipps je Task/Fenster` die Funktion; `0` deaktiviert sie vollständig. Die Lotterie läuft getrennt pro aktivem Task. Alle gültigen, signierten Tipps werden bis zum Ende des Zeitfensters gesammelt. Im normalen Lotterie-Modus werden sie anschließend mit `crypto/rand` gleichberechtigt zufällig gezogen; bei aktiviertem Beacon Hunt übernimmt stattdessen der erst nach Fensterschluss verfügbare drand-Reveal die deterministische gewichtete Ziehung. Nur gezogene Tipps werden gegen das geheime Ziel ausgewertet und können Score/Winner/NFT auslösen. Nicht gezogene Tipps verbrauchen ihre Sequenz, damit beim nächsten Fenster ein neuer deterministischer Tipp entsteht, zählen aber nicht als akzeptierter `guess_count`. Abgebrochene HTTP-Requests verbrauchen keinen Lotterie-Slot. Dadurch wird die mögliche Task-Abschluss-/NFT-Rate gedrosselt, ohne Gewinner oder Styles direkt zu manipulieren.
|
||||
|
||||
# Neural Hunt — V4.1 Split Images + Beacon Hunt + Hosted PrePaid Service
|
||||
|
||||
|
||||
> **V3.1 Admin Profile Cleanup:** Im Admin-Tab **RUNTIME** gibt es ein manuelles Bereinigungstool für alte Identitäten. Die Inaktivitätsdauer ist in Stunden/Tagen/Wochen einstellbar. Vor dem Löschen zeigt **PRÜFEN** die Anzahl löschbarer Profile. Gelöscht werden ausschließlich Profile, deren letzte Aktivität älter als die gewählte Grenze ist, die aktuell nicht verbunden sind und die niemals Gewinner eines Tasks waren. Gewinner werden immer geschützt; aktuell verbundene Clients ebenfalls. Beim Löschen werden die per Foreign Key abhängigen `task_points`, `client_unlocks` und `client_task_selection` mit entfernt. WebSocket-Verbindungsaufbau und -ende aktualisieren `clients.last_seen`, damit die Inaktivitätsgrenze tatsächliche Nutzung besser abbildet.
|
||||
@@ -49,6 +54,41 @@ Danach:
|
||||
- Echtzeit-Leaderboard: `http://localhost:8080/leaderboard`
|
||||
- Admin (private listener): `http://localhost:8081/admin`
|
||||
|
||||
### V4.1: drei Docker-Images
|
||||
|
||||
Das Runtime-Image ist jetzt nach Rollen getrennt:
|
||||
|
||||
```text
|
||||
neuralhunt-server:local -> Game/API + Game-Admin
|
||||
neuralhunt-customer-service:local -> Customer Portal / Billing / Docker-Control
|
||||
neuralhunt-worker:local -> CLI-Agent / Hosted Worker
|
||||
```
|
||||
|
||||
Alle drei lokal bauen:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.server -t neuralhunt-server:local .
|
||||
docker build -f Dockerfile.customer-service -t neuralhunt-customer-service:local .
|
||||
docker build -f Dockerfile.worker -t neuralhunt-worker:local .
|
||||
# oder ohne Bake:
|
||||
docker compose --profile images build app customer-service worker-image
|
||||
```
|
||||
|
||||
Für Registry-Tags:
|
||||
|
||||
```bash
|
||||
export NEURALHUNT_SERVER_IMAGE=registry.example.com/neuralhunt/server:v4.1
|
||||
export NEURALHUNT_CUSTOMER_IMAGE=registry.example.com/neuralhunt/customer-service:v4.1
|
||||
export CS_WORKER_IMAGE=registry.example.com/neuralhunt/worker:v4.1
|
||||
# In der CI/CD-Pipeline jeweils das passende Dockerfile bauen und pushen.
|
||||
```
|
||||
|
||||
Der Customer Service verwendet **genau** `CS_WORKER_IMAGE` für dynamisch
|
||||
erzeugte Worker. Mit `CS_WORKER_AUTO_PULL=true` darf er ein fehlendes Image bei
|
||||
Bedarf über Docker Engine nachladen. Für private Registries können dafür
|
||||
`CS_WORKER_REGISTRY_SERVER`, `CS_WORKER_REGISTRY_USERNAME` und
|
||||
`CS_WORKER_REGISTRY_PASSWORD` mit einem read-only Deploy-Token gesetzt werden.
|
||||
|
||||
|
||||
## V2.5: Task-Landing-Page und Task-Serien
|
||||
|
||||
@@ -104,6 +144,8 @@ go run ./cmd/client -url http://127.0.0.1:8080
|
||||
|
||||
Beim ersten Start wird standardmäßig `~/.neuralhunt/identity.json` mit Dateirechten `0600` erzeugt. Danach zeigt die Shell eine Task-Auswahl ähnlich der Browser-Landing-Page.
|
||||
|
||||
Diese Datei **ist die Identität** des Shell-Clients. Solange sie erhalten bleibt, bleibt auch die daraus abgeleitete Client-ID identisch. Für Container/systemd sollte sie deshalb auf einem persistenten Volume bzw. Host-Pfad liegen. Die Raw-Datei enthält den privaten Schlüssel im Klartext und sollte nicht verteilt werden; für Backups und den Wechsel in den Browser immer den verschlüsselten Export verwenden.
|
||||
|
||||
Wichtige Befehle:
|
||||
|
||||
```text
|
||||
@@ -114,10 +156,12 @@ map [n] textuelles TARGET FIELD nach Nähe-Zonen
|
||||
leaderboard [n] Echtzeit-Leaderboard abrufen
|
||||
leaderboard watch Leaderboard alle 5 Sekunden anzeigen
|
||||
leaderboard stop Watch beenden
|
||||
nfts [n] Winner-Artefakte mit Wasserzeichen-URLs
|
||||
nfts [n] öffentliche Winner-Artefakte mit Wasserzeichen-URLs
|
||||
my-nfts [n] eigene fertige Gewinner-Artefakte
|
||||
nft get <task-id> <datei> öffentliche Wasserzeichen-Preview speichern
|
||||
identity Client-ID + lokale Identity-Datei
|
||||
identity export <datei> browser-kompatibler verschlüsselter Export
|
||||
nft original <task-id> <datei> eigenes unverändertes Original speichern
|
||||
identity Client-ID + persistente Identity-Datei
|
||||
identity export <datei> browser-kompatiblen verschlüsselten Backup-Export schreiben
|
||||
quit
|
||||
```
|
||||
|
||||
@@ -140,6 +184,41 @@ go build -trimpath -o neuralhunt-client ./cmd/client
|
||||
./neuralhunt-client -url https://hunt.example.org -non-interactive -task "Aurora Vault"
|
||||
```
|
||||
|
||||
### CLI als dediziertes Worker-Image testen
|
||||
|
||||
Seit V4.1 enthält das Server-Image absichtlich nur noch den Game-Server. Baue das
|
||||
Worker-Image separat und mounte für die Identität ein persistentes Verzeichnis:
|
||||
|
||||
```bash
|
||||
docker compose --profile images build worker-image
|
||||
mkdir -p ./client-identities
|
||||
|
||||
docker run --rm -it \
|
||||
--network neuralhunt_backend \
|
||||
-v "$PWD/client-identities:/identity" \
|
||||
neuralhunt-worker:local \
|
||||
-url http://app:8080 \
|
||||
-identity /identity/test-cli.json
|
||||
```
|
||||
|
||||
Bei jedem weiteren Start mit genau diesem Pfad wird dieselbe Client-ID verwendet.
|
||||
Für einen browser-kompatiblen verschlüsselten Export:
|
||||
|
||||
```bash
|
||||
export NH_PASS='correct horse battery staple'
|
||||
docker run --rm \
|
||||
-e NEURALHUNT_IDENTITY_PASSPHRASE="$NH_PASS" \
|
||||
-v "$PWD/client-identities:/identity" \
|
||||
neuralhunt-worker:local \
|
||||
-identity /identity/test-cli.json \
|
||||
-export /identity/test-cli-browser.json
|
||||
unset NH_PASS
|
||||
```
|
||||
|
||||
`client-identities/test-cli-browser.json` kann anschließend im Web-Client unter
|
||||
**IDENTITÄT → IMPORT** eingelesen werden. Die Raw-Datei `test-cli.json` sollte
|
||||
den Host nicht unverschlüsselt verlassen.
|
||||
|
||||
Ein minimales systemd-Beispiel:
|
||||
|
||||
```ini
|
||||
@@ -178,6 +257,10 @@ go run ./cmd/client \
|
||||
-export ./neuralhunt-browser-import.json
|
||||
```
|
||||
|
||||
Der Export ist selbstbeschreibend (`neuralhunt-identity-export`), enthält die öffentliche Client-ID und verschlüsselt das eigentliche Schlüsselmaterial mit PBKDF2-HMAC-SHA256 (250.000 Iterationen) + AES-256-GCM. Neue Exporte verlangen mindestens 12 Zeichen Passphrase. Im Browser unter **IDENTITÄT → IMPORT** Datei auswählen, dieselbe Passphrase eingeben und den Identitätswechsel bestätigen. Danach meldet sich der Browser mit derselben Client-ID an.
|
||||
|
||||
Unter **MEINE NFTS** werden dann die fertigen Gewinner-Artefakte dieser Identität angezeigt. Nur der authentifizierte Gewinner darf das Original herunterladen; die öffentliche Galerie bleibt wassergezeichnet.
|
||||
|
||||
**Nicht dieselbe Identity gleichzeitig im Browser und im Shell-Client verbinden.** Das ist absichtlich durch die Single-Connection-Regel gesperrt. Unterschiedliche Identities dürfen natürlich vom selben Rechner bzw. derselben IP verbunden sein.
|
||||
|
||||
Unterstützte Shell-Parameter:
|
||||
|
||||
41
SECURITY.md
41
SECURITY.md
@@ -28,3 +28,44 @@ The USD figure is a local safety estimate based on provider-reported usage and p
|
||||
## Browser headers
|
||||
|
||||
Responses include CSP with `script-src 'self'`, `frame-ancestors 'none'`, `X-Frame-Options: DENY`, HSTS, `nosniff`, no-referrer, restricted browser permissions, and no-store caching for auth/admin resources.
|
||||
|
||||
## Hosted Customer Service trust boundaries (V4.1)
|
||||
|
||||
The optional Customer Service is a separate control plane. Treat its listeners
|
||||
as three different trust zones: 8090 public customer portal, 8091 private/VPN
|
||||
admin, and 8092 Docker-network-only worker registration/lease. The game private
|
||||
listener on 8081 is also required for delegation and must not be exposed to the
|
||||
public Internet.
|
||||
|
||||
`CUSTOMER_SERVICE_SHARED_SECRET` authenticates Customer Service to the game
|
||||
private API. Example/default-looking values are rejected by the Customer Service
|
||||
process. Customer reward ownership uses a one-shot 10-minute `nhlink_*` proof
|
||||
issued only to an already authenticated game identity. The game stores only a
|
||||
SHA-256 hash of that proof and consumes it atomically, so Customer Service never
|
||||
needs the main P-256 private key.
|
||||
|
||||
|
||||
V4.1 uses three separate runtime images. The public game image contains only the
|
||||
game binary, the Customer Service image contains only the commercial control
|
||||
plane, and managed workers contain only the CLI agent. This reduces accidental
|
||||
cross-role exposure compared with the former monolithic runtime image.
|
||||
|
||||
`CS_WORKER_IMAGE` is treated as an operator-controlled image reference. If
|
||||
`CS_WORKER_AUTO_PULL=true`, Customer Service may ask Docker Engine to pull it.
|
||||
For a private registry, use a registry-scoped read-only deploy/robot token in
|
||||
`CS_WORKER_REGISTRY_USERNAME/PASSWORD`; those credentials are used only for the
|
||||
Docker `X-Registry-Auth` pull request and are never injected into worker
|
||||
containers. Pinning production workers to an immutable digest is recommended
|
||||
when your registry/deployment workflow supports it.
|
||||
|
||||
Managed worker containers never receive the Docker socket. The Customer Service
|
||||
process itself does require Docker Engine control; a direct docker.sock mount is
|
||||
therefore a high-trust capability and should preferably be replaced with a
|
||||
narrowly permissioned socket proxy in production. Global and per-customer worker
|
||||
inventory/running limits are enforced even when reverse-proxy rate limits are
|
||||
configured separately.
|
||||
|
||||
PayPal live mode is deliberately gated, manual credit grants are disabled by
|
||||
default and available only from the private Customer Service admin listener,
|
||||
and all credit changes are written to an idempotent ledger. These controls are
|
||||
operational safeguards, not a legal classification of the commercial product.
|
||||
|
||||
114
TESTING.md
114
TESTING.md
@@ -357,3 +357,117 @@ WS: normal browser websocket connects; a foreign browser Origin is rejected
|
||||
```
|
||||
|
||||
The OpenAI circuit breaker uses rolling windows and successful usage rows. Set very small limits in Admin Runtime to verify that a winning artifact stays `pending` with an `OpenAI cost circuit breaker active` diagnostic instead of making another provider call. Restore the desired limits afterward.
|
||||
|
||||
## V3.9 portable identity + owned artifact recovery
|
||||
|
||||
1. Start the CLI with a persistent identity path and note the printed Client-ID and file path:
|
||||
|
||||
```bash
|
||||
NEURALHUNT_IDENTITY=/tmp/nh-client/identity.json go run ./cmd/client -url http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
2. Run `identity` and verify the same Client-ID/path is shown. Restart with the same file and verify the Client-ID is unchanged.
|
||||
3. Set a passphrase of at least 12 characters and create an encrypted browser-compatible backup:
|
||||
|
||||
```bash
|
||||
export NEURALHUNT_IDENTITY_PASSPHRASE='correct horse battery staple'
|
||||
go run ./cmd/client -identity /tmp/nh-client/identity.json -export /tmp/neuralhunt-browser-import.json
|
||||
```
|
||||
|
||||
4. In the browser choose **IDENTITÄT → IMPORT**, select that JSON, enter the same passphrase and confirm the switch. The browser Client-ID must exactly equal the CLI Client-ID. The import validates that public/private P-256 keys can sign/verify and that the optional export Client-ID matches.
|
||||
5. After a win is fully rendered, **MEINE NFTS** must list the artifact and **ORIGINAL** must download the unwatermarked artifact. A different identity requesting `/api/me/artifacts/<task-id>/download` must receive 404.
|
||||
6. In the CLI, `my-nfts` lists only the authenticated identity's ready winner artifacts and `nft original <task-id> <datei>` downloads the original. `nfts` / `nft get` remain public watermarked previews.
|
||||
7. Verify legacy encrypted exports without `format/clientId/iterations` metadata still import using 250,000 PBKDF2 iterations.
|
||||
|
||||
Focused identity tests can run without the optional network/database dependencies:
|
||||
|
||||
```bash
|
||||
go test ./cmd/client/identity.go ./cmd/client/identity_test.go
|
||||
node --check internal/webui/dist/app.js
|
||||
```
|
||||
|
||||
## V4.0 Beacon Hunt + Hosted Service smoke test
|
||||
|
||||
### Beacon Hunt
|
||||
|
||||
1. Start the game and enable `Guess Lottery Max Accepted > 0` plus `Beacon Hunt` in Admin Runtime.
|
||||
2. Open two browser/CLI identities and choose different paths (`PULSE`, `FLUX`, `ORBIT`).
|
||||
3. Confirm requests wait until the window closes and that `/api/public/beacon/<task-id>/latest` returns the recorded drand round, signature, derived randomness and boosted path.
|
||||
4. Disable Beacon Hunt again and confirm the legacy `crypto/rand` lottery still works.
|
||||
|
||||
### Hosted reward pairing
|
||||
|
||||
1. Build with `make images-compose`, then start with `docker compose --profile hosted up -d` and route 8090 publicly over HTTPS; keep 8081/8091/8092 private.
|
||||
2. Create/log in to a Customer Service account.
|
||||
3. Log into the normal Neural Hunt browser with the desired reward identity and click **HOSTED CODE** (or run `hosted-code` in the CLI).
|
||||
4. Paste the one-shot code into the Customer Service portal. Confirm the portal shows the proven Client-ID. Reusing the same code must fail.
|
||||
|
||||
### Protected manual credits
|
||||
|
||||
1. Set `CS_ALLOW_MANUAL_CREDITS=1` only in a test/private environment.
|
||||
2. Log into the Customer Service admin on private port 8091 and grant a small amount of credits.
|
||||
3. Verify the customer ledger contains a positive `manual_test_grant` entry.
|
||||
4. Set `CS_ALLOW_MANUAL_CREDITS=0` again and verify grants are rejected.
|
||||
|
||||
### Managed workers / prepaid stop
|
||||
|
||||
1. Create a worker and assign an active task and Beacon path.
|
||||
2. Start it. The worker should create/register its own P-256 identity and the game should install `worker -> reward owner` delegation.
|
||||
3. Let the worker win a test task (use a deliberately tiny range only in a private test environment). Confirm `winner_client_id` is the main reward owner while `winner_worker_client_id` is the worker identity.
|
||||
4. Reduce the prepaid balance to less than one billable minute and verify Customer Service stops the worker before another paid minute is allowed.
|
||||
5. Stop/start the worker and confirm its Docker identity volume preserves the same Client-ID.
|
||||
6. Download the worker identity, replace it with another valid raw identity, start again and confirm a new worker Client-ID registers while reward ownership remains delegated to the same main identity.
|
||||
|
||||
### PayPal Sandbox
|
||||
|
||||
1. Keep `PAYPAL_ENVIRONMENT=sandbox` and configure sandbox client ID/secret/webhook ID.
|
||||
2. Buy the smallest test package from the Customer Service portal.
|
||||
3. Confirm credits are added only after a server-confirmed `COMPLETED` capture whose amount and currency match the stored package.
|
||||
4. Repeat the return/capture callback or webhook and verify the `paypal:<order-id>` ledger reference prevents duplicate credits.
|
||||
5. Leave live mode disabled until the provider/legal review for the actual product is complete.
|
||||
|
||||
|
||||
## V4.1 split Docker images
|
||||
|
||||
1. Build the three roles locally:
|
||||
|
||||
```bash
|
||||
docker buildx bake --load
|
||||
```
|
||||
|
||||
2. Verify the image contents/entrypoints:
|
||||
|
||||
```bash
|
||||
docker image inspect neuralhunt-server:local --format '{{json .Config.Entrypoint}}'
|
||||
docker image inspect neuralhunt-customer-service:local --format '{{json .Config.Entrypoint}}'
|
||||
docker image inspect neuralhunt-worker:local --format '{{json .Config.Entrypoint}}'
|
||||
```
|
||||
|
||||
Expected entrypoints are `/app/neuralhunt`, `/app/neuralhunt-customer-service`
|
||||
and `/app/neuralhunt-client` respectively. The server image should not contain
|
||||
the client or Customer Service binaries.
|
||||
|
||||
3. For local Hosted Service testing, build/tag the worker image before starting
|
||||
the hosted profile:
|
||||
|
||||
```bash
|
||||
make images-compose
|
||||
docker compose --profile hosted up -d
|
||||
```
|
||||
|
||||
4. Set `CS_WORKER_IMAGE` to a deliberately missing public image with
|
||||
`CS_WORKER_AUTO_PULL=true`; starting a worker should cause Docker Engine to pull
|
||||
the configured image. Repeat with `CS_WORKER_AUTO_PULL=false`; the request must
|
||||
fail before prepaid credits are charged.
|
||||
|
||||
5. For a private registry, configure a read-only token with
|
||||
`CS_WORKER_REGISTRY_SERVER/USERNAME/PASSWORD`, remove the local worker image and
|
||||
confirm an on-demand pull succeeds. Verify the credentials do not appear in the
|
||||
managed worker container environment (`docker inspect`).
|
||||
|
||||
Focused stdlib-only tests for the Docker image orchestration can be run even
|
||||
without the application's external Go dependencies:
|
||||
|
||||
```bash
|
||||
go test ./internal/customer/docker.go ./internal/customer/docker_test.go
|
||||
```
|
||||
|
||||
@@ -190,18 +190,23 @@ type taskCard struct {
|
||||
}
|
||||
|
||||
type taskDTO struct {
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
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"`
|
||||
BeaconPaths []string `json:"beacon_paths"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
}
|
||||
|
||||
type point struct {
|
||||
@@ -243,6 +248,15 @@ type publicArtifact struct {
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
}
|
||||
|
||||
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 (c *apiClient) tasks(ctx context.Context) ([]taskCard, error) {
|
||||
var out []taskCard
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks", nil, &out)
|
||||
@@ -273,6 +287,18 @@ func (c *apiClient) me(ctx context.Context) (meDTO, error) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
type hostedLinkCode struct {
|
||||
Code string `json:"code"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (c *apiClient) hostedLinkCode(ctx context.Context) (hostedLinkCode, error) {
|
||||
var out hostedLinkCode
|
||||
err := c.do(ctx, http.MethodPost, "/api/me/customer-link", map[string]any{}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) leaderboard(ctx context.Context) ([]leader, error) {
|
||||
var out []leader
|
||||
err := c.do(ctx, http.MethodGet, "/api/leaderboard", nil, &out)
|
||||
@@ -285,14 +311,25 @@ func (c *apiClient) artifacts(ctx context.Context, limit int) ([]publicArtifact,
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64) (bool, error) {
|
||||
func (c *apiClient) ownedArtifacts(ctx context.Context, limit int) ([]ownedArtifact, error) {
|
||||
var out []ownedArtifact
|
||||
err := c.do(ctx, http.MethodGet, "/api/me/artifacts?limit="+strconv.Itoa(limit), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64, beaconPath string) (bool, error) {
|
||||
guess := expectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
|
||||
sig, err := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
|
||||
msg := fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess)
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
beaconPath = strings.ToUpper(strings.TrimSpace(beaconPath))
|
||||
msg += "|" + beaconPath
|
||||
}
|
||||
sig, err := signRaw(c.key, msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var correct bool
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &correct)
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig, "beacon_path": beaconPath}, &correct)
|
||||
return correct, err
|
||||
}
|
||||
|
||||
@@ -348,6 +385,38 @@ func (c *apiClient) downloadPreview(ctx context.Context, taskID, dest string) er
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *apiClient) downloadOwnedArtifact(ctx context.Context, taskID, dest string) error {
|
||||
path := "/api/me/artifacts/" + url.PathEscape(taskID) + "/download"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.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 &apiError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
if dir := filepath.Dir(dest); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 64<<20))
|
||||
return err
|
||||
}
|
||||
|
||||
func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
// Keep this exactly aligned with internal/core.ExpectedGuess without importing
|
||||
// implementation details into the terminal UX layer.
|
||||
@@ -355,3 +424,81 @@ func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
}
|
||||
|
||||
var errSwitching = errors.New("task switch in progress")
|
||||
|
||||
// registerHostedWorker binds this worker's freshly authenticated cryptographic
|
||||
// identity to its Customer Service worker record. The endpoint is reachable
|
||||
// only on the private Docker network and additionally requires the per-worker
|
||||
// one-time bearer token injected by the Customer Service.
|
||||
func (c *apiClient) registerHostedWorker(ctx context.Context) error {
|
||||
registerURL := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_URL"))
|
||||
workerID := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID"))
|
||||
token := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_TOKEN"))
|
||||
if registerURL == "" && workerID == "" && token == "" {
|
||||
return nil
|
||||
}
|
||||
if registerURL == "" || workerID == "" || token == "" {
|
||||
return errors.New("hosted worker registration requires NEURALHUNT_WORKER_REGISTER_URL, NEURALHUNT_WORKER_ID and NEURALHUNT_WORKER_REGISTER_TOKEN")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID, "client_id": c.cid})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, registerURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hosted worker register: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("hosted worker register HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostedWorkerLeaseLoop is a fail-closed control-plane lease. Billing remains
|
||||
// authoritative in Customer Service; this loop merely prevents a managed
|
||||
// worker from running indefinitely if the Customer Service/Docker controller
|
||||
// disappears. After three consecutive missed 20-second renewals it cancels the
|
||||
// client context and the managed container exits.
|
||||
func (c *apiClient) hostedWorkerLeaseLoop(ctx context.Context, cancel context.CancelFunc) {
|
||||
leaseURL := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_LEASE_URL"))
|
||||
workerID := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID"))
|
||||
token := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_TOKEN"))
|
||||
if leaseURL == "" || workerID == "" || token == "" {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(20 * time.Second)
|
||||
defer t.Stop()
|
||||
failures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, leaseURL, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, callErr := (&http.Client{Timeout: 8 * time.Second}).Do(req)
|
||||
if callErr == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode/100 == 2 {
|
||||
failures = 0
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
failures++
|
||||
if failures >= 3 {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,15 @@ type identityFile struct {
|
||||
PrivateJWK privateJWK `json:"privateJwk"`
|
||||
}
|
||||
|
||||
const identityKDFIterations = 250000
|
||||
|
||||
type encryptedIdentity struct {
|
||||
Version int `json:"version"`
|
||||
Format string `json:"format,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
KDF string `json:"kdf,omitempty"`
|
||||
Iterations int `json:"iterations,omitempty"`
|
||||
Cipher string `json:"cipher,omitempty"`
|
||||
Salt string `json:"salt"`
|
||||
IV string `json:"iv"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
@@ -161,6 +168,15 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if err := json.Unmarshal(b, &enc); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if enc.Format != "" && enc.Format != "neuralhunt-identity-export" {
|
||||
return identityFile{}, errors.New("unsupported identity export format")
|
||||
}
|
||||
if enc.KDF != "" && enc.KDF != "PBKDF2-HMAC-SHA256" {
|
||||
return identityFile{}, errors.New("unsupported identity KDF")
|
||||
}
|
||||
if enc.Cipher != "" && enc.Cipher != "AES-256-GCM" {
|
||||
return identityFile{}, errors.New("unsupported identity cipher")
|
||||
}
|
||||
salt, err := rawURL.DecodeString(enc.Salt)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
@@ -173,7 +189,14 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
iterations := enc.Iterations
|
||||
if iterations == 0 {
|
||||
iterations = identityKDFIterations // compatibility with v1 exports
|
||||
}
|
||||
if iterations < 100000 || iterations > 2000000 {
|
||||
return identityFile{}, errors.New("unsupported identity KDF iteration count")
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, iterations, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
@@ -193,6 +216,15 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if _, err := privateKeyFromIdentity(id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if enc.ClientID != "" {
|
||||
cid, err := auth.ClientID(id.PublicJWK)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if cid != enc.ClientID {
|
||||
return identityFile{}, errors.New("identity export client ID mismatch")
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -200,6 +232,9 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
if passphrase == "" {
|
||||
return errors.New("export requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
|
||||
}
|
||||
if len(passphrase) < 12 {
|
||||
return errors.New("identity export passphrase must be at least 12 characters")
|
||||
}
|
||||
plain, err := json.Marshal(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -212,7 +247,7 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, identityKDFIterations, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -222,7 +257,15 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
return err
|
||||
}
|
||||
ct := gcm.Seal(nil, iv, plain, nil)
|
||||
enc := encryptedIdentity{Version: 1, Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct)}
|
||||
cid, err := auth.ClientID(id.PublicJWK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := encryptedIdentity{
|
||||
Version: 1, Format: "neuralhunt-identity-export", ClientID: cid,
|
||||
KDF: "PBKDF2-HMAC-SHA256", Iterations: identityKDFIterations, Cipher: "AES-256-GCM",
|
||||
Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct),
|
||||
}
|
||||
b, err := json.MarshalIndent(enc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -24,6 +25,17 @@ func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
|
||||
if err := exportBrowserIdentity(path, "correct horse battery staple", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enc encryptedIdentity
|
||||
if err := json.Unmarshal(b, &enc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enc.Format != "neuralhunt-identity-export" || enc.ClientID == "" || enc.Iterations != identityKDFIterations || enc.Cipher != "AES-256-GCM" {
|
||||
t.Fatalf("missing portable export metadata: %#v", enc)
|
||||
}
|
||||
got, err := readIdentityImport(path, "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -36,6 +48,16 @@ func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityExportRejectsWeakPassphrase(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := exportBrowserIdentity(filepath.Join(t.TempDir(), "weak.json"), "too-short", id); err == nil {
|
||||
t.Fatal("expected short export passphrase to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawIdentityImport(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ func main() {
|
||||
maxNodes := flag.Int("max-nodes", 500, "maximum target-field working set")
|
||||
nonInteractive := flag.Bool("non-interactive", false, "run unattended without command prompt")
|
||||
quiet := flag.Bool("quiet", false, "suppress connection/status chatter")
|
||||
beaconPath := flag.String("beacon-path", envOr("NEURALHUNT_BEACON_PATH", "auto"), "Beacon Hunt path: auto, pulse, flux or orbit")
|
||||
importPath := flag.String("import", "", "import browser/terminal identity JSON before login")
|
||||
exportPath := flag.String("export", "", "export current identity in browser-compatible encrypted format and exit")
|
||||
passphrase := flag.String("passphrase", os.Getenv("NEURALHUNT_IDENTITY_PASSPHRASE"), "identity import/export passphrase (prefer environment variable)")
|
||||
@@ -52,15 +53,22 @@ func main() {
|
||||
if err := api.login(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := api.registerHostedWorker(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go api.hostedWorkerLeaseLoop(ctx, stop)
|
||||
if created {
|
||||
fmt.Println("Neue Terminal-Identität erzeugt:", *identityPath)
|
||||
}
|
||||
fmt.Println("NEURAL HUNT SHELL")
|
||||
fmt.Println("Identity:", api.cid)
|
||||
fmt.Println("Server :", strings.TrimRight(*base, "/"))
|
||||
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
|
||||
fmt.Println("Client-ID :", api.cid)
|
||||
fmt.Println("Identity-Datei:", *identityPath)
|
||||
fmt.Println("Server :", strings.TrimRight(*base, "/"))
|
||||
fmt.Println("Backup : identity export <datei> (verschlüsselt, im Browser importierbar)")
|
||||
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
|
||||
|
||||
a := newApp(api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive)
|
||||
a.beaconPathMode = strings.ToLower(strings.TrimSpace(*beaconPath))
|
||||
initial, err := selectInitialTask(ctx, a, *taskSelector, !*nonInteractive)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
102
cmd/client/ui.go
102
cmd/client/ui.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -31,18 +32,19 @@ type app struct {
|
||||
quiet bool
|
||||
unattended bool
|
||||
|
||||
mu sync.RWMutex
|
||||
task taskDTO
|
||||
seq int64
|
||||
points map[string]point
|
||||
me meDTO
|
||||
ws *websocket.Conn
|
||||
wsConnected bool
|
||||
sessionCancel context.CancelFunc
|
||||
switching bool
|
||||
leaderWatch bool
|
||||
lastGuessAt time.Time
|
||||
lastGuessOK bool
|
||||
mu sync.RWMutex
|
||||
task taskDTO
|
||||
seq int64
|
||||
points map[string]point
|
||||
me meDTO
|
||||
ws *websocket.Conn
|
||||
wsConnected bool
|
||||
sessionCancel context.CancelFunc
|
||||
switching bool
|
||||
leaderWatch bool
|
||||
lastGuessAt time.Time
|
||||
lastGuessOK bool
|
||||
beaconPathMode string
|
||||
}
|
||||
|
||||
func newApp(api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
|
||||
@@ -428,6 +430,16 @@ func (a *app) refreshTask(taskID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func chooseBeaconPath(mode, clientID string, seq int64) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "PULSE", "FLUX", "ORBIT":
|
||||
return strings.ToUpper(strings.TrimSpace(mode))
|
||||
}
|
||||
h := sha256.Sum256([]byte(fmt.Sprintf("nh-cli-beacon-auto|%s|%d", clientID, seq)))
|
||||
paths := []string{"PULSE", "FLUX", "ORBIT"}
|
||||
return paths[int(h[0])%len(paths)]
|
||||
}
|
||||
|
||||
func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
for {
|
||||
a.mu.RLock()
|
||||
@@ -461,7 +473,14 @@ func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
if t.Paused || !connected {
|
||||
continue
|
||||
}
|
||||
correct, err := a.api.guess(ctx, t, seq)
|
||||
path := ""
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
path = chooseBeaconPath(a.beaconPathMode, a.api.cid, seq)
|
||||
if !a.quiet {
|
||||
fmt.Printf("[beacon] Pfad %s · Bonusgewicht bei Treffer ×%d\n", path, t.BeaconBonusWeight)
|
||||
}
|
||||
}
|
||||
correct, err := a.api.guess(ctx, t, seq, path)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@@ -613,6 +632,31 @@ func (a *app) printNFTs(ctx context.Context, limit int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) printMyNFTs(ctx context.Context, limit int) error {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
items, err := a.api.ownedArtifacts(ctx, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("\nMEINE NFTS / ORIGINAL-ARTEFAKTE")
|
||||
fmt.Println("──────────────────────────────────────────────────────────────────────────")
|
||||
for i, n := range items {
|
||||
name := strings.TrimSpace(n.DisplayName)
|
||||
if name == "" {
|
||||
name = shortID(n.TaskID)
|
||||
}
|
||||
fmt.Printf("%2d %-24s task=%-16s %3dbit %s\n", i+1, name, shortID(n.TaskID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Printf(" Original: %s%s\n", a.api.base, n.DownloadURI)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Println("(für diese Identität noch keine fertigen Gewinner-Artefakte)")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) watcher(ctx context.Context) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
@@ -638,7 +682,7 @@ func (a *app) watcher(ctx context.Context) {
|
||||
|
||||
func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
s := bufio.NewScanner(in)
|
||||
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], nft get <task-id> <datei>, identity, identity export <datei>, quit")
|
||||
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], my-nfts [n], nft get <task-id> <datei>, nft original <task-id> <datei>, identity, identity export <datei>, hosted-code, quit")
|
||||
for {
|
||||
fmt.Print("neuralhunt> ")
|
||||
if !s.Scan() {
|
||||
@@ -658,10 +702,13 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
fmt.Println(" map [n] textuelles Target Field / Nähe")
|
||||
fmt.Println(" leaderboard [n] Live-Rangliste")
|
||||
fmt.Println(" leaderboard watch|stop Rangliste alle 5s ein/aus")
|
||||
fmt.Println(" nfts [n] Wasserzeichen-NFTs anzeigen")
|
||||
fmt.Println(" nfts [n] öffentliche Wasserzeichen-NFTs anzeigen")
|
||||
fmt.Println(" my-nfts [n] eigene Gewinner-Artefakte anzeigen")
|
||||
fmt.Println(" nft get <task-id> <datei> Wasserzeichen-Preview speichern")
|
||||
fmt.Println(" nft original <task-id> <datei> eigenes Original speichern")
|
||||
fmt.Println(" identity Client-ID und Identity-Datei")
|
||||
fmt.Println(" identity export <datei> browser-kompatiblen verschlüsselten Export schreiben")
|
||||
fmt.Println(" hosted-code 10-Minuten-Code zum Koppeln als Reward-Identität")
|
||||
fmt.Println(" quit beenden")
|
||||
case "tasks":
|
||||
if _, err := a.printTasks(ctx); err != nil {
|
||||
@@ -723,6 +770,14 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
if err := a.printNFTs(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "my-nfts", "mine":
|
||||
n := 30
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if err := a.printMyNFTs(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "nft":
|
||||
if len(parts) == 4 && strings.EqualFold(parts[1], "get") {
|
||||
if err := a.api.downloadPreview(ctx, parts[2], parts[3]); err != nil {
|
||||
@@ -730,8 +785,14 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
} else {
|
||||
fmt.Println("Wasserzeichen-Preview gespeichert:", parts[3])
|
||||
}
|
||||
} else if len(parts) == 4 && (strings.EqualFold(parts[1], "original") || strings.EqualFold(parts[1], "download")) {
|
||||
if err := a.api.downloadOwnedArtifact(ctx, parts[2], parts[3]); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Original-Artefakt gespeichert:", parts[3])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("nft get <task-id> <datei>")
|
||||
fmt.Println("nft get <task-id> <datei> | nft original <task-id> <datei>")
|
||||
}
|
||||
case "identity", "id":
|
||||
if len(parts) >= 2 && strings.EqualFold(parts[1], "export") {
|
||||
@@ -748,6 +809,15 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
fmt.Println("Client-ID:", a.api.cid)
|
||||
fmt.Println("Identity :", a.identityPath)
|
||||
}
|
||||
case "hosted-code", "hosted-link":
|
||||
x, err := a.api.hostedLinkCode(ctx)
|
||||
if err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Hosted-Code:", x.Code)
|
||||
fmt.Println("Client-ID :", x.ClientID)
|
||||
fmt.Println("Gültig bis :", x.ExpiresAt.Local().Format(time.RFC3339))
|
||||
}
|
||||
case "quit", "exit", "q":
|
||||
return nil
|
||||
default:
|
||||
|
||||
204
cmd/customer-service/main.go
Normal file
204
cmd/customer-service/main.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/customer"
|
||||
"neuralhunt/internal/customerui"
|
||||
)
|
||||
|
||||
func loadDotEnv(path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := os.LookupEnv(k); exists {
|
||||
continue
|
||||
}
|
||||
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
func env(k, d string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func boolEnv(k string, d bool) bool {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
func intEnv(k string, d int) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
func floatEnv(k string, d float64) float64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(os.Getenv(k)), 64)
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
func durationEnv(k string, d time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
if x, err := time.ParseDuration(v); err == nil {
|
||||
return x
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// CS_CREDIT_PACKAGES format: id:amount_cents:CURRENCY:credits;...
|
||||
// Example: starter:499:EUR:60;plus:1999:EUR:300
|
||||
func packagesFromEnv() ([]customer.CreditPackage, error) {
|
||||
raw := env("CS_CREDIT_PACKAGES", "starter:499:EUR:60;plus:1999:EUR:300;power:4999:EUR:900")
|
||||
var out []customer.CreditPackage
|
||||
for _, part := range strings.Split(raw, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(part, ":")
|
||||
if len(f) != 4 {
|
||||
return nil, fmt.Errorf("invalid CS_CREDIT_PACKAGES entry %q", part)
|
||||
}
|
||||
cents, err := strconv.ParseInt(f[1], 10, 64)
|
||||
if err != nil || cents <= 0 {
|
||||
return nil, fmt.Errorf("invalid package cents %q", f[1])
|
||||
}
|
||||
credits, err := strconv.ParseFloat(f[3], 64)
|
||||
if err != nil || credits <= 0 {
|
||||
return nil, fmt.Errorf("invalid package credits %q", f[3])
|
||||
}
|
||||
out = append(out, customer.CreditPackage{ID: strings.TrimSpace(f[0]), AmountCents: cents, Currency: strings.ToUpper(strings.TrimSpace(f[2])), CreditsMicros: int64(credits*1_000_000 + 0.5)})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no credit packages configured")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validate(cfg customer.Config) error {
|
||||
shared := strings.TrimSpace(cfg.SharedSecret)
|
||||
if len(shared) < 32 || strings.Contains(strings.ToLower(shared), "replace-with") || strings.Contains(strings.ToLower(shared), "change-me") {
|
||||
return fmt.Errorf("CUSTOMER_SERVICE_SHARED_SECRET must be a unique random value of at least 32 characters; example placeholders are rejected")
|
||||
}
|
||||
adminPass := strings.TrimSpace(cfg.AdminPassword)
|
||||
if len(adminPass) < 16 || strings.Contains(strings.ToLower(adminPass), "replace-with") || strings.Contains(strings.ToLower(adminPass), "change-me") {
|
||||
return fmt.Errorf("CS_ADMIN_PASSWORD must be a unique value of at least 16 characters; example placeholders are rejected")
|
||||
}
|
||||
if cfg.PublicBaseURL == "" {
|
||||
return fmt.Errorf("CS_PUBLIC_BASE_URL is required (the HTTPS customer portal URL)")
|
||||
}
|
||||
if cfg.WorkerImage == "" {
|
||||
return fmt.Errorf("CS_WORKER_IMAGE is required")
|
||||
}
|
||||
if _, err := customer.RegistryAuthHeader(cfg.WorkerRegistryUsername, cfg.WorkerRegistryPassword, cfg.WorkerRegistryServer); err != nil {
|
||||
return fmt.Errorf("worker registry auth: %w", err)
|
||||
}
|
||||
if cfg.PayPalEnabled && strings.EqualFold(cfg.PayPalEnvironment, "live") && cfg.PayPalLiveApprovalAck != "I_HAVE_PAYPAL_APPROVAL" {
|
||||
log.Printf("WARNING: PayPal live remains disabled until PAYPAL_LIVE_APPROVAL_ACK=I_HAVE_PAYPAL_APPROVAL is set after provider/legal approval")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
loadDotEnv(".env")
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
pkgs, err := packagesFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
rate := floatEnv("CS_WORKER_CREDITS_PER_MINUTE", 1.0)
|
||||
if rate <= 0 {
|
||||
log.Fatal("CS_WORKER_CREDITS_PER_MINUTE must be > 0")
|
||||
}
|
||||
cfg := customer.Config{
|
||||
PublicAddr: env("CS_HTTP_ADDR", ":8090"), AdminAddr: env("CS_ADMIN_HTTP_ADDR", ":8091"), InternalAddr: env("CS_INTERNAL_ADDR", ":8092"),
|
||||
PublicBaseURL: strings.TrimRight(env("CS_PUBLIC_BASE_URL", ""), "/"), GamePublicURL: env("CS_GAME_PUBLIC_URL", "http://app:8080"), GameAdminURL: env("CS_GAME_ADMIN_URL", "http://app:8081"), SharedSecret: env("CUSTOMER_SERVICE_SHARED_SECRET", ""),
|
||||
DockerHost: env("DOCKER_HOST", "unix:///var/run/docker.sock"), WorkerImage: env("CS_WORKER_IMAGE", "neuralhunt-worker:local"), WorkerEntrypoint: env("CS_WORKER_ENTRYPOINT", ""), WorkerNetwork: env("CS_WORKER_NETWORK", "neuralhunt_backend"), WorkerRegisterURL: env("CS_WORKER_REGISTER_URL", "http://customer-service:8092/internal/workers/register"), WorkerAutoPull: boolEnv("CS_WORKER_AUTO_PULL", true), WorkerRegistryUsername: env("CS_WORKER_REGISTRY_USERNAME", ""), WorkerRegistryPassword: env("CS_WORKER_REGISTRY_PASSWORD", ""), WorkerRegistryServer: env("CS_WORKER_REGISTRY_SERVER", ""), WorkerRateMicrosPerMinute: int64(rate*1_000_000 + 0.5), MaxWorkersPerCustomer: intEnv("CS_MAX_WORKERS_PER_CUSTOMER", 20), MaxWorkersGlobal: intEnv("CS_MAX_WORKERS_GLOBAL", 1000), MaxRunningPerCustomer: intEnv("CS_MAX_RUNNING_WORKERS_PER_CUSTOMER", 10), MaxRunningGlobal: intEnv("CS_MAX_RUNNING_WORKERS_GLOBAL", 100),
|
||||
SessionTTL: durationEnv("CS_SESSION_TTL", 24*time.Hour), CookieSecure: boolEnv("CS_COOKIE_SECURE", true), AdminUser: env("CS_ADMIN_USER", "admin"), AdminPassword: env("CS_ADMIN_PASSWORD", ""), AllowManualCredits: boolEnv("CS_ALLOW_MANUAL_CREDITS", false),
|
||||
PayPalEnabled: boolEnv("PAYPAL_ENABLED", false), PayPalEnvironment: env("PAYPAL_ENVIRONMENT", "sandbox"), PayPalWebhookID: env("PAYPAL_WEBHOOK_ID", ""), PayPalLiveApprovalAck: env("PAYPAL_LIVE_APPROVAL_ACK", ""), Packages: pkgs,
|
||||
}
|
||||
if err := validate(cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
st, err := customer.Open(ctx, env("CUSTOMER_SQLITE_PATH", "/customer-data/customer-service.db"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer st.DB.Close()
|
||||
if err := st.RecoverStartingWorkers(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
dc, err := customer.NewDockerClient(cfg.DockerHost)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := dc.Ping(ctx); err != nil {
|
||||
log.Fatalf("Docker Engine API unavailable: %v", err)
|
||||
}
|
||||
pp := customer.NewPayPalClient(env("PAYPAL_CLIENT_ID", ""), env("PAYPAL_CLIENT_SECRET", ""), cfg.PayPalEnvironment)
|
||||
svc := customer.NewService(st, dc, pp, cfg)
|
||||
go svc.RunBilling(ctx)
|
||||
servers := []*http.Server{
|
||||
{Addr: cfg.PublicAddr, Handler: svc.PublicRoutes(customerui.Public()), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second},
|
||||
{Addr: cfg.AdminAddr, Handler: svc.AdminRoutes(customerui.Admin()), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second},
|
||||
{Addr: cfg.InternalAddr, Handler: svc.InternalRoutes(), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second},
|
||||
}
|
||||
names := []string{"customer public", "customer admin/private", "customer worker/internal"}
|
||||
for i, s := range servers {
|
||||
go func(n string, hs *http.Server) {
|
||||
log.Printf("%s listener on %s", n, hs.Addr)
|
||||
if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}(names[i], s)
|
||||
}
|
||||
<-ctx.Done()
|
||||
shutdown, done := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer done()
|
||||
for _, s := range servers {
|
||||
_ = s.Shutdown(shutdown)
|
||||
}
|
||||
}
|
||||
38
docker-bake.hcl
Normal file
38
docker-bake.hcl
Normal file
@@ -0,0 +1,38 @@
|
||||
variable "NEURALHUNT_SERVER_IMAGE" {
|
||||
default = "neuralhunt-server:local"
|
||||
}
|
||||
|
||||
variable "NEURALHUNT_CUSTOMER_IMAGE" {
|
||||
default = "neuralhunt-customer-service:local"
|
||||
}
|
||||
|
||||
variable "CS_WORKER_IMAGE" {
|
||||
default = "neuralhunt-worker:local"
|
||||
}
|
||||
|
||||
group "default" {
|
||||
targets = ["server", "customer-service", "worker"]
|
||||
}
|
||||
|
||||
target "common" {
|
||||
context = "."
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
target "server" {
|
||||
inherits = ["common"]
|
||||
target = "server"
|
||||
tags = [NEURALHUNT_SERVER_IMAGE]
|
||||
}
|
||||
|
||||
target "customer-service" {
|
||||
inherits = ["common"]
|
||||
target = "customer-service"
|
||||
tags = [NEURALHUNT_CUSTOMER_IMAGE]
|
||||
}
|
||||
|
||||
target "worker" {
|
||||
inherits = ["common"]
|
||||
target = "worker"
|
||||
tags = [CS_WORKER_IMAGE]
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: ${NEURALHUNT_SERVER_IMAGE:-neuralhunt-server:local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.server
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
@@ -9,15 +12,73 @@ services:
|
||||
ARTIFACT_DIR: /data/artifacts
|
||||
ports:
|
||||
- "8080:8080"
|
||||
# Admin is bound only to host loopback by default. A VPN/reverse proxy on
|
||||
# the host can publish it privately; Docker-network proxies can use app:8081.
|
||||
# Main-game admin/control plane. Route this only through VPN/private proxy.
|
||||
- "127.0.0.1:8081:8081"
|
||||
# Lets the optional ComfyUI/A1111 providers reach a UI running on the Docker host.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- neuralhunt_data:/data
|
||||
networks:
|
||||
- neuralhunt_backend
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional hosted commercial control plane. It is intentionally a separate
|
||||
# image/process/database from the game server. Port 8090 is the customer
|
||||
# portal; 8091 is private admin; 8092 is Docker-network-only registration.
|
||||
customer-service:
|
||||
profiles: ["hosted"]
|
||||
image: ${NEURALHUNT_CUSTOMER_IMAGE:-neuralhunt-customer-service:local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.customer-service
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
CUSTOMER_SQLITE_PATH: /customer-data/customer-service.db
|
||||
CS_GAME_PUBLIC_URL: http://app:8080
|
||||
CS_GAME_ADMIN_URL: http://app:8081
|
||||
# The worker is a genuinely separate image now. Point this at any
|
||||
# compatible prebuilt/published worker image. The Customer Service can
|
||||
# auto-pull it when it is not present on the Docker host.
|
||||
CS_WORKER_IMAGE: ${CS_WORKER_IMAGE:-neuralhunt-worker:local}
|
||||
CS_WORKER_AUTO_PULL: ${CS_WORKER_AUTO_PULL:-true}
|
||||
CS_WORKER_ENTRYPOINT: ${CS_WORKER_ENTRYPOINT:-}
|
||||
CS_WORKER_NETWORK: neuralhunt_backend
|
||||
CS_WORKER_REGISTER_URL: http://customer-service:8092/internal/workers/register
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
ports:
|
||||
- "8090:8090"
|
||||
- "127.0.0.1:8091:8091"
|
||||
expose:
|
||||
- "8092"
|
||||
# Direct docker.sock access is powerful. For production, prefer a tightly
|
||||
# scoped Docker Socket Proxy and point DOCKER_HOST at that proxy instead.
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- customer_data:/customer-data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- neuralhunt_backend
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
# Build-only helper. `docker compose --profile images build worker-image`
|
||||
# creates/tags the third image without starting a hunting agent.
|
||||
worker-image:
|
||||
profiles: ["images"]
|
||||
image: ${CS_WORKER_IMAGE:-neuralhunt-worker:local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.worker
|
||||
entrypoint: ["/bin/true"]
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
neuralhunt_data: {}
|
||||
customer_data: {}
|
||||
|
||||
networks:
|
||||
neuralhunt_backend:
|
||||
name: neuralhunt_backend
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
356
internal/customer/docker.go
Normal file
356
internal/customer/docker.go
Normal file
@@ -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
|
||||
}
|
||||
112
internal/customer/docker_test.go
Normal file
112
internal/customer/docker_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
70
internal/customer/identity.go
Normal file
70
internal/customer/identity.go
Normal file
@@ -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
|
||||
}
|
||||
183
internal/customer/paypal.go
Normal file
183
internal/customer/paypal.go
Normal file
@@ -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
|
||||
}
|
||||
71
internal/customer/security.go
Normal file
71
internal/customer/security.go
Normal file
@@ -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]
|
||||
}
|
||||
1173
internal/customer/server.go
Normal file
1173
internal/customer/server.go
Normal file
File diff suppressed because it is too large
Load Diff
24
internal/customer/server_test.go
Normal file
24
internal/customer/server_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
425
internal/customer/store.go
Normal file
425
internal/customer/store.go
Normal file
@@ -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()
|
||||
}
|
||||
21
internal/customerui/customerui.go
Normal file
21
internal/customerui/customerui.go
Normal file
@@ -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
internal/customerui/dist/admin/app.js
vendored
Normal file
1
internal/customerui/dist/admin/app.js
vendored
Normal file
@@ -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
internal/customerui/dist/admin/index.html
vendored
Normal file
1
internal/customerui/dist/admin/index.html
vendored
Normal file
@@ -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
internal/customerui/dist/admin/styles.css
vendored
Normal file
1
internal/customerui/dist/admin/styles.css
vendored
Normal file
@@ -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
internal/customerui/dist/public/app.js
vendored
Normal file
15
internal/customerui/dist/public/app.js
vendored
Normal file
@@ -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
internal/customerui/dist/public/index.html
vendored
Normal file
19
internal/customerui/dist/public/index.html
vendored
Normal file
@@ -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
internal/customerui/dist/public/styles.css
vendored
Normal file
1
internal/customerui/dist/public/styles.css
vendored
Normal file
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
169
internal/server/beacon.go
Normal file
169
internal/server/beacon.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var beaconPaths = []string{"PULSE", "FLUX", "ORBIT"}
|
||||
|
||||
func normalizeBeaconPath(v string) string {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
for _, p := range beaconPaths {
|
||||
if v == p {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type drandInfo struct {
|
||||
Period int64 `json:"period"`
|
||||
GenesisTime int64 `json:"genesis_time"`
|
||||
}
|
||||
|
||||
type drandRound struct {
|
||||
Round uint64 `json:"round"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
type beaconReveal struct {
|
||||
Source string
|
||||
BeaconID string
|
||||
Round uint64
|
||||
Randomness string
|
||||
Signature string
|
||||
}
|
||||
|
||||
type beaconClient struct {
|
||||
base string
|
||||
beaconID string
|
||||
hc *http.Client
|
||||
mu sync.Mutex
|
||||
info drandInfo
|
||||
infoAt time.Time
|
||||
}
|
||||
|
||||
func newBeaconClient() *beaconClient {
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv("BEACON_DRAND_URL")), "/")
|
||||
if base == "" {
|
||||
base = "https://api.drand.sh"
|
||||
}
|
||||
id := strings.TrimSpace(os.Getenv("BEACON_DRAND_BEACON_ID"))
|
||||
if id == "" {
|
||||
id = "quicknet"
|
||||
}
|
||||
return &beaconClient{base: base, beaconID: id, hc: &http.Client{Timeout: 5 * time.Second}}
|
||||
}
|
||||
|
||||
func (b *beaconClient) chainInfo(ctx context.Context) (drandInfo, error) {
|
||||
b.mu.Lock()
|
||||
if b.info.Period > 0 && time.Since(b.infoAt) < 6*time.Hour {
|
||||
v := b.info
|
||||
b.mu.Unlock()
|
||||
return v, nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/v2/beacons/"+b.beaconID+"/info", nil)
|
||||
if err != nil {
|
||||
return drandInfo{}, err
|
||||
}
|
||||
resp, err := b.hc.Do(req)
|
||||
if err != nil {
|
||||
return drandInfo{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return drandInfo{}, fmt.Errorf("drand info HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var info drandInfo
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil {
|
||||
return drandInfo{}, err
|
||||
}
|
||||
if info.Period <= 0 || info.GenesisTime <= 0 {
|
||||
return drandInfo{}, errors.New("drand returned invalid chain info")
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.info, b.infoAt = info, time.Now()
|
||||
b.mu.Unlock()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func roundTime(info drandInfo, round uint64) time.Time {
|
||||
if round == 0 {
|
||||
return time.Unix(info.GenesisTime, 0).UTC()
|
||||
}
|
||||
return time.Unix(info.GenesisTime+int64(round-1)*info.Period, 0).UTC()
|
||||
}
|
||||
|
||||
// targetRound chooses the first beacon round that starts strictly after the
|
||||
// guess window closes. That makes the beacon value unknowable while players
|
||||
// are choosing PULSE/FLUX/ORBIT and submitting their ticket.
|
||||
func targetRound(info drandInfo, windowEnd time.Time) uint64 {
|
||||
if windowEnd.Unix() <= info.GenesisTime {
|
||||
return 1
|
||||
}
|
||||
elapsed := windowEnd.Unix() - info.GenesisTime
|
||||
return uint64(elapsed/info.Period) + 2
|
||||
}
|
||||
|
||||
func (b *beaconClient) plan(ctx context.Context, windowEnd time.Time) (uint64, time.Time, error) {
|
||||
info, err := b.chainInfo(ctx)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, err
|
||||
}
|
||||
r := targetRound(info, windowEnd)
|
||||
return r, roundTime(info, r), nil
|
||||
}
|
||||
|
||||
func (b *beaconClient) reveal(ctx context.Context, round uint64) (beaconReveal, error) {
|
||||
path := b.base + "/v2/beacons/" + b.beaconID + "/rounds/" + strconv.FormatUint(round, 10)
|
||||
var last error
|
||||
for attempt := 0; attempt < 6; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return beaconReveal{}, err
|
||||
}
|
||||
resp, err := b.hc.Do(req)
|
||||
if err == nil {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
_ = resp.Body.Close()
|
||||
if readErr == nil && resp.StatusCode/100 == 2 {
|
||||
var rr drandRound
|
||||
if json.Unmarshal(body, &rr) == nil && rr.Round == round && rr.Signature != "" {
|
||||
sig, decErr := hex.DecodeString(rr.Signature)
|
||||
if decErr != nil {
|
||||
return beaconReveal{}, fmt.Errorf("decode drand signature: %w", decErr)
|
||||
}
|
||||
h := sha256.Sum256(sig)
|
||||
return beaconReveal{Source: "drand", BeaconID: b.beaconID, Round: round, Randomness: hex.EncodeToString(h[:]), Signature: rr.Signature}, nil
|
||||
}
|
||||
}
|
||||
last = fmt.Errorf("drand round HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
} else {
|
||||
last = err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return beaconReveal{}, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt+1) * 750 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if last == nil {
|
||||
last = errors.New("drand round unavailable")
|
||||
}
|
||||
return beaconReveal{}, last
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
48
internal/webui/dist/app.js
vendored
48
internal/webui/dist/app.js
vendored
@@ -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>
|
||||
|
||||
10
internal/webui/dist/styles.css
vendored
10
internal/webui/dist/styles.css
vendored
@@ -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