RC-10
Some checks failed
release-tag / release-image (push) Failing after 2m11s

This commit is contained in:
2026-08-11 22:08:00 +02:00
parent eefe6dd415
commit 74d6b2f7d2
20 changed files with 1021 additions and 55 deletions

View File

@@ -109,6 +109,9 @@ BEACON_DRAND_BEACON_ID=quicknet
# 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
# Game -> Customer Service internal callback for durable hosted-worker credit rewards.
# Docker/Compose: http://customer-service:8092 ; local go run: http://127.0.0.1:8092
CUSTOMER_SERVICE_INTERNAL_URL=http://127.0.0.1:8092
# Customer portal (8090), private Customer-Service admin (8091), and the
# Docker-network-only worker registration listener (8092).
@@ -129,6 +132,20 @@ CS_ADMIN_PASSWORD=replace-with-another-strong-unique-password
# in production unless you explicitly need private admin grants.
CS_ALLOW_MANUAL_CREDITS=0
# Customer engagement defaults. They are seeded into customer-service SQLite
# only once and can then be changed live in the private Customer Admin UI.
CS_CUSTOMER_LOGIN_ENABLED=true
CS_CUSTOMER_REGISTRATION_ENABLED=true
# 0 disables browser registration proof-of-work; 16 is a moderate default.
CS_REGISTRATION_POW_BITS=16
# When true, registration also requires an admin-generated one-shot invite.
CS_REGISTRATION_INVITE_REQUIRED=false
# Free prepaid credits for a newly created customer account. 0 disables.
CS_NEW_CUSTOMER_CREDITS=0
# Credits granted when a hosted worker improves its personal best score.
# 0 disables the reward. Normal browser/CLI identities are never credited here.
CS_POSITIVE_TIP_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.

View File

@@ -241,3 +241,21 @@ 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.
## V4.2.6 engagement credits and account controls
The Hosted Service has two optional customer-funded-by-game credit rewards. Both default to zero and are administered persistently from the private Customer Admin:
```env
CS_NEW_CUSTOMER_CREDITS=0
CS_POSITIVE_TIP_CREDITS=0
```
`CS_NEW_CUSTOMER_CREDITS` is booked atomically with account creation. `CS_POSITIVE_TIP_CREDITS` is booked only for a delegated Hosted Worker when an accepted/evaluated guess creates a new personal best score. A ticket that is not selected by the lottery is not scored and earns no reward. Normal browser/CLI identities do not create these Customer-Service reward events.
The game persists each positive-tip event to an outbox and retries delivery to the private Customer Service (`CUSTOMER_SERVICE_INTERNAL_URL`, normally `http://customer-service:8092` in Docker). Customer Service applies its current configured reward amount and uses an idempotent ledger reference, so temporary outages and retries neither lose nor duplicate credits.
When PayPal is disabled the public Customer Portal hides the purchase panel entirely. Its visible credit history is aggregated by UTC day/reason for readability; the raw ledger remains unchanged in the database.
The private Customer Admin can also block/unblock accounts, stop individual/all workers, disable new logins, disable new registrations, configure registration Proof-of-Work and optionally enforce time-limited one-shot invite codes. Blocking an account deletes its active sessions and revokes Worker leases immediately.

View File

@@ -1,3 +1,5 @@
> **V4.2.6 Customer Engagement + Admin Controls:** Der Hosted Customer Service kann neuen Konten ein konfigurierbares Startguthaben geben und Hosted Workern für echte persönliche Best-Score-Verbesserungen konfigurierbare Bonus-Credits gutschreiben. Das Customer Portal blendet PayPal vollständig aus, wenn PayPal deaktiviert ist, und fasst den Credit-Verlauf kompakt nach Tag/Buchungsart zusammen. Im privaten Customer-Admin gibt es Benutzer sperren/freigeben, Worker-Stop, Login-/Registrierungs-Kill-Switches, Registrierungs-Proof-of-Work und optionale einmalige Invite-Codes. Positive-Tip-Rewards werden über eine persistente Game-Outbox idempotent an den Customer Service zugestellt. Details: `V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md`.
> **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.
> **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`.

View File

@@ -480,3 +480,24 @@ After publishing the new worker image, start a hosted worker that already owns
an identity volume from an older release. It must start without `permission
denied`, keep the same client identity, and remain writable at
`/identity/identity.json`. Do not delete the named volume for this test.
## V4.2.6 Customer engagement/admin smoke test
1. Start Game + Customer Service with the same `CUSTOMER_SERVICE_SHARED_SECRET`. In Docker ensure the Game container has:
```env
CUSTOMER_SERVICE_INTERNAL_URL=http://customer-service:8092
```
2. In private Customer Admin set a small **Startguthaben**, e.g. `3`, register a fresh customer and confirm its first balance is exactly `3` Credits and the compact ledger contains `Startguthaben`.
3. Set a small **Reward pro positivem Worker-Tipp**, link a main reward identity, start a Hosted Worker, and let it produce evaluated guesses. A guess earns a reward only when it raises that Worker's personal best. Lottery-rejected/non-evaluated tickets and non-improving guesses must not add credits.
4. Stop Customer Service temporarily while a Hosted Worker gets a personal-best improvement, then restart it. Confirm the Game DB's pending outbox is delivered later and the Customer ledger receives the reward exactly once.
5. Set `PAYPAL_ENABLED=false`; after reload the public portal must not show **Credits kaufen** and must not request package data as part of normal portal loading.
6. Generate enough minute/reward entries to verify the Customer credit history groups repeated rows by UTC day and reason. Confirm the raw `credit_ledger` rows still exist separately in SQLite.
7. In private Customer Admin block a test user. Existing Customer sessions must become unauthorized, all its Worker leases must be revoked/stopped, and login must return 403 until the user is unblocked.
8. Disable **Benutzer-Anmeldung erlauben**. New login attempts must receive 503; an already-authenticated, non-blocked session intentionally remains valid. Re-enable afterwards.
9. Disable **Neue Registrierungen erlauben** and confirm both challenge/registration are rejected. Re-enable and set PoW to a small test value (e.g. 8) to verify the browser solves the challenge automatically.
10. Enable **Registrierung nur mit einmaligem Invite-Code**, create an invite in Customer Admin, register one account with it, then verify the same invite cannot create a second account.
For production/open registration, do not rely on PoW as a one-human/one-account guarantee. Keep Traefik/Caddy rate/concurrency limits on registration/login routes and consider invite-only registration when free signup credits have meaningful value.

View File

@@ -0,0 +1,69 @@
# V4.2.6 — Customer Engagement + Admin Controls
## Credit rewards
Two optional reward values are managed persistently by the private Customer-Service admin UI:
- **Signup bonus**: credited atomically when a new Customer account is created.
- **Positive-tip bonus**: credited when a **Hosted Worker** submits a signed, lottery-selected/evaluated guess that improves that worker's own best score for the task.
The defaults are intentionally `0`, so an update does not change the existing economy until the operator enables it:
```env
CS_NEW_CUSTOMER_CREDITS=0
CS_POSITIVE_TIP_CREDITS=0
```
These environment values seed SQLite only the first time the setting exists. Afterwards the live values are controlled from Customer Admin and survive restarts.
A positive-tip reward is **not** granted for every submitted guess and not for a guess that loses the task lottery before evaluation. Ordinary browser/CLI identities also do not receive Customer-Service credits. The Game server creates the reward event only when the submitting identity has an active Hosted Worker delegation.
To avoid losing rewards during a Customer-Service restart/outage, the Game DB contains a durable `hosted_credit_events` outbox. The server retries delivery to the private Customer Service endpoint. Both the outbox event ID and Customer-Service ledger reference are idempotent, so retries cannot double-credit the customer.
For Docker/Compose the Game server therefore needs:
```env
CUSTOMER_SERVICE_INTERNAL_URL=http://customer-service:8092
```
For a direct local `go run` setup use `http://127.0.0.1:8092` instead.
## Customer dashboard
When PayPal is disabled or not fully configured, **Credits kaufen** is not rendered as an available purchase area and package requests are skipped. The credit history is intentionally compact: entries are grouped by UTC day and booking reason, while the raw immutable-style `credit_ledger` remains unchanged in SQLite for audit/idempotency.
## Private Customer Admin
The private `:8091` Customer Admin now supports:
- block / unblock a Customer account;
- stop one Worker or all Workers of a Customer;
- disable/enable new Customer logins globally;
- disable/enable new registrations independently;
- configure registration Proof-of-Work difficulty (`0..24` bits);
- optionally require a one-shot registration invite;
- create time-limited one-shot invite codes;
- configure signup and positive-tip credit rewards.
Blocking an account invalidates all Customer sessions immediately and revokes/stops its Worker leases. Docker stop is attempted immediately. Even if that Docker operation fails, the Worker cannot renew its lease and its existing lease-failure shutdown path terminates it.
The global **login kill switch** intentionally blocks **new logins**; it does not mass-log-out already authenticated customers. Use individual account blocking when an existing session must be invalidated. Registration has a separate switch.
## Anti-bot / account farming
Open registration can require a stateless, username-bound SHA-256 Proof-of-Work. The challenge is HMAC-authenticated by Customer Service, short lived and cannot be moved to another username. This adds a measurable cost to bulk account creation, especially when signup credits are enabled.
Proof-of-Work is friction, not proof that one account equals one human. For controlled launches or valuable signup credits, enable **invite required** as the stronger control. Each `nhinvite_...` code is one-shot, has an expiry and is stored only as a hash. Continue to rate-limit `/api/register/challenge`, `/api/register` and `/api/login` at Traefik/Caddy as an independent layer.
Suggested starting point for an open beta:
```env
CS_CUSTOMER_LOGIN_ENABLED=true
CS_CUSTOMER_REGISTRATION_ENABLED=true
CS_REGISTRATION_POW_BITS=16
CS_REGISTRATION_INVITE_REQUIRED=false
CS_NEW_CUSTOMER_CREDITS=0
CS_POSITIVE_TIP_CREDITS=0
```
If signup credits become meaningful, increase PoW gradually and/or switch to invite-only registration before raising the free balance.

View File

@@ -132,6 +132,12 @@ func validate(cfg customer.Config) error {
if cfg.WorkerImage == "" {
return fmt.Errorf("CS_WORKER_IMAGE is required")
}
if cfg.RegistrationPOWBits < 0 || cfg.RegistrationPOWBits > 24 {
return fmt.Errorf("CS_REGISTRATION_POW_BITS must be between 0 and 24")
}
if cfg.NewCustomerCreditsMicros < 0 || cfg.PositiveTipCreditsMicros < 0 {
return fmt.Errorf("CS_NEW_CUSTOMER_CREDITS and CS_POSITIVE_TIP_CREDITS must be >= 0")
}
if _, err := customer.RegistryAuthHeader(cfg.WorkerRegistryUsername, cfg.WorkerRegistryPassword, cfg.WorkerRegistryServer); err != nil {
return fmt.Errorf("worker registry auth: %w", err)
}
@@ -158,6 +164,7 @@ func main() {
PublicBaseURL: strings.TrimRight(env("CS_PUBLIC_BASE_URL", ""), "/"), GamePublicURL: env("CS_GAME_PUBLIC_URL", "http://127.0.0.1:8080"), GameAdminURL: env("CS_GAME_ADMIN_URL", "http://127.0.0.1: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), AdminCookieSecureMode: env("CS_ADMIN_COOKIE_SECURE", "auto"), AdminUser: env("CS_ADMIN_USER", "admin"), AdminPassword: env("CS_ADMIN_PASSWORD", ""), AllowManualCredits: boolEnv("CS_ALLOW_MANUAL_CREDITS", false),
LoginEnabled: boolEnv("CS_CUSTOMER_LOGIN_ENABLED", true), RegistrationEnabled: boolEnv("CS_CUSTOMER_REGISTRATION_ENABLED", true), RegistrationPOWBits: intEnv("CS_REGISTRATION_POW_BITS", 16), RegistrationInviteRequired: boolEnv("CS_REGISTRATION_INVITE_REQUIRED", false), NewCustomerCreditsMicros: int64(floatEnv("CS_NEW_CUSTOMER_CREDITS", 0)*1_000_000 + 0.5), PositiveTipCreditsMicros: int64(floatEnv("CS_POSITIVE_TIP_CREDITS", 0)*1_000_000 + 0.5),
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 {
@@ -180,6 +187,9 @@ func main() {
}
pp := customer.NewPayPalClient(env("PAYPAL_CLIENT_ID", ""), env("PAYPAL_CLIENT_SECRET", ""), cfg.PayPalEnvironment)
svc := customer.NewService(st, dc, pp, cfg)
if err := svc.SeedPortalSettings(ctx); err != nil {
log.Fatal(err)
}
checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second)
if err := svc.CheckGameControlPlane(checkCtx); err != nil {
log.Printf("WARNING: Hosted-Code/Reward-Control-Plane nicht bereit: %v", err)

View File

@@ -10,6 +10,9 @@ services:
environment:
SQLITE_PATH: /data/neuralhunt.db
ARTIFACT_DIR: /data/artifacts
# Durable positive-tip credit outbox is delivered to the Customer Service
# only when the hosted profile is running.
CUSTOMER_SERVICE_INTERNAL_URL: http://customer-service:8092
ports:
- "8080:8080"
# Main-game admin/control plane. Route this only through VPN/private proxy.

View File

@@ -0,0 +1,328 @@
package customer
import (
"context"
"crypto/subtle"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
settingLoginEnabled = "customer_login_enabled"
settingRegistrationEnabled = "customer_registration_enabled"
settingRegistrationPOWBits = "customer_registration_pow_bits"
settingInviteRequired = "customer_registration_invite_required"
settingSignupBonusMicros = "customer_signup_bonus_micros"
settingPositiveTipBonusMicro = "customer_positive_tip_bonus_micros"
)
type PortalSettings struct {
LoginEnabled bool `json:"login_enabled"`
RegistrationEnabled bool `json:"registration_enabled"`
RegistrationPOWBits int `json:"registration_pow_bits"`
InviteRequired bool `json:"invite_required"`
SignupBonusMicros int64 `json:"signup_bonus_micros"`
PositiveTipBonusMicros int64 `json:"positive_tip_bonus_micros"`
ActiveRegistrationInvites int `json:"active_registration_invites"`
}
func boolSetting(v string, fallback bool) bool {
v = strings.ToLower(strings.TrimSpace(v))
if v == "" {
return fallback
}
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func intSetting(v string, fallback int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return fallback
}
return n
}
func int64Setting(v string, fallback int64) int64 {
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return fallback
}
return n
}
// SeedPortalSettings copies environment defaults into SQLite only once. After
// that the private admin UI owns the live values across restarts.
func (s *Service) SeedPortalSettings(ctx context.Context) error {
defaults := map[string]string{
settingLoginEnabled: strconv.FormatBool(s.cfg.LoginEnabled),
settingRegistrationEnabled: strconv.FormatBool(s.cfg.RegistrationEnabled),
settingRegistrationPOWBits: strconv.Itoa(s.cfg.RegistrationPOWBits),
settingInviteRequired: strconv.FormatBool(s.cfg.RegistrationInviteRequired),
settingSignupBonusMicros: strconv.FormatInt(s.cfg.NewCustomerCreditsMicros, 10),
settingPositiveTipBonusMicro: strconv.FormatInt(s.cfg.PositiveTipCreditsMicros, 10),
}
for k, v := range defaults {
if err := s.store.SeedSetting(ctx, k, v); err != nil {
return err
}
}
return nil
}
func (s *Service) portalSettings(ctx context.Context) PortalSettings {
pow := intSetting(s.store.Setting(ctx, settingRegistrationPOWBits, strconv.Itoa(s.cfg.RegistrationPOWBits)), s.cfg.RegistrationPOWBits)
if pow < 0 {
pow = 0
}
if pow > 24 {
pow = 24
}
signup := int64Setting(s.store.Setting(ctx, settingSignupBonusMicros, strconv.FormatInt(s.cfg.NewCustomerCreditsMicros, 10)), s.cfg.NewCustomerCreditsMicros)
positive := int64Setting(s.store.Setting(ctx, settingPositiveTipBonusMicro, strconv.FormatInt(s.cfg.PositiveTipCreditsMicros, 10)), s.cfg.PositiveTipCreditsMicros)
if signup < 0 {
signup = 0
}
if positive < 0 {
positive = 0
}
return PortalSettings{
LoginEnabled: boolSetting(s.store.Setting(ctx, settingLoginEnabled, strconv.FormatBool(s.cfg.LoginEnabled)), s.cfg.LoginEnabled),
RegistrationEnabled: boolSetting(s.store.Setting(ctx, settingRegistrationEnabled, strconv.FormatBool(s.cfg.RegistrationEnabled)), s.cfg.RegistrationEnabled),
RegistrationPOWBits: pow,
InviteRequired: boolSetting(s.store.Setting(ctx, settingInviteRequired, strconv.FormatBool(s.cfg.RegistrationInviteRequired)), s.cfg.RegistrationInviteRequired),
SignupBonusMicros: signup,
PositiveTipBonusMicros: positive,
ActiveRegistrationInvites: s.store.ActiveInviteCount(ctx),
}
}
func (s *Service) registrationConfig(w http.ResponseWriter, r *http.Request) {
cfg := s.portalSettings(r.Context())
jsonOut(w, http.StatusOK, map[string]any{
"login_enabled": cfg.LoginEnabled,
"registration_enabled": cfg.RegistrationEnabled,
"pow_bits": cfg.RegistrationPOWBits,
"invite_required": cfg.InviteRequired,
"signup_bonus_micros": cfg.SignupBonusMicros,
})
}
func (s *Service) registrationChallenge(w http.ResponseWriter, r *http.Request) {
cfg := s.portalSettings(r.Context())
if !cfg.RegistrationEnabled {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "registration is currently disabled"})
return
}
var in struct {
Username string `json:"username"`
}
if decode(r, &in) != nil || len(strings.TrimSpace(in.Username)) < 3 || len(strings.TrimSpace(in.Username)) > 80 {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "valid username required"})
return
}
if cfg.RegistrationPOWBits <= 0 {
jsonOut(w, http.StatusOK, map[string]any{"pow_bits": 0, "challenge": ""})
return
}
jsonOut(w, http.StatusOK, map[string]any{
"pow_bits": cfg.RegistrationPOWBits,
"challenge": RegistrationChallenge(s.cfg.SharedSecret, in.Username, 300),
})
}
func (s *Service) adminSettings(w http.ResponseWriter, r *http.Request) {
jsonOut(w, http.StatusOK, s.portalSettings(r.Context()))
}
func (s *Service) adminUpdateSettings(w http.ResponseWriter, r *http.Request) {
var in struct {
LoginEnabled bool `json:"login_enabled"`
RegistrationEnabled bool `json:"registration_enabled"`
RegistrationPOWBits int `json:"registration_pow_bits"`
InviteRequired bool `json:"invite_required"`
SignupBonusCredits float64 `json:"signup_bonus_credits"`
PositiveTipBonusCredit float64 `json:"positive_tip_bonus_credits"`
}
if decode(r, &in) != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "bad json"})
return
}
if in.RegistrationPOWBits < 0 || in.RegistrationPOWBits > 24 {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "registration_pow_bits must be 0..24"})
return
}
if in.SignupBonusCredits < 0 || in.SignupBonusCredits > 1_000_000 || in.PositiveTipBonusCredit < 0 || in.PositiveTipBonusCredit > 1_000_000 {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "credit rewards must be between 0 and 1000000"})
return
}
vals := map[string]string{
settingLoginEnabled: strconv.FormatBool(in.LoginEnabled),
settingRegistrationEnabled: strconv.FormatBool(in.RegistrationEnabled),
settingRegistrationPOWBits: strconv.Itoa(in.RegistrationPOWBits),
settingInviteRequired: strconv.FormatBool(in.InviteRequired),
settingSignupBonusMicros: strconv.FormatInt(int64(in.SignupBonusCredits*1_000_000+0.5), 10),
settingPositiveTipBonusMicro: strconv.FormatInt(int64(in.PositiveTipBonusCredit*1_000_000+0.5), 10),
}
for k, v := range vals {
if err := s.store.SetSetting(r.Context(), k, v); err != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
}
jsonOut(w, http.StatusOK, s.portalSettings(r.Context()))
}
func (s *Service) stopAllCustomerWorkers(ctx context.Context, cid string) []string {
workers, err := s.store.Workers(ctx, cid)
if err != nil {
return []string{err.Error()}
}
var warnings []string
for _, wk := range workers {
if wk.Status == "stopped" && wk.ContainerID == "" {
continue
}
if err := s.stopWorkerInternal(ctx, wk, "stopped"); err != nil {
warnings = append(warnings, wk.ID+": "+err.Error())
}
}
return warnings
}
func (s *Service) adminBlockCustomer(w http.ResponseWriter, r *http.Request) {
cid := chi.URLParam(r, "id")
var in struct {
Reason string `json:"reason"`
}
_ = decode(r, &in)
if _, err := s.store.CustomerByID(r.Context(), cid); err != nil {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "customer not found"})
return
}
if err := s.store.SetCustomerBlocked(r.Context(), cid, true, in.Reason); err != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
_ = s.store.DeleteCustomerSessions(r.Context(), cid)
warnings := s.stopAllCustomerWorkers(r.Context(), cid)
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "warnings": warnings})
}
func (s *Service) adminUnblockCustomer(w http.ResponseWriter, r *http.Request) {
cid := chi.URLParam(r, "id")
if err := s.store.SetCustomerBlocked(r.Context(), cid, false, ""); err != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Service) adminStopCustomerWorkers(w http.ResponseWriter, r *http.Request) {
cid := chi.URLParam(r, "id")
if _, err := s.store.CustomerByID(r.Context(), cid); err != nil {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "customer not found"})
return
}
warnings := s.stopAllCustomerWorkers(r.Context(), cid)
jsonOut(w, http.StatusOK, map[string]any{"ok": len(warnings) == 0, "warnings": warnings})
}
func (s *Service) adminStopAnyWorker(w http.ResponseWriter, r *http.Request) {
wk, err := s.store.WorkerByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "worker not found"})
return
}
if err := s.stopWorkerInternal(r.Context(), wk, "stopped"); err != nil {
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Service) adminCreateInvite(w http.ResponseWriter, r *http.Request) {
var in struct {
Label string `json:"label"`
Hours int `json:"hours"`
}
if decode(r, &in) != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "bad json"})
return
}
if in.Hours <= 0 {
in.Hours = 24
}
if in.Hours > 24*30 {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "invite lifetime must be <= 720 hours"})
return
}
code := "nhinvite_" + RandomToken(24)
expires := time.Now().UTC().Add(time.Duration(in.Hours) * time.Hour)
if err := s.store.CreateRegistrationInvite(r.Context(), HashInviteCode(code), in.Label, expires); err != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusCreated, map[string]any{"invite_code": code, "expires_at": expires})
}
func (s *Service) authenticateGameInternal(r *http.Request) bool {
provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
want := strings.TrimSpace(s.cfg.SharedSecret)
return want != "" && len(provided) == len(want) && subtle.ConstantTimeCompare([]byte(provided), []byte(want)) == 1
}
// internalGamePositiveTip is called only by the private game control-plane
// dispatcher. The amount is deliberately chosen here, not trusted from the
// game payload, and the event id is unique in the immutable credit ledger.
func (s *Service) internalGamePositiveTip(w http.ResponseWriter, r *http.Request) {
if !s.authenticateGameInternal(r) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var in struct {
EventID string `json:"event_id"`
WorkerClientID string `json:"worker_client_id"`
RewardClientID string `json:"reward_client_id"`
TaskID string `json:"task_id"`
Seq int64 `json:"seq"`
Score float64 `json:"score"`
}
if decode(r, &in) != nil || strings.TrimSpace(in.EventID) == "" || strings.TrimSpace(in.WorkerClientID) == "" {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "invalid positive-tip event"})
return
}
var cid string
if wk, err := s.store.WorkerByClientID(r.Context(), in.WorkerClientID); err == nil {
cid = wk.CustomerID
} else if strings.TrimSpace(in.RewardClientID) != "" {
if c, err := s.store.CustomerByRewardClientID(r.Context(), in.RewardClientID); err == nil {
cid = c.ID
}
}
if cid == "" {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "hosted worker/customer not found"})
return
}
customer, err := s.store.CustomerByID(r.Context(), cid)
if err != nil {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "customer not found"})
return
}
if customer.Blocked {
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "credited": false, "blocked": true, "credits_micros": 0})
return
}
amount := s.portalSettings(r.Context()).PositiveTipBonusMicros
if amount <= 0 {
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "credited": false, "credits_micros": 0})
return
}
added, err := s.store.AddLedgerOnce(r.Context(), cid, amount, "positive_tip", "positive_tip:"+in.EventID)
if err != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "credited": added, "credits_micros": amount})
}

View File

@@ -8,7 +8,9 @@ import (
"encoding/base64"
"encoding/binary"
"errors"
"strconv"
"strings"
"time"
)
var rawURL = base64.RawURLEncoding
@@ -69,3 +71,63 @@ func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
}
return out[:keyLen]
}
// RegistrationChallenge is a stateless, username-bound proof-of-work challenge.
// It prevents a solved challenge from being reused to create multiple different
// accounts while avoiding an unbounded server-side challenge store.
func RegistrationChallenge(secret, username string, ttlSeconds int) string {
if ttlSeconds <= 0 {
ttlSeconds = 300
}
nonce := RandomToken(18)
expires := time.Now().UTC().Add(time.Duration(ttlSeconds) * time.Second).Unix()
body := strings.ToLower(strings.TrimSpace(username)) + "|" + nonce + "|" + strconv.FormatInt(expires, 10)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
return nonce + "." + strconv.FormatInt(expires, 10) + "." + rawURL.EncodeToString(mac.Sum(nil))
}
func VerifyRegistrationChallenge(secret, username, challenge string) bool {
parts := strings.Split(challenge, ".")
if len(parts) != 3 {
return false
}
expires, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || expires < time.Now().UTC().Unix() || expires > time.Now().UTC().Add(10*time.Minute).Unix() {
return false
}
body := strings.ToLower(strings.TrimSpace(username)) + "|" + parts[0] + "|" + parts[1]
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
want := mac.Sum(nil)
got, err := rawURL.DecodeString(parts[2])
return err == nil && len(got) == len(want) && subtle.ConstantTimeCompare(got, want) == 1
}
func VerifyRegistrationProof(username, challenge string, counter uint64, bits int) bool {
if bits <= 0 {
return true
}
if bits > 28 {
return false
}
msg := "nh-register|" + strings.ToLower(strings.TrimSpace(username)) + "|" + challenge + "|" + strconv.FormatUint(counter, 10)
h := sha256.Sum256([]byte(msg))
full := bits / 8
rem := bits % 8
for i := 0; i < full; i++ {
if h[i] != 0 {
return false
}
}
if rem > 0 {
mask := byte(0xff << (8 - rem))
return h[full]&mask == 0
}
return true
}
func HashInviteCode(code string) string {
h := sha256.Sum256([]byte(strings.TrimSpace(code)))
return base64.RawURLEncoding.EncodeToString(h[:])
}

View File

@@ -0,0 +1,42 @@
package customer
import "testing"
func TestRegistrationChallengeAndProof(t *testing.T) {
secret := "0123456789abcdef0123456789abcdef"
user := "Alice"
challenge := RegistrationChallenge(secret, user, 60)
if !VerifyRegistrationChallenge(secret, user, challenge) {
t.Fatal("valid challenge rejected")
}
if VerifyRegistrationChallenge(secret, "Bob", challenge) {
t.Fatal("challenge must be username-bound")
}
const bits = 12
var counter uint64
for ; counter < 1<<20; counter++ {
if VerifyRegistrationProof(user, challenge, counter, bits) {
break
}
}
if counter == 1<<20 {
t.Fatal("could not find proof")
}
if !VerifyRegistrationProof(user, challenge, counter, bits) {
t.Fatal("valid proof rejected")
}
if VerifyRegistrationProof("Bob", challenge, counter, bits) {
t.Fatal("proof must be username-bound")
}
}
func TestInviteHashIsStableAndDoesNotExposeCode(t *testing.T) {
code := "nhinvite_example-secret"
h := HashInviteCode(code)
if h == "" || h == code {
t.Fatalf("unexpected invite hash %q", h)
}
if h != HashInviteCode(code) {
t.Fatal("invite hash not stable")
}
}

View File

@@ -47,6 +47,12 @@ type Config struct {
AdminCookieSecureMode string
AdminUser, AdminPassword string
AllowManualCredits bool
LoginEnabled bool
RegistrationEnabled bool
RegistrationPOWBits int
RegistrationInviteRequired bool
NewCustomerCreditsMicros int64
PositiveTipCreditsMicros int64
PayPalEnabled bool
PayPalEnvironment string
PayPalWebhookID string
@@ -210,6 +216,8 @@ func (s *Service) PublicRoutes(ui http.Handler) http.Handler {
r := chi.NewRouter()
r.Use(s.security)
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
r.Get("/api/registration/config", s.registrationConfig)
r.Post("/api/register/challenge", s.registrationChallenge)
r.Post("/api/register", s.register)
r.Post("/api/login", s.login)
r.Post("/api/logout", s.logout)
@@ -243,7 +251,14 @@ func (s *Service) AdminRoutes(ui http.Handler) http.Handler {
r.Group(func(r chi.Router) {
r.Use(s.requireAdmin)
r.Get("/api/admin/overview", s.adminOverview)
r.Get("/api/admin/settings", s.adminSettings)
r.Put("/api/admin/settings", s.adminUpdateSettings)
r.Post("/api/admin/credits/grant", s.adminCreditGrant)
r.Post("/api/admin/customers/{id}/block", s.adminBlockCustomer)
r.Post("/api/admin/customers/{id}/unblock", s.adminUnblockCustomer)
r.Post("/api/admin/customers/{id}/workers/stop", s.adminStopCustomerWorkers)
r.Post("/api/admin/workers/{id}/stop", s.adminStopAnyWorker)
r.Post("/api/admin/invites", s.adminCreateInvite)
})
r.Mount("/", ui)
return r
@@ -253,12 +268,24 @@ func (s *Service) InternalRoutes() http.Handler {
r.Use(s.security)
r.Post("/internal/workers/register", s.internalWorkerRegister)
r.Post("/internal/workers/lease", s.internalWorkerLease)
r.Post("/internal/game/positive-tip", s.internalGamePositiveTip)
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
return r
}
func (s *Service) register(w http.ResponseWriter, r *http.Request) {
var in struct{ Username, Password string }
cfg := s.portalSettings(r.Context())
if !cfg.RegistrationEnabled {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "Registrierungen sind derzeit deaktiviert."})
return
}
var in struct {
Username string `json:"Username"`
Password string `json:"Password"`
Challenge string `json:"Challenge"`
ProofCounter uint64 `json:"ProofCounter"`
InviteCode string `json:"InviteCode"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
@@ -268,13 +295,31 @@ func (s *Service) register(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": "username must be 3..80 characters"})
return
}
if cfg.RegistrationPOWBits > 0 {
if !VerifyRegistrationChallenge(s.cfg.SharedSecret, in.Username, in.Challenge) || !VerifyRegistrationProof(in.Username, in.Challenge, in.ProofCounter, cfg.RegistrationPOWBits) {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "Registrierungs-Proof-of-Work ist ungültig oder abgelaufen."})
return
}
}
inviteHash := ""
if cfg.InviteRequired {
if strings.TrimSpace(in.InviteCode) == "" {
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Für die Registrierung ist ein einmaliger Invite-Code erforderlich."})
return
}
inviteHash = HashInviteCode(in.InviteCode)
}
salt, hash, err := NewPasswordHash(in.Password)
if err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
cid := "cust_" + RandomToken(16)
if err := s.store.CreateCustomer(r.Context(), cid, in.Username, salt, hash); err != nil {
if err := s.store.CreateCustomer(r.Context(), cid, in.Username, salt, hash, cfg.SignupBonusMicros, inviteHash); err != nil {
if errors.Is(err, ErrInvalidInvite) {
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Invite-Code ist ungültig, abgelaufen oder bereits verwendet."})
return
}
jsonOut(w, 409, map[string]string{"error": "username already exists"})
return
}
@@ -284,9 +329,14 @@ func (s *Service) register(w http.ResponseWriter, r *http.Request) {
return
}
s.setCookie(w, r, customerCookie, sid, s.cfg.SessionTTL)
jsonOut(w, 201, map[string]string{"id": cid, "username": in.Username})
jsonOut(w, 201, map[string]any{"id": cid, "username": in.Username, "signup_bonus_micros": cfg.SignupBonusMicros})
}
func (s *Service) login(w http.ResponseWriter, r *http.Request) {
if !s.portalSettings(r.Context()).LoginEnabled {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "Anmeldung ist momentan administrativ deaktiviert."})
return
}
var in struct{ Username, Password string }
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
@@ -298,6 +348,10 @@ func (s *Service) login(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
return
}
if c.Blocked {
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Dieses Kundenkonto ist gesperrt."})
return
}
sid := RandomToken(32)
if err := s.store.CreateSession(r.Context(), sid, c.ID, s.cfg.SessionTTL); err != nil {
jsonOut(w, 500, map[string]string{"error": "session failed"})
@@ -324,7 +378,7 @@ func (s *Service) me(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]any{"customer": c, "balance_micros": bal, "worker_rate_micros_per_minute": s.cfg.WorkerRateMicrosPerMinute, "paypal_enabled": s.paypalAllowed()})
}
func (s *Service) ledger(w http.ResponseWriter, r *http.Request) {
x, err := s.store.Ledger(r.Context(), customerID(r), 100)
x, err := s.store.LedgerSummary(r.Context(), customerID(r), 60)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
@@ -689,12 +743,18 @@ func (s *Service) stopWorker(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) stopWorkerInternal(ctx context.Context, wk Worker, status string) error {
// Revoke the lease in SQLite first. Even if Docker itself is temporarily
// unavailable, the worker's next lease renewal will fail and its agent will
// self-terminate instead of continuing with a blocked/stopped account.
if err := s.store.SetWorkerRuntime(ctx, wk.ID, status, wk.ContainerID, ""); err != nil {
return err
}
if wk.ContainerID != "" {
if err := s.docker.Stop(ctx, wk.ContainerID, 5); err != nil && !strings.Contains(err.Error(), "304") {
return err
}
}
return s.store.SetWorkerRuntime(ctx, wk.ID, status, wk.ContainerID, "")
return nil
}
func (s *Service) deleteWorker(w http.ResponseWriter, r *http.Request) {
cid, wid := customerID(r), chi.URLParam(r, "id")
@@ -798,6 +858,10 @@ func (s *Service) internalWorkerLease(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 409, map[string]string{"error": "worker lease revoked"})
return
}
if cust, err := s.store.CustomerByID(r.Context(), wk.CustomerID); err != nil || cust.Blocked {
jsonOut(w, 409, map[string]string{"error": "worker lease revoked"})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "lease_sec": 45})
}
@@ -988,6 +1052,11 @@ func (s *Service) billOnce(ctx context.Context) {
}
now := time.Now().UTC()
for _, wk := range workers {
if cust, err := s.store.CustomerByID(ctx, wk.CustomerID); err == nil && cust.Blocked {
_ = s.stopWorkerInternal(ctx, wk, "stopped")
log.Printf("billing worker %s stopped: customer account blocked", wk.ID)
continue
}
if wk.ContainerID == "" {
continue
}
@@ -1020,7 +1089,12 @@ func (s *Service) billOnce(ctx context.Context) {
}
func (s *Service) billingPackages(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]any{"paypal_enabled": s.paypalAllowed(), "environment": s.cfg.PayPalEnvironment, "packages": s.cfg.Packages})
enabled := s.paypalAllowed()
packages := s.cfg.Packages
if !enabled {
packages = nil
}
jsonOut(w, 200, map[string]any{"paypal_enabled": enabled, "environment": s.cfg.PayPalEnvironment, "packages": packages})
}
func (s *Service) paypalAllowed() bool {
if !s.cfg.PayPalEnabled || s.paypal == nil || !s.paypal.Ready() {
@@ -1274,7 +1348,7 @@ func (s *Service) adminLogout(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.username,c.reward_client_id,c.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0),(SELECT count(*) FROM workers w WHERE w.customer_id=c.id),(SELECT count(*) FROM workers w WHERE w.customer_id=c.id AND w.status='running') FROM customers c ORDER BY c.created_at DESC LIMIT 500`)
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.username,c.reward_client_id,c.blocked,c.blocked_reason,c.blocked_at,c.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0) FROM customers c ORDER BY c.created_at DESC LIMIT 500`)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
@@ -1282,16 +1356,39 @@ func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
var out []map[string]any
for rows.Next() {
var id, user, reward string
var id, user, reward, blockedReason string
var blocked int
var blockedAt sql.NullInt64
var created, bal int64
var workers, running int
if err := rows.Scan(&id, &user, &reward, &created, &bal, &workers, &running); err != nil {
if err := rows.Scan(&id, &user, &reward, &blocked, &blockedReason, &blockedAt, &created, &bal); err != nil {
continue
}
out = append(out, map[string]any{"id": id, "username": user, "reward_client_id": reward, "created_at": time.UnixMilli(created).UTC(), "balance_micros": bal, "workers": workers, "running": running})
workers, _ := s.store.Workers(r.Context(), id)
running := 0
for _, wk := range workers {
if wk.Status == "running" {
running++
}
}
item := map[string]any{
"id": id, "username": user, "reward_client_id": reward,
"blocked": blocked != 0, "blocked_reason": blockedReason,
"created_at": time.UnixMilli(created).UTC(), "balance_micros": bal,
"workers": len(workers), "running": running, "worker_items": workers,
}
if blockedAt.Valid {
item["blocked_at"] = time.UnixMilli(blockedAt.Int64).UTC()
}
out = append(out, item)
}
jsonOut(w, 200, map[string]any{"manual_credits_enabled": s.cfg.AllowManualCredits, "customers": out})
jsonOut(w, 200, map[string]any{
"manual_credits_enabled": s.cfg.AllowManualCredits,
"paypal_enabled": s.paypalAllowed(),
"settings": s.portalSettings(r.Context()),
"customers": out,
})
}
func (s *Service) adminCreditGrant(w http.ResponseWriter, r *http.Request) {
if !s.cfg.AllowManualCredits {
jsonOut(w, 403, map[string]string{"error": "manual credit bypass disabled; set CS_ALLOW_MANUAL_CREDITS=1 on the private admin service"})

View File

@@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customers(
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
reward_client_id TEXT NOT NULL DEFAULT '',
blocked INTEGER NOT NULL DEFAULT 0,
blocked_reason TEXT NOT NULL DEFAULT '',
blocked_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
@@ -71,6 +74,20 @@ CREATE TABLE IF NOT EXISTS paypal_orders(
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS paypal_orders_customer_idx ON paypal_orders(customer_id,created_at DESC);
CREATE TABLE IF NOT EXISTS customer_settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS registration_invites(
token_hash TEXT PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL,
used_by TEXT REFERENCES customers(id) ON DELETE SET NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS registration_invites_exp_idx ON registration_invites(expires_at,used_at);
`
type Store struct{ DB *sql.DB }
@@ -116,14 +133,51 @@ func Open(ctx context.Context, path string) (*Store, error) {
return nil, fmt.Errorf("customer schema %d: %w", i+1, err)
}
}
// Existing V4 customer databases need explicit column evolution because
// CREATE TABLE IF NOT EXISTS does not alter an already-created table.
for _, m := range []struct{ name, def string }{
{"blocked", "INTEGER NOT NULL DEFAULT 0"},
{"blocked_reason", "TEXT NOT NULL DEFAULT ''"},
{"blocked_at", "INTEGER"},
} {
if err := ensureCustomerColumn(ctx, db, "customers", m.name, m.def); err != nil {
db.Close()
return nil, fmt.Errorf("customer add customers.%s: %w", m.name, err)
}
}
return &Store{DB: db}, nil
}
func ensureCustomerColumn(ctx context.Context, db *sql.DB, table, name, def string) error {
rows, err := db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var col, typ string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &col, &typ, &notnull, &dflt, &pk); err != nil {
return err
}
if strings.EqualFold(col, name) {
return nil
}
}
_, err = db.ExecContext(ctx, `ALTER TABLE `+table+` ADD COLUMN `+name+` `+def)
return err
}
type Customer struct {
ID string `json:"id"`
Username string `json:"username"`
RewardClientID string `json:"reward_client_id"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Username string `json:"username"`
RewardClientID string `json:"reward_client_id"`
Blocked bool `json:"blocked"`
BlockedReason string `json:"blocked_reason,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Worker struct {
@@ -143,31 +197,87 @@ type Worker struct {
LastError string `json:"last_error,omitempty"`
}
func (s *Store) CreateCustomer(ctx context.Context, id, username, salt, hash string) error {
var ErrInvalidInvite = errors.New("registration invite invalid, expired or already used")
func scanCustomer(row interface{ Scan(...any) error }, withPassword bool) (Customer, string, string, error) {
var c Customer
var blocked int
var blockedAt sql.NullInt64
var created int64
var salt, hash string
args := []any{&c.ID, &c.Username, &c.RewardClientID, &blocked, &c.BlockedReason, &blockedAt, &created}
if withPassword {
args = append(args, &salt, &hash)
}
err := row.Scan(args...)
c.Blocked = blocked != 0
c.CreatedAt = time.UnixMilli(created).UTC()
if blockedAt.Valid {
v := time.UnixMilli(blockedAt.Int64).UTC()
c.BlockedAt = &v
}
return c, salt, hash, err
}
// CreateCustomer atomically creates the account, consumes an optional one-shot
// invite and grants the configured signup credits. A failed invite therefore
// cannot leave behind a partially-created account or free ledger entry.
func (s *Store) CreateCustomer(ctx context.Context, id, username, salt, hash string, signupBonusMicros int64, inviteHash string) error {
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
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
if _, err := tx.ExecContext(ctx, `INSERT INTO customers(id,username,password_salt,password_hash,created_at,updated_at) VALUES(?,?,?,?,?,?)`, id, strings.TrimSpace(username), salt, hash, now, now); err != nil {
return err
}
if strings.TrimSpace(inviteHash) != "" {
res, err := tx.ExecContext(ctx, `UPDATE registration_invites SET used_by=?,used_at=? WHERE token_hash=? AND used_by IS NULL AND expires_at>?`, id, now, inviteHash, now)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n != 1 {
return ErrInvalidInvite
}
}
if signupBonusMicros > 0 {
if _, err := tx.ExecContext(ctx, `INSERT INTO credit_ledger(customer_id,delta_micros,reason,reference,created_at) VALUES(?,?,?,?,?)`, id, signupBonusMicros, "signup_bonus", "signup:"+id, now); err != nil {
return err
}
}
return tx.Commit()
}
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
return scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at,password_salt,password_hash FROM customers WHERE username=?`, strings.TrimSpace(username)), true)
}
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()
c, _, _, err := scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at FROM customers WHERE id=?`, id), false)
return c, err
}
func (s *Store) CustomerByRewardClientID(ctx context.Context, rewardClientID string) (Customer, error) {
c, _, _, err := scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at FROM customers WHERE reward_client_id=? ORDER BY created_at LIMIT 1`, strings.TrimSpace(rewardClientID)), false)
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) SetCustomerBlocked(ctx context.Context, id string, blocked bool, reason string) error {
now := time.Now().UTC().UnixMilli()
if blocked {
_, err := s.DB.ExecContext(ctx, `UPDATE customers SET blocked=1,blocked_reason=?,blocked_at=?,updated_at=? WHERE id=?`, strings.TrimSpace(reason), now, now, id)
return err
}
_, err := s.DB.ExecContext(ctx, `UPDATE customers SET blocked=0,blocked_reason='',blocked_at=NULL,updated_at=? WHERE id=?`, now, id)
return err
}
func (s *Store) DeleteCustomerSessions(ctx context.Context, cid string) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM customer_sessions WHERE customer_id=?`, cid)
return err
}
func (s *Store) CreateSession(ctx context.Context, sid, cid string, ttl time.Duration) error {
now := time.Now().UTC()
@@ -176,7 +286,7 @@ func (s *Store) CreateSession(ctx context.Context, sid, cid string, ttl time.Dur
}
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)
err := s.DB.QueryRowContext(ctx, `SELECT s.customer_id FROM customer_sessions s JOIN customers c ON c.id=s.customer_id WHERE s.id=? AND s.expires_at>? AND c.blocked=0`, sid, time.Now().UTC().UnixMilli()).Scan(&cid)
return cid, err
}
func (s *Store) DeleteSession(ctx context.Context, sid string) {
@@ -228,6 +338,84 @@ func (s *Store) Ledger(ctx context.Context, cid string, limit int) ([]LedgerItem
return out, rows.Err()
}
type LedgerSummaryItem struct {
DeltaMicros int64 `json:"delta_micros"`
Reason string `json:"reason"`
Count int `json:"count"`
CreatedAt time.Time `json:"created_at"`
Day string `json:"day"`
}
// LedgerSummary intentionally collapses high-frequency worker-minute and
// positive-tip entries into daily reason buckets. The raw ledger stays intact
// in SQLite for auditing and idempotency.
func (s *Store) LedgerSummary(ctx context.Context, cid string, limit int) ([]LedgerSummaryItem, error) {
if limit < 1 || limit > 120 {
limit = 45
}
rows, err := s.DB.QueryContext(ctx, `SELECT reason,COALESCE(sum(delta_micros),0),count(*),max(created_at),strftime('%Y-%m-%d',max(created_at)/1000,'unixepoch')
FROM credit_ledger WHERE customer_id=?
GROUP BY reason,strftime('%Y-%m-%d',created_at/1000,'unixepoch')
ORDER BY max(created_at) DESC LIMIT ?`, cid, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LedgerSummaryItem
for rows.Next() {
var x LedgerSummaryItem
var ms int64
if err := rows.Scan(&x.Reason, &x.DeltaMicros, &x.Count, &ms, &x.Day); err != nil {
return nil, err
}
x.CreatedAt = time.UnixMilli(ms).UTC()
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) AddLedgerOnce(ctx context.Context, cid string, delta int64, reason, ref string) (bool, error) {
if delta == 0 {
return false, nil
}
res, err := s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO credit_ledger(customer_id,delta_micros,reason,reference,created_at) VALUES(?,?,?,?,?)`, cid, delta, reason, ref, time.Now().UTC().UnixMilli())
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n == 1, err
}
func (s *Store) SeedSetting(ctx context.Context, key, value string) error {
_, err := s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO customer_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, time.Now().UTC().UnixMilli())
return err
}
func (s *Store) Setting(ctx context.Context, key, fallback string) string {
var v string
if err := s.DB.QueryRowContext(ctx, `SELECT value FROM customer_settings WHERE key=?`, key).Scan(&v); err != nil {
return fallback
}
return v
}
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
_, err := s.DB.ExecContext(ctx, `INSERT INTO customer_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, key, value, time.Now().UTC().UnixMilli())
return err
}
func (s *Store) CreateRegistrationInvite(ctx context.Context, tokenHash, label string, expiresAt time.Time) error {
_, err := s.DB.ExecContext(ctx, `INSERT INTO registration_invites(token_hash,label,expires_at,created_at) VALUES(?,?,?,?)`, tokenHash, strings.TrimSpace(label), expiresAt.UTC().UnixMilli(), time.Now().UTC().UnixMilli())
return err
}
func (s *Store) ActiveInviteCount(ctx context.Context) int {
var n int
_ = s.DB.QueryRowContext(ctx, `SELECT count(*) FROM registration_invites WHERE used_by IS NULL AND expires_at>?`, time.Now().UTC().UnixMilli()).Scan(&n)
return n
}
func (s *Store) WorkerByClientID(ctx context.Context, clientID string) (Worker, error) {
return scanWorker(s.DB.QueryRowContext(ctx, `SELECT `+workerCols+` FROM workers WHERE worker_client_id=? ORDER BY created_at DESC LIMIT 1`, strings.TrimSpace(clientID)))
}
func scanWorker(row interface{ Scan(...any) error }) (Worker, error) {
var w Worker
var last sql.NullInt64

View File

@@ -1 +1,13 @@
const $=id=>document.getElementById(id);let manual=false;function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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(()=>{});
const $=id=>document.getElementById(id);let manual=false,last=null;
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function msg(t,e=false){$('msg').textContent=t;$('msg').className='msg'+(e?' err':'');$('msg').classList.remove('hidden');setTimeout(()=>$('msg').classList.add('hidden'),7000)}
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){const e=new Error(x.error||`HTTP ${r.status}`);e.status=r.status;throw e}return x}
const cr=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});
function settingsToUI(s){$('loginEnabled').checked=!!s.login_enabled;$('registrationEnabled').checked=!!s.registration_enabled;$('inviteRequired').checked=!!s.invite_required;$('powBits').value=s.registration_pow_bits??0;$('signupBonus').value=Number(s.signup_bonus_micros||0)/1e6;$('positiveBonus').value=Number(s.positive_tip_bonus_micros||0)/1e6;$('inviteCount').textContent=s.active_registration_invites??0}
function settingsPayload(){return{login_enabled:$('loginEnabled').checked,registration_enabled:$('registrationEnabled').checked,invite_required:$('inviteRequired').checked,registration_pow_bits:Number($('powBits').value||0),signup_bonus_credits:Number($('signupBonus').value||0),positive_tip_bonus_credits:Number($('positiveBonus').value||0)}}
async function saveSettings(){try{const x=await api('/api/admin/settings',{method:'PUT',body:settingsPayload()});settingsToUI(x);msg('Portal-Einstellungen gespeichert')}catch(e){msg(e.message,true)}}
function workerRows(c){if(!c.worker_items?.length)return'<div class="small">Keine Worker.</div>';return `<div class="worker-list">${c.worker_items.map(w=>`<div class="worker-row"><div><strong>${esc(w.id)}</strong><div class="small">${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}</div></div><span class="pill ${w.status==='running'?'on':''}">${esc(w.status)}</span><button class="ghost stopWorker" data-wid="${esc(w.id)}" ${w.status==='stopped'?'disabled':''}>STOP</button></div>`).join('')}</div>`}
function customerHTML(c){const status=c.blocked?`<span class="pill danger">GESPERRT</span>`:'<span class="pill on">AKTIV</span>';return `<article class="customer ${c.blocked?'blocked':''}" data-id="${esc(c.id)}"><div class="customer-head"><div><h3>${esc(c.username)} ${status}</h3><div class="small">${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}</div>${c.blocked_reason?`<div class="small danger-text">Grund: ${esc(c.blocked_reason)}</div>`:''}</div><div class="customer-stats"><strong>${cr(c.balance_micros)} Credits</strong><span>${c.running}/${c.workers} Worker aktiv</span></div></div><div class="actions">${c.blocked?'<button class="unblock">FREIGEBEN</button>':'<button class="danger block">BENUTZER SPERREN</button>'}<button class="ghost stopAll" ${c.running?'' :'disabled'}>ALLE WORKER STOPPEN</button>${manual?'<input class="amount" type="number" min="0.001" step="1" placeholder="Credits"><button class="grantBtn">+ CREDIT</button>':''}</div>${workerRows(c)}</article>`}
function bindCustomerActions(){document.querySelectorAll('.block').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const reason=prompt('Optionaler Sperrgrund:','');if(reason===null)return;if(!confirm('Benutzer sperren, Sessions invalidieren und alle Worker stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/block`,{method:'POST',body:{reason}});msg(x.warnings?.length?`Benutzer gesperrt · Warnungen: ${x.warnings.join(' · ')}`:'Benutzer gesperrt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.unblock').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/unblock`,{method:'POST'});msg('Benutzer freigegeben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopAll').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');if(!confirm('Alle Worker dieses Benutzers stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers/stop`,{method:'POST'});msg(x.warnings?.length?x.warnings.join(' · '):'Worker gestoppt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopWorker').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/stop`,{method:'POST'});msg('Worker gestoppt');await load()}catch(e){msg(e.message,true)}});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} Credits gebucht`);await load()}catch(e){msg(e.message,true)}})}
async function load(){const x=await api('/api/admin/overview');last=x;manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER CREDIT-BYPASS IST AKTIV. Jede Buchung bleibt im Ledger nachvollziehbar.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';settingsToUI(x.settings||{});$('customers').innerHTML=(x.customers||[]).map(customerHTML).join('')||'<div class="notice">Noch keine Benutzer.</div>';bindCustomerActions()}
$('saveSettings').onclick=saveSettings;$('saveRewards').onclick=saveSettings;$('createInvite').onclick=async()=>{try{const x=await api('/api/admin/invites',{method:'POST',body:{label:$('inviteLabel').value,hours:Number($('inviteHours').value||24)}});$('inviteOut').textContent=x.invite_code;$('inviteOut').classList.remove('hidden');msg('Invite erzeugt · jetzt sicher an genau einen Benutzer weitergeben');await load()}catch(e){msg(e.message,true)}};$('refresh').onclick=()=>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(()=>{});

View File

@@ -1,2 +1,13 @@
<!doctype html><html lang="de"><head>
<link rel="icon" href="data:,"><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>
<link rel="icon" href="data:,"><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" autocomplete="username"></label><label>Passwort<input id="pass" type="password" autocomplete="current-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 class="admin-grid">
<article><h2>Portal-Steuerung</h2><label class="toggle"><input id="loginEnabled" type="checkbox"> Benutzer-Anmeldung erlauben</label><label class="toggle"><input id="registrationEnabled" type="checkbox"> Neue Registrierungen erlauben</label><label class="toggle"><input id="inviteRequired" type="checkbox"> Registrierung nur mit einmaligem Invite-Code</label><label>Anti-Bot Proof-of-Work · Bit<input id="powBits" type="number" min="0" max="24" step="1"></label><p class="small">0 deaktiviert PoW. Für offene Registrierung sind 1620 Bit ein sinnvoller Start; Reverse-Proxy-Limits bleiben zusätzlich empfehlenswert.</p><button id="saveSettings">EINSTELLUNGEN SPEICHERN</button></article>
<article><h2>Credit-Rewards</h2><label>Startguthaben pro neuem Konto · Credits<input id="signupBonus" type="number" min="0" step="0.001"></label><label>Reward pro positivem Worker-Tipp · Credits<input id="positiveBonus" type="number" min="0" step="0.001"></label><p class="small">Positiver Tipp = der signierte Tipp verbessert den persönlichen Best-Score dieses Hosted Workers. Normale Browser-/CLI-Spieler erhalten darüber keine Customer-Credits.</p><button id="saveRewards">REWARDS SPEICHERN</button></article>
<article><h2>Invite-Codes</h2><p class="small">Optionaler Schutz gegen Account-Farming. Jeder Code ist einmalig und wird nur hier bei der Erstellung im Klartext gezeigt.</p><label>Bezeichnung<input id="inviteLabel" placeholder="z. B. Beta Tester"></label><label>Gültigkeit · Stunden<input id="inviteHours" type="number" min="1" max="720" value="24"></label><button id="createInvite">INVITE ERZEUGEN</button><div id="inviteOut" class="invite hidden"></div><div class="small">Aktive, unbenutzte Invites: <strong id="inviteCount">0</strong></div></article>
</div>
<div id="manual" class="notice"></div>
<div class="section-title"><h2>Benutzer</h2><button id="refresh" class="ghost">AKTUALISIEREN</button></div><div id="customers"></div>
</section></main><script src="/app.js" defer></script></body></html>

View File

@@ -1 +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}}
:root{color-scheme:dark;--bg:#080b10;--card:#111821;--line:#293746;--text:#edf4fb;--muted:#8fa1b2;--accent:#54f0a6;--danger:#ff6978;--warn:#ffca56}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,monospace}main{max-width:1200px;margin:auto;padding:34px 20px}.eyebrow{color:var(--accent);letter-spacing:.2em}h1{font:700 38px system-ui}h2,h3{font-family:system-ui;margin:0 0 10px}section,.customer,article{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}label.toggle{display:flex;align-items:center;gap:9px;color:var(--text)}label.toggle input{width:auto;margin: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);color:#06120c;border:0;border-radius:8px;padding:9px 12px;font-weight:900;cursor:pointer}button:disabled{opacity:.4;cursor:not-allowed}.ghost{background:#1a2530;color:var(--text);border:1px solid var(--line)}button.danger{background:#4b1d24;color:#ffd7dc;border:1px solid #79343f}.hidden{display:none!important}.head,.section-title,.customer-head,.actions,.worker-row{display:flex;justify-content:space-between;gap:12px;align-items:center}.admin-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.admin-grid article{margin:0}.small{font-size:12px;color:var(--muted);word-break:break-all}.notice,.msg,.invite{padding:10px;border-radius:8px;background:#18261f;margin:10px 0}.msg.err{background:#32171d}.invite{border:1px solid var(--accent);font-weight:800;word-break:break-all;user-select:all}.customer{margin:12px 0}.customer.blocked{border-color:#6b333b}.customer h3{display:flex;align-items:center;gap:8px}.customer-stats{display:flex;flex-direction:column;align-items:flex-end;color:var(--muted)}.actions{justify-content:flex-start;flex-wrap:wrap;margin:14px 0}.actions input{width:130px;margin:0}.worker-list{display:grid;gap:6px;border-top:1px solid var(--line);padding-top:10px}.worker-row{background:#0b1118;border-radius:8px;padding:8px 10px}.pill{font-size:11px;padding:3px 7px;border-radius:99px;background:#222d38;color:var(--muted)}.pill.on{background:#123022;color:var(--accent)}.pill.danger{background:#3c171d;color:#ff9ba6}.danger-text{color:#ff9ba6}@media(max-width:900px){.admin-grid{grid-template-columns:1fr}.customer-head,.head,.section-title{align-items:flex-start;flex-direction:column}.customer-stats{align-items:flex-start}.worker-row{align-items:flex-start;flex-wrap:wrap}}

View File

@@ -1,17 +1,23 @@
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)}
const $=id=>document.getElementById(id);let state={me:null,tasks:[],workers:[],registration:null};
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'),6500)}
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){const e=new Error(b?.error||b||`HTTP ${r.status}`);e.status=r.status;e.path=path;throw e}return b}
const credits=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});
function showAuth(){state.me=null;$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden')}
function showPortal(me){$('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);$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt'}
function showAuth(){state.me=null;$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden');loadRegistrationConfig().catch(()=>{})}
function showPortal(me){$('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);$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt';$('paypalSection').classList.toggle('hidden',!me.paypal_enabled);$('topGrid').classList.toggle('single',!me.paypal_enabled)}
async function loadRegistrationConfig(){const c=await api('/api/registration/config');state.registration=c;$('login').disabled=!c.login_enabled;$('loginNote').textContent=c.login_enabled?'':'Anmeldungen sind derzeit administrativ deaktiviert.';$('register').disabled=!c.registration_enabled;$('regInviteRow').classList.toggle('hidden',!c.invite_required);const bonus=credits(c.signup_bonus_micros);let note=c.registration_enabled?(c.signup_bonus_micros>0?`Neukonten erhalten ${bonus} Start-Credits.`:'Registrierung ist geöffnet.'):'Registrierungen sind derzeit deaktiviert.';if(c.registration_enabled&&c.pow_bits>0)note+=` Anti-Bot Proof-of-Work: ${c.pow_bits} Bit.`;if(c.invite_required)note+=' Ein einmaliger Invite-Code ist erforderlich.';$('registrationNote').textContent=note}
async function boot(){try{await load()}catch(e){if(e.status===401)showAuth();else{showAuth();msg(`Portal konnte nicht geladen werden: ${e.message}`,true)}}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;showPortal(me);const reqs=[['tasks','/api/tasks'],['workers','/api/workers'],['ledger','/api/ledger'],['packages','/api/billing/packages']];const rs=await Promise.allSettled(reqs.map(([,path])=>api(path)));const errors=[];let tasks=[],workers=[],ledger=[],packages={paypal_enabled:false,packages:[]};rs.forEach((r,i)=>{const [name,path]=reqs[i];if(r.status==='fulfilled'){if(name==='tasks')tasks=r.value;if(name==='workers')workers=r.value;if(name==='ledger')ledger=r.value;if(name==='packages')packages=r.value}else{errors.push(`${path}: ${r.reason?.message||'Fehler'}`)}});state.tasks=Array.isArray(tasks)?tasks:[];state.workers=Array.isArray(workers)?workers:[];$('workerCount').textContent=state.workers.length;$('runningCount').textContent=`${state.workers.filter(x=>x.status==='running').length} aktiv`;renderTasks();renderWorkers();renderLedger(Array.isArray(ledger)?ledger:[]);renderPackages(packages||{paypal_enabled:false,packages:[]});if(errors.length)msg(`Session ist aktiv, aber Teile des Portals sind nicht erreichbar: ${errors.join(' · ')}`,true)}
async function load(){const me=await api('/api/me');state.me=me;showPortal(me);const reqs=[['tasks','/api/tasks'],['workers','/api/workers'],['ledger','/api/ledger']];if(me.paypal_enabled)reqs.push(['packages','/api/billing/packages']);const rs=await Promise.allSettled(reqs.map(([,path])=>api(path)));const errors=[];let tasks=[],workers=[],ledger=[],packages={paypal_enabled:false,packages:[]};rs.forEach((r,i)=>{const [name,path]=reqs[i];if(r.status==='fulfilled'){if(name==='tasks')tasks=r.value;if(name==='workers')workers=r.value;if(name==='ledger')ledger=r.value;if(name==='packages')packages=r.value}else{errors.push(`${path}: ${r.reason?.message||'Fehler'}`)}});state.tasks=Array.isArray(tasks)?tasks:[];state.workers=Array.isArray(workers)?workers:[];$('workerCount').textContent=state.workers.length;$('runningCount').textContent=`${state.workers.filter(x=>x.status==='running').length} aktiv`;renderTasks();renderWorkers();renderLedger(Array.isArray(ledger)?ledger:[]);renderPackages(packages||{paypal_enabled:false,packages:[]});if(errors.length)msg(`Session ist aktiv, aber Teile des Portals sind nicht erreichbar: ${errors.join(' · ')}`,true)}
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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');const x=await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg(x.warning||'Haupt-Identität sicher gekoppelt',!!x.warning);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();
function ledgerLabel(reason){if(reason==='worker_minute')return'Worker-Laufzeit';if(reason==='positive_tip')return'Positive Tipps';if(reason==='signup_bonus')return'Startguthaben';if(reason==='paypal_topup')return'PayPal-Aufladung';if(reason.startsWith('manual_test_grant'))return'Manuelle Gutschrift';if(reason.startsWith('worker_start_refund'))return'Worker-Erstattung';return reason.replaceAll('_',' ')}
function renderLedger(xs){$('ledger').innerHTML=xs.length?xs.map(x=>`<div class="ledger-row"><span><strong>${esc(ledgerLabel(x.reason))}</strong>${x.count>1?` <small>× ${x.count}</small>`:''}</span><span>${new Date(x.created_at).toLocaleDateString()} </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 sec=$('paypalSection');if(!v.paypal_enabled){sec.classList.add('hidden');$('packages').innerHTML='';return}sec.classList.remove('hidden');$('paypalNote').textContent=`PayPal ${v.environment||''} · Credits werden erst nach serverseitig bestätigtem Capture verbucht.`;$('packages').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)}">PAYPAL</button></div>`).join('');$('packages').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)}})}
function leadingZeroBits(buf,bits){const a=new Uint8Array(buf);let full=Math.floor(bits/8),rem=bits%8;for(let i=0;i<full;i++)if(a[i]!==0)return false;if(rem)return (a[full]&(0xff<<(8-rem)))===0;return true}
async function solveRegistrationPow(username,challenge,bits){if(!bits)return 0;if(!globalThis.crypto?.subtle)throw new Error('WebCrypto wird für den Anti-Bot-Proof benötigt. Bitte HTTPS/aktuellen Browser verwenden.');const enc=new TextEncoder(),batch=192;let base=0;while(true){const jobs=[];for(let i=0;i<batch;i++){const n=base+i;const msg=`nh-register|${username.trim().toLowerCase()}|${challenge}|${n}`;jobs.push(crypto.subtle.digest('SHA-256',enc.encode(msg)).then(h=>[n,h]))}const out=await Promise.all(jobs);for(const [n,h] of out)if(leadingZeroBits(h,bits))return n;base+=batch;if(base%(batch*40)===0){$('register').textContent=`ANTI-BOT · ${base.toLocaleString('de-DE')}`;await new Promise(r=>setTimeout(r,0))}}}
$('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()=>{const b=$('register');try{const username=$('regUser').value,password=$('regPass').value;const cfg=state.registration||await api('/api/registration/config');if(!cfg.registration_enabled)throw new Error('Registrierungen sind derzeit deaktiviert.');b.disabled=true;b.textContent='PRÜFE…';const ch=await api('/api/register/challenge',{method:'POST',body:{username}});const counter=await solveRegistrationPow(username,ch.challenge,ch.pow_bits||0);b.textContent='REGISTRIERE…';const x=await api('/api/register',{method:'POST',body:{Username:username,Password:password,Challenge:ch.challenge||'',ProofCounter:counter,InviteCode:$('regInvite').value.trim()}});msg(x.signup_bonus_micros>0?`Konto erstellt · ${credits(x.signup_bonus_micros)} Start-Credits`:'Konto erstellt');await load()}catch(e){msg(e.message,true)}finally{b.textContent='REGISTRIEREN';b.disabled=!(state.registration?.registration_enabled??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');const x=await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg(x.warning||'Haupt-Identität sicher gekoppelt',!!x.warning);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();

View File

@@ -5,16 +5,16 @@
<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>
<article id="loginCard"><h2>Anmelden</h2><p id="loginNote" class="hint"></p><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 id="registerCard"><h2>Konto erstellen</h2><p id="registrationNote" class="hint"></p><label>Benutzername<input id="regUser" autocomplete="username"></label><label>Passwort · min. 12 Zeichen<input id="regPass" type="password" autocomplete="new-password"></label><label id="regInviteRow" class="hidden">Invite-Code<input id="regInvite" autocomplete="off" placeholder="nhinvite_…"></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 class="grid two" id="topGrid">
<article><h2>Haupt-Identität / Rewards</h2><p class="muted">Gewinne aller verwalteten Worker werden deiner Haupt-Identität zugeordnet. 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. Worker behalten jeweils eine eigene kryptografische Identität und Presence.</p></article>
<article id="paypalSection"><h2>Credits kaufen</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>
<section><h2>Credit-Verlauf</h2><p class="muted">Häufige Worker-Minuten und positive Tipps werden pro Tag zusammengefasst. Das vollständige Ledger bleibt serverseitig unverändert erhalten.</p><div id="ledger" class="ledger"></div></section>
</div>
</main><script src="/app.js" defer></script></body></html>

View File

@@ -1 +1,3 @@
: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}}
.two.single{grid-template-columns:1fr}

View File

@@ -0,0 +1,62 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"neuralhunt/internal/data"
)
func (s *Server) dispatchHostedCreditEvents(ctx context.Context) {
if strings.TrimSpace(s.customerServiceInternalURL) == "" || strings.TrimSpace(s.internalServiceSecret) == "" {
return
}
events, err := s.store.PendingHostedCreditEvents(ctx, 25)
if err != nil {
log.Printf("hosted credit outbox: %v", err)
return
}
for _, ev := range events {
if ev.Attempts > 0 && ev.Attempts%20 == 0 {
log.Printf("hosted credit event %s still pending after %d attempts", ev.EventID, ev.Attempts)
}
if err := s.deliverHostedCreditEvent(ctx, ev); err != nil {
_ = s.store.MarkHostedCreditEventAttempt(ctx, ev.EventID, err.Error())
continue
}
_ = s.store.MarkHostedCreditEventDelivered(ctx, ev.EventID)
}
}
func (s *Server) deliverHostedCreditEvent(ctx context.Context, ev data.HostedCreditEvent) error {
body, _ := json.Marshal(map[string]any{
"event_id": ev.EventID,
"worker_client_id": ev.WorkerClientID,
"reward_client_id": ev.RewardClientID,
"task_id": ev.TaskID,
"seq": ev.Seq,
"score": ev.Score,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.customerServiceInternalURL+"/internal/game/positive-tip", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.internalServiceSecret)
resp, err := s.customerServiceHTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("customer service HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}

View File

@@ -47,6 +47,8 @@ type Server struct {
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
internalServiceSecret string
customerServiceInternalURL string
customerServiceHTTP *http.Client
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS int64
@@ -65,14 +67,16 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub,
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)),
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")),
customerServiceInternalURL: strings.TrimRight(strings.TrimSpace(os.Getenv("CUSTOMER_SERVICE_INTERNAL_URL")), "/"),
customerServiceHTTP: &http.Client{Timeout: 5 * time.Second},
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
@@ -851,6 +855,17 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
s.runtime.MarkSQLiteWrite()
// A positive hosted-worker tip means a genuine personal-best improvement,
// not merely a submitted or lottery-selected guess. Record it durably and
// let the scheduler deliver the idempotent credit event to Customer Service.
if accepted.Improved {
rewardOwner := s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
if _, created, err := s.store.RecordHostedPositiveCreditEvent(r.Context(), id, c.ClientID, rewardOwner, in.Seq, accepted.State.BestScore); err != nil {
log.Printf("hosted positive-tip outbox: %v", err)
} else if created {
s.runtime.MarkSQLiteWrite()
}
}
p.Score = round(p.Score, s.settings.Get().PublicScorePrecision)
s.hub.PublishPoint(id, c.ClientID, p)
}
@@ -1825,6 +1840,7 @@ func (s *Server) Scheduler(ctx context.Context) {
s.runDueActions(ctx)
maintenance++
if maintenance%5 == 0 {
s.dispatchHostedCreditEvents(ctx)
if err := s.store.EnsureActiveTasks(ctx, s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits); err != nil {
log.Printf("scheduler: %v", err)
}