diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..5a6729d0
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,24 @@
+# CalorieApp project continuity
+
+- Continue the existing checkpoint; never rebuild the CalorieVerse concept from
+ scratch. Read `docs/CALORIEVERSE_PREVIEW_CHECKPOINT.md` before changing it.
+- Draft PR #150, branch `feat/participation-node-simulator`, continues from
+ `f8711ee1a7d3c6e43c300fc6b1123ebe0329ad44`. No merge or deployment is authorized
+ for this work. Keep the PR a draft; do not change live services or WordPress.
+- `/gameverse/preview` is the small meadow review route. `/gameverse` retains
+ the larger existing draft with Studio, identity and participation integrations.
+ The extra route is a review boundary, not a second world or product version.
+- CalorieVerse grows continuously: preserve the original world/starter IDs,
+ module IDs, storage keys and earned progress. Never require a player reset.
+- Reuse the eleven-language display registry. Keep UI copy out of reducers.
+- Content packs, pure activity reducers, rendering and persistence are separate.
+ Unknown module fields must survive; unsupported formats must not be overwritten.
+- Ordinary play requires no account, wallet, storage contribution, compute role
+ or reward. Participation is separately opt-in, capped and reversible, with
+ independent storage/compute consent and pause/resume/full-stop controls.
+- Local guest progress is untrusted and cannot authorize calT/CAL/XRP rewards.
+ Synthetic simulators are not an active network or settlement system.
+- Never place private food logs, identity, credentials or age records in public
+ participant data. Preserve the existing private PostgreSQL boundary.
+- Keep deferred architecture, publication and governance work in repository docs;
+ never store secrets or temporary chat credentials here.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd2ca8bd..7ae7334a 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: |
@@ -63,6 +64,17 @@ jobs:
python -m unittest tools.tests.test_offline_age_custody
python -m unittest tools.tests.test_tracked_secret_patterns
+ - name: Test local synthetic participation simulators
+ run: |
+ python -m unittest tools.tests.test_participation_simulator
+ python -m unittest tools.tests.test_participation_compute_simulator
+ python -m unittest tools.tests.test_participation_ui_adapter
+ python -m unittest tools.tests.test_participation_growth_simulator
+ python -m unittest tools.tests.test_participation_reliability_simulator
+ python -m unittest tools.tests.test_participation_data_release_simulator
+ python -m unittest tools.tests.test_participation_resource_policy
+ python -m unittest tools.tests.test_gameverse_character_identity
+
wordpress-plugin-release-check:
name: WordPress plugin release check
runs-on: ubuntu-latest
@@ -76,7 +88,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 +149,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 +324,21 @@ 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: Test participation control state machine
+ run: node --test tools/tests/participation_lab_state.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/CONTRIBUTING.md b/CONTRIBUTING.md
index 9812d8e1..aaa3593a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -5,6 +5,22 @@ Do not submit code, documentation, designs, data, or other material unless you
have the right to do so and the project owner has agreed in writing to review
the contribution.
+## Independent ecosystem projects
+
+Building an independent compatible project is different from contributing code
+to the official repository.
+
+An independent project does not need prior operator approval merely to create
+its own lawful implementation against public ecosystem interfaces, provided it
+uses its own namespace/branding, respects applicable licences and third-party
+rights, and does not claim official status or access private production state.
+
+The official repository still has its own inbound contribution requirements
+below. Open compatibility does not silently relicense official source code.
+
+See `docs/ECOSYSTEM_OPEN_STEWARDSHIP_AND_FUNDING.md` and
+`contracts/ecosystem/v2/open-stewardship-and-funding.json`.
+
## No implied acceptance or transfer
- Opening an issue or pull request does not mean a contribution is accepted.
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/IP_CLEARANCE.md b/IP_CLEARANCE.md
index a3b84595..81aaa113 100644
--- a/IP_CLEARANCE.md
+++ b/IP_CLEARANCE.md
@@ -5,3 +5,23 @@ This repository publishes reviewed application expression; it does not claim own
Contributions require documented provenance and compatible rights. Dependencies, datasets, images, names and marks retain their own licences and restrictions. Open Food Facts reuse must preserve the attribution and database-licence requirements described in `DATA_LICENSING.md`.
A fresh legal and technical review is required before adding financial execution, wallet custody, regulated claims, bulk datasets, biometric or health profiling, new branding, restrictive SDKs or copied third-party material. This document is an engineering boundary, not legal advice or a freedom-to-operate opinion.
+
+
+## CalorieVerse / open ecosystem clearance gates
+
+The proposed CalorieVerse name has not been declared cleared merely because no
+obvious conflict appeared in a general web search. Before a commercial public
+launch or filing, perform an exact trade-mark clearance search across the
+relevant EUIPO/TMview/BOIP records, classes and similar names, and retain the
+search evidence.
+
+The long-term intention to let third parties build on ecosystem protocols does
+not automatically change this repository's current licence. Existing code,
+assets and documentation require a rights/provenance audit before they can be
+placed into an open-licence manifest. Newly created protocol/SDK components may
+use a recognised open-source licence only after the relevant rights holder has
+approved that licence and the component is explicitly identified.
+
+Creator-uploaded content needs its own production terms, rights warranty,
+licence scope, notice/takedown process and moderation rules before public
+hosting is enabled.
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/REGULATORY.md b/REGULATORY.md
index 409ed7d6..7c4312a8 100644
--- a/REGULATORY.md
+++ b/REGULATORY.md
@@ -8,3 +8,19 @@ investment services or financial advice.
External identity may be used only for authentication context. Any future financial, token, reward, health-profiling or regulated feature requires separate legal, privacy, security and architecture review before public claims or implementation.
This summary is not legal advice and does not claim certification or regulatory approval.
+
+
+## CalorieVerse / CalorieStudio commercial boundary
+
+Optional premium digital features, creator tools or managed services may be
+introduced as a sustainability model, but charging EU consumers requires a
+separate consumer-contract, VAT/OSS, privacy and refund review before launch.
+
+If CalorieStudio or CalorieVerse begins hosting/intermediating public
+user-generated content or marketplace activity, Digital Services Act and
+platform/content-moderation obligations must be reviewed before production
+activation.
+
+Real CAL/XRP payment rails, crypto-asset rewards, exchange/custody/transfer
+services or other token-linked commercial functions remain outside the current
+non-financial boundary and require separate MiCA/financial-regulatory review.
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/TRADEMARKS.md b/TRADEMARKS.md
index 9c0afc86..ffc4bcf2 100644
--- a/TRADEMARKS.md
+++ b/TRADEMARKS.md
@@ -32,3 +32,40 @@ Registration of a figurative mark does not by itself establish copyright
authorship or assignment. The copyright chain for the finished CalorieToken
logo artwork remains subject to separate provenance and assignment
verification. Trade-mark rights and copyright in artwork are distinct rights.
+
+
+## CalorieVerse and future ecosystem marks
+
+`CalorieVerse` is the current proposed public name for the project's live
+metaverse/open-world product. This repository does **not** claim that
+CalorieVerse is a registered trade mark. The name requires an exact clearance
+search for the intended goods/services before any registration or registered
+symbol is claimed. A general web search is not a freedom-to-operate opinion.
+
+Open ecosystem compatibility does not itself grant a right to use official
+branding in a misleading way. Independent implementations may need a future
+trade-mark policy for truthful compatibility references, community naming and
+conformance badges.
+
+Long-term decentralisation does not require the current proprietor to abandon
+marks. A future stewardship entity may receive ownership of, or a documented
+licence to administer, marks only through an explicit legal instrument and any
+required registry recordal. No transfer occurs automatically through code
+governance, token ownership, community participation or publication of an open
+protocol.
+
+Until a separate decision and legal instrument exist, the current ownership
+records control.
+
+
+### CalorieStudio
+
+`CalorieStudio` is the current proposed public name for the creator/workshop
+surface within CalorieVerse. The internal route `/gallery` and legacy
+"Creator Gallery" identifiers may remain for compatibility.
+
+This repository does **not** claim that CalorieStudio is a registered trade
+mark or that the name has received a formal freedom-to-operate clearance.
+Before a commercial brand filing or registered-symbol claim, perform an exact
+EUIPO/TMview/BOIP clearance search for the intended goods and services and
+retain the evidence.
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..cd697e5d 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)
@@ -160,6 +173,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 +181,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 +212,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 +261,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..f92089e4 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.
@@ -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,71 @@ 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 _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 +330,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 +341,44 @@ def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]:
brand=_extract_brand(product),
serving_size=serving_size,
nutri_score=_extract_nutri_score(product),
- )
)
+ 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,'