diff --git a/.env.example b/.env.example index 4172155..29a272b 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/HOSTED_SERVICE.md b/HOSTED_SERVICE.md index 4882a08..9f21e53 100644 --- a/HOSTED_SERVICE.md +++ b/HOSTED_SERVICE.md @@ -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. diff --git a/README.md b/README.md index d3859bf..bb19e4e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/TESTING.md b/TESTING.md index 62d8147..079cddc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md b/V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md new file mode 100644 index 0000000..2892190 --- /dev/null +++ b/V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md @@ -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. diff --git a/cmd/customer-service/main.go b/cmd/customer-service/main.go index b09bf40..85febbd 100644 --- a/cmd/customer-service/main.go +++ b/cmd/customer-service/main.go @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 936af4e..5725de4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/internal/customer/portal_controls.go b/internal/customer/portal_controls.go new file mode 100644 index 0000000..a8063ef --- /dev/null +++ b/internal/customer/portal_controls.go @@ -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}) +} diff --git a/internal/customer/security.go b/internal/customer/security.go index f1a7858..1facc9f 100644 --- a/internal/customer/security.go +++ b/internal/customer/security.go @@ -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[:]) +} diff --git a/internal/customer/security_registration_test.go b/internal/customer/security_registration_test.go new file mode 100644 index 0000000..847b0f4 --- /dev/null +++ b/internal/customer/security_registration_test.go @@ -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") + } +} diff --git a/internal/customer/server.go b/internal/customer/server.go index 28995db..6fccb3f 100644 --- a/internal/customer/server.go +++ b/internal/customer/server.go @@ -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"}) diff --git a/internal/customer/store.go b/internal/customer/store.go index 525331d..37fc028 100644 --- a/internal/customer/store.go +++ b/internal/customer/store.go @@ -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, ¬null, &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 diff --git a/internal/customerui/dist/admin/app.js b/internal/customerui/dist/admin/app.js index c73ece9..0934692 100644 --- a/internal/customerui/dist/admin/app.js +++ b/internal/customerui/dist/admin/app.js @@ -1 +1,13 @@ -const $=id=>document.getElementById(id);let manual=false;function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function msg(t,e=false){$('msg').textContent=t;$('msg').className='msg'+(e?' err':'');$('msg').classList.remove('hidden')}async function api(p,o={}){if(o.body&&typeof o.body!=='string'){o.headers={'Content-Type':'application/json',...(o.headers||{})};o.body=JSON.stringify(o.body)}const r=await fetch(p,{credentials:'same-origin',...o});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||`HTTP ${r.status}`);return x}const cr=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});async function load(){const x=await api('/api/admin/overview');manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER TEST-CREDIT-BYPASS IST AKTIV. Nur auf dem privaten Listener verwenden.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';$('customers').innerHTML=(x.customers||[]).map(c=>`
${esc(c.username)}
${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}
${cr(c.balance_micros)} Credits
${c.running}/${c.workers} Worker aktiv
${manual?'
':'
'}
`).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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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'
Keine Worker.
';return `
${c.worker_items.map(w=>`
${esc(w.id)}
${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}
${esc(w.status)}
`).join('')}
`} +function customerHTML(c){const status=c.blocked?`GESPERRT`:'AKTIV';return `

${esc(c.username)} ${status}

${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}
${c.blocked_reason?`
Grund: ${esc(c.blocked_reason)}
`:''}
${cr(c.balance_micros)} Credits${c.running}/${c.workers} Worker aktiv
${c.blocked?'':''}${manual?'':''}
${workerRows(c)}
`} +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('')||'
Noch keine Benutzer.
';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(()=>{}); diff --git a/internal/customerui/dist/admin/index.html b/internal/customerui/dist/admin/index.html index 894900d..7f47dd0 100644 --- a/internal/customerui/dist/admin/index.html +++ b/internal/customerui/dist/admin/index.html @@ -1,2 +1,13 @@ - Neural Hunt · Customer Admin
NEURAL HUNT · PRIVATE CONTROL PLANE

Customer Service Admin

+Neural Hunt · Customer Admin
+
NEURAL HUNT · PRIVATE CONTROL PLANE

Customer Service Admin

+
+
diff --git a/internal/customerui/dist/admin/styles.css b/internal/customerui/dist/admin/styles.css index 458ad47..0941f8c 100644 --- a/internal/customerui/dist/admin/styles.css +++ b/internal/customerui/dist/admin/styles.css @@ -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}} diff --git a/internal/customerui/dist/public/app.js b/internal/customerui/dist/public/app.js index 9f091ab..63c2d18 100644 --- a/internal/customerui/dist/public/app.js +++ b/internal/customerui/dist/public/app.js @@ -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=>``).join('');$('newTask').innerHTML=html} function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} function renderWorkers(){const host=$('workers');if(!state.workers.length){host.innerHTML='
Noch keine Worker angelegt.
';return}host.innerHTML=state.workers.map(w=>{const opts=state.tasks.map(t=>``).join('');return `
${esc(w.status)}

${esc(w.id)}

Worker-ID: ${esc(w.worker_client_id||'wird beim ersten Start erzeugt')}
${w.last_error?`Fehler: ${esc(w.last_error)}`:`Rate: ${credits(w.rate_micros_per_minute)} Credits/min`}
${w.status==='running'?'':''}
`}).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=>`
${esc(x.reason)}${new Date(x.created_at).toLocaleString()}${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}
`).join(''):'
Noch keine Buchungen.
'} -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=>`
${credits(p.credits_micros)} Credits
${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}
`).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=>`
${esc(ledgerLabel(x.reason))}${x.count>1?` × ${x.count}`:''}${new Date(x.created_at).toLocaleDateString()} ${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}
`).join(''):'
Noch keine Buchungen.
'} +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=>`
${credits(p.credits_micros)} Credits
${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}
`).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[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(); diff --git a/internal/customerui/dist/public/index.html b/internal/customerui/dist/public/index.html index 0baea7d..f81e3a5 100644 --- a/internal/customerui/dist/public/index.html +++ b/internal/customerui/dist/public/index.html @@ -5,16 +5,16 @@
NEURAL HUNT

Customer Service

PrePaid Worker · Task-Zuordnung · Reward Management

-

Anmelden

-

Konto erstellen

+

Anmelden

+

Konto erstellen

diff --git a/internal/customerui/dist/public/styles.css b/internal/customerui/dist/public/styles.css index e325c8c..45a7a18 100644 --- a/internal/customerui/dist/public/styles.css +++ b/internal/customerui/dist/public/styles.css @@ -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} diff --git a/internal/server/customer_credit.go b/internal/server/customer_credit.go new file mode 100644 index 0000000..56a4fb7 --- /dev/null +++ b/internal/server/customer_credit.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 671060f..943605e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) }