diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..b0f9903a
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,17 @@
+# Project continuity
+
+- Keep the currently deployed CalorieApp stable; changes to a new surface should
+ not silently deploy unrelated draft backend, identity or database work.
+- CalorieVerse is one continuously growing world. Preserve the original meadow,
+ starter identity, stable content IDs and earned progress. Internal schema
+ migrations must not create a replacement game or require a user reset.
+- Read `docs/CALORIEVERSE_LIVE_CHECKPOINT.md` before continuing CalorieVerse. The
+ larger architecture and built simulator/Studio/F&B modules are preserved in
+ PR #150 at `f8711ee`; do not repeat or discard that work.
+- Reuse the shared display-language provider and eleven-locale registry.
+- Ordinary exploration never requires a wallet, node, storage, compute or rewards.
+ Those roles need separate opt-in, resource limits, pause/resume/full stop.
+- Local guest game state is not verified reward evidence. Keep private identity,
+ food logs and credentials outside public participant storage/compute.
+- Preserve deferred architecture/governance decisions in repository documents;
+ never record secrets or temporary chat credentials.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd2ca8bd..bcf5eee3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,6 +53,7 @@ jobs:
python tools/sync_identity_contracts.py --check
python -m unittest tools.tests.test_identity_contracts
python -m unittest tools.tests.test_localization_contracts
+ python -m unittest tools.tests.test_build_total_review_package
- name: Test mobile, offline custody and tracked-secret guards
run: |
@@ -76,7 +77,10 @@ jobs:
run: find wordpress-plugins -type f -name '*.php' -print0 | xargs -0 -n1 php -l
- name: Validate standalone Site Style package
- run: node --test tools/tests/wordpress_site_style_package.test.mjs tools/tests/wordpress_app_origin.test.mjs
+ run: node --test tools/tests/wordpress_site_style_package.test.mjs tools/tests/wordpress_app_origin.test.mjs tools/tests/wordpress_cookie_controls.test.mjs
+
+ - name: Test read-only Site Style review inventory
+ run: php tools/tests/wordpress_site_style_review.test.php
- name: Test legal footer compatibility
shell: bash
@@ -134,6 +138,11 @@ jobs:
- name: Build and inspect release archive
run: python tools/build_wordpress_plugin_release.py
+ - name: Build and verify Site Style release
+ run: |
+ python -m unittest tools.tests.test_build_site_style_release
+ python tools/build_site_style_release.py
+
backend-tests:
name: Backend tests (Python 3.11)
runs-on: ubuntu-latest
@@ -304,34 +313,18 @@ jobs:
working-directory: frontend
run: npm ci
- - name: Audit frontend production dependencies
+ - name: Audit frontend runtime and development dependencies
working-directory: frontend
- run: npm audit --omit=dev --audit-level=critical
+ run: npm audit --audit-level=high
- name: Lint frontend
working-directory: frontend
run: npm run lint
- - name: Test embedded login and private account controls
- run: >-
- node --test
- tools/tests/auth_callback_return.test.mjs
- tools/tests/account_data_import_ui.test.mjs
- tools/tests/account_data_export_validation.test.mjs
- tools/tests/account_erasure_ui.test.mjs
- tools/tests/account_privacy_locales.test.mjs
- tools/tests/account_privacy_display.test.mjs
- tools/tests/display_language_protocol.test.mjs
- tools/tests/display_language_ui.test.mjs
- tools/tests/testnet_entry.test.mjs
- tools/tests/backend_proxy_logout_fallback.test.mjs
- tools/tests/backend_warmup_rate_limit.test.mjs
- tools/tests/calorieapp_embed_readiness.test.mjs
- tools/tests/food_logging_ui.test.mjs
- tools/tests/food_search_deadline.test.mjs
- tools/tests/identity_locales.test.mjs
- tools/tests/xaman_logout_request.test.mjs
- tools/tests/xaman_login_start_retry.test.mjs
+ - name: Test all frontend and WordPress user flows
+ # Discover new test files automatically so follow-up checks cannot be
+ # forgotten in a manually maintained list.
+ run: node --test tools/tests/*.test.mjs
- name: Build frontend
working-directory: frontend
diff --git a/.github/workflows/food-ux-isolated-check.yml b/.github/workflows/food-ux-isolated-check.yml
new file mode 100644
index 00000000..12e26298
--- /dev/null
+++ b/.github/workflows/food-ux-isolated-check.yml
@@ -0,0 +1,124 @@
+name: Food UX isolated check
+
+on:
+ push:
+ branches: [repair/food-ux-integration-20260915]
+ pull_request:
+ branches: [herstel/vervolg-20260915]
+ paths:
+ - 'frontend/**'
+ - 'backend/app/**'
+ - 'backend/tests/**'
+ - 'wordpress-plugins/calorieapp-account-profile/**'
+ - 'backend/app/schemas.py'
+ - 'backend/app/services/open_food_facts.py'
+ - 'backend/tests/test_food_log_view.py'
+ - 'backend/tests/test_open_food_facts_normalization.py'
+ - 'tools/tests/**'
+ - 'tools/build_heading_repair_release.py'
+ - 'wordpress-plugins/calorietoken-heading-repair/**'
+ - '.github/workflows/food-ux-isolated-check.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: food-ux-isolated-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 12
+ env:
+ NEXT_TELEMETRY_DISABLED: '1'
+ CI: 'true'
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Record the source under test
+ run: |
+ mkdir -p ux-check-evidence
+ git rev-parse HEAD > ux-check-evidence/verified-commit.txt
+ git rev-parse 'HEAD^{tree}' > ux-check-evidence/verified-tree.txt
+ git archive --format=tar.gz -o ux-check-evidence/verified-source.tar.gz HEAD
+ - name: Install locked dependencies and test tools
+ run: |
+ npm ci --prefix frontend --no-audit --no-fund
+ python -m pip install -r backend/requirements.txt 'playwright==1.57.0'
+ python -m playwright install --with-deps chromium
+ - name: Full regression suite and production build
+ shell: bash
+ run: |
+ set -euo pipefail
+ node --test tools/tests/*.test.mjs 2>&1 | tee ux-check-evidence/regressions.log
+ PYTHONPATH=backend python -m pytest -q \
+ backend/tests/test_account_profile.py \
+ backend/tests/test_account_data_export.py \
+ backend/tests/test_account_data_import.py \
+ backend/tests/test_account_erasure.py \
+ backend/tests/test_database.py \
+ backend/tests/test_identity_endpoints.py \
+ backend/tests/test_inactive_account_erasure_execution.py \
+ backend/tests/test_food_log_view.py \
+ backend/tests/test_open_food_facts_normalization.py \
+ 2>&1 | tee ux-check-evidence/backend-food-tests.log
+ python -m unittest tools.tests.test_build_heading_repair_release 2>&1 | tee ux-check-evidence/heading-release-tests.log
+ find wordpress-plugins/calorietoken-heading-repair -type f -name '*.php' -print0 \
+ | xargs -0 -n1 php -l 2>&1 | tee ux-check-evidence/heading-php-lint.log
+ php -l wordpress-plugins/calorieapp-account-profile/calorieapp-account-profile.php
+ php wordpress-plugins/calorieapp-account-profile/tests/profile-test.php
+ python tools/build_heading_repair_release.py \
+ --output-dir ux-check-evidence/heading-release \
+ 2>&1 | tee ux-check-evidence/heading-release.log
+ cd frontend
+ ./node_modules/.bin/tsc --noEmit 2>&1 | tee ../ux-check-evidence/typecheck.log
+ npm run build 2>&1 | tee ../ux-check-evidence/build.log
+ - name: Production-browser check with synthetic backend only
+ shell: bash
+ run: |
+ set -euo pipefail
+ npm run start --prefix frontend -- --hostname 127.0.0.1 --port 3100 > ux-check-evidence/server.log 2>&1 &
+ server_pid=$!
+ trap 'kill "$server_pid" 2>/dev/null || true' EXIT
+ for i in $(seq 1 30); do curl --silent --fail http://127.0.0.1:3100/ > /dev/null && break; sleep 1; done
+ curl --silent --fail http://127.0.0.1:3100/ > /dev/null
+ # Collect every independent browser result even if one flow fails.
+ browser_failed=0
+ python tools/tests/browser_food_ux.py 2>&1 | tee ux-check-evidence/browser.log || browser_failed=1
+ python tools/tests/browser_account_profile.py 2>&1 | tee ux-check-evidence/account-profile-browser.log || browser_failed=1
+ python tools/tests/browser_account_guides.py 2>&1 | tee ux-check-evidence/account-guides-browser.log || browser_failed=1
+ HEADING_NUTRITION_EVIDENCE_DIR=ux-check-evidence/heading-nutrition-browser \
+ python tools/tests/browser_heading_nutrition.py \
+ 2>&1 | tee ux-check-evidence/heading-nutrition-browser.log || browser_failed=1
+ test "$browser_failed" -eq 0
+ python - <<'PY'
+ import json
+ from pathlib import Path
+ profile=json.loads(Path('ux-check-evidence/account-profile-browser/report.json').read_text())
+ assert profile['status']=='passed' and not profile['errors'] and len(profile['checks'])>=40, profile
+ r=json.loads(Path('ux-check-evidence/browser/report.json').read_text())
+ assert r['status']=='passed' and not r['errors'], r
+ assert len(r['checks']) >= 55 and len(r['writes']) == 1, r
+ h=json.loads(Path('ux-check-evidence/heading-nutrition-browser/report.json').read_text())
+ assert h['status']=='passed' and not h['errors'], h
+ assert len(h['checks']) >= 57, h
+ a=json.loads(Path('ux-check-evidence/account-guides-browser/report.json').read_text())
+ assert a['status']=='passed' and not a['errors'] and not a['unexpected_requests'], a
+ assert len(a['checks']) >= 40 and a['faucet_requests']==1, a
+ PY
+ - name: Retain test evidence (no publication or deployment)
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: food-ux-browser-evidence
+ path: ux-check-evidence/
+ retention-days: 3
+ if-no-files-found: error
diff --git a/.github/workflows/wordpress-content-check.yml b/.github/workflows/wordpress-content-check.yml
new file mode 100644
index 00000000..dff729a1
--- /dev/null
+++ b/.github/workflows/wordpress-content-check.yml
@@ -0,0 +1,52 @@
+name: WordPress content styling check
+on:
+ push:
+ branches: [repair/food-ux-integration-20260915]
+ paths:
+ - 'wordpress-plugins/calorietoken-heading-repair/**'
+ - 'tools/tests/browser_wordpress_content.py'
+ - 'tools/tests/fixtures/wordpress-content/**'
+ - '.github/workflows/wordpress-content-check.yml'
+ pull_request:
+ branches: [herstel/vervolg-20260915]
+ paths:
+ - 'wordpress-plugins/calorietoken-heading-repair/**'
+ - 'tools/tests/browser_wordpress_content.py'
+ - 'tools/tests/fixtures/wordpress-content/**'
+ - '.github/workflows/wordpress-content-check.yml'
+permissions:
+ contents: read
+concurrency:
+ group: wordpress-content-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 8
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install isolated browser tools
+ run: |
+ python -m pip install 'playwright==1.57.0'
+ python -m playwright install --with-deps chromium
+ - name: Verify package and render public-content fixtures
+ run: |
+ mkdir -p ux-check-evidence
+ git rev-parse HEAD > ux-check-evidence/verified-commit.txt
+ git rev-parse 'HEAD^{tree}' > ux-check-evidence/verified-tree.txt
+ php -l wordpress-plugins/calorietoken-heading-repair/calorietoken-heading-repair.php
+ python -m unittest tools.tests.test_build_heading_repair_release
+ python tools/build_heading_repair_release.py --output-dir ux-check-evidence/heading-release
+ python tools/tests/browser_wordpress_content.py
+ - name: Retain preview and verification evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: wordpress-content-style-evidence
+ path: ux-check-evidence/
+ retention-days: 5
diff --git a/DATA_LICENSING.md b/DATA_LICENSING.md
index 73ccf6c4..845b7e3f 100644
--- a/DATA_LICENSING.md
+++ b/DATA_LICENSING.md
@@ -56,6 +56,32 @@ The current frontend bundles three dated reference-food records from USDA FoodDa
The interface does not silently combine alternative energy methods, treat missing data as measured zero, or import these examples into a private diary. USDA reference provenance remains separate from Open Food Facts licensing and private user records. External names and marks retain their own rights.
+## USDA search catalogue — live since 15 September 2026
+
+The food-discovery continuation adds a separate, dated snapshot in
+`frontend/public/data/usda-search-foods.json`: 363 Foundation records from April
+2026 and 7,793 SR Legacy records from April 2018. It does not claim to cover all
+FoodData Central collections. The original three-food reference remains intact.
+
+The catalogue preserves FDC identifiers, English source descriptions, collection,
+edition, nutrient units and original numeric precision. Its source manifest records
+the original download URLs and archive SHA-256 values. The builder is
+`tools/build_usda_search_catalog.py`; the catalogue is kept separate from OFF data.
+FoodData Central's official [download page](https://fdc.nal.usda.gov/download-datasets/)
+and [documentation](https://fdc.nal.usda.gov/data-documentation/) describe these
+collections and reuse conditions.
+
+The browser requests one fixed first-party catalogue file only after a USDA
+search is submitted. Search matching then runs locally; no search phrase or
+account identifier is sent to USDA. Opening a source link visits USDA separately.
+Saving requires the existing explicit portion confirmation and authenticated
+backend route. The diary entry retains the USDA source, FDC identifier and chosen
+gram basis; it has no fabricated barcode or Nutri-Score.
+
+Name-based alternatives help visitors inspect other records. They are not
+personalised nutrition recommendations, allergen checks or claims of healthier
+equivalence. The visitor must check the actual label, preparation and portion.
+
## User and identity data
Authentication identifiers and food logs are application data, not assets
diff --git a/README.md b/README.md
index 9fb1b29a..82041371 100644
--- a/README.md
+++ b/README.md
@@ -31,14 +31,26 @@ Current application stack:
- Frontend: Next.js + TypeScript + Tailwind
- Backend: FastAPI + SQLModel
- Data: SQLite for local development and tests; PostgreSQL is required for live user data
-- External food data: Open Food Facts search adapter; a separate three-food USDA reference selection
+- External food data: Open Food Facts search adapter; a dated USDA search catalogue and separate reference selection
- Identity/authentication: server-side identity flow with session cookies
+### Live food-discovery update — 15 September 2026
+
+The live continuation adds a separate search of 8,156 dated USDA Foundation
+and SR Legacy records, an edible-gram preview, and optional similar-name food
+choices. Interface text covers the existing eleven display languages. Selecting
+a food opens the existing portion confirmation; it does not save automatically.
+The existing barcode flow is preserved. App commit `40ed5f4` was deployed and
+the new flow was checked live in all eleven interface languages. CalorieHelp
+now explains these steps on the website. See the
+[feature and validation record](docs/public/food-discovery-2026-09.md) and
+[CalorieHelp update](docs/public/caloriehelp-2026-09.md).
+
## Current Status
### Implemented in the repository (V2 completion in progress)
-The latest website package and app journey still need live owner acceptance. Current source additions include eleven-language UI synchronization, startup/rate-limit feedback, diary filtering, attributed USDA reference foods and a Testnet-guide link. Historical page translations are only partly complete; see the [dated scope and verification record](docs/public/website-update-2026-09.md).
+The food-discovery update and targeted CalorieHelp update are live. Complete mobile website acceptance and updated campaign material remain open. Existing capabilities include eleven-language UI synchronization, startup/rate-limit feedback, diary filtering, dated USDA search and reference foods, comparable-food choices and a Testnet-guide link. Historical page translations are only partly complete; see the [dated scope and verification record](docs/public/website-update-2026-09.md).
- Food search via backend integration with Open Food Facts
- Nutrition result display in the web UI
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 3a66ec26..1bd615bd 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -27,3 +27,16 @@ source-clearance work are recorded in
`DATA_LICENSING.md`.
The standalone WordPress Site Style component in `wordpress-plugins/calorietoken-site-style/` also declares GPL-2.0-or-later. Its packaged licence applies to its code. Historical site images/fonts remain references to the existing site and retain their original rights; they are not granted a new licence here. The display-language runtime is shared with CalorieApp.
+
+The optional-on-use food barcode decoder bundles `@zxing/browser` 0.1.5 (MIT),
+`@zxing/library` 0.21.3 (Apache-2.0, with its included additional notices), and
+`ts-custom-error` as resolved in the lockfile (MIT). Complete upstream texts
+are retained at `frontend/public/barcode-licenses.txt`, served with the app.
+The libraries decode locally; no CDN service or external image processing is
+used. This does not relicense the surrounding CalorieApp or brand.
+
+The ZXing library's npm metadata says MIT, while its shipped LICENSE retains
+Apache-2.0 and additional upstream notices. This release preserves that full
+file and does not treat the metadata as a blanket relicense. The optional
+`@zxing/text-encoding` 0.9.0 dependency's complete LICENSE.md is retained too;
+it identifies its public-domain/Apache-2.0 terms and Encoding Standard material.
diff --git a/backend/README.md b/backend/README.md
index be1f746b..8a81f4d4 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -87,3 +87,22 @@ migration or verified restore.
later without paywalling identity or personal-data rights.
- Open Food Facts is consumed only by backend service endpoints and is the
current adapter, not the canonical or exclusive food-data model.
+
+
+### Public product search
+
+Name searches use Open Food Facts' indexed Search-a-licious API. The request is
+read-only, sends only literal product-name text and requested nutrition/display
+fields, and supports the application's eleven display languages. One bounded
+legacy CGI transport fallback is allowed after a transport or invalid-response
+failure, never after a provider HTTP rejection (including 429/503). Barcode
+lookup continues to use the exact v3 product endpoint and GTIN verification.
+
+Successful results are cached for one hour in a 256-entry process-local cache.
+Case and repeated whitespace share one name-search key. A cached answer remains
+available during a provider cooldown, without another source request. Empty or
+failed responses are not cached. The cache is lost on restart; it is not a local
+copy of the complete OFF database. Existing shared PostgreSQL egress quotas,
+queue limits, duplicate coalescing and Retry-After pauses remain in force.
+
+Source documentation: https://openfoodfacts.github.io/search-a-licious/users/ref-openapi/
diff --git a/backend/app/account_data_import.py b/backend/app/account_data_import.py
index e455d201..ebaed6f8 100644
--- a/backend/app/account_data_import.py
+++ b/backend/app/account_data_import.py
@@ -346,9 +346,17 @@ def _validate_shape(parsed: dict[str, Any]) -> str:
else _V2_TOP_LEVEL_FIELDS
)
_require_exact_fields(parsed, expected_top_level, field_name="payload")
+ # Optional v2 profile extension: older exports remain valid. The nickname
+ # is exported for access, but never copied to another account by food import.
+ account_fields = _ACCOUNT_FIELDS
+ if export_version != LEGACY_EXPORT_VERSION and isinstance(parsed["account"], dict) and "nickname" in parsed["account"]:
+ account_fields = _ACCOUNT_FIELDS | {"nickname"}
+ nickname = parsed["account"]["nickname"]
+ if nickname is not None and (not isinstance(nickname, str) or not 2 <= len(nickname) <= 32):
+ raise AccountDataImportSafetyError("account.nickname is invalid")
account = _require_exact_fields(
parsed["account"],
- _ACCOUNT_FIELDS,
+ account_fields,
field_name="account",
)
_require_explicit_timezone(parsed["exported_at"], field_name="exported_at")
diff --git a/backend/app/bridge_codes.py b/backend/app/bridge_codes.py
new file mode 100644
index 00000000..81b24a69
--- /dev/null
+++ b/backend/app/bridge_codes.py
@@ -0,0 +1,138 @@
+"""Short-lived codes issued for authenticated WordPress identity assertions.
+
+Uses the existing authorization-code table and origin-browser callback. No
+session is created by the bridge request; only the callback may consume it.
+"""
+
+import json
+from datetime import UTC, datetime, timedelta
+from hashlib import sha256
+from secrets import compare_digest, token_urlsafe
+
+from fastapi import HTTPException
+from sqlalchemy import delete, update
+from sqlmodel import Session, select
+
+from .models import AuthorizationCodeDB, PendingLoginStateDB
+from .schemas import BridgeCodeRequest, BridgeCodeResponse, IdentityClaimsResponse
+from .services.identity import get_pending_login_locale, hash_login_state
+
+BACKEND_CODE_PREFIX = "cb1."
+BRIDGE_CODE_CONTEXT = "issue_login_code_v1"
+BACKEND_CODE_RECORD_PREFIX = "bridge-code:"
+
+
+def bridge_code_canonical_payload(
+ *, client_id: str, timestamp: int, nonce: str, payload: BridgeCodeRequest
+) -> str:
+ # Fixed field order, UTF-8, no whitespace; mirrored by WordPress.
+ return json.dumps(
+ {
+ "version": "v2",
+ "purpose": BRIDGE_CODE_CONTEXT,
+ "client_id": client_id,
+ "timestamp": str(timestamp),
+ "nonce": nonce,
+ "state": payload.state,
+ "external_subject": payload.external_subject,
+ "xrpl_address": payload.xrpl_address,
+ "locale": payload.locale,
+ },
+ ensure_ascii=False,
+ separators=(",", ":"),
+ )
+
+
+def issue_bridge_code(
+ session: Session, payload: BridgeCodeRequest, *, client_id: str
+) -> BridgeCodeResponse:
+ now = datetime.now(UTC)
+ state_hash = hash_login_state(payload.state)
+ # Expire only this transport's cache records, in a bounded batch. Retain
+ # all rows for an unexpired state so its three-code allowance never resets.
+ live_state = (
+ select(PendingLoginStateDB.id)
+ .where(PendingLoginStateDB.state_hash == AuthorizationCodeDB.state)
+ .where(PendingLoginStateDB.expires_at >= now)
+ .exists()
+ )
+ expired_ids = session.exec(
+ select(AuthorizationCodeDB.id)
+ .where(AuthorizationCodeDB.login_session_id.startswith(BACKEND_CODE_RECORD_PREFIX))
+ .where(AuthorizationCodeDB.expires_at < now)
+ .where(~live_state)
+ .order_by(AuthorizationCodeDB.expires_at, AuthorizationCodeDB.id)
+ .limit(200)
+ ).all()
+ if expired_ids:
+ session.exec(delete(AuthorizationCodeDB).where(AuthorizationCodeDB.id.in_(expired_ids)))
+ # Serialize issuance per login transaction on PostgreSQL. This also
+ # serializes against the callback's atomic pending-state reservation.
+ pending = session.exec(
+ select(PendingLoginStateDB)
+ .where(PendingLoginStateDB.state_hash == state_hash)
+ .with_for_update()
+ ).first()
+ if (
+ pending is None
+ or pending.status != "pending"
+ or pending.consumed_at is not None
+ or pending.client_id != client_id
+ or pending.expires_at.replace(tzinfo=UTC) <= now
+ ):
+ raise HTTPException(400, "Unknown, expired or consumed login state")
+ if get_pending_login_locale(session, payload.state) != payload.locale:
+ raise HTTPException(409, "Login locale mismatch")
+ existing = session.exec(
+ select(AuthorizationCodeDB.id).where(
+ AuthorizationCodeDB.login_session_id == BACKEND_CODE_RECORD_PREFIX + pending.id
+ )
+ ).all()
+ if len(existing) >= 3:
+ raise HTTPException(429, "Authorization refresh limit reached")
+
+ code = BACKEND_CODE_PREFIX + token_urlsafe(32)
+ expires_at = min(pending.expires_at.replace(tzinfo=UTC), now + timedelta(seconds=60))
+ row = AuthorizationCodeDB(
+ code_hash=sha256(code.encode("utf-8")).hexdigest(),
+ external_subject=payload.external_subject,
+ xrpl_address=payload.xrpl_address,
+ state=state_hash,
+ login_session_id=BACKEND_CODE_RECORD_PREFIX + pending.id,
+ created_at=now,
+ expires_at=expires_at,
+ )
+ session.add(row)
+ session.commit()
+ return BridgeCodeResponse(code=code, expires_at=expires_at, jti=row.id, locale=payload.locale)
+
+
+def consume_bridge_code(session: Session, *, code: str, state: str) -> IdentityClaimsResponse:
+ now = datetime.now(UTC)
+ row = session.exec(
+ select(AuthorizationCodeDB).where(
+ AuthorizationCodeDB.code_hash == sha256(code.encode("utf-8")).hexdigest()
+ )
+ ).first()
+ if row is None or not compare_digest(row.state, hash_login_state(state)):
+ raise HTTPException(400, "Authorization code exchange rejected")
+ changed = session.exec(
+ update(AuthorizationCodeDB)
+ .where(AuthorizationCodeDB.id == row.id)
+ .where(AuthorizationCodeDB.used_at.is_(None))
+ .where(AuthorizationCodeDB.expires_at > now)
+ .values(used_at=now)
+ .execution_options(synchronize_session=False)
+ ).rowcount
+ if changed != 1:
+ session.rollback()
+ raise HTTPException(400, "Authorization code exchange rejected")
+ claims = IdentityClaimsResponse(
+ external_subject=row.external_subject,
+ xrpl_address=row.xrpl_address,
+ issued_at=row.created_at,
+ expires_at=row.expires_at,
+ jti=row.id,
+ )
+ session.commit()
+ return claims
diff --git a/backend/app/food_log_view.py b/backend/app/food_log_view.py
new file mode 100644
index 00000000..f6d29654
--- /dev/null
+++ b/backend/app/food_log_view.py
@@ -0,0 +1,67 @@
+"""Owner-scoped diary pages and whole-period totals, without changing stored logs."""
+from datetime import UTC, datetime
+
+from fastapi import HTTPException
+from sqlalchemy import and_, case, func
+from sqlmodel import Session, select
+
+from .models import FoodLogDB
+from .schemas import FoodLog, FoodLogOverview
+
+
+def food_log_overview(session: Session, owner_id: int, start: datetime | None,
+ end: datetime | None, before: int | None, limit: int) -> FoodLogOverview:
+ if (start is None) != (end is None):
+ raise HTTPException(422, "Supply both start and end, or neither")
+ conditions = [FoodLogDB.owner_id == owner_id]
+ if start is not None and end is not None:
+ if start.utcoffset() is None or end.utcoffset() is None:
+ raise HTTPException(422, "Diary boundaries must include a time zone")
+ if end <= start or (end - start).total_seconds() > 32 * 86400:
+ raise HTTPException(422, "Diary range must be positive and at most 32 days")
+ # The existing table stores UTC in timezone-naive SQL DateTime columns.
+ conditions += [FoodLogDB.created_at >= start.astimezone(UTC).replace(tzinfo=None),
+ FoodLogDB.created_at < end.astimezone(UTC).replace(tzinfo=None)]
+ # New CalorieApp entries already carry enough bounded provenance to report
+ # the two active discovery lanes without changing private stored rows:
+ # OFF products retain their product barcode, while our fixed USDA lane uses
+ # the exact FoodData Central brand prefix and intentionally has no barcode.
+ # Anything that cannot be established from those fields remains "other";
+ # it is never guessed into either source.
+ open_food_facts = and_(
+ FoodLogDB.barcode.is_not(None),
+ func.length(func.trim(FoodLogDB.barcode)) > 0,
+ )
+ usda = and_(
+ FoodLogDB.barcode.is_(None),
+ func.lower(func.trim(func.coalesce(FoodLogDB.brand, ""))).like(
+ "usda fooddata central · fdc %"
+ ),
+ )
+ columns = [func.count(FoodLogDB.id)] + [
+ func.coalesce(func.sum(getattr(FoodLogDB, key)), 0)
+ for key in ("calories", "protein", "fat", "carbohydrates")
+ ] + [func.coalesce(func.sum(case((and_(open_food_facts,
+ func.upper(func.trim(FoodLogDB.nutri_score)) == grade), 1), else_=0)), 0)
+ for grade in "ABCDE"] + [
+ func.coalesce(func.sum(case((open_food_facts, 1), else_=0)), 0),
+ func.coalesce(func.sum(case((usda, 1), else_=0)), 0),
+ ]
+ totals = session.exec(select(*columns).where(*conditions)).one()
+ page_conditions = conditions + ([FoodLogDB.id < before] if before is not None else [])
+ entries = session.exec(select(FoodLogDB).where(*page_conditions)
+ .order_by(FoodLogDB.id.desc()).limit(limit + 1)).all()
+ open_food_facts_count = int(totals[10])
+ usda_count = int(totals[11])
+ total_count = int(totals[0])
+ return FoodLogOverview(
+ entries=[FoodLog.model_validate(row.model_dump()) for row in entries[:limit]],
+ next_before=entries[limit - 1].id if len(entries) > limit else None,
+ count=total_count, calories=totals[1], protein=totals[2], fat=totals[3], carbohydrates=totals[4],
+ grades=dict(zip("ABCDE", totals[5:10])),
+ sources={
+ "open_food_facts": open_food_facts_count,
+ "usda": usda_count,
+ "other": total_count - open_food_facts_count - usda_count,
+ },
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
index 2a13e50d..9f6e1d8a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -21,6 +21,13 @@
from sqlmodel import Session, select
from . import database as db_module
+from .bridge_codes import (
+ BACKEND_CODE_PREFIX,
+ BRIDGE_CODE_CONTEXT,
+ bridge_code_canonical_payload,
+ consume_bridge_code,
+ issue_bridge_code,
+)
from .account_data_import import (
AccountDataImportSafetyError,
plan_account_data_import,
@@ -42,6 +49,8 @@
validate_capacity_configuration,
)
from .database import database_readiness, get_session, init_db
+from .food_log_view import food_log_overview
+from .schemas import FoodLogOverview
from .data_growth import (
DataGrowthAdmissionRejected,
create_food_log_with_subject_budget,
@@ -77,6 +86,11 @@
AccountExportImportReceipt,
AccountExportLoginHandoff,
CurrentUserResponse,
+ NicknameUpdateRequest,
+ NicknameResponse,
+ WordpressProfileRequest,
+ BridgeCodeRequest,
+ BridgeCodeResponse,
FoodLog,
FoodLogCreate,
IdentityCallbackResponse,
@@ -106,7 +120,7 @@
validate_identity_start_admission_configuration,
validate_origin_login_handoff,
)
-from .services.open_food_facts import search_food_products
+from .services.open_food_facts import search_food_products, valid_food_barcode
from .services.food_search_availability import FoodSearchUnavailable
logger = logging.getLogger(__name__)
@@ -118,10 +132,11 @@
BRIDGE_STATE_VALIDATE_CONTEXT = "login_state_validate"
-def _build_identifier(value: str | None) -> str:
+def _build_identifier(value: str | None, *, render_commit: str | None = None) -> str:
candidate = value.strip() if value else ""
if not candidate:
- return "development"
+ commit = render_commit.strip() if render_commit else ""
+ return commit if re.fullmatch(r"[A-Fa-f0-9]{40}", commit) else "development"
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", candidate) is None:
raise RuntimeError(
"CALORIEAPP_BUILD_ID must be 1-64 letters, digits, dots, "
@@ -151,7 +166,9 @@ def _build_identifier(value: str | None) -> str:
_SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "lax").strip().lower()
_CALORIEAPP_ENV_RAW = os.getenv("CALORIEAPP_ENV")
_CALORIEAPP_ENV = _CALORIEAPP_ENV_RAW.strip().lower() if _CALORIEAPP_ENV_RAW and _CALORIEAPP_ENV_RAW.strip() else None
-_CALORIEAPP_BUILD_ID = _build_identifier(os.getenv("CALORIEAPP_BUILD_ID"))
+_CALORIEAPP_BUILD_ID = _build_identifier(
+ os.getenv("CALORIEAPP_BUILD_ID"), render_commit=os.getenv("RENDER_GIT_COMMIT")
+)
_BRIDGE_AUTH_MAX_AGE_SECONDS = int(os.getenv("BRIDGE_AUTH_MAX_AGE_SECONDS", "300"))
_BRIDGE_AUTH_MAX_FUTURE_SECONDS = int(os.getenv("BRIDGE_AUTH_MAX_FUTURE_SECONDS", "30"))
_BRIDGE_NONCE_RETENTION_SECONDS = int(
@@ -505,11 +522,12 @@ def _reserve_bridge_auth_nonce(
return True
-def _authenticate_bridge_state_validate_request(
+def _authenticate_bridge_request(
*,
request: Request,
session: Session,
state: str,
+ code_payload: Optional[BridgeCodeRequest] = None,
) -> tuple[bool, str]:
if not _WORDPRESS_BRIDGE_SECRET:
return False, "missing_config"
@@ -552,6 +570,10 @@ def _authenticate_bridge_state_validate_request(
nonce=nonce,
state=state,
)
+ if code_payload is not None:
+ canonical_payload = bridge_code_canonical_payload(
+ client_id=client_id, timestamp=timestamp, nonce=nonce, payload=code_payload
+ )
expected_signature = _bridge_auth_signature(canonical_payload, _WORDPRESS_BRIDGE_SECRET)
if not compare_digest(signature.lower(), expected_signature):
return False, "invalid_signature"
@@ -561,7 +583,7 @@ def _authenticate_bridge_state_validate_request(
session,
client_id=client_id,
nonce=nonce,
- context=BRIDGE_STATE_VALIDATE_CONTEXT,
+ context=BRIDGE_CODE_CONTEXT if code_payload is not None else BRIDGE_STATE_VALIDATE_CONTEXT,
)
if not reserved:
return False, "replayed_nonce"
@@ -808,6 +830,17 @@ def _exchange_code_for_claims(code: str, state: str) -> IdentityClaimsResponse:
logger.warning("WordPress bridge rejected code exchange (status=%s)", response.status_code)
raise HTTPException(status_code=400, detail="Authorization code exchange rejected")
+ content_type = response.headers.get("content-type", "").partition(";")[0].strip().lower()
+ if content_type == "text/html":
+ # Hosting verification pages can return 200 before WordPress runs. A
+ # browser retry cannot complete that server-to-server check. Expose a
+ # fixed error code, never the page body, headers, URL, or credentials.
+ logger.warning("WordPress bridge returned HTML instead of identity JSON")
+ raise HTTPException(
+ status_code=502,
+ detail={"code": "wordpress_bridge_html_response"},
+ )
+
try:
payload = response.json()
except ValueError as exc:
@@ -925,7 +958,7 @@ def identity_validate_pending_state(
session: DbSession,
) -> IdentityStateValidationResponse:
"""Server-to-server endpoint for bridge validation of pending login state."""
- authenticated, reason = _authenticate_bridge_state_validate_request(
+ authenticated, reason = _authenticate_bridge_request(
request=request,
session=session,
state=payload.state,
@@ -963,6 +996,26 @@ def identity_validate_pending_state(
)
+@app.post("/api/identity/bridge/code", response_model=BridgeCodeResponse)
+def identity_issue_bridge_code(
+ request: Request,
+ payload: BridgeCodeRequest,
+ session: DbSession,
+) -> BridgeCodeResponse:
+ """Accept a signed WordPress assertion and issue only a one-time code."""
+ authenticated, reason = _authenticate_bridge_request(
+ request=request, session=session, state=payload.state, code_payload=payload
+ )
+ if not authenticated:
+ if reason == "missing_config":
+ raise HTTPException(500, "Bridge authentication is not configured")
+ raise HTTPException(403, "Bridge authentication failed")
+ subject_prefix = "wp:" + str(urlsplit(_WORDPRESS_URL).hostname).lower() + ":"
+ if not re.fullmatch(re.escape(subject_prefix) + r"[1-9][0-9]*", payload.external_subject):
+ raise HTTPException(400, "Invalid WordPress identity subject")
+ return issue_bridge_code(session, payload, client_id=_CALORIEAPP_CLIENT_ID)
+
+
@app.post("/api/identity/callback", response_model=IdentityCallbackResponse)
def identity_callback(
payload: IdentityCallbackRequest,
@@ -994,7 +1047,10 @@ def identity_callback(
raise HTTPException(status_code=400, detail="Unknown login state")
try:
- claims = _exchange_code_for_claims(code=code, state=state)
+ if code.startswith(BACKEND_CODE_PREFIX):
+ claims = consume_bridge_code(session, code=code, state=state)
+ else:
+ claims = _exchange_code_for_claims(code=code, state=state)
except HTTPException as exc:
if exc.status_code in {429, 502, 503, 504}:
restored = restore_pending_login_state_after_transient_failure(session, state)
@@ -1146,7 +1202,53 @@ def identity_me(
return CurrentUserResponse(
user_id=current_user.id,
created_at=current_user.created_at,
+ nickname=current_user.nickname,
+ )
+
+
+@app.post("/api/identity/profile", response_model=CurrentUserResponse)
+def identity_update_profile(
+ payload: NicknameUpdateRequest,
+ request: Request,
+ session: DbSession,
+ current_user: CurrentUser,
+) -> CurrentUserResponse:
+ # Require a non-simple request at both the public proxy and the backend.
+ if request.headers.get("x-calorieapp-request") != "account-profile":
+ raise HTTPException(status_code=403, detail="Profile request marker required")
+ origin = request.headers.get("origin")
+ if origin and origin not in _CORS_ORIGINS:
+ raise HTTPException(status_code=403, detail="Origin not allowed")
+ if payload.user_id != current_user.id:
+ raise HTTPException(status_code=409, detail="Account changed; reload your profile")
+ current_user.nickname = payload.nickname
+ current_user.updated_at = datetime.now(UTC).replace(tzinfo=None)
+ session.add(current_user)
+ session.commit()
+ session.refresh(current_user)
+ return CurrentUserResponse(user_id=current_user.id, created_at=current_user.created_at, nickname=current_user.nickname)
+
+
+@app.post("/api/identity/profile/wordpress", response_model=NicknameResponse)
+def identity_wordpress_profile(
+ payload: WordpressProfileRequest,
+ request: Request,
+ session: DbSession,
+) -> NicknameResponse:
+ # Domain separation binds the signature to this read and this exact account.
+ authenticated, _ = _authenticate_bridge_request(
+ request=request, session=session, state="account-profile-v1:" + payload.external_subject,
)
+ if not authenticated:
+ raise HTTPException(status_code=403, detail="Profile authentication failed")
+ user = session.exec(
+ select(CalorieAppUserDB).join(ExternalIdentityDB).where(
+ ExternalIdentityDB.provider == _IDENTITY_PROVIDER,
+ ExternalIdentityDB.external_subject == payload.external_subject,
+ CalorieAppUserDB.status == "active",
+ )
+ ).first()
+ return NicknameResponse(nickname=user.nickname if user else None)
@app.get("/api/identity/export", response_model=AccountDataExportResponse)
@@ -1221,6 +1323,7 @@ def identity_export(
account=AccountExportAccount(
user_id=current_user.id,
status=current_user.status,
+ nickname=current_user.nickname,
created_at=current_user.created_at,
updated_at=current_user.updated_at,
last_authenticated_activity_at=(
@@ -1640,6 +1743,20 @@ def get_logs(
return [FoodLog.model_validate(e.model_dump()) for e in entries]
+@app.get("/logs/overview", response_model=FoodLogOverview)
+def get_log_overview(
+ session: DbSession,
+ current_user: CurrentUser,
+ response: Response,
+ start: datetime | None = None,
+ end: datetime | None = None,
+ before: int | None = Query(default=None, ge=1),
+ limit: int = Query(default=100, ge=1, le=200),
+) -> FoodLogOverview:
+ response.headers["Cache-Control"] = "private, no-store"
+ return food_log_overview(session, current_user.id, start, end, before, limit)
+
+
@app.delete("/logs/{log_id}")
def delete_log(
log_id: int,
@@ -1684,13 +1801,16 @@ def delete_all_logs(
@app.get("/search-food", response_model=FoodSearchResponse)
-async def search_food(q: str = Query(..., min_length=1, max_length=120)) -> FoodSearchResponse:
+async def search_food(q: str = Query(..., min_length=1, max_length=120), mode: str = Query("name", pattern="^(name|barcode)$")) -> FoodSearchResponse:
query = q.strip()
if not query:
raise HTTPException(status_code=422, detail="Search query must contain visible characters")
+ if mode == "barcode" and valid_food_barcode(query) is None:
+ raise HTTPException(status_code=422, detail="Invalid food barcode")
+
try:
- results = await search_food_products(query)
+ results = await search_food_products(query, barcode=True) if mode == "barcode" else await search_food_products(query)
except FoodSearchUnavailable as exc:
logger.warning("Open Food Facts unavailable (status=%s)", exc.status_code)
raise HTTPException(
diff --git a/backend/app/models.py b/backend/app/models.py
index 965db944..538cc549 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -487,6 +487,7 @@ class CalorieAppUserDB(SQLModel, table=True):
default_factory=utc_now,
index=True,
)
+ nickname: Optional[str] = Field(default=None, max_length=32)
class InactiveAccountNoticeDB(SQLModel, table=True):
diff --git a/backend/app/request_limits.py b/backend/app/request_limits.py
index bba9e300..59af0c7d 100644
--- a/backend/app/request_limits.py
+++ b/backend/app/request_limits.py
@@ -13,6 +13,9 @@
ROUTE_BODY_LIMIT_BYTES: dict[tuple[str, str], int] = {
("POST", "/api/identity/login/start"): 2 * 1024,
("POST", "/api/identity/login/state/validate"): 2 * 1024,
+ ("POST", "/api/identity/bridge/code"): 2 * 1024,
+ ("POST", "/api/identity/profile"): 2 * 1024,
+ ("POST", "/api/identity/profile/wordpress"): 2 * 1024,
("POST", "/api/identity/callback"): 4 * 1024,
("POST", "/api/identity/login/status"): 4 * 1024,
("POST", "/api/identity/import"): 5 * 1024 * 1024,
diff --git a/backend/app/route_rate_limiter.py b/backend/app/route_rate_limiter.py
index b9e1391e..f3d3e201 100644
--- a/backend/app/route_rate_limiter.py
+++ b/backend/app/route_rate_limiter.py
@@ -55,17 +55,21 @@ def __post_init__(self) -> None:
120,
),
("POST", "/api/identity/callback"): RouteRatePolicy("identity_callback", 30),
+ ("POST", "/api/identity/bridge/code"): RouteRatePolicy("identity_bridge_code", 120),
("POST", "/api/identity/login/status"): RouteRatePolicy(
"identity_login_status",
240,
),
("GET", "/api/identity/me"): RouteRatePolicy("identity_me", 240),
+ ("POST", "/api/identity/profile"): RouteRatePolicy("identity_profile", 60),
+ ("POST", "/api/identity/profile/wordpress"): RouteRatePolicy("identity_widget_profile", 240),
("GET", "/api/identity/export"): RouteRatePolicy("identity_export", 30),
("POST", "/api/identity/import"): RouteRatePolicy("identity_import", 5),
("DELETE", "/api/identity/account"): RouteRatePolicy("identity_account_delete", 10),
("POST", "/api/identity/logout"): RouteRatePolicy("identity_logout", 120),
("POST", "/log-food"): RouteRatePolicy("food_log_create", 120),
("GET", "/logs"): RouteRatePolicy("food_log_list", 240),
+ ("GET", "/logs/overview"): RouteRatePolicy("food_log_list", 240),
("DELETE", "/logs"): RouteRatePolicy("food_log_delete_all", 30),
("GET", "/search-food"): RouteRatePolicy("food_search", 60),
}
diff --git a/backend/app/schema_migrations/runner.py b/backend/app/schema_migrations/runner.py
index a4b79f75..a512abe3 100644
--- a/backend/app/schema_migrations/runner.py
+++ b/backend/app/schema_migrations/runner.py
@@ -26,6 +26,7 @@
v20260902_0014,
v20260902_0015,
v20260902_0016,
+ v20260917_0017,
)
@@ -138,6 +139,12 @@ class Migration:
upgrade=v20260902_0016.upgrade,
validate=v20260902_0016.validate,
),
+ Migration(
+ revision=v20260917_0017.revision,
+ down_revision=v20260917_0017.down_revision,
+ upgrade=v20260917_0017.upgrade,
+ validate=v20260917_0017.validate,
+ ),
)
SCHEMA_HEAD = MIGRATIONS[-1].revision
diff --git a/backend/app/schema_migrations/versions/v20260830_0001.py b/backend/app/schema_migrations/versions/v20260830_0001.py
index 7782f7b4..5f72fc2d 100644
--- a/backend/app/schema_migrations/versions/v20260830_0001.py
+++ b/backend/app/schema_migrations/versions/v20260830_0001.py
@@ -200,7 +200,7 @@
# Later forward migrations may extend a baseline table. Keep this list explicit
# so the baseline validator still rejects every unrelated extra column.
_allowed_later_columns = {
- "calorieappuser": {"last_authenticated_activity_at"},
+ "calorieappuser": {"last_authenticated_activity_at", "nickname"},
"pendingloginstate": {"client_id"},
}
diff --git a/backend/app/schema_migrations/versions/v20260917_0017.py b/backend/app/schema_migrations/versions/v20260917_0017.py
new file mode 100644
index 00000000..d20f127a
--- /dev/null
+++ b/backend/app/schema_migrations/versions/v20260917_0017.py
@@ -0,0 +1,19 @@
+"""Add an optional private account nickname, preserving all existing rows."""
+import sqlalchemy as sa
+from sqlalchemy.engine import Connection
+
+revision = "20260917_0017"
+down_revision = "20260902_0016"
+
+
+def upgrade(connection: Connection) -> None:
+ columns = {column["name"] for column in sa.inspect(connection).get_columns("calorieappuser")}
+ if "nickname" not in columns:
+ connection.execute(sa.text("ALTER TABLE calorieappuser ADD COLUMN nickname VARCHAR(32) NULL"))
+
+
+def validate(connection: Connection) -> None:
+ columns = {column["name"]: column for column in sa.inspect(connection).get_columns("calorieappuser")}
+ column = columns.get("nickname")
+ if column is None or not column["nullable"] or not isinstance(column["type"], sa.String) or column["type"].length != 32:
+ raise RuntimeError("Account nickname column is missing or has drifted")
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index f4a147a3..1a24afa0 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -1,4 +1,5 @@
from datetime import UTC, datetime
+import unicodedata
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -56,6 +57,18 @@ def serialize_created_at_as_utc(cls, value: datetime) -> datetime:
return _ensure_utc(value)
+class FoodLogOverview(BaseModel):
+ entries: list[FoodLog]
+ next_before: int | None
+ count: int
+ calories: float
+ protein: float
+ fat: float
+ carbohydrates: float
+ grades: dict[str, int]
+ sources: dict[str, int]
+
+
class FoodSearchResult(BaseModel):
model_config = ConfigDict(allow_inf_nan=False)
@@ -69,6 +82,9 @@ class FoodSearchResult(BaseModel):
brand: Optional[str] = None
serving_size: Optional[str] = None
nutri_score: Optional[str] = None
+ # Optional source metadata is discovery-only, not a diary/schema migration.
+ labels_tags: list[str] = Field(default_factory=list)
+ nutriscore_version: Optional[str] = None
class FoodSearchResponse(BaseModel):
@@ -160,6 +176,7 @@ class IdentityStateValidationResponse(BaseModel):
valid: bool
expires_at: datetime
locale: str
+ code_transport: Literal["backend_v1"] = "backend_v1"
@field_validator("expires_at", mode="after")
@classmethod
@@ -167,6 +184,22 @@ def serialize_expires_at_as_utc(cls, value: datetime) -> datetime:
return _ensure_utc(value)
+class BridgeCodeRequest(BaseModel):
+ """Identity asserted only by the authenticated WordPress server."""
+
+ state: str = Field(min_length=32, max_length=255, pattern=r"^[A-Za-z0-9._~-]+$")
+ external_subject: str = Field(min_length=1, max_length=120)
+ xrpl_address: str = Field(min_length=25, max_length=34, pattern=r"^r[1-9A-HJ-NP-Za-km-z]+$")
+ locale: str = Field(min_length=2, max_length=16)
+
+
+class BridgeCodeResponse(BaseModel):
+ code: str
+ expires_at: datetime
+ jti: str
+ locale: str
+
+
class IdentityClaimsResponse(BaseModel):
"""Verified identity claims from WordPress bridge."""
@@ -182,11 +215,43 @@ def normalize_claim_timestamps_to_utc(cls, value: datetime) -> datetime:
return _ensure_utc(value)
+class NicknameUpdateRequest(BaseModel):
+ """Only the authenticated account may set its private display nickname."""
+
+ model_config = ConfigDict(extra="forbid")
+ user_id: str = Field(min_length=1, max_length=255)
+ nickname: str | None
+
+ @field_validator("nickname", mode="after")
+ @classmethod
+ def valid_nickname(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ normalized = unicodedata.normalize("NFC", value).strip()
+ if not 2 <= len(normalized) <= 32 or any(
+ ord(char) < 32 or 127 <= ord(char) <= 159 or char in "<>"
+ or 0x202A <= ord(char) <= 0x202E or 0x2066 <= ord(char) <= 0x2069
+ for char in value
+ ):
+ raise ValueError("Nickname must contain 2–32 visible characters")
+ return normalized
+
+
+class WordpressProfileRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ external_subject: str = Field(min_length=1, max_length=255, pattern=r"^wp:[^\s:]+:[1-9][0-9]*$")
+
+
+class NicknameResponse(BaseModel):
+ nickname: str | None
+
+
class CurrentUserResponse(BaseModel):
"""Current authenticated user information."""
user_id: str
created_at: datetime
+ nickname: str | None = None
@field_validator("created_at", mode="after")
@classmethod
@@ -199,6 +264,7 @@ class AccountExportAccount(BaseModel):
user_id: str
status: str
+ nickname: str | None = None
created_at: datetime
updated_at: datetime
last_authenticated_activity_at: datetime
diff --git a/backend/app/services/food_search_availability.py b/backend/app/services/food_search_availability.py
index 399bcdbe..87669c28 100644
--- a/backend/app/services/food_search_availability.py
+++ b/backend/app/services/food_search_availability.py
@@ -46,8 +46,8 @@ class FoodSearchAvailability:
def __init__(
self,
*,
- max_entries: int = 64,
- ttl_seconds: float = 300,
+ max_entries: int = 256,
+ ttl_seconds: float = 3600,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.max_entries = max_entries
@@ -59,32 +59,32 @@ def __init__(
self._status_code = 503
@staticmethod
- def _key(query: str, page_size: int) -> bytes:
- return hashlib.sha256(f"{page_size}\0{query}".encode("utf-8")).digest()
+ def _key(query: str, page_size: int, barcode: bool = False) -> bytes:
+ return hashlib.sha256(f"{barcode}\0{page_size}\0{query}".encode("utf-8")).digest()
def _expire(self, now: float) -> None:
for key, (expires_at, _) in list(self._cache.items()):
if expires_at <= now:
del self._cache[key]
- def get(self, query: str, page_size: int) -> list[FoodSearchResult] | None:
+ def get(self, query: str, page_size: int, *, barcode: bool = False) -> list[FoodSearchResult] | None:
with self._lock:
self._expire(self.clock())
- key = self._key(query, page_size)
+ key = self._key(query, page_size, barcode)
entry = self._cache.get(key)
if entry is None:
return None
self._cache.move_to_end(key)
return [item.model_copy(deep=True) for item in entry[1]]
- def remember(self, query: str, page_size: int, results: list[FoodSearchResult]) -> None:
+ def remember(self, query: str, page_size: int, results: list[FoodSearchResult], *, barcode: bool = False) -> None:
# Do not turn a transient empty provider response into a cached absence.
if not results:
return
with self._lock:
now = self.clock()
self._expire(now)
- key = self._key(query, page_size)
+ key = self._key(query, page_size, barcode)
self._cache[key] = (
now + self.ttl_seconds,
[item.model_copy(deep=True) for item in results],
diff --git a/backend/app/services/open_food_facts.py b/backend/app/services/open_food_facts.py
index 4e1bb951..6be28e04 100644
--- a/backend/app/services/open_food_facts.py
+++ b/backend/app/services/open_food_facts.py
@@ -9,15 +9,16 @@
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlsplit
from urllib.request import Request, urlopen
from urllib.error import HTTPError as UrllibHTTPError, URLError
import httpx
+from pydantic import ValidationError
from app.database import engine
from app.provider_rate_governor import build_provider_rate_governor
-from app.schemas import FoodSearchResult
+from app.schemas import FoodLogCreate, FoodSearchResult
from app.services.food_search_availability import (
FoodSearchAvailability,
FoodSearchUnavailable,
@@ -33,12 +34,14 @@
T = TypeVar("T")
OPEN_FOOD_FACTS_SEARCH_URL = "https://world.openfoodfacts.org/cgi/search.pl"
+OPEN_FOOD_FACTS_INDEX_URL = "https://search.openfoodfacts.org/search"
REQUEST_HEADERS = {
"User-Agent": "CalorieApp/0.2.0 (https://calorietoken.net; info@calorietoken.net)",
"Accept": "application/json",
}
_PRIMARY_TIMEOUT_SECONDS = 10.0
+_INDEX_TIMEOUT_SECONDS = 15.0
# One normal request plus at most one alternate-transport request. Nested
# transport retries would amplify one user search into enough upstream traffic
# to exhaust Open Food Facts' public per-IP search allowance.
@@ -47,7 +50,7 @@
_MAX_UPSTREAM_ATTEMPTS_PER_SEARCH = _PRIMARY_MAX_ATTEMPTS + _FALLBACK_MAX_ATTEMPTS
_OPEN_FOOD_FACTS_FIELDS = (
"product_name,code,image_front_url,image_url,image_small_url,image_front_small_url,"
- "brands,serving_size,nutriscore_grade,nutriments"
+ "brands,serving_size,nutriscore_grade,nutriments,labels_tags,nutriscore_version"
)
_OPEN_FOOD_FACTS_ADMISSION = AdapterAdmissionController(
@@ -95,7 +98,7 @@ def _repair_common_mojibake(text: str) -> str:
def _to_float(value: Any) -> float | None:
- if value is None:
+ if value is None or isinstance(value, bool):
return None
try:
result = float(value)
@@ -104,7 +107,7 @@ def _to_float(value: Any) -> float | None:
if result < 0:
return None
return round(result, 2)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
return None
@@ -116,16 +119,27 @@ def _to_optional_text(value: Any) -> str | None:
def _extract_image_url(product: dict[str, Any]) -> str | None:
- """Prefer higher-quality Open Food Facts image fields when available."""
+ """Prefer an exact OFF image host; unsafe/missing values use the UI fallback."""
for key in ("image_front_url", "image_url", "image_small_url", "image_front_small_url"):
image_url = _to_optional_text(product.get(key))
if image_url:
+ try:
+ parsed = urlsplit(image_url)
+ except ValueError:
+ continue
+ if (parsed.scheme != "https" or parsed.hostname != "images.openfoodfacts.org"
+ or not parsed.path.startswith("/images/products/")):
+ continue
return image_url
return None
def _extract_brand(product: dict[str, Any]) -> str | None:
- brands = _to_optional_text(product.get("brands"))
+ raw_brands = product.get("brands")
+ # Search-a-licious returns an array; the product API uses a comma-separated string.
+ if isinstance(raw_brands, list):
+ raw_brands = next((brand for brand in raw_brands if isinstance(brand, str) and brand.strip()), None)
+ brands = _to_optional_text(raw_brands)
if not brands:
return None
# Open Food Facts often returns comma-separated brands; show the first clean label.
@@ -141,21 +155,24 @@ def _extract_nutri_score(product: dict[str, Any]) -> str | None:
return normalized if normalized in {"A", "B", "C", "D", "E"} else None
-async def search_food_products(query: str, page_size: int = 10) -> list[FoodSearchResult]:
- safe_query = query.strip()
- cached = _OPEN_FOOD_FACTS_AVAILABILITY.get(safe_query, page_size)
+async def search_food_products(query: str, page_size: int = 10, *, barcode: bool = False) -> list[FoodSearchResult]:
+ safe_query = query.strip() if barcode else " ".join(query.split()).casefold()
+ if barcode and valid_food_barcode(safe_query) is None:
+ raise ValueError("Invalid food barcode")
+ cached = _OPEN_FOOD_FACTS_AVAILABILITY.get(safe_query, page_size, barcode=barcode)
if cached is not None:
return cached
_OPEN_FOOD_FACTS_AVAILABILITY.check_provider()
return await _OPEN_FOOD_FACTS_COALESCER.run(
- (safe_query, page_size),
- lambda: _search_food_products_once(safe_query, page_size),
+ (safe_query, page_size, barcode),
+ lambda: _search_food_products_once(safe_query, page_size, barcode=barcode),
)
async def _search_food_products_once(
safe_query: str,
page_size: int,
+ *, barcode: bool = False,
) -> list[FoodSearchResult]:
permit = _OPEN_FOOD_FACTS_ADMISSION.begin_action()
params = {
@@ -165,35 +182,42 @@ async def _search_food_products_once(
"json": 1,
"page_size": page_size,
"fields": _OPEN_FOOD_FACTS_FIELDS,
+ # The UI does not display a total across the complete OFF database.
+ "no_count": 1,
}
try:
- try:
+ if barcode:
payload = await _OPEN_FOOD_FACTS_ADMISSION.run_attempt(
- lambda: _governed_attempt(lambda: _fetch_primary(params))
- )
- except httpx.HTTPStatusError:
- # Do not bypass an upstream status (especially 429/503) through
- # another transport. That would multiply load precisely when the
- # source asks us to stop or is unavailable.
- raise
- except (httpx.RequestError, ValueError) as exc:
- logger.warning(
- "Primary Open Food Facts request failed; using fallback (%s)",
- type(exc).__name__,
+ lambda: _governed_attempt(lambda: _fetch_product(safe_query))
)
+ else:
try:
payload = await _OPEN_FOOD_FACTS_ADMISSION.run_attempt(
- lambda: _governed_attempt(lambda: _fetch_fallback(params))
+ lambda: _governed_attempt(lambda: _fetch_primary(params))
)
- except ValueError as fallback_exc:
- logger.error(
- "Open Food Facts fallback failed (%s)",
- type(fallback_exc).__name__,
+ except httpx.HTTPStatusError:
+ # Do not bypass an upstream status (especially 429/503) through
+ # another transport. That would multiply load precisely when the
+ # source asks us to stop or is unavailable.
+ raise
+ except (httpx.RequestError, ValueError) as exc:
+ logger.warning(
+ "Primary Open Food Facts request failed; using fallback (%s)",
+ type(exc).__name__,
)
- raise httpx.HTTPError(
- f"Open Food Facts fallback failed: {fallback_exc}"
- ) from fallback_exc
+ try:
+ payload = await _OPEN_FOOD_FACTS_ADMISSION.run_attempt(
+ lambda: _governed_attempt(lambda: _fetch_fallback(params))
+ )
+ except ValueError as fallback_exc:
+ logger.error(
+ "Open Food Facts fallback failed (%s)",
+ type(fallback_exc).__name__,
+ )
+ raise httpx.HTTPError(
+ f"Open Food Facts fallback failed: {fallback_exc}"
+ ) from fallback_exc
results = _normalize_products(payload)
except httpx.HTTPStatusError as exc:
@@ -216,25 +240,80 @@ async def _search_food_products_once(
raise
else:
_OPEN_FOOD_FACTS_ADMISSION.record_success(permit)
- _OPEN_FOOD_FACTS_AVAILABILITY.remember(safe_query, page_size, results)
+ _OPEN_FOOD_FACTS_AVAILABILITY.remember(safe_query, page_size, results, barcode=barcode)
return results
+def valid_food_barcode(value: str) -> str | None:
+ code = value.strip()
+ if not re.fullmatch(r"(?:[0-9]{8}|[0-9]{12}|[0-9]{13}|[0-9]{14})", code) or set(code) == {"0"}:
+ return None
+ total = sum(int(digit) * (3 if index % 2 == 0 else 1)
+ for index, digit in enumerate(reversed(code[:-1])))
+ return code if (10 - total % 10) % 10 == int(code[-1]) else None
+
+
+async def _fetch_product(code: str) -> dict[str, Any]:
+ """One exact, read-only OFF v3 product request. No image upload or redirects."""
+ async with httpx.AsyncClient(timeout=_PRIMARY_TIMEOUT_SECONDS, follow_redirects=False) as client:
+ response = await client.get(
+ f"https://world.openfoodfacts.org/api/v3/product/{code}",
+ params={"fields": _OPEN_FOOD_FACTS_FIELDS + ",product_type", "product_type": "food"},
+ headers=REQUEST_HEADERS,
+ )
+ if response.status_code == 404:
+ return {"products": []}
+ response.raise_for_status()
+ try:
+ payload = response.json()
+ except ValueError as exc:
+ raise httpx.HTTPError("Invalid product response") from exc
+ if not isinstance(payload, dict) or payload.get("status") not in ("success", "success_with_warnings"):
+ raise httpx.HTTPError("Invalid product response")
+ product = payload.get("product")
+ if not isinstance(product, dict) or not isinstance(product.get("code"), str):
+ raise httpx.HTTPError("Invalid product record")
+ # OFF normalizes UPC/EAN leading zeros. Compare equivalent GTIN values
+ # as strings, rejecting any unrelated barcode or non-food result.
+ returned = valid_food_barcode(product["code"])
+ if returned is None or returned.zfill(14) != code.zfill(14) or product.get("product_type", "food") != "food":
+ raise httpx.HTTPError("Product identity mismatch")
+ if not isinstance(product.get("product_name"), str) or not isinstance(product.get("nutriments"), dict):
+ return {"products": []}
+ return {"products": [product]}
+
+
+def _extract_label_tags(product: dict[str, Any]) -> list[str]:
+ values = product.get("labels_tags")
+ if not isinstance(values, list):
+ return []
+ # Bound untrusted provider metadata; never derive labels from marketing text.
+ return list(dict.fromkeys(value for value in values[:100]
+ if isinstance(value, str) and 0 < len(value) <= 100))
+
+
def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]:
results: list[FoodSearchResult] = []
+ products = payload.get("products", []) if isinstance(payload, dict) else None
+ if not isinstance(products, list):
+ raise httpx.HTTPError("Invalid Open Food Facts product list")
nutrient_fields = {
"calories": "energy-kcal",
"protein": "proteins",
"fat": "fat",
"carbohydrates": "carbohydrates",
}
- for product in payload.get("products", []):
- raw_product_name = (product.get("product_name") or "").strip()
+ for product in products:
+ if not isinstance(product, dict) or not isinstance(product.get("product_name"), str):
+ continue
+ raw_product_name = product["product_name"].strip()
product_name = _repair_common_mojibake(raw_product_name)
if not product_name:
continue
- nutriments = product.get("nutriments") or {}
+ nutriments = product.get("nutriments")
+ if not isinstance(nutriments, dict):
+ continue
serving_size = _to_optional_text(product.get("serving_size"))
nutrition = {
name: _to_float(nutriments.get(f"{field}_serving"))
@@ -260,8 +339,7 @@ def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]:
if any(value is None for value in nutrition.values()):
continue
- results.append(
- FoodSearchResult(
+ result = FoodSearchResult(
product_name=product_name,
calories=nutrition["calories"],
protein=nutrition["protein"],
@@ -272,30 +350,47 @@ def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]:
brand=_extract_brand(product),
serving_size=serving_size,
nutri_score=_extract_nutri_score(product),
- )
+ labels_tags=_extract_label_tags(product),
+ nutriscore_version=(str(product.get("nutriscore_version"))
+ if product.get("nutriscore_version") in ("2021", "2023", 2021, 2023) else None),
)
+ try:
+ # Every offered result must fit the existing diary contract. Do not
+ # silently truncate a provider's product identity or source fields.
+ FoodLogCreate.model_validate(result.model_dump())
+ except ValidationError:
+ continue
+ results.append(result)
return results
async def _fetch_primary(params: dict[str, Any]) -> dict[str, Any]:
- """Make one primary Open Food Facts request; the caller owns fallback policy."""
- async with httpx.AsyncClient(timeout=_PRIMARY_TIMEOUT_SECONDS) as client:
- response = await client.get(
- OPEN_FOOD_FACTS_SEARCH_URL,
- params=params,
+ """Use OFF's indexed full-text API; keep the bounded legacy transport fallback.
+
+ https://openfoodfacts.github.io/search-a-licious/users/ref-openapi/
+ Search is a read operation. POST keeps the query out of upstream access URLs.
+ """
+ # The app accepts product names, not Lucene filters or wildcard expressions.
+ # Escape reserved syntax while preserving separate words and Unicode text.
+ query = re.sub(r'([+\-=&|> dict[str, Any]:
diff --git a/backend/tests/test_account_profile.py b/backend/tests/test_account_profile.py
new file mode 100644
index 00000000..2beeb7d1
--- /dev/null
+++ b/backend/tests/test_account_profile.py
@@ -0,0 +1,134 @@
+"""Private profile persistence, account isolation and authenticated widget reads."""
+import hashlib
+import hmac
+import json
+from datetime import UTC, datetime, timedelta
+from secrets import token_urlsafe
+
+import pytest
+from sqlmodel import Session, create_engine
+
+import app.database as database
+import app.main as main
+from app.models import AuthSessionDB, CalorieAppUserDB, ExternalIdentityDB
+from app.schema_migrations import upgrade_database, assert_database_at_head
+
+HEADERS = {'X-CalorieApp-Request': 'account-profile'}
+
+def save(client, value, user_id=None, headers=HEADERS):
+ user_id = user_id or client.get('/api/identity/me').json()['user_id']
+ return client.post('/api/identity/profile', headers=headers, json={'user_id': user_id, 'nickname': value})
+
+def session_for(client, user_id):
+ token = token_urlsafe(48)
+ now = datetime.now(UTC)
+ with Session(database.engine) as db:
+ db.add(AuthSessionDB(calorieapp_user_id=user_id, session_token_hash=hashlib.sha256(token.encode()).hexdigest(), created_at=now, last_seen_at=now, expires_at=now+timedelta(hours=1)))
+ db.commit()
+ client.cookies.clear()
+ client.cookies.set('calorieapp_session', token)
+
+
+def test_nickname_survives_logout_and_fresh_session(authenticated_client):
+ c = authenticated_client
+ uid = c.get('/api/identity/me').json()['user_id']
+ response = save(c, ' Piet ')
+ assert response.status_code == 200
+ assert response.json()['nickname'] == 'Piet'
+ assert response.headers['cache-control'] == 'no-store'
+ assert c.post('/api/identity/logout').status_code == 200
+ assert c.get('/api/identity/me').status_code == 401
+ session_for(c, uid)
+ assert c.get('/api/identity/me').json()['nickname'] == 'Piet'
+ assert c.get('/api/identity/export').json()['account']['nickname'] == 'Piet'
+ assert save(c, None).json()['nickname'] is None
+ session_for(c, uid)
+ assert c.get('/api/identity/me').json()['nickname'] is None
+
+
+def test_account_switch_cannot_read_or_overwrite_previous_nickname(authenticated_client):
+ c=authenticated_client
+ original=c.get('/api/identity/me').json()['user_id']
+ assert save(c,'Piet').status_code == 200
+ with Session(database.engine) as db:
+ other=CalorieAppUserDB();db.add(other);db.commit();db.refresh(other);other_id=other.id
+ session_for(c,other_id)
+ assert c.get('/api/identity/me').json()['nickname'] is None
+ assert save(c,'Changed',original).status_code == 409
+ assert save(c,'Another').status_code == 200
+ session_for(c,original)
+ assert c.get('/api/identity/me').json()['nickname'] == 'Piet'
+
+
+@pytest.mark.parametrize('value',['A','a'*33,'