From d82413403a224c10ff0688ef5a6e1edabdbc344e Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 23 May 2026 17:24:49 +0700 Subject: [PATCH] feat: polish Supabase Nakama auth HUD --- CHANGELOG.md | 3 + ROADMAP.md | 3 + .../Scripts/AI/SecondSpawnGatewayClient.cs | 58 ++++++++- .../_SecondSpawn/Scripts/UI/HUDController.cs | 112 ++++++++++++++++-- backend/nakama/README.md | 21 ++++ .../tests/supabase_custom_auth.test.mjs | 36 ++++++ docs/ARCHITECTURE.md | 5 + docs/setup/play-mode-smoke-checklist.md | 1 + 8 files changed, 224 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d36fe36..eebb3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. with a clean / controlled PBR material pipeline, including reference examples, negative examples, asset search language, selection guardrails, and PC/Web/mobile scalability notes. +- Auth state HUD polish: Play Mode now shows a dedicated Supabase-backed versus + local-fallback auth block, a retry button, and more actionable Supabase or + Nakama custom-auth failure messages. - Prototype combat training target lifecycle hardening: the target registry now prunes destroyed references, and the scene helper respawns a missing training target without duplicating live or rebuilding targets. diff --git a/ROADMAP.md b/ROADMAP.md index 794dbdc..69c0efb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,6 +60,9 @@ Recommended views: - [x] Unity Play Mode Supabase anonymous signup uses the DOS Supabase publishable key and passes the resulting access token through Nakama custom auth, with local device auth kept as an explicit fallback only. +- [x] Unity Play Mode auth now has a dedicated HUD block that distinguishes + Supabase-backed auth, local device fallback, missing Nakama session, fallback + lock state, and retryable Supabase or Nakama bridge errors. - [x] Nakama runtime scaffold with health, character context, memory, soul update, NPC chat, BodyTime, legacy reincarnation/reinhabitation, OpenClaw, and agent decision RPCs. diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index d690573..7b855bb 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -155,7 +155,7 @@ public IEnumerator Authenticate(Action onSuccess = null, Action onError if (supabaseSession == null || string.IsNullOrWhiteSpace(supabaseSession.access_token)) { yield return TryDeviceFallbackOrFail( - "Supabase anonymous auth failed: " + (string.IsNullOrWhiteSpace(supabaseError) ? "missing access token." : supabaseError), + "Supabase anonymous auth failed: " + HumanizeSupabaseAuthError(supabaseError), onSuccess, onError); yield break; @@ -175,7 +175,7 @@ public IEnumerator Authenticate(Action onSuccess = null, Action onError if (nakamaSession == null || string.IsNullOrWhiteSpace(nakamaSession.token)) { yield return TryDeviceFallbackOrFail( - "Nakama custom auth failed: " + (string.IsNullOrWhiteSpace(nakamaError) ? "missing session token." : nakamaError), + "Nakama custom auth failed: " + HumanizeNakamaCustomAuthError(nakamaError), onSuccess, onError); yield break; @@ -914,6 +914,60 @@ private static bool IsNakamaAuthInvalid(string error) error.Contains("Auth token invalid", StringComparison.OrdinalIgnoreCase); } + private static string HumanizeSupabaseAuthError(string error) + { + if (string.IsNullOrWhiteSpace(error)) + { + return "missing access token. Check the Supabase URL, publishable key, and anonymous sign-in setting."; + } + + var normalized = error.Trim(); + if (normalized.IndexOf("anonymous_provider_disabled", StringComparison.OrdinalIgnoreCase) >= 0 || + normalized.IndexOf("anonymous sign-ins are disabled", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "anonymous sign-in is disabled in Supabase Auth. Enable anonymous sign-ins for local Play Mode."; + } + + if (normalized.StartsWith("401:", StringComparison.OrdinalIgnoreCase) || + normalized.StartsWith("403:", StringComparison.OrdinalIgnoreCase) || + normalized.IndexOf("invalid api key", StringComparison.OrdinalIgnoreCase) >= 0 || + normalized.IndexOf("jwt", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Supabase rejected the publishable key or token. Check SECOND_SPAWN_SUPABASE_URL and SECOND_SPAWN_SUPABASE_PUBLISHABLE_KEY."; + } + + if (normalized.StartsWith("404:", StringComparison.OrdinalIgnoreCase)) + { + return "Supabase auth endpoint was not found. Check the configured Supabase project URL."; + } + + return normalized; + } + + private static string HumanizeNakamaCustomAuthError(string error) + { + if (string.IsNullOrWhiteSpace(error)) + { + return "missing session token. Check Nakama SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY runtime env."; + } + + var normalized = error.Trim(); + if (normalized.StartsWith("401:", StringComparison.OrdinalIgnoreCase) || + normalized.IndexOf("invalid", StringComparison.OrdinalIgnoreCase) >= 0 || + normalized.IndexOf("unauthorized", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Nakama rejected the Supabase token. Check token freshness and the Nakama Supabase runtime env."; + } + + if (normalized.StartsWith("500:", StringComparison.OrdinalIgnoreCase) || + normalized.IndexOf("missing SUPABASE_URL", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Nakama custom auth bridge is misconfigured. Set SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY in Nakama runtime env."; + } + + return normalized; + } + private static string ExtractJwtStringClaim(string jwt, string claimName) { if (string.IsNullOrWhiteSpace(jwt)) diff --git a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs index d59282c..f2a3255 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs @@ -68,6 +68,9 @@ public sealed class HUDController : MonoBehaviour private bool _gameplayLedgerRefreshInFlight; private string _gameplayLedgerSummary = "No gameplay ledger yet."; private Text _hudTitleText; + private Text _hudAuthText; + private RectTransform _authActionRow; + private Button _authRetryButton; private Text _hudBodyText; private Text _hudFrameText; private Text _hudAgentText; @@ -212,7 +215,7 @@ private void BuildRuntimeHud() panel.anchorMax = new Vector2(0f, 1f); panel.pivot = new Vector2(0f, 1f); panel.anchoredPosition = new Vector2(_panelPosition.x, -_panelPosition.y); - panel.sizeDelta = new Vector2(360f, 560f); + panel.sizeDelta = new Vector2(380f, 620f); var image = panel.gameObject.AddComponent(); image.color = new Color(0.05f, 0.07f, 0.08f, 0.86f); @@ -230,17 +233,34 @@ private void BuildRuntimeHud() _hudTitleText.text = "SECOND SPAWN"; SetPreferredHeight(_hudTitleText.rectTransform, 20f); + _hudAuthText = CreateText("AuthState", panel, 13, FontStyle.Bold, new Color(0.72f, 0.9f, 1f)); + SetPreferredHeight(_hudAuthText.rectTransform, 58f); + + _authActionRow = CreateRect("AuthActions", panel); + SetPreferredHeight(_authActionRow, 28f); + var authActionLayout = _authActionRow.gameObject.AddComponent(); + authActionLayout.spacing = 8f; + authActionLayout.childAlignment = TextAnchor.MiddleLeft; + authActionLayout.childControlHeight = true; + authActionLayout.childControlWidth = true; + authActionLayout.childForceExpandHeight = true; + authActionLayout.childForceExpandWidth = false; + + _authRetryButton = CreateButton("RetryAuthButton", _authActionRow, "Retry Auth"); + SetPreferredWidth(_authRetryButton.transform as RectTransform, 112f); + _authRetryButton.onClick.AddListener(RetryAuthentication); + _hudBodyText = CreateText("Body", panel, 15, FontStyle.Normal, Color.white); - SetPreferredHeight(_hudBodyText.rectTransform, 116f); + SetPreferredHeight(_hudBodyText.rectTransform, 104f); _hudFrameText = CreateText("BodyIdentity", panel, 14, FontStyle.Normal, Color.white); - SetPreferredHeight(_hudFrameText.rectTransform, 160f); + SetPreferredHeight(_hudFrameText.rectTransform, 132f); _hudAgentText = CreateText("AgentRuntime", panel, 14, FontStyle.Normal, Color.white); - SetPreferredHeight(_hudAgentText.rectTransform, 106f); + SetPreferredHeight(_hudAgentText.rectTransform, 90f); _hudTraceText = CreateText("PromptTrace", panel, 13, FontStyle.Normal, new Color(0.78f, 0.84f, 0.88f)); - SetPreferredHeight(_hudTraceText.rectTransform, 132f); + SetPreferredHeight(_hudTraceText.rectTransform, 118f); _questTrackerPanel = CreateRect("QuestTracker", transform); _questTrackerPanel.anchorMin = new Vector2(1f, 1f); @@ -432,6 +452,9 @@ private void BuildRuntimeHud() private bool HasCompleteRuntimeHud() { return _hudBodyText != null && + _hudAuthText != null && + _authActionRow != null && + _authRetryButton != null && _hudFrameText != null && _hudAgentText != null && _hudTraceText != null && @@ -471,6 +494,9 @@ private bool HasCompleteRuntimeHud() private void ResetRuntimeHudRefs() { _hudTitleText = null; + _hudAuthText = null; + _authActionRow = null; + _authRetryButton = null; _hudBodyText = null; _hudFrameText = null; _hudAgentText = null; @@ -556,6 +582,7 @@ private void RenderRuntimeHud() } _hudBodyText.text = _showPrototypeStats ? BuildStatsText(player) : ""; + RenderAuthState(gateway); _hudFrameText.text = _showFrameIdentity ? BuildFrameIdentityText(context) : ""; _hudAgentText.text = _showAgentActivity ? BuildAgentActivityText(context) : ""; _hudTraceText.text = BuildRuntimeDebugText(gateway); @@ -1457,15 +1484,43 @@ private static string FormatAiReason(string reason) }; } + private void RenderAuthState(SecondSpawnGatewayClient gateway) + { + if (_hudAuthText != null) + { + _hudAuthText.text = BuildAuthDebugText(gateway); + _hudAuthText.color = ResolveAuthColor(gateway); + } + + if (_authActionRow != null) + { + _authActionRow.gameObject.SetActive(gateway != null); + } + + if (_authRetryButton != null) + { + var canRetry = gateway != null && !gateway.IsAuthInProgress; + _authRetryButton.interactable = canRetry; + SetButtonLabel(_authRetryButton, gateway != null && gateway.HasNakamaSession ? "Refresh Auth" : "Retry Auth"); + } + } + + private void RetryAuthentication() + { + var gateway = ResolveGateway(); + if (gateway == null || gateway.IsAuthInProgress) + { + return; + } + + StartCoroutine(gateway.Authenticate()); + } + private string BuildRuntimeDebugText(SecondSpawnGatewayClient gateway) { var builder = new StringBuilder(); - builder.AppendLine("Auth"); - builder.AppendLine(BuildAuthDebugText(gateway)); - if (_showPromptTrace) { - builder.AppendLine(); builder.AppendLine("Prompt Trace"); builder.AppendLine(_promptTraceSummary); } @@ -1491,7 +1546,32 @@ private static string BuildAuthDebugState(SecondSpawnGatewayClient gateway) return "auth:no-gateway"; } - return $"{gateway.HasNakamaSession}|{gateway.IsAuthInProgress}|{gateway.AuthSource}|{gateway.NakamaUserId}|{gateway.SupabaseUserId}|{gateway.LastAuthError}|{gateway.AuthStatus}"; + return $"{gateway.HasNakamaSession}|{gateway.IsAuthInProgress}|{gateway.AuthSource}|" + + $"{gateway.NakamaUserId}|{gateway.SupabaseUserId}|{gateway.LastAuthError}|" + + $"{gateway.AuthStatus}|{gateway.IsDeviceFallbackAllowedForDebug}|" + + $"{gateway.IsLocalNakamaEndpointForDebug}"; + } + + private static Color ResolveAuthColor(SecondSpawnGatewayClient gateway) + { + if (gateway == null) + { + return new Color(1f, 0.42f, 0.36f); + } + + if (gateway.IsAuthInProgress) + { + return new Color(0.94f, 0.86f, 0.48f); + } + + if (!gateway.HasNakamaSession) + { + return new Color(1f, 0.42f, 0.36f); + } + + return string.Equals(gateway.AuthSource, "device_auth", System.StringComparison.OrdinalIgnoreCase) + ? new Color(1f, 0.68f, 0.28f) + : new Color(0.58f, 1f, 0.74f); } private static string BuildAuthDebugText(SecondSpawnGatewayClient gateway) @@ -1506,14 +1586,20 @@ private static string BuildAuthDebugText(SecondSpawnGatewayClient gateway) : gateway.AuthSource; var fallback = gateway.IsDeviceFallbackAllowedForDebug ? "LOCAL DEVICE FALLBACK ENABLED" : "device fallback locked"; var local = gateway.IsLocalNakamaEndpointForDebug ? "local" : "remote"; + var mode = gateway.HasNakamaSession + ? (string.Equals(gateway.AuthSource, "device_auth", System.StringComparison.OrdinalIgnoreCase) + ? "LOCAL FALLBACK" + : "SUPABASE BACKED") + : "NO SESSION"; var status = TrimForHud(gateway.AuthStatus, 72); var error = string.IsNullOrWhiteSpace(gateway.LastAuthError) ? "" - : "\nLast auth issue: " + TrimForHud(gateway.LastAuthError, 72); + : "\nIssue: " + TrimForHud(gateway.LastAuthError, 88); - return $"{source} | {local} Nakama | {fallback}\n" + + return $"{mode} | {source} | {local} Nakama\n" + + $"{fallback}\n" + $"Nakama {Fallback(gateway.NakamaUserId, "no session")} | Supabase {Fallback(gateway.SupabaseUserId, "none")}\n" + - $"{TrimForHud(gateway.ResolvedNakamaBaseUrl, 56)} | {status}{error}"; + $"{status}{error}"; } private void DrawGameplayLedger() diff --git a/backend/nakama/README.md b/backend/nakama/README.md index c6de35d..0f9e154 100644 --- a/backend/nakama/README.md +++ b/backend/nakama/README.md @@ -109,6 +109,27 @@ back to Nakama device auth so local Play Mode is not blocked. That fallback is for local iteration only; production account binding must use Supabase custom auth or a later approved identity ADR. +### Unity Auth HUD Troubleshooting + +The Play Mode HUD has a dedicated auth block: + +- `SUPABASE BACKED` means Unity received a Supabase access token and Nakama + accepted it through custom auth. +- `LOCAL FALLBACK` means Unity used Nakama device auth. This requires the + explicit debug flag or command-line opt-in and should not be treated as real + account binding. +- `NO SESSION` means Unity has no usable Nakama session. The retry button calls + the full Supabase -> Nakama custom-auth flow again. + +Common failure messages: + +| HUD Issue | Fix | +| ---- | ---- | +| Anonymous sign-in is disabled | Enable anonymous sign-ins in Supabase Auth for local Play Mode. | +| Supabase rejected the publishable key or token | Check `SECOND_SPAWN_SUPABASE_URL` and `SECOND_SPAWN_SUPABASE_PUBLISHABLE_KEY`. | +| Nakama custom auth bridge is misconfigured | Set `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` in Nakama runtime env. | +| Device fallback is disabled | Use this as the default for Supabase-auth testing, or explicitly opt into local fallback only for local iteration. | + No game auth secret belongs in `api.dos.ai`. The model service receives only bounded game context after Nakama has authenticated the player and shaped the request. diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 494e961..fb0613d 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -3463,4 +3463,40 @@ const invalidSupabasePayloadRejected = harness.registeredHooks[0]( ); assert.equal(invalidSupabasePayloadRejected, null); +const supabaseServerErrorRejected = harness.registeredHooks[0]( + { + env: { + SUPABASE_URL: "https://project.supabase.co", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + }, + }, + { error: () => {}, info: () => {}, debug: () => {} }, + { + httpRequest: () => ({ + code: 500, + body: "temporary Supabase outage", + }), + }, + { account: { id: "supabase-access-token" } } +); +assert.equal(supabaseServerErrorRejected, null); + +const invalidSupabaseJsonRejected = harness.registeredHooks[0]( + { + env: { + SUPABASE_URL: "https://project.supabase.co", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + }, + }, + { error: () => {}, info: () => {}, debug: () => {} }, + { + httpRequest: () => ({ + code: 200, + body: "{not valid json", + }), + }, + { account: { id: "supabase-access-token" } } +); +assert.equal(invalidSupabaseJsonRejected, null); + console.log("supabase_custom_auth tests passed"); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e91b693..46ad657 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -423,6 +423,11 @@ quest state directly. - Can be used where it clearly reduces integration work - Not the primary game backend baseline after ADR 0010 - Never used for combat / movement sync +- For the current Play Mode identity bridge, Unity may use the public Supabase + publishable key to obtain an anonymous access token, but Nakama must verify + that token through Supabase Auth before issuing a Nakama session. +- Local Nakama device auth is an explicit debug fallback only. The HUD must make + fallback mode visible and must not hide Supabase or Nakama rejection reasons. ### `api.dos.ai` Model Service diff --git a/docs/setup/play-mode-smoke-checklist.md b/docs/setup/play-mode-smoke-checklist.md index a6e8a2c..9471e9d 100644 --- a/docs/setup/play-mode-smoke-checklist.md +++ b/docs/setup/play-mode-smoke-checklist.md @@ -38,6 +38,7 @@ too early can mix stale cleanup asserts into the next run. | Fusion CodeGen | No invalid `AssetDatabase` path errors from Fusion diagnostics. | | Nakama | HTTP endpoint returns 200 locally. | | Auth HUD | Shows `supabase_custom_auth`, Nakama user id, Supabase user id, and `device fallback locked` for normal Play Mode. | +| Auth retry | When auth fails, the HUD shows an actionable Supabase or Nakama bridge issue and the retry button starts the full auth flow again. | | Local fallback test | When launched with `-secondspawn-no-supabase -secondspawn-allow-device-auth`, HUD shows `LOCAL DEVICE FALLBACK ENABLED`. | ## NPC Dialogue