RC-8
release-tag / release-image (push) Successful in 4m39s

This commit is contained in:
2026-08-11 19:02:09 +02:00
parent d9eb570629
commit ba3818ef30
8 changed files with 171 additions and 32 deletions
+113 -27
View File
@@ -280,36 +280,65 @@ func (s *Service) rewardIdentity(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
clientID := ""
if !in.Clear {
if strings.TrimSpace(in.LinkCode) == "" {
jsonOut(w, 400, map[string]string{"error": "pairing code required; generate it while logged in with the reward identity"})
return
}
var err error
clientID, err = s.redeemRewardLink(r.Context(), in.LinkCode)
if err != nil {
jsonOut(w, 400, map[string]string{"error": "reward identity proof failed: " + err.Error()})
return
}
}
// Install delegations before committing the new owner locally. If any game
// control-plane call fails, the customer's previous reward owner remains
// unchanged and no half-applied portal state is presented as successful.
workers, _ := s.store.Workers(r.Context(), cid)
for _, wk := range workers {
if wk.WorkerClientID != "" {
if err := s.syncDelegation(r.Context(), wk.WorkerClientID, clientID); err != nil {
jsonOut(w, 400, map[string]string{"error": "reward identity could not be registered in game: " + err.Error()})
if in.Clear {
// Clearing must reach the game first; otherwise old worker delegations
// could silently remain active while the portal already looks unlinked.
if err := s.checkGameControlPlane(r.Context()); err != nil {
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
for _, wk := range workers {
if wk.WorkerClientID == "" {
continue
}
if err := s.syncDelegation(r.Context(), wk.WorkerClientID, ""); err != nil {
jsonOut(w, http.StatusBadGateway, map[string]string{"error": "Reward-Kopplung konnte im Spiel nicht entfernt werden: " + err.Error()})
return
}
}
}
if err := s.store.SetRewardClientID(r.Context(), cid, clientID); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
if err := s.store.SetRewardClientID(r.Context(), cid, ""); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "reward_client_id": ""})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "reward_client_id": clientID})
if strings.TrimSpace(in.LinkCode) == "" {
jsonOut(w, 400, map[string]string{"error": "Hosted-Code fehlt. Erzeuge ihn im Neural-Hunt-Spiel mit der gewünschten Haupt-Identität."})
return
}
clientID, err := s.redeemRewardLink(r.Context(), in.LinkCode)
if err != nil {
jsonOut(w, http.StatusBadGateway, map[string]string{"error": "Hosted-Code konnte nicht gekoppelt werden: " + err.Error()})
return
}
// The one-shot code has now been consumed. Persist the proven owner before
// syncing already-known workers so a transient delegation failure does not
// destroy a valid pairing and force the customer to generate another code.
if err := s.store.SetRewardClientID(r.Context(), cid, clientID); err != nil {
jsonOut(w, 500, map[string]string{"error": "Hosted-Code wurde bestätigt, aber die Haupt-Identität konnte lokal nicht gespeichert werden: " + err.Error()})
return
}
var warnings []string
for _, wk := range workers {
if wk.WorkerClientID == "" {
continue
}
if err := s.syncDelegation(r.Context(), wk.WorkerClientID, clientID); err != nil {
warnings = append(warnings, fmt.Sprintf("Worker %s: %v", wk.ID, err))
}
}
out := map[string]any{"ok": true, "reward_client_id": clientID}
if len(warnings) > 0 {
out["warning"] = "Haupt-Identität ist gekoppelt; bestehende Worker konnten noch nicht vollständig synchronisiert werden: " + strings.Join(warnings, "; ")
log.Printf("customer reward identity %s coupled with delegation warnings: %s", cid, strings.Join(warnings, "; "))
}
jsonOut(w, 200, out)
}
func (s *Service) proxyGET(ctx context.Context, path string, out any) error {
@@ -735,9 +764,60 @@ func (s *Service) internalWorkerRegister(w http.ResponseWriter, r *http.Request)
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) redeemRewardLink(ctx context.Context, code string) (string, error) {
// CheckGameControlPlane verifies that the configured private game listener is
// reachable and that both services use the same hosted-service secret. It is
// safe to call at startup and does not consume a Hosted Code.
func (s *Service) CheckGameControlPlane(ctx context.Context) error {
return s.checkGameControlPlane(ctx)
}
func (s *Service) checkGameControlPlane(ctx context.Context) error {
if strings.TrimSpace(s.cfg.SharedSecret) == "" {
return "", errors.New("CUSTOMER_SERVICE_SHARED_SECRET missing")
return errors.New("CUSTOMER_SERVICE_SHARED_SECRET fehlt")
}
base := strings.TrimRight(strings.TrimSpace(s.cfg.GameAdminURL), "/")
if base == "" {
return errors.New("CS_GAME_ADMIN_URL fehlt")
}
endpoint := base + "/api/internal/customer-service/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret)
resp, err := s.hc.Do(req)
if err != nil {
return fmt.Errorf("Game-Control-Plane unter %s nicht erreichbar: %w. Bei lokalem 'go run' normalerweise CS_GAME_ADMIN_URL=http://127.0.0.1:8081; im Compose-Netz http://app:8081", base, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode == http.StatusUnauthorized {
return errors.New("Game-Control-Plane erreichbar, aber CUSTOMER_SERVICE_SHARED_SECRET stimmt zwischen Game Server und Customer Service nicht überein")
}
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("Game-Control-Plane antwortet mit HTTP 404. CS_GAME_ADMIN_URL=%s zeigt wahrscheinlich auf den öffentlichen Listener/Reverse-Proxy oder auf ein älteres Game-Image ohne Hosted-Control-Plane", base)
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("Game-Control-Plane HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var out struct {
OK bool `json:"ok"`
Service string `json:"service"`
CustomerLinkSupported bool `json:"customer_link_supported"`
}
if err := json.Unmarshal(b, &out); err != nil {
return fmt.Errorf("Game-Control-Plane liefert keine gültige JSON-Antwort: %w", err)
}
if !out.OK || out.Service != "neuralhunt-game-control-plane" || !out.CustomerLinkSupported {
return errors.New("Game-Control-Plane ist erreichbar, unterstützt aber die erwartete Hosted-Code-Schnittstelle nicht")
}
return nil
}
func (s *Service) redeemRewardLink(ctx context.Context, code string) (string, error) {
if err := s.checkGameControlPlane(ctx); err != nil {
return "", err
}
body, _ := json.Marshal(map[string]string{"code": strings.TrimSpace(code)})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.GameAdminURL, "/")+"/api/internal/customer-link/consume", strings.NewReader(string(body)))
@@ -752,8 +832,14 @@ func (s *Service) redeemRewardLink(ctx context.Context, code string) (string, er
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode == http.StatusUnauthorized {
return "", errors.New("Game Server lehnt CUSTOMER_SERVICE_SHARED_SECRET ab")
}
if resp.StatusCode == http.StatusNotFound {
return "", errors.New("Hosted-Code ist abgelaufen, ungültig oder bereits verwendet")
}
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("game pairing HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
return "", fmt.Errorf("Game-Pairing HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var out struct {
ClientID string `json:"client_id"`
+1 -1
View File
@@ -12,4 +12,4 @@ async function workerAction(card,act){const id=card.dataset.id;try{if(act==='sav
async function uploadIdentity(card,file){if(!file)return;if(!confirm('Die aktuelle Worker-Identity wird ersetzt. Der Worker wird dabei gestoppt. Fortfahren?'))return;try{const r=await fetch(`/api/workers/${encodeURIComponent(card.dataset.id)}/identity`,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:await file.text()});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||'Upload fehlgeschlagen');msg('Identity ersetzt');await load()}catch(e){msg(e.message,true)}}
function renderLedger(xs){$('ledger').innerHTML=xs.length?xs.map(x=>`<div class="ledger-row"><span>${esc(x.reason)}</span><span>${new Date(x.created_at).toLocaleString()}</span><strong class="${x.delta_micros>=0?'credit':'debit'}">${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}</strong></div>`).join(''):'<div class="muted">Noch keine Buchungen.</div>'}
function renderPackages(v){const h=$('packages');$('paypalNote').textContent=v.paypal_enabled?`PayPal ${v.environment||''} · Credits werden erst nach serverseitig bestätigtem Capture verbucht.`:'PayPal ist derzeit deaktiviert.';h.innerHTML=(v.packages||[]).map(p=>`<div class="package"><span><strong>${credits(p.credits_micros)} Credits</strong><br><small>${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}</small></span><button data-pkg="${esc(p.id)}" ${v.paypal_enabled?'':'disabled'}>PAYPAL</button></div>`).join('');h.querySelectorAll('[data-pkg]').forEach(b=>b.onclick=async()=>{try{const x=await api('/api/billing/paypal/order',{method:'POST',body:{package_id:b.dataset.pkg}});location.href=x.approval_url}catch(e){msg(e.message,true)}})}
$('login').onclick=async()=>{try{await api('/api/login',{method:'POST',body:{Username:$('loginUser').value,Password:$('loginPass').value}});await load()}catch(e){msg(e.message,true)}};$('register').onclick=async()=>{try{await api('/api/register',{method:'POST',body:{Username:$('regUser').value,Password:$('regPass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/logout',{method:'POST'}).catch(()=>{});location.reload()};$('saveReward').onclick=async()=>{try{const code=$('rewardLinkCode').value.trim();if(!code)throw new Error('Hosted-Code einfügen');await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg('Haupt-Identität sicher gekoppelt');await load()}catch(e){msg(e.message,true)}};$('clearReward').onclick=async()=>{if(!confirm('Reward-Kopplung wirklich lösen? Laufende Worker sollten vorher gestoppt werden.'))return;try{await api('/api/reward-identity',{method:'PUT',body:{clear:true}});msg('Reward-Kopplung gelöst');await load()}catch(e){msg(e.message,true)}};$('newWorker').onclick=()=> $('newWorkerBox').classList.toggle('hidden');$('createWorker').onclick=async()=>{try{await api('/api/workers',{method:'POST',body:{TaskID:$('newTask').value,BeaconPath:$('newPath').value}});$('newWorkerBox').classList.add('hidden');await load()}catch(e){msg(e.message,true)}};boot();
$('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();
+15
View File
@@ -310,6 +310,7 @@ func (s *Server) Routes() http.Handler {
r.Post("/api/internal/delegations", s.internalDelegation)
r.Post("/api/internal/identity-exists", s.internalIdentityExists)
r.Post("/api/internal/customer-link/consume", s.internalCustomerLinkConsume)
r.Get("/api/internal/customer-service/health", s.internalCustomerServiceHealth)
r.Group(func(r chi.Router) {
r.Use(func(n http.Handler) http.Handler { return s.require("user", n) })
r.Get("/api/tasks", s.clientTasks)
@@ -903,6 +904,20 @@ func (s *Server) customerLinkCode(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 201, map[string]any{"code": code, "client_id": c.ClientID, "expires_at": expires})
}
func (s *Server) internalCustomerServiceHealth(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.internalServiceSecret)
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
w.Header().Set("Cache-Control", "no-store")
jsonOut(w, http.StatusOK, map[string]any{
"ok": true,
"service": "neuralhunt-game-control-plane",
"customer_link_supported": true,
})
}
func (s *Server) internalCustomerLinkConsume(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.internalServiceSecret)
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {