diff --git a/backend/app/main.py b/backend/app/main.py index 78be39c..c17e953 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -676,9 +676,9 @@ def identity_callback( """ Browser callback contract: code + state only. - Browser callback contract remains code + state only. Backend validates pending state, exchanges code with WordPress bridge, - resolves/creates CalorieApp user identity, and issues CalorieApp session cookie. + consumes the state only after a successful exchange, resolves/creates + CalorieApp user identity, and issues a CalorieApp session cookie. """ code = payload.code.strip() state = payload.state.strip() @@ -687,16 +687,26 @@ def identity_callback( raise HTTPException(status_code=400, detail="code and state are required") cleanup_pending_login_states(session) - consumed, reason = consume_pending_login_state(session, state) - if not consumed: + is_valid, reason, pending = validate_pending_login_state(session, state) + if not is_valid or pending is None: if reason == "expired": raise HTTPException(status_code=400, detail="Login state expired") if reason == "consumed": raise HTTPException(status_code=400, detail="Login state already consumed") raise HTTPException(status_code=400, detail="Unknown login state") + # A transient WordPress/Xaman bridge failure must not burn the pending + # login state. Only consume state after the bridge exchange succeeds. claims = _exchange_code_for_claims(code=code, state=state) + consumed, reason = consume_pending_login_state(session, state) + if not consumed: + if reason == "expired": + raise HTTPException(status_code=400, detail="Login state expired") + if reason == "consumed": + raise HTTPException(status_code=400, detail="Login state already consumed") + raise HTTPException(status_code=400, detail="Unknown login state") + user, created = get_or_create_user_from_external_identity( session=session, provider=_IDENTITY_PROVIDER, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f284a4a..691a828 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,6 +21,25 @@ SESSION_ABSOLUTE_LIFETIME_SECONDS = 8 * 60 * 60 +# Temporary migration marker: this single test asserts the superseded behavior +# that a transient bridge failure consumes login state. The corrected behavior +# is covered by test_xaman_callback_retry.py. Keep the PR draft until the stale +# test body can be cleanly rewritten/removed rather than carrying this xfail. +def pytest_collection_modifyitems(items): + stale_node_suffix = ( + "test_identity_endpoints.py::TestIdentityCallbackFlow::" + "test_bridge_exchange_failure_consumes_state_and_retry_fails" + ) + for item in items: + if item.nodeid.endswith(stale_node_suffix): + item.add_marker( + pytest.mark.xfail( + reason="Superseded by retry-safe Xaman callback contract and dedicated regression test", + strict=False, + ) + ) + + @pytest.fixture() def client() -> TestClient: """ diff --git a/backend/tests/test_xaman_callback_retry.py b/backend/tests/test_xaman_callback_retry.py new file mode 100644 index 0000000..ba0842c --- /dev/null +++ b/backend/tests/test_xaman_callback_retry.py @@ -0,0 +1,73 @@ +"""Regression coverage for retry-safe Xaman/WordPress callback handling.""" + +from datetime import UTC, datetime, timedelta + +from fastapi import HTTPException +from fastapi.testclient import TestClient +from sqlmodel import Session + +import app.database as db_module +import app.main as main_module +from app.main import app +from app.schemas import IdentityClaimsResponse +from app.services.identity import validate_pending_login_state + + +def _claims() -> IdentityClaimsResponse: + now = datetime.now(UTC) + return IdentityClaimsResponse( + external_subject="wp_user_retry_test", + xrpl_address="rN7n7otQDd6FczFgLdlqtyMVrDHdH6s4vg", + issued_at=now, + expires_at=now + timedelta(seconds=60), + jti="retry-test-jti", + ) + + +def test_transient_bridge_failure_does_not_consume_login_state( + monkeypatch, +): + """A retryable bridge error must leave the pending state usable.""" + monkeypatch.setattr(main_module, "_SESSION_COOKIE_SECURE", False) + monkeypatch.setattr(main_module, "_CALORIEAPP_ENV", "local") + monkeypatch.setattr(main_module, "_WORDPRESS_BRIDGE_SECRET", "test-secret") + + calls = {"count": 0} + + def flaky_exchange(code: str, state: str) -> IdentityClaimsResponse: + calls["count"] += 1 + if calls["count"] == 1: + raise HTTPException(status_code=502, detail="WordPress bridge exchange failed") + return _claims() + + monkeypatch.setattr(main_module, "_exchange_code_for_claims", flaky_exchange) + + with TestClient(app) as client: + start = client.post("/api/identity/login/start") + assert start.status_code == 200 + state = start.json()["state"] + + failed = client.post( + "/api/identity/callback", + json={"code": "bridge-code", "state": state}, + ) + assert failed.status_code == 502 + + with Session(db_module.engine) as session: + valid, reason, pending = validate_pending_login_state(session, state) + assert valid is True + assert reason == "ok" + assert pending is not None + + retried = client.post( + "/api/identity/callback", + json={"code": "bridge-code", "state": state}, + ) + assert retried.status_code == 200 + + replay = client.post( + "/api/identity/callback", + json={"code": "bridge-code", "state": state}, + ) + assert replay.status_code == 400 + assert "already consumed" in replay.json()["detail"] diff --git a/docs/CALORIEAPP_ENVIRONMENT_TOPOLOGY.md b/docs/CALORIEAPP_ENVIRONMENT_TOPOLOGY.md new file mode 100644 index 0000000..d41fcbe --- /dev/null +++ b/docs/CALORIEAPP_ENVIRONMENT_TOPOLOGY.md @@ -0,0 +1,258 @@ +# CalorieApp Environment Topology + +**Status:** authoritative working checkpoint for current V1 environment reconciliation + +**Purpose:** prevent environment drift, duplicate deployments, and accidental mixing of local development, bridge staging, production services, and ChatGPT-assisted project work. + +This document records only verified state. Unknowns remain explicitly marked as unknown. + +## 1. Operating model: five environments, five roles + +| Environment | Role | Authoritative for | Must NOT be used for | +|---|---|---|---| +| GitHub `CalorieToken/CalorieApp` | Source control | Application code, reviewed docs, branches, CI | Runtime secrets or ad-hoc deployment state | +| Windows VM / VS Code | Local development workstation | Local code inspection, development, WP Studio staging | Production hosting or a second live backend | +| WP Studio `CalorieApp Bridge Staging` | Local WordPress bridge staging | Testing the companion WordPress identity bridge before production install | Real-user production identity traffic | +| Render | Production CalorieApp runtime | Live frontend and backend services | Bridge development or duplicate experimental backends | +| ChatGPT workspace/conversation | Orchestration and project control | Planning, audits, GitHub changes, evidence reconciliation, guided checks | Being treated as a deployment/runtime environment | + +### Core rule + +There is one production application path and one staging bridge path. Do not create additional live equivalents unless a deliberate architecture decision says otherwise. + +## 2. Source-of-truth rules + +1. **GitHub `CalorieToken/CalorieApp` is the source of truth for application code.** +2. **Render Production is the source of truth for the live CalorieApp application runtime.** +3. **The Windows VM is development/tooling only.** +4. **WP Studio on the VM is the only current WordPress bridge staging environment.** +5. **The production WordPress bridge is not yet installed on `calorietoken.net`; production Xaman login is therefore intentionally incomplete.** +6. **ChatGPT is a coordination layer, not another environment.** Any architecture/runtime claim made in a chat must be reconciled against this document and verified infrastructure before acting on it. +7. **Do not create a second backend, second production bridge, or second Xaman identity path while the existing intended path remains recoverable.** +8. **Do not copy production secrets into GitHub, chat, screenshots, or local staging unless a deliberate secret-management decision requires a staging-specific value.** + +## 3. Verified production topology + +```text +Browser + -> https://calorieapp-frontend.onrender.com + -> https://calorieapp-backend-rvul.onrender.com + -> [production WordPress bridge NOT YET INSTALLED] + -> future production bridge on https://calorietoken.net + -> Xaman/XUMM identity flow + -> WordPress bridge authorization code + -> CalorieApp backend callback + -> opaque CalorieApp session cookie + -> authenticated CalorieApp APIs +``` + +### Render frontend + +- Service: `calorieapp-frontend` +- Public URL: `https://calorieapp-frontend.onrender.com` +- Repository: `CalorieToken/CalorieApp` +- Branch: `main` +- Verified frontend environment variable: `NEXT_PUBLIC_BACKEND_URL=https://calorieapp-backend-rvul.onrender.com` +- Status observed: deployed. + +### Render backend + +- Service: `calorieapp-backend` +- Render service ID: `srv-da34poht0dsc73cpc1kg` +- Public URL: `https://calorieapp-backend-rvul.onrender.com` +- Repository: `CalorieToken/CalorieApp` +- Branch: `main` +- Dashboard location: `My project -> Production -> calorieapp-backend` +- `/health` verified live with response identifying `calorieapp-backend`. + +## 4. Canonical local development checkout + +Windows VM: + +- Canonical working checkout: `C:\Users\p\CalorieApp` +- Remote: `https://github.com/CalorieToken/CalorieApp.git` +- Branch: `main` +- Observed HEAD checkpoint: `f419519` (`Align local, VS Code, and CI release validation`) +- Working tree was clean when inspected. +- Current checkout contains the committed identity/Xaman implementation, including backend identity services/tests, identity documentation, frontend auth events, and `XamanLoginPanel.tsx`. + +**Role:** this checkout is the only normal local CalorieApp development checkout. It must not be treated as another production backend. + +## 5. Local WP Studio bridge staging + +Verified WP Studio site: + +- Site name: `CalorieApp Bridge Staging` +- Local WordPress URL: `http://localhost:8881` +- Site is runnable through WordPress Studio on the VM. +- Plugin: `CalorieApp Identity Bridge` +- Plugin version observed: `0.1.1` +- Plugin status observed: active. +- REST `/calorieapp/v1/authorize` is registered locally; requesting it without `state` returns the expected missing-parameter error, confirming the route is active. + +Underlying staging tree: + +`C:\Users\p\Studio\calorieapp-bridge-staging` + +Bridge plugin tree: + +`C:\Users\p\Studio\calorieapp-bridge-staging\wp-content\plugins\calorieapp-identity-bridge` + +Separate bridge source tree: + +`C:\Users\p\calorieapp-identity-bridge` + +The staging plugin implements the expected identity contract: + +- REST `/authorize` +- REST `/exchange` +- state validation against `/api/identity/login/state/validate` +- short-lived authorization-code storage +- state matching / one-time consumption +- callback allowlist +- configurable CalorieApp backend URL +- configurable backend client ID +- bridge shared secret + +Observed bridge settings screen currently showed: + +- callback allowlist: empty +- default callback URL: empty +- CalorieApp backend URL: empty +- backend client ID: `calorieapp-backend` +- bridge secret field: no visible value shown +- code TTL: `60` seconds + +**Interpretation:** staging infrastructure exists and the plugin is active, but staging configuration is not yet complete. This is the correct place to finish bridge validation before any production WordPress install. + +## 6. Production WordPress status + +Verified current state: + +- `calorietoken.net` is the intended production WordPress host for the companion bridge. +- The CalorieApp bridge has **not yet been installed on the production WordPress site**. +- The public WordPress REST index therefore does not currently expose the `calorieapp/v1` namespace. + +This is expected given the unfinished deployment sequence and must not be misdiagnosed as a broken production plugin. + +## 7. Historical/local copies on the VM + +The VM also contains older/checkpoint material: + +- `C:\Users\p\CalorieApp-test` +- `C:\Users\p\CalorieApp_PRIVATE_CHECKPOINT_2026-08-20` +- `C:\Users\p\CalorieApp_PRIVATE_CHECKPOINT_6C2_2026-08-20` + +These are classified as **historical/checkpoint material**, not active environments. + +**Rule:** do not run, deploy from, or modify them during normal development. Do not delete them until the bridge/Xaman reconciliation is complete and their preservation value has been reviewed. + +## 8. Identity architecture contract + +Current V1 identity boundary: + +- Xaman/XUMM is used only as an external identity mechanism. +- CalorieApp maintains its own internal user and session model. +- Browser callback contract is `code + state`. +- WordPress companion bridge maps an authenticated WordPress/Xaman session to a short-lived CalorieApp authorization code. +- Backend validates the pending login state and exchanges the authorization code server-side. +- CalorieApp then issues an opaque application session cookie. + +Not approved in this V1 identity path: + +- private keys / seed phrases +- wallet custody +- XRPL transaction signing/submission +- payments / transfers +- trading / exchange +- token administration +- rewards or value transfer + +## 9. Environment flow rules + +### Development flow + +```text +GitHub feature branch + -> C:\Users\p\CalorieApp (local development/testing) + -> GitHub PR + CI +``` + +### Bridge staging flow + +```text +Bridge source + -> WP Studio CalorieApp Bridge Staging (localhost:8881) + -> validate bridge routes/config/contract + -> only after passing staging: prepare production WordPress install +``` + +### Production flow + +```text +GitHub main + -> Render frontend/backend + -> production WordPress bridge on calorietoken.net (after controlled install) + -> Xaman +``` + +### ChatGPT flow + +```text +User request/evidence + -> verify against GitHub/topology/infrastructure + -> propose or execute only within the correct environment + -> update this topology document when environment roles change +``` + +ChatGPT must not infer that a component is deployed merely because source files exist, or infer that a component is absent merely because it is not visible in one dashboard view. + +## 10. Xaman repair branch + +Branch: `fix/xaman-callback-retry` + +Branch work includes: + +- clearer sanitized callback diagnostics; +- retry-safe callback ordering so a transient WordPress/Xaman bridge failure does not burn pending login state; +- regression coverage for retry and replay behavior; +- updated identity documentation; +- this environment topology checkpoint. + +The branch remains intentionally unmerged until bridge staging is validated, production bridge deployment is prepared, and a real end-to-end login is tested. + +## 11. Ordered next gates + +Do these in order. Do not skip ahead. + +1. **Finish WP Studio staging configuration** using staging-safe values and a defined backend target. +2. **Validate local bridge behavior**: `/authorize`, backend state validation, `/exchange`, callback allowlist, one-time code behavior. +3. **Reconcile Render backend identity configuration** with the bridge contract without exposing secrets. +4. **Package/version the bridge for production** from the validated source; do not edit production ad hoc. +5. **Install/configure the bridge on `calorietoken.net`** in a controlled production step. +6. **Run end-to-end Xaman login**: Xaman -> WordPress bridge -> FastAPI callback -> CalorieApp session. +7. **Verify authenticated food logging/retrieval/deletion.** +8. **Only then merge/deploy the Xaman retry PR** as appropriate. +9. **Only after stable authentication resume the frozen showcase work.** +10. **Review/archive historical VM checkpoints** after the environment is stable. + +## 12. Change-control rules + +Until the ordered gates above complete: + +- no new Render backend; +- no second WordPress staging environment; +- no second production WordPress bridge; +- no new Xaman integration path; +- no running/deploying from historical checkpoint folders; +- no production secret copying into Git, chat, or screenshots; +- no merge of the Xaman repair PR merely because application CI is green; +- no showcase claim that authenticated Xaman login is working. + +Any future environment-role change must update this document in the same branch/PR that changes the deployment contract. + +--- + +**Checkpoint date:** 2026-08-25 + +**Current conclusion:** the project has accumulated multiple copies and tools, but the intended architecture is now clear: GitHub controls code, the VM is development, WP Studio is the single bridge staging environment, Render is the single production application runtime, production WordPress will host one bridge after staging passes, and ChatGPT coordinates work rather than acting as another runtime. \ No newline at end of file diff --git a/docs/IDENTITY_FOUNDATION.md b/docs/IDENTITY_FOUNDATION.md index b01af23..fc67029 100644 --- a/docs/IDENTITY_FOUNDATION.md +++ b/docs/IDENTITY_FOUNDATION.md @@ -40,7 +40,7 @@ The browser is not given a CalorieApp raw user ID as an authentication credentia `PendingLoginStateDB` persists a hashed login state with creation, expiration, status, and consumption timestamps. The backend generates a high-entropy state when login begins; its stored form is SHA-256 hashed. The current default state lifetime is 300 seconds, configurable through `LOGIN_STATE_LIFETIME_SECONDS`. -The callback consumes pending state using a conditional database update. A state can proceed only once while pending and unexpired, which prevents callback replay and concurrent double consumption. +The callback validates the pending state before the external bridge exchange and consumes it only after a successful exchange. Consumption still uses a conditional database update, so a state can complete only once while pending and unexpired. This preserves replay resistance while allowing a transient bridge failure to be retried without burning the login attempt. ### Opaque application sessions @@ -87,14 +87,16 @@ The signature is an HMAC-SHA256 over a canonical payload containing the protocol The browser calls `POST /api/identity/callback` with only `code` and `state`. -The backend validates and consumes the pending state before calling the configured WordPress exchange endpoint. The current exchange request uses: +The backend validates the pending state, calls the configured WordPress exchange endpoint, validates the returned identity claims, and only then atomically consumes the pending state. A transient bridge/network failure therefore leaves the state retryable; after a successful exchange, replay or concurrent completion is rejected by the consume gate. + +The exchange request uses: - `WORDPRESS_BRIDGE_EXCHANGE_URL` - JSON body: `code` and `state` - `X-CalorieApp-Bridge-Secret` - `X-CalorieApp-Client-Id` -The backend validates the returned identity claims, resolves or creates the CalorieApp user and external-identity mapping, and creates an opaque CalorieApp session. The browser does not provide an XRPL address as an authoritative callback claim. +After successful consumption, the backend resolves or creates the CalorieApp user and external-identity mapping and creates an opaque CalorieApp session. The browser does not provide an XRPL address as an authoritative callback claim. ### 5. Session creation and use @@ -135,7 +137,8 @@ Secrets belong in the relevant runtime secret store and must not be placed in do ## Implemented security controls - High-entropy pending login state stored as a hash. -- Atomic pending-state consumption for single-use callback semantics. +- Retry-safe pending-state flow: validation before exchange, atomic consumption after successful exchange. +- Single-use callback semantics after successful exchange, including replay/concurrent-completion rejection. - Opaque, high-entropy session tokens stored only as hashes. - Server-side session expiry, idle expiry, revocation, and replacement support. - HMAC-SHA256 bridge validation with client ID, timestamp, and one-time nonce replay protection. @@ -145,13 +148,13 @@ Secrets belong in the relevant runtime secret store and must not be placed in do ## Historical / superseded documentation -Earlier versions of this document described a `calorieapp_user_id` authentication cookie, `SameSite=Strict`, a frontend-managed login-session identifier, ownerless legacy logs being normally readable, and an older WordPress exchange endpoint/header contract. Those descriptions are historical and superseded by the current implementation above. +Earlier versions of this document described a `calorieapp_user_id` authentication cookie, `SameSite=Strict`, a frontend-managed login-session identifier, ownerless legacy logs being normally readable, an older WordPress exchange endpoint/header contract, and consuming callback state before the external code exchange. Those descriptions are historical and superseded by the current implementation above. The repository still contains `AuthorizationCodeDB` and related helper functions from the earlier identity-foundation work. They are retained as historical implementation context, but the active backend callback contract is the pending-state validation and external WordPress bridge exchange described in this document. This repository does not establish the implementation or live status of the external WordPress companion plugin. ## Testing and infrastructure status -Backend identity and endpoint tests cover current session, callback, state, nonce replay, and food-authorization behavior. Test presence does not verify external WordPress/Xaman hosting or a live end-to-end deployment. +Backend identity and endpoint tests cover current session, callback, state, nonce replay, retry-safe bridge failure handling, and food-authorization behavior. Test presence does not verify external WordPress/Xaman hosting or a live end-to-end deployment. Local development uses the repository's backend and frontend configuration. Staging and production infrastructure, including DNS, TLS, hosts, deployment platforms, bridge configuration, secrets, and database operations, remain planned or external and require independent verification. @@ -161,5 +164,5 @@ The following are not authorized by this document: financial, token, custody, wa --- -**Last reconciled:** 2026-08-20 -**Status:** Current implementation and approved V1 boundary documented; external infrastructure remains unverified. +**Last reconciled:** 2026-08-24 +**Status:** Retry-safe current implementation and approved V1 boundary documented; external infrastructure remains unverified. diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx index 8145f3b..8010776 100644 --- a/frontend/app/auth/callback/page.tsx +++ b/frontend/app/auth/callback/page.tsx @@ -10,6 +10,10 @@ type CallbackResponse = { redirect_to: string; }; +type ErrorResponse = { + detail?: unknown; +}; + function safeLocalRedirect(value: unknown): string { if ( typeof value !== "string" || @@ -32,6 +36,14 @@ function safeLocalRedirect(value: unknown): string { } } +function safeBackendErrorMessage(status: number, payload: ErrorResponse | null): string { + const detail = payload?.detail; + if (typeof detail === "string" && detail.trim()) { + return `Sign-in failed (${status}): ${detail.trim()}`; + } + return `Sign-in failed (${status}). Please try logging in again.`; +} + function AuthCallbackContent() { const params = useSearchParams(); const router = useRouter(); @@ -70,7 +82,15 @@ function AuthCallbackContent() { }); if (!response.ok) { - throw new Error("Callback failed"); + let payload: ErrorResponse | null = null; + try { + payload = (await response.json()) as ErrorResponse; + } catch { + payload = null; + } + setStatus("error"); + setMessage(safeBackendErrorMessage(response.status, payload)); + return; } const payload = (await response.json()) as CallbackResponse; @@ -78,7 +98,7 @@ function AuthCallbackContent() { } catch { if (!controller.signal.aborted) { setStatus("error"); - setMessage("Sign-in failed. Please try logging in again."); + setMessage("Sign-in failed before the callback completed. Please try again."); } } }