diff --git a/backend/.env.example b/backend/.env.example index d3b668b..ee74817 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -64,3 +64,7 @@ LOGIN_STATE_LIFETIME_SECONDS=300 SESSION_COOKIE_SECURE=true # Use none only for secure cross-site showcase hosting; keep lax for same-site/local deployments. SESSION_COOKIE_SAMESITE=lax + +# Irreversible primary-store account erasure is disabled until the recovery +# window, backup-erasure schedule, privacy notice and translated UI are approved. +ACCOUNT_ERASURE_ENABLED=false diff --git a/backend/README.md b/backend/README.md index c55ceab..be1f746 100644 --- a/backend/README.md +++ b/backend/README.md @@ -16,6 +16,8 @@ data and Identity Bridge completion work. - GET /search-food?q= - POST /log-food - GET /logs +- GET /api/identity/export +- DELETE /api/identity/account (disabled by default pending human release approval) ## Local Run diff --git a/backend/app/main.py b/backend/app/main.py index ed00b23..efe5be7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,7 @@ from .locales import resolve_locale from .models import ( AuthSessionDB, + AuthorizationCodeDB, BridgeAuthNonceDB, CalorieAppUserDB, ExternalIdentityDB, @@ -30,6 +31,8 @@ OriginLoginHandoffDB, ) from .schemas import ( + AccountErasureRequest, + AccountErasureResponse, AccountDataExportResponse, AccountExportAccount, AccountExportAuthSession, @@ -103,6 +106,11 @@ str(max(_BRIDGE_AUTH_MAX_AGE_SECONDS + _BRIDGE_AUTH_MAX_FUTURE_SECONDS, 330)), ) ) +_ACCOUNT_ERASURE_ENABLED = os.getenv("ACCOUNT_ERASURE_ENABLED", "false").lower() in { + "1", + "true", + "yes", +} _IDENTITY_PROVIDER = "wordpress_xumm" if not _WORDPRESS_BRIDGE_SECRET: @@ -1074,6 +1082,129 @@ def identity_export( ) +@app.delete("/api/identity/account", response_model=AccountErasureResponse) +def identity_erase_account( + payload: AccountErasureRequest, + response: Response, + session: DbSession, + current_user: CurrentUser, +) -> AccountErasureResponse: + """Erase one authenticated account from the primary store when explicitly enabled. + + The endpoint is disabled by default. Enabling it remains a human release decision + after the recovery window, backup-erasure schedule, privacy notice and translated + confirmation UI have been approved. + """ + if not _ACCOUNT_ERASURE_ENABLED: + raise HTTPException(status_code=503, detail="Account erasure is not enabled") + + if payload.confirm_user_id != current_user.id: + raise HTTPException(status_code=409, detail="Account confirmation did not match") + + identities = session.exec( + select(ExternalIdentityDB).where( + ExternalIdentityDB.calorieapp_user_id == current_user.id + ) + ).all() + external_subjects = sorted({identity.external_subject for identity in identities}) + + # Authorization activity predates direct internal-user and provider ownership. + # Refuse erasure rather than assigning or deleting another user's legacy activity. + if external_subjects: + ambiguous_identity = session.exec( + select(ExternalIdentityDB).where( + ExternalIdentityDB.external_subject.in_(external_subjects), + ExternalIdentityDB.calorieapp_user_id != current_user.id, + ) + ).first() + if ambiguous_identity is not None: + raise HTTPException( + status_code=409, + detail="Account identity requires operator review before erasure", + ) + + legacy_authorization = session.exec( + select(AuthorizationCodeDB).where( + AuthorizationCodeDB.external_subject.in_(external_subjects) + ) + ).first() + if legacy_authorization is not None: + raise HTTPException( + status_code=409, + detail=( + "Account authorization history requires operator review " + "before erasure" + ), + ) + + try: + session.exec(delete(FoodLogDB).where(FoodLogDB.owner_id == current_user.id)) + session.exec( + delete(OriginLoginHandoffDB).where( + OriginLoginHandoffDB.calorieapp_user_id == current_user.id + ) + ) + + # Break both outgoing and incoming self-references before removing every + # session for the account. An older session cookie can belong to another + # account while pointing at the replacement session created after login. + auth_sessions = session.exec( + select(AuthSessionDB).where( + AuthSessionDB.calorieapp_user_id == current_user.id + ) + ).all() + auth_session_ids = [ + auth_session.id + for auth_session in auth_sessions + if auth_session.id is not None + ] + inbound_references = ( + session.exec( + select(AuthSessionDB).where( + AuthSessionDB.replaced_by_session_id.in_(auth_session_ids) + ) + ).all() + if auth_session_ids + else [] + ) + for inbound_reference in inbound_references: + inbound_reference.replaced_by_session_id = None + session.add(inbound_reference) + for auth_session in auth_sessions: + auth_session.replaced_by_session_id = None + session.add(auth_session) + session.flush() + session.exec( + delete(AuthSessionDB).where( + AuthSessionDB.calorieapp_user_id == current_user.id + ) + ) + + session.exec( + delete(ExternalIdentityDB).where( + ExternalIdentityDB.calorieapp_user_id == current_user.id + ) + ) + session.exec( + delete(CalorieAppUserDB).where(CalorieAppUserDB.id == current_user.id) + ) + session.commit() + except Exception: + session.rollback() + raise + + response.delete_cookie( + key=SESSION_COOKIE_NAME, + path="/", + domain=None, + secure=_SESSION_COOKIE_SECURE, + httponly=True, + samesite=_SESSION_COOKIE_SAMESITE, + ) + logger.info("Authenticated account erased from primary store") + return AccountErasureResponse(status="erased") + + @app.post("/api/identity/logout", response_model=LogoutResponse) def identity_logout( response: Response, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 63a9a33..ceb1152 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -308,6 +308,21 @@ def normalize_export_timestamp(cls, value: datetime) -> datetime: return _ensure_utc(value) +class AccountErasureRequest(BaseModel): + """Explicit confirmation bound to the authenticated internal account.""" + + model_config = ConfigDict(str_strip_whitespace=True) + + confirm_user_id: str = Field(..., min_length=1, max_length=64) + acknowledgement: Literal["delete-my-calorieapp-account"] + + +class AccountErasureResponse(BaseModel): + """Minimal response after irreversible primary-store erasure.""" + + status: Literal["erased"] + + class LogoutResponse(BaseModel): """Response after logout.""" diff --git a/backend/tests/test_account_erasure.py b/backend/tests/test_account_erasure.py new file mode 100644 index 0000000..811dafd --- /dev/null +++ b/backend/tests/test_account_erasure.py @@ -0,0 +1,338 @@ +"""Safety tests for the disabled-by-default account-erasure endpoint.""" + +from datetime import UTC, datetime, timedelta + +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +import app.database as db_module +import app.main as main_module +from app.models import ( + AuthSessionDB, + AuthorizationCodeDB, + CalorieAppUserDB, + ExternalIdentityDB, + FoodLogDB, + OriginLoginHandoffDB, +) + + +def _confirmation(user_id: str) -> dict[str, str]: + return { + "confirm_user_id": user_id, + "acknowledgement": "delete-my-calorieapp-account", + } + + +def test_account_erasure_requires_authentication(client: TestClient) -> None: + response = client.request( + "DELETE", + "/api/identity/account", + json=_confirmation("unknown-user"), + ) + + assert response.status_code == 401 + assert response.headers["cache-control"] == "no-store" + + +def test_account_erasure_is_disabled_by_default( + authenticated_client: TestClient, + monkeypatch, +) -> None: + user_id = authenticated_client.get("/api/identity/me").json()["user_id"] + monkeypatch.setattr(main_module, "_ACCOUNT_ERASURE_ENABLED", False) + + response = authenticated_client.request( + "DELETE", + "/api/identity/account", + json=_confirmation(user_id), + ) + + assert response.status_code == 503 + with Session(db_module.engine) as session: + assert session.get(CalorieAppUserDB, user_id) is not None + + +def test_account_erasure_rejects_mismatched_confirmation_without_mutation( + authenticated_client: TestClient, + monkeypatch, +) -> None: + user_id = authenticated_client.get("/api/identity/me").json()["user_id"] + monkeypatch.setattr(main_module, "_ACCOUNT_ERASURE_ENABLED", True) + + response = authenticated_client.request( + "DELETE", + "/api/identity/account", + json=_confirmation("different-user-id"), + ) + + assert response.status_code == 409 + with Session(db_module.engine) as session: + assert session.get(CalorieAppUserDB, user_id) is not None + assert session.exec( + select(AuthSessionDB).where(AuthSessionDB.calorieapp_user_id == user_id) + ).first() is not None + + +def test_account_erasure_removes_only_authenticated_users_primary_data( + authenticated_client: TestClient, + monkeypatch, +) -> None: + user_id = authenticated_client.get("/api/identity/me").json()["user_id"] + monkeypatch.setattr(main_module, "_ACCOUNT_ERASURE_ENABLED", True) + now = datetime.now(UTC) + own_subject = "wp:calorietoken.net:erase-owner" + other_subject = "wp:calorietoken.net:keep-other" + + with Session(db_module.engine) as session: + other_user = CalorieAppUserDB(status="active") + session.add(other_user) + session.commit() + session.refresh(other_user) + + current_auth_session = session.exec( + select(AuthSessionDB).where(AuthSessionDB.calorieapp_user_id == user_id) + ).one() + replacement = AuthSessionDB( + session_token_hash="1" * 64, + calorieapp_user_id=user_id, + created_at=now, + last_seen_at=now, + expires_at=now + timedelta(hours=1), + ) + session.add(replacement) + session.flush() + current_auth_session.replaced_by_session_id = replacement.id + session.add(current_auth_session) + + session.add_all( + [ + ExternalIdentityDB( + calorieapp_user_id=user_id, + provider="wordpress_xumm", + external_subject=own_subject, + ), + ExternalIdentityDB( + calorieapp_user_id=other_user.id, + provider="wordpress_xumm", + external_subject=other_subject, + ), + AuthorizationCodeDB( + code_hash="3" * 64, + external_subject=other_subject, + state="other-state", + login_session_id="other-login", + expires_at=now + timedelta(minutes=5), + ), + FoodLogDB( + product_name="Erase private apple", + calories=52, + owner_id=user_id, + ), + FoodLogDB( + product_name="Keep private oats", + calories=380, + owner_id=other_user.id, + ), + OriginLoginHandoffDB( + state_hash="4" * 64, + handoff_token_hash="5" * 64, + status="claimed", + calorieapp_user_id=user_id, + created_at=now, + expires_at=now + timedelta(minutes=5), + ), + OriginLoginHandoffDB( + state_hash="6" * 64, + handoff_token_hash="7" * 64, + status="completed", + calorieapp_user_id=other_user.id, + created_at=now, + expires_at=now + timedelta(minutes=5), + ), + ] + ) + session.commit() + other_user_id = other_user.id + + response = authenticated_client.request( + "DELETE", + "/api/identity/account", + json=_confirmation(user_id), + ) + + assert response.status_code == 200 + assert response.json() == {"status": "erased"} + assert "calorieapp_session=" in response.headers["set-cookie"] + assert "Max-Age=0" in response.headers["set-cookie"] + assert authenticated_client.get("/api/identity/me").status_code == 401 + + with Session(db_module.engine) as session: + assert session.get(CalorieAppUserDB, user_id) is None + assert session.get(CalorieAppUserDB, other_user_id) is not None + assert session.exec( + select(FoodLogDB).where(FoodLogDB.owner_id == user_id) + ).all() == [] + assert session.exec( + select(AuthSessionDB).where(AuthSessionDB.calorieapp_user_id == user_id) + ).all() == [] + assert session.exec( + select(ExternalIdentityDB).where( + ExternalIdentityDB.calorieapp_user_id == user_id + ) + ).all() == [] + assert session.exec( + select(OriginLoginHandoffDB).where( + OriginLoginHandoffDB.calorieapp_user_id == user_id + ) + ).all() == [] + assert session.exec( + select(AuthorizationCodeDB).where( + AuthorizationCodeDB.external_subject == other_subject + ) + ).one().external_subject == other_subject + + +def test_account_erasure_stops_on_ambiguous_legacy_identity_without_mutation( + authenticated_client: TestClient, + monkeypatch, +) -> None: + user_id = authenticated_client.get("/api/identity/me").json()["user_id"] + monkeypatch.setattr(main_module, "_ACCOUNT_ERASURE_ENABLED", True) + shared_subject = "shared-subject-across-providers" + + with Session(db_module.engine) as session: + other_user = CalorieAppUserDB(status="active") + session.add(other_user) + session.commit() + session.refresh(other_user) + session.add_all( + [ + ExternalIdentityDB( + calorieapp_user_id=user_id, + provider="wordpress_xumm", + external_subject=shared_subject, + ), + ExternalIdentityDB( + calorieapp_user_id=other_user.id, + provider="future_provider", + external_subject=shared_subject, + ), + FoodLogDB( + product_name="Must remain after rejected erasure", + calories=10, + owner_id=user_id, + ), + ] + ) + session.commit() + + response = authenticated_client.request( + "DELETE", + "/api/identity/account", + json=_confirmation(user_id), + ) + + assert response.status_code == 409 + with Session(db_module.engine) as session: + assert session.get(CalorieAppUserDB, user_id) is not None + assert session.exec( + select(FoodLogDB).where(FoodLogDB.owner_id == user_id) + ).one().product_name == "Must remain after rejected erasure" + + +def test_account_erasure_stops_on_unowned_legacy_authorization_without_mutation( + authenticated_client: TestClient, + monkeypatch, +) -> None: + user_id = authenticated_client.get("/api/identity/me").json()["user_id"] + monkeypatch.setattr(main_module, "_ACCOUNT_ERASURE_ENABLED", True) + legacy_subject = "wp:calorietoken.net:unowned-legacy-authorization" + + with Session(db_module.engine) as session: + session.add_all( + [ + ExternalIdentityDB( + calorieapp_user_id=user_id, + provider="wordpress_xumm", + external_subject=legacy_subject, + ), + AuthorizationCodeDB( + code_hash="8" * 64, + external_subject=legacy_subject, + state="unowned-legacy-state", + login_session_id="unowned-legacy-login", + expires_at=datetime.now(UTC) + timedelta(minutes=5), + ), + FoodLogDB( + product_name="Must remain with unowned authorization", + calories=11, + owner_id=user_id, + ), + ] + ) + session.commit() + + response = authenticated_client.request( + "DELETE", + "/api/identity/account", + json=_confirmation(user_id), + ) + + assert response.status_code == 409 + assert response.json()["detail"] == ( + "Account authorization history requires operator review before erasure" + ) + with Session(db_module.engine) as session: + assert session.get(CalorieAppUserDB, user_id) is not None + assert session.exec( + select(FoodLogDB).where(FoodLogDB.owner_id == user_id) + ).one().product_name == "Must remain with unowned authorization" + assert session.exec( + select(AuthorizationCodeDB).where( + AuthorizationCodeDB.external_subject == legacy_subject + ) + ).one().used_at is None + + +def test_account_erasure_clears_other_users_inbound_session_replacement_reference( + authenticated_client: TestClient, + monkeypatch, +) -> None: + user_id = authenticated_client.get("/api/identity/me").json()["user_id"] + monkeypatch.setattr(main_module, "_ACCOUNT_ERASURE_ENABLED", True) + now = datetime.now(UTC) + + with Session(db_module.engine) as session: + target_session = session.exec( + select(AuthSessionDB).where(AuthSessionDB.calorieapp_user_id == user_id) + ).one() + other_user = CalorieAppUserDB(status="active") + session.add(other_user) + session.flush() + other_session = AuthSessionDB( + session_token_hash="9" * 64, + calorieapp_user_id=other_user.id, + created_at=now, + last_seen_at=now, + expires_at=now + timedelta(hours=1), + replaced_by_session_id=target_session.id, + ) + session.add(other_session) + session.commit() + other_user_id = other_user.id + other_session_id = other_session.id + + response = authenticated_client.request( + "DELETE", + "/api/identity/account", + json=_confirmation(user_id), + ) + + assert response.status_code == 200 + with Session(db_module.engine) as session: + assert session.get(CalorieAppUserDB, user_id) is None + assert session.get(CalorieAppUserDB, other_user_id) is not None + preserved_session = session.get(AuthSessionDB, other_session_id) + assert preserved_session is not None + assert preserved_session.replaced_by_session_id is None diff --git a/backend/tests/test_data_safety_contract.py b/backend/tests/test_data_safety_contract.py index e353893..2832484 100644 --- a/backend/tests/test_data_safety_contract.py +++ b/backend/tests/test_data_safety_contract.py @@ -118,6 +118,37 @@ def test_account_export_is_private_versioned_and_secret_free() -> None: assert export["account_erasure_or_retention_policy_changed"] is False +def test_account_erasure_is_private_fail_closed_and_human_gated() -> None: + erasure = _load_json("data-safety.json")["account_erasure"] + + assert erasure["status"] == ( + "v2-backend-implemented-disabled-pending-policy-ui-and-notice" + ) + assert erasure["enabled_by_default"] is False + assert erasure["authenticated_user_only"] is True + assert erasure["explicit_internal_user_id_confirmation_required"] is True + assert erasure["fixed_machine_acknowledgement_required"] is True + assert erasure["cross_user_deletion_allowed"] is False + assert erasure["ambiguous_legacy_identity_fails_closed"] is True + assert erasure["unowned_legacy_authorization_fails_closed"] is True + assert erasure["legacy_authorization_events_deleted_without_direct_ownership"] is False + assert erasure[ + "direct_ownership_migration_required_before_legacy_authorization_erasure" + ] is True + assert erasure["all_primary_authentication_sessions_removed"] is True + assert erasure["inbound_session_replacement_references_cleared"] is True + assert erasure[ + "directly_owned_primary_food_history_identity_links_sessions_and_handoffs_removed" + ] is True + assert erasure["browser_session_cookie_cleared"] is True + assert erasure["backup_erasure_claimed_complete"] is False + assert erasure["recovery_window_selected"] is False + assert erasure["eleven_language_identity_bridge_ui_required"] is True + assert erasure["privacy_notice_alignment_required"] is True + assert erasure["human_release_approval_required_to_enable"] is True + assert erasure["production_enabled_or_data_mutation_performed"] is False + + def test_xrpl_linking_is_optional_off_chain_and_privacy_preserving() -> None: contract = _load_json("data-safety.json") linking = contract["xrpl_transaction_linking"] @@ -446,6 +477,10 @@ def test_all_required_durable_data_release_gates_are_explicit_and_blocking() -> assert "backend/tests/test_account_data_export.py" in gates["user_data_export"][ "evidence" ] + assert gates["user_erasure"]["status"] == "partial" + assert "backend/tests/test_account_erasure.py" in gates["user_erasure"][ + "evidence" + ] assert gates["retention_policy"]["status"] == "decision_required" assert matrix["release_state"] == "blocked" diff --git a/backend/tests/test_postgresql_integration.py b/backend/tests/test_postgresql_integration.py index 370fe18..638dd9d 100644 --- a/backend/tests/test_postgresql_integration.py +++ b/backend/tests/test_postgresql_integration.py @@ -12,6 +12,7 @@ from sqlmodel import Session, create_engine, select import app.database as db_module +import app.main as main_module from app.main import SESSION_COOKIE_NAME, app from app.models import ( AuthSessionDB, @@ -233,3 +234,60 @@ def test_postgresql_identity_history_survives_application_engine_restart( verification_engine.dispose() finally: db_module.engine = original_engine + + +def test_postgresql_account_erasure_clears_cross_account_session_reference( + postgres_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> None: + upgrade_database( + postgres_engine, + approval_reference="CI-POSTGRES-ACCOUNT-ERASURE-REFERENCES", + ) + other_user_id, _ = _create_user_session( + postgres_engine, + "synthetic-erasure-reference-owner", + ) + target_user_id, target_token = _create_user_session( + postgres_engine, + "synthetic-erasure-target", + ) + + with Session(postgres_engine) as session: + target_session = session.exec( + select(AuthSessionDB).where( + AuthSessionDB.calorieapp_user_id == target_user_id + ) + ).one() + other_session = session.exec( + select(AuthSessionDB).where( + AuthSessionDB.calorieapp_user_id == other_user_id + ) + ).one() + other_session.replaced_by_session_id = target_session.id + session.add(other_session) + session.commit() + other_session_id = other_session.id + + original_engine = db_module.engine + monkeypatch.setattr(main_module, "_ACCOUNT_ERASURE_ENABLED", True) + try: + with _client(postgres_engine, target_token) as client: + response = client.request( + "DELETE", + "/api/identity/account", + json={ + "confirm_user_id": target_user_id, + "acknowledgement": "delete-my-calorieapp-account", + }, + ) + assert response.status_code == 200 + + with Session(postgres_engine) as session: + assert session.get(CalorieAppUserDB, target_user_id) is None + assert session.get(CalorieAppUserDB, other_user_id) is not None + preserved_session = session.get(AuthSessionDB, other_session_id) + assert preserved_session is not None + assert preserved_session.replaced_by_session_id is None + finally: + db_module.engine = original_engine diff --git a/contracts/data-safety/v1/data-safety.json b/contracts/data-safety/v1/data-safety.json index dc7f694..0f60735 100644 --- a/contracts/data-safety/v1/data-safety.json +++ b/contracts/data-safety/v1/data-safety.json @@ -121,6 +121,28 @@ "privacy_notice_alignment_required": true, "account_erasure_or_retention_policy_changed": false }, + "account_erasure": { + "status": "v2-backend-implemented-disabled-pending-policy-ui-and-notice", + "enabled_by_default": false, + "authenticated_user_only": true, + "explicit_internal_user_id_confirmation_required": true, + "fixed_machine_acknowledgement_required": true, + "cross_user_deletion_allowed": false, + "ambiguous_legacy_identity_fails_closed": true, + "unowned_legacy_authorization_fails_closed": true, + "legacy_authorization_events_deleted_without_direct_ownership": false, + "direct_ownership_migration_required_before_legacy_authorization_erasure": true, + "all_primary_authentication_sessions_removed": true, + "inbound_session_replacement_references_cleared": true, + "directly_owned_primary_food_history_identity_links_sessions_and_handoffs_removed": true, + "browser_session_cookie_cleared": true, + "backup_erasure_claimed_complete": false, + "recovery_window_selected": false, + "eleven_language_identity_bridge_ui_required": true, + "privacy_notice_alignment_required": true, + "human_release_approval_required_to_enable": true, + "production_enabled_or_data_mutation_performed": false + }, "backup_and_recovery": { "encrypted_at_rest_and_in_transit_required": true, "restricted_operator_access_required": true, diff --git a/contracts/data-safety/v1/release-test-matrix.json b/contracts/data-safety/v1/release-test-matrix.json index 98652ee..28b7a9c 100644 --- a/contracts/data-safety/v1/release-test-matrix.json +++ b/contracts/data-safety/v1/release-test-matrix.json @@ -121,7 +121,12 @@ "id": "user_erasure", "status": "partial", "release_blocking": true, - "evidence": ["backend/app/main.py", "backend/tests/test_endpoints.py"] + "evidence": [ + "backend/app/main.py", + "backend/app/schemas.py", + "backend/tests/test_account_erasure.py", + "docs/ACCOUNT_ERASURE.md" + ] }, { "id": "retention_policy", diff --git a/docs/ACCOUNT_DATA_EXPORT.md b/docs/ACCOUNT_DATA_EXPORT.md index 56c778b..a24cb22 100644 --- a/docs/ACCOUNT_DATA_EXPORT.md +++ b/docs/ACCOUNT_DATA_EXPORT.md @@ -45,5 +45,7 @@ is transparent. - obtain a human decision on account erasure, recovery window, inactive-account retention and backup-erasure timing. -This change does not enable account erasure, choose a retention period, send an -export to a third party or publish private history. +The export endpoint does not choose a retention period, send an export to a +third party or publish private history. The separately implemented account- +erasure endpoint remains disabled by default and fails closed on matching +legacy authorization rows without direct ownership; see `ACCOUNT_ERASURE.md`. diff --git a/docs/ACCOUNT_ERASURE.md b/docs/ACCOUNT_ERASURE.md new file mode 100644 index 0000000..9c86029 --- /dev/null +++ b/docs/ACCOUNT_ERASURE.md @@ -0,0 +1,46 @@ +# Authenticated account erasure + +Status: V2 backend implemented and disabled by default. This is still a +release-blocking partial gate, not a production-ready deletion policy. + +## Backend contract + +`DELETE /api/identity/account` requires the normal opaque CalorieApp session +cookie plus two explicit JSON confirmations: + +- the authenticated internal CalorieApp user identifier; +- the fixed machine acknowledgement `delete-my-calorieapp-account`. + +When `ACCOUNT_ERASURE_ENABLED=true`, one successful transaction removes the +authenticated user's directly owned primary-store food history, Identity Bridge +links, browser handoffs, all authentication sessions and the internal account. +Before session deletion it clears incoming replacement references, including a +reference from an older session belonging to another account, while preserving +that other account and session. It then clears the browser session cookie. It +does not touch another user's records, external WordPress/Xaman accounts, +public ledgers, third-party source data or unrelated ecosystem data. + +Legacy authorization activity is keyed by external subject rather than by the +internal user identifier or provider. A matching subject therefore does not +prove ownership. If a current identity is ambiguous, or if any matching legacy +authorization row exists without direct ownership, the endpoint fails with +`409` before mutation and requires operator review. It never deletes such a row +on subject alone. A separate migration must record direct ownership before a +future erasure flow may include legacy authorization activity. + +## Permanent safety boundary + +The endpoint is disabled unless explicitly enabled in deployment configuration. +Code completion does not authorize live activation. Before activation, a human +must approve: + +- whether deletion is immediate or has a recovery window; +- encrypted-backup retention and when erasure reaches backups; +- privacy-notice wording and support/escalation handling; +- the translated eleven-language confirmation and consequence UI; +- a PostgreSQL staging test and documented restore/erasure drill; +- the exact production deployment and rollback plan. + +The current implementation makes no claim that backups are already erased. No +live account, session or personal record was mutated while implementing this +gate.